mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 15:46:37 +08:00
wavelet init
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user