'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 = { 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 | undefined >(undefined); function useMotionHighlight(): MotionHighlightContextType { const context = React.useContext(MotionHighlightContext); if (!context) { throw new Error( 'useMotionHighlight must be used within a MotionHighlightProvider', ); } return context as unknown as MotionHighlightContextType; } type BaseMotionHighlightProps = { 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; containerClassName?: string; forceUpdateBounds?: boolean; }; type ControlledParentModeMotionHighlightProps = BaseMotionHighlightProps & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems: true; children: React.ReactNode; }; type ControlledChildrenModeMotionHighlightProps = BaseMotionHighlightProps & { mode?: 'children' | undefined; controlledItems: true; children: React.ReactNode; }; type UncontrolledParentModeMotionHighlightProps = BaseMotionHighlightProps & ParentModeMotionHighlightProps & { mode: 'parent'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; }; type UncontrolledChildrenModeMotionHighlightProps = BaseMotionHighlightProps & { mode?: 'children'; controlledItems?: false; itemsClassName?: string; children: React.ReactElement | React.ReactElement[]; }; type MotionHighlightProps = React.ComponentProps<'div'> & ( | ControlledParentModeMotionHighlightProps | ControlledChildrenModeMotionHighlightProps | UncontrolledParentModeMotionHighlightProps | UncontrolledChildrenModeMotionHighlightProps ); function MotionHighlight({ ref, ...props }: MotionHighlightProps) { 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(null); React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement); const [activeValue, setActiveValue] = React.useState( value ?? defaultValue ?? null, ); const [boundsState, setBoundsState] = React.useState(null); const [activeClassNameState, setActiveClassNameState] = React.useState(''); 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( `[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 (
{boundsState && ( )} {children}
); } return children; }, [ mode, props, boundsState, transition, exitDelay, className, activeClassNameState, ], ); return ( {enabled ? controlledItems ? render(children) : render( React.Children.map(children, (child, index) => ( {child} )), ) : children} ); } function getNonOverridingDataAttributes( element: React.ReactElement, dataAttributes: Record, ): Record { return Object.keys(dataAttributes).reduce>( (acc, key) => { if ((element.props as Record)[key] === undefined) { acc[key] = dataAttributes[key]; } return acc; }, {}, ); } type ExtendedChildProps = React.ComponentProps<'div'> & { id?: string; ref?: React.Ref; '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; 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(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) => { setActiveValue(childValue); element.props.onMouseEnter?.(e); }, onMouseLeave: (e: React.MouseEvent) => { setActiveValue(null); element.props.onMouseLeave?.(e); }, } : { onClick: (e: React.MouseEvent) => { 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, }, <> {isActive && !isDisabled && ( )}
{children}
, ); } return React.cloneElement(element, { ref: localRef, ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), ...commonHandlers, }); } return enabled ? (
{mode === 'children' && ( {isActive && !isDisabled && ( )} )} {React.cloneElement(element, { className: cn('relative z-[1]', element.props.className), ...getNonOverridingDataAttributes(element, { ...dataAttributes, 'data-slot': 'motion-highlight-item', }), })}
) : ( children ); } export { MotionHighlight, MotionHighlightItem, useMotionHighlight, type MotionHighlightProps, type MotionHighlightItemProps, };