550 lines
15 KiB
TypeScript
550 lines
15 KiB
TypeScript
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<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('bg-muted absolute 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('bg-muted absolute inset-0 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('bg-muted absolute inset-0 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
|
|
}
|