import * as React from 'react' import type { Transition } from 'motion/react' import { AnimatePresence, motion } from 'motion/react' import { cn } from '@/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 }