593 lines
16 KiB
TypeScript
593 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { AnimatePresence, Transition, motion } from 'motion/react';
|
|
|
|
import { cn } from '@workspace/ui/lib/utils';
|
|
|
|
type MotionHighlightMode = 'children' | 'parent';
|
|
|
|
type Bounds = {
|
|
top: number;
|
|
left: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type MotionHighlightContextType<T extends string> = {
|
|
mode: MotionHighlightMode;
|
|
activeValue: T | null;
|
|
setActiveValue: (value: T | null) => void;
|
|
setBounds: (bounds: DOMRect) => void;
|
|
clearBounds: () => void;
|
|
id: string;
|
|
hover: boolean;
|
|
className?: string;
|
|
activeClassName?: string;
|
|
setActiveClassName: (className: string) => void;
|
|
transition?: Transition;
|
|
disabled?: boolean;
|
|
enabled?: boolean;
|
|
exitDelay?: number;
|
|
forceUpdateBounds?: boolean;
|
|
};
|
|
|
|
const MotionHighlightContext = React.createContext<
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
MotionHighlightContextType<any> | undefined
|
|
>(undefined);
|
|
|
|
function useMotionHighlight<T extends string>(): MotionHighlightContextType<T> {
|
|
const context = React.useContext(MotionHighlightContext);
|
|
if (!context) {
|
|
throw new Error(
|
|
'useMotionHighlight must be used within a MotionHighlightProvider',
|
|
);
|
|
}
|
|
return context as unknown as MotionHighlightContextType<T>;
|
|
}
|
|
|
|
type BaseMotionHighlightProps<T extends string> = {
|
|
mode?: MotionHighlightMode;
|
|
value?: T | null;
|
|
defaultValue?: T | null;
|
|
onValueChange?: (value: T | null) => void;
|
|
className?: string;
|
|
transition?: Transition;
|
|
hover?: boolean;
|
|
disabled?: boolean;
|
|
enabled?: boolean;
|
|
exitDelay?: number;
|
|
};
|
|
|
|
type ParentModeMotionHighlightProps = {
|
|
boundsOffset?: Partial<Bounds>;
|
|
containerClassName?: string;
|
|
forceUpdateBounds?: boolean;
|
|
};
|
|
|
|
type ControlledParentModeMotionHighlightProps<T extends string> =
|
|
BaseMotionHighlightProps<T> &
|
|
ParentModeMotionHighlightProps & {
|
|
mode: 'parent';
|
|
controlledItems: true;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
type ControlledChildrenModeMotionHighlightProps<T extends string> =
|
|
BaseMotionHighlightProps<T> & {
|
|
mode?: 'children' | undefined;
|
|
controlledItems: true;
|
|
children: React.ReactNode;
|
|
};
|
|
|
|
type UncontrolledParentModeMotionHighlightProps<T extends string> =
|
|
BaseMotionHighlightProps<T> &
|
|
ParentModeMotionHighlightProps & {
|
|
mode: 'parent';
|
|
controlledItems?: false;
|
|
itemsClassName?: string;
|
|
children: React.ReactElement | React.ReactElement[];
|
|
};
|
|
|
|
type UncontrolledChildrenModeMotionHighlightProps<T extends string> =
|
|
BaseMotionHighlightProps<T> & {
|
|
mode?: 'children';
|
|
controlledItems?: false;
|
|
itemsClassName?: string;
|
|
children: React.ReactElement | React.ReactElement[];
|
|
};
|
|
|
|
type MotionHighlightProps<T extends string> = React.ComponentProps<'div'> &
|
|
(
|
|
| ControlledParentModeMotionHighlightProps<T>
|
|
| ControlledChildrenModeMotionHighlightProps<T>
|
|
| UncontrolledParentModeMotionHighlightProps<T>
|
|
| UncontrolledChildrenModeMotionHighlightProps<T>
|
|
);
|
|
|
|
function MotionHighlight<T extends string>({
|
|
ref,
|
|
...props
|
|
}: MotionHighlightProps<T>) {
|
|
const {
|
|
children,
|
|
value,
|
|
defaultValue,
|
|
onValueChange,
|
|
className,
|
|
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
|
hover = false,
|
|
enabled = true,
|
|
controlledItems,
|
|
disabled = false,
|
|
exitDelay = 0.2,
|
|
mode = 'children',
|
|
} = props;
|
|
|
|
const localRef = React.useRef<HTMLDivElement>(null);
|
|
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
|
|
|
const [activeValue, setActiveValue] = React.useState<T | null>(
|
|
value ?? defaultValue ?? null,
|
|
);
|
|
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null);
|
|
const [activeClassNameState, setActiveClassNameState] =
|
|
React.useState<string>('');
|
|
|
|
const safeSetActiveValue = React.useCallback(
|
|
(id: T | null) => {
|
|
setActiveValue((prev) => (prev === id ? prev : id));
|
|
if (id !== activeValue) onValueChange?.(id as T);
|
|
},
|
|
[activeValue, onValueChange],
|
|
);
|
|
|
|
const safeSetBounds = React.useCallback(
|
|
(bounds: DOMRect) => {
|
|
if (!localRef.current) return;
|
|
|
|
const boundsOffset = (props as ParentModeMotionHighlightProps)
|
|
?.boundsOffset ?? {
|
|
top: 0,
|
|
left: 0,
|
|
width: 0,
|
|
height: 0,
|
|
};
|
|
|
|
const containerRect = localRef.current.getBoundingClientRect();
|
|
const newBounds: Bounds = {
|
|
top: bounds.top - containerRect.top + (boundsOffset.top ?? 0),
|
|
left: bounds.left - containerRect.left + (boundsOffset.left ?? 0),
|
|
width: bounds.width + (boundsOffset.width ?? 0),
|
|
height: bounds.height + (boundsOffset.height ?? 0),
|
|
};
|
|
|
|
setBoundsState((prev) => {
|
|
if (
|
|
prev &&
|
|
prev.top === newBounds.top &&
|
|
prev.left === newBounds.left &&
|
|
prev.width === newBounds.width &&
|
|
prev.height === newBounds.height
|
|
) {
|
|
return prev;
|
|
}
|
|
return newBounds;
|
|
});
|
|
},
|
|
[props],
|
|
);
|
|
|
|
const clearBounds = React.useCallback(() => {
|
|
setBoundsState((prev) => (prev === null ? prev : null));
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
if (value !== undefined) setActiveValue(value);
|
|
else if (defaultValue !== undefined) setActiveValue(defaultValue);
|
|
}, [value, defaultValue]);
|
|
|
|
const id = React.useId();
|
|
|
|
React.useEffect(() => {
|
|
if (mode !== 'parent') return;
|
|
const container = localRef.current;
|
|
if (!container) return;
|
|
|
|
const onScroll = () => {
|
|
if (!activeValue) return;
|
|
const activeEl = container.querySelector<HTMLElement>(
|
|
`[data-value="${activeValue}"][data-highlight="true"]`,
|
|
);
|
|
if (activeEl) safeSetBounds(activeEl.getBoundingClientRect());
|
|
};
|
|
|
|
container.addEventListener('scroll', onScroll, { passive: true });
|
|
return () => container.removeEventListener('scroll', onScroll);
|
|
}, [mode, activeValue, safeSetBounds]);
|
|
|
|
const render = React.useCallback(
|
|
(children: React.ReactNode) => {
|
|
if (mode === 'parent') {
|
|
return (
|
|
<div
|
|
ref={localRef}
|
|
data-slot="motion-highlight-container"
|
|
className={cn(
|
|
'relative',
|
|
(props as ParentModeMotionHighlightProps)?.containerClassName,
|
|
)}
|
|
>
|
|
<AnimatePresence initial={false}>
|
|
{boundsState && (
|
|
<motion.div
|
|
data-slot="motion-highlight"
|
|
animate={{
|
|
top: boundsState.top,
|
|
left: boundsState.left,
|
|
width: boundsState.width,
|
|
height: boundsState.height,
|
|
opacity: 1,
|
|
}}
|
|
initial={{
|
|
top: boundsState.top,
|
|
left: boundsState.left,
|
|
width: boundsState.width,
|
|
height: boundsState.height,
|
|
opacity: 0,
|
|
}}
|
|
exit={{
|
|
opacity: 0,
|
|
transition: {
|
|
...transition,
|
|
delay: (transition?.delay ?? 0) + (exitDelay ?? 0),
|
|
},
|
|
}}
|
|
transition={transition}
|
|
className={cn(
|
|
'absolute bg-muted z-0',
|
|
className,
|
|
activeClassNameState,
|
|
)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return children;
|
|
},
|
|
[
|
|
mode,
|
|
props,
|
|
boundsState,
|
|
transition,
|
|
exitDelay,
|
|
className,
|
|
activeClassNameState,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<MotionHighlightContext.Provider
|
|
value={{
|
|
mode,
|
|
activeValue,
|
|
setActiveValue: safeSetActiveValue,
|
|
id,
|
|
hover,
|
|
className,
|
|
transition,
|
|
disabled,
|
|
enabled,
|
|
exitDelay,
|
|
setBounds: safeSetBounds,
|
|
clearBounds,
|
|
activeClassName: activeClassNameState,
|
|
setActiveClassName: setActiveClassNameState,
|
|
forceUpdateBounds: (props as ParentModeMotionHighlightProps)
|
|
?.forceUpdateBounds,
|
|
}}
|
|
>
|
|
{enabled
|
|
? controlledItems
|
|
? render(children)
|
|
: render(
|
|
React.Children.map(children, (child, index) => (
|
|
<MotionHighlightItem
|
|
key={index}
|
|
className={props?.itemsClassName}
|
|
>
|
|
{child}
|
|
</MotionHighlightItem>
|
|
)),
|
|
)
|
|
: children}
|
|
</MotionHighlightContext.Provider>
|
|
);
|
|
}
|
|
|
|
function getNonOverridingDataAttributes(
|
|
element: React.ReactElement,
|
|
dataAttributes: Record<string, unknown>,
|
|
): Record<string, unknown> {
|
|
return Object.keys(dataAttributes).reduce<Record<string, unknown>>(
|
|
(acc, key) => {
|
|
if ((element.props as Record<string, unknown>)[key] === undefined) {
|
|
acc[key] = dataAttributes[key];
|
|
}
|
|
return acc;
|
|
},
|
|
{},
|
|
);
|
|
}
|
|
|
|
type ExtendedChildProps = React.ComponentProps<'div'> & {
|
|
id?: string;
|
|
ref?: React.Ref<HTMLElement>;
|
|
'data-active'?: string;
|
|
'data-value'?: string;
|
|
'data-disabled'?: boolean;
|
|
'data-highlight'?: boolean;
|
|
'data-slot'?: string;
|
|
};
|
|
|
|
type MotionHighlightItemProps = React.ComponentProps<'div'> & {
|
|
children: React.ReactElement;
|
|
id?: string;
|
|
value?: string;
|
|
className?: string;
|
|
transition?: Transition;
|
|
activeClassName?: string;
|
|
disabled?: boolean;
|
|
exitDelay?: number;
|
|
asChild?: boolean;
|
|
forceUpdateBounds?: boolean;
|
|
};
|
|
|
|
function MotionHighlightItem({
|
|
ref,
|
|
children,
|
|
id,
|
|
value,
|
|
className,
|
|
transition,
|
|
disabled = false,
|
|
activeClassName,
|
|
exitDelay,
|
|
asChild = false,
|
|
forceUpdateBounds,
|
|
...props
|
|
}: MotionHighlightItemProps) {
|
|
const itemId = React.useId();
|
|
const {
|
|
activeValue,
|
|
setActiveValue,
|
|
mode,
|
|
setBounds,
|
|
clearBounds,
|
|
hover,
|
|
enabled,
|
|
className: contextClassName,
|
|
transition: contextTransition,
|
|
id: contextId,
|
|
disabled: contextDisabled,
|
|
exitDelay: contextExitDelay,
|
|
forceUpdateBounds: contextForceUpdateBounds,
|
|
setActiveClassName,
|
|
} = useMotionHighlight();
|
|
|
|
const element = children as React.ReactElement<ExtendedChildProps>;
|
|
const childValue =
|
|
id ?? value ?? element.props?.['data-value'] ?? element.props?.id ?? itemId;
|
|
const isActive = activeValue === childValue;
|
|
const isDisabled = disabled === undefined ? contextDisabled : disabled;
|
|
const itemTransition = transition ?? contextTransition;
|
|
|
|
const localRef = React.useRef<HTMLDivElement>(null);
|
|
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
|
|
|
React.useEffect(() => {
|
|
if (mode !== 'parent') return;
|
|
let rafId: number;
|
|
let previousBounds: Bounds | null = null;
|
|
const shouldUpdateBounds =
|
|
forceUpdateBounds === true ||
|
|
(contextForceUpdateBounds && forceUpdateBounds !== false);
|
|
|
|
const updateBounds = () => {
|
|
if (!localRef.current) return;
|
|
|
|
const bounds = localRef.current.getBoundingClientRect();
|
|
|
|
if (shouldUpdateBounds) {
|
|
if (
|
|
previousBounds &&
|
|
previousBounds.top === bounds.top &&
|
|
previousBounds.left === bounds.left &&
|
|
previousBounds.width === bounds.width &&
|
|
previousBounds.height === bounds.height
|
|
) {
|
|
rafId = requestAnimationFrame(updateBounds);
|
|
return;
|
|
}
|
|
previousBounds = bounds;
|
|
rafId = requestAnimationFrame(updateBounds);
|
|
}
|
|
|
|
setBounds(bounds);
|
|
};
|
|
|
|
if (isActive) {
|
|
updateBounds();
|
|
setActiveClassName(activeClassName ?? '');
|
|
} else if (!activeValue) clearBounds();
|
|
|
|
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId);
|
|
}, [
|
|
mode,
|
|
isActive,
|
|
activeValue,
|
|
setBounds,
|
|
clearBounds,
|
|
activeClassName,
|
|
setActiveClassName,
|
|
forceUpdateBounds,
|
|
contextForceUpdateBounds,
|
|
]);
|
|
|
|
if (!React.isValidElement(children)) return children;
|
|
|
|
const dataAttributes = {
|
|
'data-active': isActive ? 'true' : 'false',
|
|
'aria-selected': isActive,
|
|
'data-disabled': isDisabled,
|
|
'data-value': childValue,
|
|
'data-highlight': true,
|
|
};
|
|
|
|
const commonHandlers = hover
|
|
? {
|
|
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
|
|
setActiveValue(childValue);
|
|
element.props.onMouseEnter?.(e);
|
|
},
|
|
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
|
|
setActiveValue(null);
|
|
element.props.onMouseLeave?.(e);
|
|
},
|
|
}
|
|
: {
|
|
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
|
|
setActiveValue(childValue);
|
|
element.props.onClick?.(e);
|
|
},
|
|
};
|
|
|
|
if (asChild) {
|
|
if (mode === 'children') {
|
|
return React.cloneElement(
|
|
element,
|
|
{
|
|
key: childValue,
|
|
ref: localRef,
|
|
className: cn('relative', element.props.className),
|
|
...getNonOverridingDataAttributes(element, {
|
|
...dataAttributes,
|
|
'data-slot': 'motion-highlight-item-container',
|
|
}),
|
|
...commonHandlers,
|
|
...props,
|
|
},
|
|
<>
|
|
<AnimatePresence initial={false}>
|
|
{isActive && !isDisabled && (
|
|
<motion.div
|
|
layoutId={`transition-background-${contextId}`}
|
|
data-slot="motion-highlight"
|
|
className={cn(
|
|
'absolute inset-0 bg-muted z-0',
|
|
contextClassName,
|
|
activeClassName,
|
|
)}
|
|
transition={itemTransition}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{
|
|
opacity: 0,
|
|
transition: {
|
|
...itemTransition,
|
|
delay:
|
|
(itemTransition?.delay ?? 0) +
|
|
(exitDelay ?? contextExitDelay ?? 0),
|
|
},
|
|
}}
|
|
{...dataAttributes}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<div
|
|
data-slot="motion-highlight-item"
|
|
className={cn('relative z-[1]', className)}
|
|
{...dataAttributes}
|
|
>
|
|
{children}
|
|
</div>
|
|
</>,
|
|
);
|
|
}
|
|
|
|
return React.cloneElement(element, {
|
|
ref: localRef,
|
|
...getNonOverridingDataAttributes(element, {
|
|
...dataAttributes,
|
|
'data-slot': 'motion-highlight-item',
|
|
}),
|
|
...commonHandlers,
|
|
});
|
|
}
|
|
|
|
return enabled ? (
|
|
<div
|
|
key={childValue}
|
|
ref={localRef}
|
|
data-slot="motion-highlight-item-container"
|
|
className={cn(mode === 'children' && 'relative', className)}
|
|
{...dataAttributes}
|
|
{...props}
|
|
{...commonHandlers}
|
|
>
|
|
{mode === 'children' && (
|
|
<AnimatePresence initial={false}>
|
|
{isActive && !isDisabled && (
|
|
<motion.div
|
|
layoutId={`transition-background-${contextId}`}
|
|
data-slot="motion-highlight"
|
|
className={cn(
|
|
'absolute inset-0 bg-muted z-0',
|
|
contextClassName,
|
|
activeClassName,
|
|
)}
|
|
transition={itemTransition}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{
|
|
opacity: 0,
|
|
transition: {
|
|
...itemTransition,
|
|
delay:
|
|
(itemTransition?.delay ?? 0) +
|
|
(exitDelay ?? contextExitDelay ?? 0),
|
|
},
|
|
}}
|
|
{...dataAttributes}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
)}
|
|
|
|
{React.cloneElement(element, {
|
|
className: cn('relative z-[1]', element.props.className),
|
|
...getNonOverridingDataAttributes(element, {
|
|
...dataAttributes,
|
|
'data-slot': 'motion-highlight-item',
|
|
}),
|
|
})}
|
|
</div>
|
|
) : (
|
|
children
|
|
);
|
|
}
|
|
|
|
export {
|
|
MotionHighlight,
|
|
MotionHighlightItem,
|
|
useMotionHighlight,
|
|
type MotionHighlightProps,
|
|
type MotionHighlightItemProps,
|
|
};
|