2025-08-20 04:12:49 -06:00

360 lines
8.6 KiB
TypeScript

'use client';
import * as React from 'react';
import {
SVGMotionProps,
useAnimation,
type LegacyAnimationControls,
type Variants,
} from 'motion/react';
import { cn } from '@workspace/ui/lib/utils';
const staticAnimations = {
path: {
initial: { pathLength: 1, opacity: 1 },
animate: {
pathLength: [0.05, 1],
opacity: [0, 1],
transition: {
duration: 0.8,
ease: 'easeInOut',
opacity: { duration: 0.01 },
},
},
} as Variants,
'path-loop': {
initial: { pathLength: 1, opacity: 1 },
animate: {
pathLength: [1, 0.05, 1],
opacity: [1, 0, 1],
transition: {
duration: 1.6,
ease: 'easeInOut',
opacity: { duration: 0.01 },
},
},
} as Variants,
} as const;
type StaticAnimations = keyof typeof staticAnimations;
type TriggerProp<T = string> = boolean | StaticAnimations | T;
interface AnimateIconContextValue {
controls: LegacyAnimationControls | undefined;
animation: StaticAnimations | string;
loop: boolean;
loopDelay: number;
}
interface DefaultIconProps<T = string> {
animate?: TriggerProp<T>;
onAnimateChange?: (
value: boolean,
animation: StaticAnimations | string,
) => void;
animateOnHover?: TriggerProp<T>;
animateOnTap?: TriggerProp<T>;
animation?: T | StaticAnimations;
loop?: boolean;
loopDelay?: number;
onAnimateStart?: () => void;
onAnimateEnd?: () => void;
}
interface AnimateIconProps<T = string> extends DefaultIconProps<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children: React.ReactElement<any, any>;
}
interface IconProps<T>
extends DefaultIconProps<T>,
Omit<
SVGMotionProps<SVGSVGElement>,
'animate' | 'onAnimationStart' | 'onAnimationEnd'
> {
size?: number;
}
interface IconWrapperProps<T> extends IconProps<T> {
icon: React.ComponentType<IconProps<T>>;
}
const AnimateIconContext = React.createContext<AnimateIconContextValue | null>(
null,
);
function useAnimateIconContext() {
const context = React.useContext(AnimateIconContext);
if (!context)
return {
controls: undefined,
animation: 'default',
loop: false,
loopDelay: 0,
};
return context;
}
function AnimateIcon({
animate,
onAnimateChange,
animateOnHover,
animateOnTap,
animation = 'default',
loop = false,
loopDelay = 0,
onAnimateStart,
onAnimateEnd,
children,
}: AnimateIconProps) {
const controls = useAnimation();
const [localAnimate, setLocalAnimate] = React.useState(!!animate);
const currentAnimation = React.useRef(animation);
const startAnimation = React.useCallback(
(trigger: TriggerProp) => {
currentAnimation.current =
typeof trigger === 'string' ? trigger : animation;
setLocalAnimate(true);
},
[animation],
);
const stopAnimation = React.useCallback(() => {
setLocalAnimate(false);
}, []);
React.useEffect(() => {
currentAnimation.current =
typeof animate === 'string' ? animate : animation;
setLocalAnimate(!!animate);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [animate]);
React.useEffect(
() => onAnimateChange?.(localAnimate, currentAnimation.current),
[localAnimate, onAnimateChange],
);
React.useEffect(() => {
if (localAnimate) onAnimateStart?.();
controls.start(localAnimate ? 'animate' : 'initial').then(() => {
if (localAnimate) onAnimateEnd?.();
});
}, [localAnimate, controls, onAnimateStart, onAnimateEnd]);
const handleMouseEnter = (e: MouseEvent) => {
if (animateOnHover) startAnimation(animateOnHover);
children.props?.onMouseEnter?.(e);
};
const handleMouseLeave = (e: MouseEvent) => {
if (animateOnHover || animateOnTap) stopAnimation();
children.props?.onMouseLeave?.(e);
};
const handlePointerDown = (e: PointerEvent) => {
if (animateOnTap) startAnimation(animateOnTap);
children.props?.onPointerDown?.(e);
};
const handlePointerUp = (e: PointerEvent) => {
if (animateOnTap) stopAnimation();
children.props?.onPointerUp?.(e);
};
const child = React.Children.only(children);
const cloned = React.cloneElement(child, {
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onPointerDown: handlePointerDown,
onPointerUp: handlePointerUp,
});
return (
<AnimateIconContext.Provider
value={{
controls,
animation: currentAnimation.current,
loop,
loopDelay,
}}
>
{cloned}
</AnimateIconContext.Provider>
);
}
const pathClassName =
"[&_[stroke-dasharray='1px_1px']]:![stroke-dasharray:1px_0px]";
function IconWrapper<T extends string>({
size = 28,
animation: animationProp,
animate,
onAnimateChange,
animateOnHover = false,
animateOnTap = false,
icon: IconComponent,
loop = false,
loopDelay = 0,
onAnimateStart,
onAnimateEnd,
className,
...props
}: IconWrapperProps<T>) {
const context = React.useContext(AnimateIconContext);
if (context) {
const {
controls,
animation: parentAnimation,
loop: parentLoop,
loopDelay: parentLoopDelay,
} = context;
const animationToUse = animationProp ?? parentAnimation;
const loopToUse = loop || parentLoop;
const loopDelayToUse = loopDelay || parentLoopDelay;
return (
<AnimateIconContext.Provider
value={{
controls,
animation: animationToUse,
loop: loopToUse,
loopDelay: loopDelayToUse,
}}
>
<IconComponent
size={size}
className={cn(
className,
(animationToUse === 'path' || animationToUse === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIconContext.Provider>
);
}
if (
animate !== undefined ||
onAnimateChange !== undefined ||
animateOnHover ||
animateOnTap ||
animationProp
) {
return (
<AnimateIcon
animate={animate}
onAnimateChange={onAnimateChange}
animateOnHover={animateOnHover}
animateOnTap={animateOnTap}
animation={animationProp}
loop={loop}
loopDelay={loopDelay}
onAnimateStart={onAnimateStart}
onAnimateEnd={onAnimateEnd}
>
<IconComponent
size={size}
className={cn(
className,
(animationProp === 'path' || animationProp === 'path-loop') &&
pathClassName,
)}
{...props}
/>
</AnimateIcon>
);
}
return (
<IconComponent
size={size}
className={cn(
className,
(animationProp === 'path' || animationProp === 'path-loop') &&
pathClassName,
)}
{...props}
/>
);
}
function getVariants<
V extends { default: T; [key: string]: T },
T extends Record<string, Variants>,
>(animations: V): T {
// eslint-disable-next-line react-hooks/rules-of-hooks
const { animation: animationType, loop, loopDelay } = useAnimateIconContext();
let result: T;
if (animationType in staticAnimations) {
const variant = staticAnimations[animationType as StaticAnimations];
result = {} as T;
for (const key in animations.default) {
if (
(animationType === 'path' || animationType === 'path-loop') &&
key.includes('group')
)
continue;
result[key] = variant as T[Extract<keyof T, string>];
}
} else {
result = (animations[animationType as keyof V] as T) ?? animations.default;
}
if (loop) {
for (const key in result) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const state = result[key] as any;
const transition = state.animate?.transition;
if (!transition) continue;
const hasNestedKeys = Object.values(transition).some(
(v) =>
typeof v === 'object' &&
v !== null &&
('ease' in v || 'duration' in v || 'times' in v),
);
if (hasNestedKeys) {
for (const prop in transition) {
const subTrans = transition[prop];
if (typeof subTrans === 'object' && subTrans !== null) {
transition[prop] = {
...subTrans,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: loopDelay,
};
}
}
} else {
state.animate.transition = {
...transition,
repeat: Infinity,
repeatType: 'loop',
repeatDelay: loopDelay,
};
}
}
}
return result;
}
export {
pathClassName,
staticAnimations,
AnimateIcon,
IconWrapper,
useAnimateIconContext,
getVariants,
type IconProps,
type IconWrapperProps,
type AnimateIconProps,
type AnimateIconContextValue,
};