wavelet init

This commit is contained in:
ryan
2026-06-18 15:24:48 +08:00
parent d6a7011885
commit 99738bbc17
714 changed files with 139987 additions and 0 deletions
@@ -0,0 +1,96 @@
'use client';
import * as React from 'react';
import {type HTMLMotionProps, isMotionComponent, motion} from 'motion/react';
import {cn} from '@/lib/utils';
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
HTMLMotionProps<keyof HTMLElementTagNameMap>,
'ref'
> & { ref?: React.Ref<T> };
type WithAsChild<Base extends object> =
| (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
children?: any;
} & DOMMotionProps<T>;
function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === 'function') {
ref(node);
} else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(
childProps: AnyProps,
slotProps: DOMMotionProps<T>,
): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(
childProps.className as string,
slotProps.className as string,
);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion =
typeof children.type === 'object' &&
children.type !== null &&
isMotionComponent(children.type);
const Base = React.useMemo(
() =>
isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType),
[isAlreadyMotion, children.type],
);
if (!React.isValidElement(children)) return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return (
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
export {
Slot,
type SlotProps,
type WithAsChild,
type DOMMotionProps,
type AnyProps,
};
@@ -0,0 +1,32 @@
'use client';
import * as React from 'react';
import {type HTMLMotionProps, motion} from 'motion/react';
import {Slot, type WithAsChild} from '@/components/animate-ui/primitives/animate/slot';
type ButtonProps = WithAsChild<
HTMLMotionProps<'button'> & {
hoverScale?: number;
tapScale?: number;
}
>;
function Button({
hoverScale = 1.05,
tapScale = 0.95,
asChild = false,
...props
}: ButtonProps) {
const Component = asChild ? Slot : motion.button;
return (
<Component
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
{...props}
/>
);
}
export { Button, type ButtonProps };
@@ -0,0 +1,144 @@
'use client';
import * as React from 'react';
import {type HTMLMotionProps, motion} from 'motion/react';
import {getStrictContext} from '@/lib/utils/get-strict-context';
import {Slot, type WithAsChild} from '@/components/animate-ui/primitives/animate/slot';
type Ripple = {
id: number;
x: number;
y: number;
};
type RippleButtonContextType = {
ripples: Ripple[];
setRipples: (ripples: Ripple[]) => void;
};
const [RippleButtonProvider, useRippleButton] =
getStrictContext<RippleButtonContextType>('RippleButtonContext');
type RippleButtonProps = WithAsChild<
HTMLMotionProps<'button'> & {
hoverScale?: number;
tapScale?: number;
}
>;
function RippleButton({
ref,
onClick,
hoverScale = 1,
tapScale = 0.95,
asChild = false,
style,
...props
}: RippleButtonProps) {
const [ripples, setRipples] = React.useState<Ripple[]>([]);
const buttonRef = React.useRef<HTMLButtonElement>(null);
React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
const createRipple = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
const button = buttonRef.current;
if (!button) return;
const rect = button.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
const newRipple: Ripple = {
id: Date.now(),
x,
y,
};
setRipples((prev) => [...prev, newRipple]);
setTimeout(() => {
setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
}, 600);
},
[],
);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
createRipple(event);
if (onClick) {
onClick(event);
}
},
[createRipple, onClick],
);
const Component = asChild ? Slot : motion.button;
return (
<RippleButtonProvider value={{ ripples, setRipples }}>
<Component
ref={buttonRef}
data-slot="ripple-button"
onClick={handleClick}
whileTap={{ scale: tapScale }}
whileHover={{ scale: hoverScale }}
style={{
position: 'relative',
overflow: 'hidden',
...style,
}}
{...props}
/>
</RippleButtonProvider>
);
}
type RippleButtonRipplesProps = WithAsChild<
HTMLMotionProps<'span'> & {
color?: string;
scale?: number;
}
>;
function RippleButtonRipples({
color = 'var(--ripple-button-ripple-color)',
scale = 10,
transition = { duration: 0.6, ease: 'easeOut' },
asChild = false,
style,
...props
}: RippleButtonRipplesProps) {
const { ripples } = useRippleButton();
const Component = asChild ? Slot : motion.span;
return ripples.map((ripple) => (
<Component
key={ripple.id}
initial={{ scale: 0, opacity: 0.5 }}
animate={{ scale, opacity: 0 }}
transition={transition}
style={{
position: 'absolute',
borderRadius: '50%',
pointerEvents: 'none',
width: '20px',
height: '20px',
backgroundColor: color,
top: ripple.y - 10,
left: ripple.x - 10,
...style,
}}
{...props}
/>
));
}
export {
RippleButton,
RippleButtonRipples,
type RippleButtonProps,
type RippleButtonRipplesProps,
};
@@ -0,0 +1,110 @@
'use client';
import * as React from 'react';
import {Collapsible as CollapsiblePrimitive} from 'radix-ui';
import {AnimatePresence, type HTMLMotionProps, motion} from 'motion/react';
import {getStrictContext} from '@/lib/utils/get-strict-context';
import {useControlledState} from '@/hooks/use-controlled-state';
type CollapsibleContextType = {
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
};
const [CollapsibleProvider, useCollapsible] =
getStrictContext<CollapsibleContextType>('CollapsibleContext');
type CollapsibleProps = React.ComponentProps<typeof CollapsiblePrimitive.Root>;
function Collapsible(props: CollapsibleProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<CollapsibleProvider value={{ isOpen, setIsOpen }}>
<CollapsiblePrimitive.Root
data-slot="collapsible"
{...props}
onOpenChange={setIsOpen}
/>
</CollapsibleProvider>
);
}
type CollapsibleTriggerProps = React.ComponentProps<
typeof CollapsiblePrimitive.Trigger
>;
function CollapsibleTrigger(props: CollapsibleTriggerProps) {
return (
<CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
);
}
type CollapsibleContentProps = Omit<
React.ComponentProps<typeof CollapsiblePrimitive.Content>,
'asChild' | 'forceMount'
> &
HTMLMotionProps<'div'> & {
keepRendered?: boolean;
};
function CollapsibleContent({
keepRendered = false,
transition = { duration: 0.35, ease: 'easeInOut' },
...props
}: CollapsibleContentProps) {
const { isOpen } = useCollapsible();
return (
<AnimatePresence>
{keepRendered ? (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
layout
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
animate={
isOpen
? { opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }
: { opacity: 0, height: 0, overflow: 'hidden', y: 20 }
}
transition={transition}
{...props}
/>
</CollapsiblePrimitive.Content>
) : (
isOpen && (
<CollapsiblePrimitive.Content asChild forceMount>
<motion.div
key="collapsible-content"
data-slot="collapsible-content"
layout
initial={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
animate={{ opacity: 1, height: 'auto', overflow: 'hidden', y: 0 }}
exit={{ opacity: 0, height: 0, overflow: 'hidden', y: 20 }}
transition={transition}
{...props}
/>
</CollapsiblePrimitive.Content>
)
)}
</AnimatePresence>
);
}
export {
Collapsible,
CollapsibleTrigger,
CollapsibleContent,
useCollapsible,
type CollapsibleProps,
type CollapsibleTriggerProps,
type CollapsibleContentProps,
type CollapsibleContextType,
};
@@ -0,0 +1,133 @@
'use client';
import * as React from 'react';
import {type SpringOptions, useMotionValue, useSpring} from 'motion/react';
import {useIsInView, type UseIsInViewOptions,} from '@/hooks/use-is-in-view';
type CountingNumberProps = Omit<React.ComponentProps<'span'>, 'children'> & {
number: number;
fromNumber?: number;
padStart?: boolean;
decimalSeparator?: string;
decimalPlaces?: number;
transition?: SpringOptions;
delay?: number;
initiallyStable?: boolean;
thousandSeparator?: string;
} & UseIsInViewOptions;
function CountingNumber({
ref,
number,
fromNumber = 0,
padStart = false,
inView = false,
inViewMargin = '0px',
inViewOnce = true,
decimalSeparator = '.',
transition = { stiffness: 200, damping: 30 },
decimalPlaces = 0,
delay = 0,
initiallyStable = false,
thousandSeparator = '',
...props
}: CountingNumberProps) {
const { ref: localRef, isInView } = useIsInView(
ref as React.Ref<HTMLElement>,
{
inView,
inViewOnce,
inViewMargin,
},
);
const numberStr = number.toString();
const decimals =
typeof decimalPlaces === 'number'
? decimalPlaces
: numberStr.includes('.')
? (numberStr.split('.')[1]?.length ?? 0)
: 0;
const motionVal = useMotionValue(initiallyStable ? number : fromNumber);
const springVal = useSpring(motionVal, transition);
React.useEffect(() => {
const timeoutId = setTimeout(() => {
if (isInView) motionVal.set(number);
}, delay);
return () => clearTimeout(timeoutId);
}, [isInView, number, motionVal, delay]);
React.useEffect(() => {
const unsubscribe = springVal.on('change', (latest) => {
if (localRef.current) {
let formatted =
decimals > 0
? latest.toFixed(decimals)
: Math.round(latest).toString();
if (decimals > 0) {
formatted = formatted.replace('.', decimalSeparator);
}
if (padStart) {
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const [intPart, fracPart] = formatted.split(decimalSeparator);
const paddedInt = intPart?.padStart(finalIntLength, '0') ?? '';
formatted = fracPart
? `${ paddedInt }${ decimalSeparator }${ fracPart }`
: paddedInt;
}
if (thousandSeparator) {
const [intPart, fracPart] = formatted.split(decimalSeparator);
const regex = /\B(?=(\d{3})+(?!\d))/g;
const separatedInt = intPart.replace(regex, thousandSeparator);
formatted = fracPart
? `${ separatedInt }${ decimalSeparator }${ fracPart }`
: separatedInt;
}
localRef.current.textContent = formatted;
}
});
return () => unsubscribe();
}, [springVal, decimals, padStart, number, decimalSeparator, localRef, thousandSeparator]);
const finalIntLength = Math.floor(Math.abs(number)).toString().length;
const formatValue = (val: number) => {
let out = decimals > 0 ? val.toFixed(decimals) : Math.round(val).toString();
if (decimals > 0) out = out.replace('.', decimalSeparator);
if (padStart) {
const [intPart, fracPart] = out.split(decimalSeparator);
const paddedInt = (intPart ?? '').padStart(finalIntLength, '0');
out = fracPart ? `${ paddedInt }${ decimalSeparator }${ fracPart }` : paddedInt;
}
if (thousandSeparator) {
const [intPart, fracPart] = out.split(decimalSeparator);
const regex = /\B(?=(\d{3})+(?!\d))/g;
const separatedInt = intPart.replace(regex, thousandSeparator);
out = fracPart ? `${ separatedInt }${ decimalSeparator }${ fracPart }` : separatedInt;
}
return out;
};
const zeroText = padStart
? '0'.padStart(finalIntLength, '0') +
(decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '')
: '0' + (decimals > 0 ? decimalSeparator + '0'.repeat(decimals) : '');
const initialText = initiallyStable ? formatValue(number) : zeroText;
return (
<span ref={localRef} data-slot="counting-number" {...props}>
{initialText}
</span>
);
}
export { CountingNumber, type CountingNumberProps };