360 lines
8.6 KiB
TypeScript
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,
|
|
};
|