chore: add MotionHighlight component from animate-ui

This commit is contained in:
javayhu 2025-06-14 15:00:55 +08:00
parent e610fe7335
commit c23383fdde
2 changed files with 594 additions and 0 deletions

View File

@ -19,6 +19,7 @@
"tailwind.config.ts",
"src/components/ui/*.tsx",
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/app/[[]locale]/preview/**",
"src/db/schema.ts",
@ -77,6 +78,7 @@
"tailwind.config.ts",
"src/components/ui/*.tsx",
"src/components/magicui/*.tsx",
"src/components/animate-ui/*.tsx",
"src/components/tailark/*.tsx",
"src/app/[[]locale]/preview/**",
"src/db/schema.ts",

View File

@ -0,0 +1,592 @@
'use client';
import * as React from 'react';
import { AnimatePresence, Transition, 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(
'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,
};