mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 03:59:41 +00:00
feat(design): animated design system foundation with animate-ui and motion
Install motion + animate-ui, overhaul design tokens with brand cyan accent, and replace CSS keyframe animations in Dialog, Tabs, Switch, and Tooltip with spring-physics and blur-fade transitions via animate-ui Radix primitives.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, isMotionComponent, type HTMLMotionProps } 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,55 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
motion,
|
||||
type HTMLMotionProps,
|
||||
type LegacyAnimationControls,
|
||||
type TargetAndTransition,
|
||||
type Transition,
|
||||
} from 'motion/react';
|
||||
|
||||
import { useAutoHeight } from '@/hooks/use-auto-height';
|
||||
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
|
||||
|
||||
type AutoHeightProps = WithAsChild<
|
||||
{
|
||||
children: React.ReactNode;
|
||||
deps?: React.DependencyList;
|
||||
animate?: TargetAndTransition | LegacyAnimationControls;
|
||||
transition?: Transition;
|
||||
} & Omit<HTMLMotionProps<'div'>, 'animate'>
|
||||
>;
|
||||
|
||||
function AutoHeight({
|
||||
children,
|
||||
deps = [],
|
||||
transition = {
|
||||
type: 'spring',
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
bounce: 0,
|
||||
restDelta: 0.01,
|
||||
},
|
||||
style,
|
||||
animate,
|
||||
asChild = false,
|
||||
...props
|
||||
}: AutoHeightProps) {
|
||||
const { ref, height } = useAutoHeight<HTMLDivElement>(deps);
|
||||
|
||||
const Comp = asChild ? Slot : motion.div;
|
||||
|
||||
return (
|
||||
<Comp
|
||||
style={{ overflow: 'hidden', ...style }}
|
||||
animate={{ height, ...animate }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
>
|
||||
<div ref={ref}>{children}</div>
|
||||
</Comp>
|
||||
);
|
||||
}
|
||||
|
||||
export { AutoHeight, type AutoHeightProps };
|
||||
@@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import {
|
||||
useIsInView,
|
||||
type UseIsInViewOptions,
|
||||
} from '@/hooks/use-is-in-view';
|
||||
import { Slot, type WithAsChild } from '@/components/animate-ui/primitives/animate/slot';
|
||||
|
||||
type FadeProps = WithAsChild<
|
||||
{
|
||||
children?: React.ReactNode;
|
||||
delay?: number;
|
||||
initialOpacity?: number;
|
||||
opacity?: number;
|
||||
ref?: React.Ref<HTMLElement>;
|
||||
} & UseIsInViewOptions &
|
||||
HTMLMotionProps<'div'>
|
||||
>;
|
||||
|
||||
function Fade({
|
||||
ref,
|
||||
transition = { type: 'spring', stiffness: 200, damping: 20 },
|
||||
delay = 0,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
initialOpacity = 0,
|
||||
opacity = 1,
|
||||
asChild = false,
|
||||
...props
|
||||
}: FadeProps) {
|
||||
const { ref: localRef, isInView } = useIsInView(
|
||||
ref as React.Ref<HTMLElement>,
|
||||
{
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
},
|
||||
);
|
||||
|
||||
const Component = asChild ? Slot : motion.div;
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={localRef as React.Ref<HTMLDivElement>}
|
||||
initial="hidden"
|
||||
animate={isInView ? 'visible' : 'hidden'}
|
||||
exit="hidden"
|
||||
variants={{
|
||||
hidden: { opacity: initialOpacity },
|
||||
visible: { opacity },
|
||||
}}
|
||||
transition={{
|
||||
...transition,
|
||||
delay: (transition?.delay ?? 0) + delay / 1000,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FadeListProps = Omit<FadeProps, 'children'> & {
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
holdDelay?: number;
|
||||
};
|
||||
|
||||
function Fades({
|
||||
children,
|
||||
delay = 0,
|
||||
holdDelay = 0,
|
||||
...props
|
||||
}: FadeListProps) {
|
||||
const array = React.Children.toArray(children) as React.ReactElement[];
|
||||
|
||||
return (
|
||||
<>
|
||||
{array.map((child, index) => (
|
||||
<Fade
|
||||
key={child.key ?? index}
|
||||
delay={delay + index * holdDelay}
|
||||
{...props}
|
||||
>
|
||||
{child}
|
||||
</Fade>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { Fade, Fades, type FadeProps, type FadeListProps };
|
||||
@@ -0,0 +1,640 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { AnimatePresence, motion, type Transition } from 'motion/react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type HighlightMode = 'children' | 'parent';
|
||||
|
||||
type Bounds = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
const DEFAULT_BOUNDS_OFFSET: Bounds = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
type HighlightContextType<T extends string> = {
|
||||
as?: keyof HTMLElementTagNameMap;
|
||||
mode: HighlightMode;
|
||||
activeValue: T | null;
|
||||
setActiveValue: (value: T | null) => void;
|
||||
setBounds: (bounds: DOMRect) => void;
|
||||
clearBounds: () => void;
|
||||
id: string;
|
||||
hover: boolean;
|
||||
click: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
activeClassName?: string;
|
||||
setActiveClassName: (className: string) => void;
|
||||
transition?: Transition;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
exitDelay?: number;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
|
||||
const HighlightContext = React.createContext<
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
HighlightContextType<any> | undefined
|
||||
>(undefined);
|
||||
|
||||
function useHighlight<T extends string>(): HighlightContextType<T> {
|
||||
const context = React.useContext(HighlightContext);
|
||||
if (!context) {
|
||||
throw new Error('useHighlight must be used within a HighlightProvider');
|
||||
}
|
||||
return context as unknown as HighlightContextType<T>;
|
||||
}
|
||||
|
||||
type BaseHighlightProps<T extends React.ElementType = 'div'> = {
|
||||
as?: T;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
mode?: HighlightMode;
|
||||
value?: string | null;
|
||||
defaultValue?: string | null;
|
||||
onValueChange?: (value: string | null) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
transition?: Transition;
|
||||
hover?: boolean;
|
||||
click?: boolean;
|
||||
disabled?: boolean;
|
||||
enabled?: boolean;
|
||||
exitDelay?: number;
|
||||
};
|
||||
|
||||
type ParentModeHighlightProps = {
|
||||
boundsOffset?: Partial<Bounds>;
|
||||
containerClassName?: string;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
|
||||
type ControlledParentModeHighlightProps<T extends React.ElementType = 'div'> =
|
||||
BaseHighlightProps<T> &
|
||||
ParentModeHighlightProps & {
|
||||
mode: 'parent';
|
||||
controlledItems: true;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type ControlledChildrenModeHighlightProps<T extends React.ElementType = 'div'> =
|
||||
BaseHighlightProps<T> & {
|
||||
mode?: 'children' | undefined;
|
||||
controlledItems: true;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type UncontrolledParentModeHighlightProps<T extends React.ElementType = 'div'> =
|
||||
BaseHighlightProps<T> &
|
||||
ParentModeHighlightProps & {
|
||||
mode: 'parent';
|
||||
controlledItems?: false;
|
||||
itemsClassName?: string;
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
};
|
||||
|
||||
type UncontrolledChildrenModeHighlightProps<
|
||||
T extends React.ElementType = 'div',
|
||||
> = BaseHighlightProps<T> & {
|
||||
mode?: 'children';
|
||||
controlledItems?: false;
|
||||
itemsClassName?: string;
|
||||
children: React.ReactElement | React.ReactElement[];
|
||||
};
|
||||
|
||||
type HighlightProps<T extends React.ElementType = 'div'> =
|
||||
| ControlledParentModeHighlightProps<T>
|
||||
| ControlledChildrenModeHighlightProps<T>
|
||||
| UncontrolledParentModeHighlightProps<T>
|
||||
| UncontrolledChildrenModeHighlightProps<T>;
|
||||
|
||||
function Highlight<T extends React.ElementType = 'div'>({
|
||||
ref,
|
||||
...props
|
||||
}: HighlightProps<T>) {
|
||||
const {
|
||||
as: Component = 'div',
|
||||
children,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
className,
|
||||
style,
|
||||
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
||||
hover = false,
|
||||
click = true,
|
||||
enabled = true,
|
||||
controlledItems,
|
||||
disabled = false,
|
||||
exitDelay = 200,
|
||||
mode = 'children',
|
||||
} = props;
|
||||
|
||||
const localRef = React.useRef<HTMLDivElement>(null);
|
||||
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
|
||||
|
||||
const propsBoundsOffset = (props as ParentModeHighlightProps)?.boundsOffset;
|
||||
const boundsOffset = propsBoundsOffset ?? DEFAULT_BOUNDS_OFFSET;
|
||||
const boundsOffsetTop = boundsOffset.top ?? 0;
|
||||
const boundsOffsetLeft = boundsOffset.left ?? 0;
|
||||
const boundsOffsetWidth = boundsOffset.width ?? 0;
|
||||
const boundsOffsetHeight = boundsOffset.height ?? 0;
|
||||
|
||||
const boundsOffsetRef = React.useRef({
|
||||
top: boundsOffsetTop,
|
||||
left: boundsOffsetLeft,
|
||||
width: boundsOffsetWidth,
|
||||
height: boundsOffsetHeight,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
boundsOffsetRef.current = {
|
||||
top: boundsOffsetTop,
|
||||
left: boundsOffsetLeft,
|
||||
width: boundsOffsetWidth,
|
||||
height: boundsOffsetHeight,
|
||||
};
|
||||
}, [
|
||||
boundsOffsetTop,
|
||||
boundsOffsetLeft,
|
||||
boundsOffsetWidth,
|
||||
boundsOffsetHeight,
|
||||
]);
|
||||
|
||||
const [activeValue, setActiveValue] = React.useState<string | null>(
|
||||
value ?? defaultValue ?? null,
|
||||
);
|
||||
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null);
|
||||
const [activeClassNameState, setActiveClassNameState] =
|
||||
React.useState<string>('');
|
||||
|
||||
const safeSetActiveValue = (id: string | null) => {
|
||||
setActiveValue((prev) => {
|
||||
if (prev !== id) {
|
||||
onValueChange?.(id);
|
||||
return id;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const safeSetBoundsRef = React.useRef<
|
||||
((bounds: DOMRect) => void) | undefined
|
||||
>(undefined);
|
||||
|
||||
React.useEffect(() => {
|
||||
safeSetBoundsRef.current = (bounds: DOMRect) => {
|
||||
if (!localRef.current) return;
|
||||
|
||||
const containerRect = localRef.current.getBoundingClientRect();
|
||||
const offset = boundsOffsetRef.current;
|
||||
const newBounds: Bounds = {
|
||||
top: bounds.top - containerRect.top + offset.top,
|
||||
left: bounds.left - containerRect.left + offset.left,
|
||||
width: bounds.width + offset.width,
|
||||
height: bounds.height + offset.height,
|
||||
};
|
||||
|
||||
setBoundsState((prev) => {
|
||||
if (
|
||||
prev &&
|
||||
prev.top === newBounds.top &&
|
||||
prev.left === newBounds.left &&
|
||||
prev.width === newBounds.width &&
|
||||
prev.height === newBounds.height
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return newBounds;
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
const safeSetBounds = (bounds: DOMRect) => {
|
||||
safeSetBoundsRef.current?.(bounds);
|
||||
};
|
||||
|
||||
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)
|
||||
safeSetBoundsRef.current?.(activeEl.getBoundingClientRect());
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => container.removeEventListener('scroll', onScroll);
|
||||
}, [mode, activeValue]);
|
||||
|
||||
const render = (children: React.ReactNode) => {
|
||||
if (mode === 'parent') {
|
||||
return (
|
||||
<Component
|
||||
ref={localRef}
|
||||
data-slot="motion-highlight-container"
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
className={(props as ParentModeHighlightProps)?.containerClassName}
|
||||
>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{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) / 1000,
|
||||
},
|
||||
}}
|
||||
transition={transition}
|
||||
style={{ position: 'absolute', zIndex: 0, ...style }}
|
||||
className={cn(className, activeClassNameState)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{children}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
return (
|
||||
<HighlightContext.Provider
|
||||
value={{
|
||||
mode,
|
||||
activeValue,
|
||||
setActiveValue: safeSetActiveValue,
|
||||
id,
|
||||
hover,
|
||||
click,
|
||||
className,
|
||||
style,
|
||||
transition,
|
||||
disabled,
|
||||
enabled,
|
||||
exitDelay,
|
||||
setBounds: safeSetBounds,
|
||||
clearBounds,
|
||||
activeClassName: activeClassNameState,
|
||||
setActiveClassName: setActiveClassNameState,
|
||||
forceUpdateBounds: (props as ParentModeHighlightProps)
|
||||
?.forceUpdateBounds,
|
||||
}}
|
||||
>
|
||||
{enabled
|
||||
? controlledItems
|
||||
? render(children)
|
||||
: render(
|
||||
React.Children.map(children, (child, index) => (
|
||||
<HighlightItem key={index} className={props?.itemsClassName}>
|
||||
{child}
|
||||
</HighlightItem>
|
||||
)),
|
||||
)
|
||||
: children}
|
||||
</HighlightContext.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 HighlightItemProps<T extends React.ElementType = 'div'> =
|
||||
React.ComponentProps<T> & {
|
||||
as?: T;
|
||||
children: React.ReactElement;
|
||||
id?: string;
|
||||
value?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
transition?: Transition;
|
||||
activeClassName?: string;
|
||||
disabled?: boolean;
|
||||
exitDelay?: number;
|
||||
asChild?: boolean;
|
||||
forceUpdateBounds?: boolean;
|
||||
};
|
||||
|
||||
function HighlightItem<T extends React.ElementType>({
|
||||
ref,
|
||||
as,
|
||||
children,
|
||||
id,
|
||||
value,
|
||||
className,
|
||||
style,
|
||||
transition,
|
||||
disabled = false,
|
||||
activeClassName,
|
||||
exitDelay,
|
||||
asChild = false,
|
||||
forceUpdateBounds,
|
||||
...props
|
||||
}: HighlightItemProps<T>) {
|
||||
const itemId = React.useId();
|
||||
const {
|
||||
activeValue,
|
||||
setActiveValue,
|
||||
mode,
|
||||
setBounds,
|
||||
clearBounds,
|
||||
hover,
|
||||
click,
|
||||
enabled,
|
||||
className: contextClassName,
|
||||
style: contextStyle,
|
||||
transition: contextTransition,
|
||||
id: contextId,
|
||||
disabled: contextDisabled,
|
||||
exitDelay: contextExitDelay,
|
||||
forceUpdateBounds: contextForceUpdateBounds,
|
||||
setActiveClassName,
|
||||
} = useHighlight();
|
||||
|
||||
const Component = as ?? 'div';
|
||||
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);
|
||||
|
||||
const refCallback = React.useCallback((node: HTMLElement | null) => {
|
||||
localRef.current = node 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);
|
||||
},
|
||||
}
|
||||
: click
|
||||
? {
|
||||
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
setActiveValue(childValue);
|
||||
element.props.onClick?.(e);
|
||||
},
|
||||
}
|
||||
: {};
|
||||
|
||||
if (asChild) {
|
||||
if (mode === 'children') {
|
||||
return React.cloneElement(
|
||||
element,
|
||||
{
|
||||
key: childValue,
|
||||
ref: refCallback,
|
||||
className: cn('relative', element.props.className),
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item-container',
|
||||
}),
|
||||
...commonHandlers,
|
||||
...props,
|
||||
},
|
||||
<>
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot="motion-highlight"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 0,
|
||||
...contextStyle,
|
||||
...style,
|
||||
}}
|
||||
className={cn(contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay:
|
||||
(itemTransition?.delay ?? 0) +
|
||||
(exitDelay ?? contextExitDelay ?? 0) / 1000,
|
||||
},
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<Component
|
||||
data-slot="motion-highlight-item"
|
||||
style={{ position: 'relative', zIndex: 1 }}
|
||||
className={className}
|
||||
{...dataAttributes}
|
||||
>
|
||||
{children}
|
||||
</Component>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
||||
return React.cloneElement(element, {
|
||||
ref: refCallback,
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item',
|
||||
}),
|
||||
...commonHandlers,
|
||||
});
|
||||
}
|
||||
|
||||
return enabled ? (
|
||||
<Component
|
||||
key={childValue}
|
||||
ref={localRef}
|
||||
data-slot="motion-highlight-item-container"
|
||||
className={cn(mode === 'children' && 'relative', className)}
|
||||
{...dataAttributes}
|
||||
{...props}
|
||||
{...commonHandlers}
|
||||
>
|
||||
{mode === 'children' && (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isActive && !isDisabled && (
|
||||
<motion.div
|
||||
layoutId={`transition-background-${contextId}`}
|
||||
data-slot="motion-highlight"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 0,
|
||||
...contextStyle,
|
||||
...style,
|
||||
}}
|
||||
className={cn(contextClassName, activeClassName)}
|
||||
transition={itemTransition}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transition: {
|
||||
...itemTransition,
|
||||
delay:
|
||||
(itemTransition?.delay ?? 0) +
|
||||
(exitDelay ?? contextExitDelay ?? 0) / 1000,
|
||||
},
|
||||
}}
|
||||
{...dataAttributes}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
{React.cloneElement(element, {
|
||||
style: { position: 'relative', zIndex: 1 },
|
||||
className: element.props.className,
|
||||
...getNonOverridingDataAttributes(element, {
|
||||
...dataAttributes,
|
||||
'data-slot': 'motion-highlight-item',
|
||||
}),
|
||||
})}
|
||||
</Component>
|
||||
) : (
|
||||
children
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Highlight,
|
||||
HighlightItem,
|
||||
useHighlight,
|
||||
type HighlightProps,
|
||||
type HighlightItemProps,
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
|
||||
type DialogContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: DialogProps['onOpenChange'];
|
||||
};
|
||||
|
||||
const [DialogProvider, useDialog] =
|
||||
getStrictContext<DialogContextType>('DialogContext');
|
||||
|
||||
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DialogProvider value={{ isOpen, setIsOpen }}>
|
||||
<DialogPrimitive.Root
|
||||
data-slot="dialog"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DialogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
|
||||
|
||||
function DialogTrigger(props: DialogTriggerProps) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type DialogPortalProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function DialogPortal(props: DialogPortalProps) {
|
||||
const { isOpen } = useDialog();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DialogPrimitive.Portal
|
||||
data-slot="dialog-portal"
|
||||
forceMount
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogOverlayProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Overlay>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DialogOverlay({
|
||||
transition = { duration: 0.2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: DialogOverlayProps) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
|
||||
<motion.div
|
||||
key="dialog-overlay"
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogFlipDirection = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
type DialogContentProps = Omit<
|
||||
React.ComponentProps<typeof DialogPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
from?: DialogFlipDirection;
|
||||
};
|
||||
|
||||
function DialogContent({
|
||||
from = 'top',
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onInteractOutside,
|
||||
transition = { type: 'spring', stiffness: 150, damping: 25 },
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
const initialRotation =
|
||||
from === 'bottom' || from === 'left' ? '20deg' : '-20deg';
|
||||
const isVertical = from === 'top' || from === 'bottom';
|
||||
const rotateAxis = isVertical ? 'rotateX' : 'rotateY';
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="dialog-content"
|
||||
data-slot="dialog-content"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
filter: 'blur(4px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
filter: 'blur(4px)',
|
||||
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
|
||||
}}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</DialogPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
|
||||
|
||||
function DialogClose(props: DialogCloseProps) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
type DialogHeaderProps = React.ComponentProps<'div'>;
|
||||
|
||||
function DialogHeader(props: DialogHeaderProps) {
|
||||
return <div data-slot="dialog-header" {...props} />;
|
||||
}
|
||||
|
||||
type DialogFooterProps = React.ComponentProps<'div'>;
|
||||
|
||||
function DialogFooter(props: DialogFooterProps) {
|
||||
return <div data-slot="dialog-footer" {...props} />;
|
||||
}
|
||||
|
||||
type DialogTitleProps = React.ComponentProps<typeof DialogPrimitive.Title>;
|
||||
|
||||
function DialogTitle(props: DialogTitleProps) {
|
||||
return <DialogPrimitive.Title data-slot="dialog-title" {...props} />;
|
||||
}
|
||||
|
||||
type DialogDescriptionProps = React.ComponentProps<
|
||||
typeof DialogPrimitive.Description
|
||||
>;
|
||||
|
||||
function DialogDescription(props: DialogDescriptionProps) {
|
||||
return (
|
||||
<DialogPrimitive.Description data-slot="dialog-description" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
useDialog,
|
||||
type DialogProps,
|
||||
type DialogTriggerProps,
|
||||
type DialogPortalProps,
|
||||
type DialogCloseProps,
|
||||
type DialogOverlayProps,
|
||||
type DialogContentProps,
|
||||
type DialogHeaderProps,
|
||||
type DialogFooterProps,
|
||||
type DialogTitleProps,
|
||||
type DialogDescriptionProps,
|
||||
type DialogContextType,
|
||||
type DialogFlipDirection,
|
||||
};
|
||||
@@ -0,0 +1,563 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import {
|
||||
Highlight,
|
||||
HighlightItem,
|
||||
type HighlightItemProps,
|
||||
type HighlightProps,
|
||||
} from '@/components/animate-ui/primitives/effects/highlight';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import { useDataState } from '@/hooks/use-data-state';
|
||||
|
||||
type DropdownMenuContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (o: boolean) => void;
|
||||
highlightedValue: string | null;
|
||||
setHighlightedValue: (value: string | null) => void;
|
||||
};
|
||||
|
||||
type DropdownMenuSubContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (o: boolean) => void;
|
||||
};
|
||||
|
||||
const [DropdownMenuProvider, useDropdownMenu] =
|
||||
getStrictContext<DropdownMenuContextType>('DropdownMenuContext');
|
||||
|
||||
const [DropdownMenuSubProvider, useDropdownMenuSub] =
|
||||
getStrictContext<DropdownMenuSubContextType>('DropdownMenuSubContext');
|
||||
|
||||
type DropdownMenuProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Root
|
||||
>;
|
||||
|
||||
function DropdownMenu(props: DropdownMenuProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
const [highlightedValue, setHighlightedValue] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuProvider
|
||||
value={{ isOpen, setIsOpen, highlightedValue, setHighlightedValue }}
|
||||
>
|
||||
<DropdownMenuPrimitive.Root
|
||||
data-slot="dropdown-menu"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DropdownMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuTriggerProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function DropdownMenuTrigger(props: DropdownMenuTriggerProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuPortalProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Portal
|
||||
>;
|
||||
|
||||
function DropdownMenuPortal(props: DropdownMenuPortalProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuGroupProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Group
|
||||
>;
|
||||
|
||||
function DropdownMenuGroup(props: DropdownMenuGroupProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Sub
|
||||
>;
|
||||
|
||||
function DropdownMenuSub(props: DropdownMenuSubProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuSubProvider value={{ isOpen, setIsOpen }}>
|
||||
<DropdownMenuPrimitive.Sub
|
||||
data-slot="dropdown-menu-sub"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</DropdownMenuSubProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioGroupProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.RadioGroup
|
||||
>;
|
||||
|
||||
function DropdownMenuRadioGroup(props: DropdownMenuRadioGroupProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubTriggerProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
disabled,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuSubTriggerProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={highlightedRef}
|
||||
disabled={disabled}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSubContentProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
|
||||
'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
loop,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
sideOffset,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { duration: 0.2 },
|
||||
style,
|
||||
container,
|
||||
...props
|
||||
}: DropdownMenuSubContentProps) {
|
||||
const { isOpen } = useDropdownMenuSub();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DropdownMenuPortal forceMount container={container}>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
asChild
|
||||
forceMount
|
||||
loop={loop}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
sideOffset={sideOffset}
|
||||
alignOffset={alignOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="dropdown-menu-sub-content"
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
style={{ willChange: 'opacity, transform', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
</DropdownMenuPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuHighlightProps = Omit<
|
||||
HighlightProps,
|
||||
'controlledItems' | 'enabled' | 'hover'
|
||||
> & {
|
||||
animateOnHover?: boolean;
|
||||
};
|
||||
|
||||
function DropdownMenuHighlight({
|
||||
transition = { type: 'spring', stiffness: 350, damping: 35 },
|
||||
...props
|
||||
}: DropdownMenuHighlightProps) {
|
||||
const { highlightedValue } = useDropdownMenu();
|
||||
|
||||
return (
|
||||
<Highlight
|
||||
data-slot="dropdown-menu-highlight"
|
||||
click={false}
|
||||
controlledItems
|
||||
transition={transition}
|
||||
value={highlightedValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuContentProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Portal>,
|
||||
'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuContent({
|
||||
loop,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
side,
|
||||
sideOffset,
|
||||
align,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { duration: 0.2 },
|
||||
style,
|
||||
container,
|
||||
...props
|
||||
}: DropdownMenuContentProps) {
|
||||
const { isOpen } = useDropdownMenu();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<DropdownMenuPortal forceMount container={container}>
|
||||
<DropdownMenuPrimitive.Content
|
||||
asChild
|
||||
loop={loop}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="dropdown-menu-content"
|
||||
data-slot="dropdown-menu-content"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={transition}
|
||||
style={{ willChange: 'opacity, transform', ...style }}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPortal>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuHighlightItemProps = HighlightItemProps;
|
||||
|
||||
function DropdownMenuHighlightItem(props: DropdownMenuHighlightItemProps) {
|
||||
return <HighlightItem data-slot="dropdown-menu-highlight-item" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuItemProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.Item>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuItem({
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuItemProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={highlightedRef}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-item"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuCheckboxItemProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
checked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuCheckboxItemProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={highlightedRef}
|
||||
checked={checked}
|
||||
onCheckedChange={onCheckedChange}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuRadioItemProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
value,
|
||||
disabled,
|
||||
onSelect,
|
||||
textValue,
|
||||
...props
|
||||
}: DropdownMenuRadioItemProps) {
|
||||
const { setHighlightedValue } = useDropdownMenu();
|
||||
const [, highlightedRef] = useDataState<HTMLDivElement>(
|
||||
'highlighted',
|
||||
undefined,
|
||||
(value) => {
|
||||
if (value === true) {
|
||||
const el = highlightedRef.current;
|
||||
const v = el?.dataset.value || el?.id || null;
|
||||
if (v) setHighlightedValue(v);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={highlightedRef}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onSelect={onSelect}
|
||||
textValue={textValue}
|
||||
asChild
|
||||
>
|
||||
<motion.div
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuLabelProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Label
|
||||
>;
|
||||
|
||||
function DropdownMenuLabel(props: DropdownMenuLabelProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label data-slot="dropdown-menu-label" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuSeparatorProps = React.ComponentProps<
|
||||
typeof DropdownMenuPrimitive.Separator
|
||||
>;
|
||||
|
||||
function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type DropdownMenuShortcutProps = React.ComponentProps<'span'>;
|
||||
|
||||
function DropdownMenuShortcut(props: DropdownMenuShortcutProps) {
|
||||
return <span data-slot="dropdown-menu-shortcut" {...props} />;
|
||||
}
|
||||
|
||||
type DropdownMenuItemIndicatorProps = Omit<
|
||||
React.ComponentProps<typeof DropdownMenuPrimitive.ItemIndicator>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function DropdownMenuItemIndicator(props: DropdownMenuItemIndicatorProps) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.ItemIndicator
|
||||
data-slot="dropdown-menu-item-indicator"
|
||||
asChild
|
||||
>
|
||||
<motion.div {...props} />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuHighlight,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuItemIndicator,
|
||||
DropdownMenuHighlightItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
useDropdownMenu,
|
||||
useDropdownMenuSub,
|
||||
type DropdownMenuProps,
|
||||
type DropdownMenuTriggerProps,
|
||||
type DropdownMenuHighlightProps,
|
||||
type DropdownMenuContentProps,
|
||||
type DropdownMenuItemProps,
|
||||
type DropdownMenuItemIndicatorProps,
|
||||
type DropdownMenuHighlightItemProps,
|
||||
type DropdownMenuCheckboxItemProps,
|
||||
type DropdownMenuRadioItemProps,
|
||||
type DropdownMenuLabelProps,
|
||||
type DropdownMenuSeparatorProps,
|
||||
type DropdownMenuShortcutProps,
|
||||
type DropdownMenuGroupProps,
|
||||
type DropdownMenuPortalProps,
|
||||
type DropdownMenuSubProps,
|
||||
type DropdownMenuSubContentProps,
|
||||
type DropdownMenuSubTriggerProps,
|
||||
type DropdownMenuRadioGroupProps,
|
||||
type DropdownMenuContextType,
|
||||
type DropdownMenuSubContextType,
|
||||
};
|
||||
@@ -0,0 +1,207 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { HoverCard as HoverCardPrimitive } from 'radix-ui';
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
type MotionValue,
|
||||
type HTMLMotionProps,
|
||||
type SpringOptions,
|
||||
} from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type HoverCardContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
x: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
const [HoverCardProvider, useHoverCard] =
|
||||
getStrictContext<HoverCardContextType>('HoverCardContext');
|
||||
|
||||
type HoverCardProps = React.ComponentProps<typeof HoverCardPrimitive.Root> & {
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
function HoverCard({
|
||||
followCursor = false,
|
||||
followCursorSpringOptions = { stiffness: 200, damping: 17 },
|
||||
...props
|
||||
}: HoverCardProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
return (
|
||||
<HoverCardProvider
|
||||
value={{
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
x,
|
||||
y,
|
||||
followCursor,
|
||||
followCursorSpringOptions,
|
||||
}}
|
||||
>
|
||||
<HoverCardPrimitive.Root
|
||||
data-slot="hover-card"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</HoverCardProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardTriggerProps = React.ComponentProps<
|
||||
typeof HoverCardPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function HoverCardTrigger({ onMouseMove, ...props }: HoverCardTriggerProps) {
|
||||
const { x, y, followCursor } = useHoverCard();
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
onMouseMove?.(event);
|
||||
|
||||
const target = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
if (followCursor === 'x' || followCursor === true) {
|
||||
const eventOffsetX = event.clientX - target.left;
|
||||
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
|
||||
x.set(offsetXFromCenter);
|
||||
}
|
||||
|
||||
if (followCursor === 'y' || followCursor === true) {
|
||||
const eventOffsetY = event.clientY - target.top;
|
||||
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
|
||||
y.set(offsetYFromCenter);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger
|
||||
data-slot="hover-card-trigger"
|
||||
onMouseMove={handleMouseMove}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardPortalProps = Omit<
|
||||
React.ComponentProps<typeof HoverCardPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function HoverCardPortal(props: HoverCardPortalProps) {
|
||||
const { isOpen } = useHoverCard();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<HoverCardPrimitive.Portal
|
||||
forceMount
|
||||
data-slot="hover-card-portal"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardContentProps = React.ComponentProps<
|
||||
typeof HoverCardPrimitive.Content
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function HoverCardContent({
|
||||
align,
|
||||
alignOffset,
|
||||
side,
|
||||
sideOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
style,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: HoverCardContentProps) {
|
||||
const { x, y, followCursor, followCursorSpringOptions } = useHoverCard();
|
||||
const translateX = useSpring(x, followCursorSpringOptions);
|
||||
const translateY = useSpring(y, followCursorSpringOptions);
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
>
|
||||
<motion.div
|
||||
key="hover-card-content"
|
||||
data-slot="hover-card-content"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
style={{
|
||||
x:
|
||||
followCursor === 'x' || followCursor === true
|
||||
? translateX
|
||||
: undefined,
|
||||
y:
|
||||
followCursor === 'y' || followCursor === true
|
||||
? translateY
|
||||
: undefined,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type HoverCardArrowProps = React.ComponentProps<
|
||||
typeof HoverCardPrimitive.Arrow
|
||||
>;
|
||||
|
||||
function HoverCardArrow(props: HoverCardArrowProps) {
|
||||
return <HoverCardPrimitive.Arrow data-slot="hover-card-arrow" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
HoverCard,
|
||||
HoverCardTrigger,
|
||||
HoverCardPortal,
|
||||
HoverCardContent,
|
||||
HoverCardArrow,
|
||||
useHoverCard,
|
||||
type HoverCardProps,
|
||||
type HoverCardTriggerProps,
|
||||
type HoverCardPortalProps,
|
||||
type HoverCardContentProps,
|
||||
type HoverCardArrowProps,
|
||||
type HoverCardContextType,
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Popover as PopoverPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type PopoverContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const [PopoverProvider, usePopover] =
|
||||
getStrictContext<PopoverContextType>('PopoverContext');
|
||||
|
||||
type PopoverProps = React.ComponentProps<typeof PopoverPrimitive.Root>;
|
||||
|
||||
function Popover(props: PopoverProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<PopoverProvider value={{ isOpen, setIsOpen }}>
|
||||
<PopoverPrimitive.Root
|
||||
data-slot="popover"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</PopoverProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type PopoverTriggerProps = React.ComponentProps<
|
||||
typeof PopoverPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function PopoverTrigger(props: PopoverTriggerProps) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type PopoverPortalProps = Omit<
|
||||
React.ComponentProps<typeof PopoverPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function PopoverPortal(props: PopoverPortalProps) {
|
||||
const { isOpen } = usePopover();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<PopoverPrimitive.Portal
|
||||
forceMount
|
||||
data-slot="popover-portal"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type PopoverContentProps = Omit<
|
||||
React.ComponentProps<typeof PopoverPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function PopoverContent({
|
||||
onOpenAutoFocus,
|
||||
onCloseAutoFocus,
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
onFocusOutside,
|
||||
onInteractOutside,
|
||||
align,
|
||||
alignOffset,
|
||||
side,
|
||||
sideOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: PopoverContentProps) {
|
||||
return (
|
||||
<PopoverPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
onOpenAutoFocus={onOpenAutoFocus}
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
onInteractOutside={onInteractOutside}
|
||||
onFocusOutside={onFocusOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="popover-content"
|
||||
data-slot="popover-content"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type PopoverAnchorProps = React.ComponentProps<typeof PopoverPrimitive.Anchor>;
|
||||
|
||||
function PopoverAnchor({ ...props }: PopoverAnchorProps) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
type PopoverArrowProps = React.ComponentProps<typeof PopoverPrimitive.Arrow>;
|
||||
|
||||
function PopoverArrow(props: PopoverArrowProps) {
|
||||
return <PopoverPrimitive.Arrow data-slot="popover-arrow" {...props} />;
|
||||
}
|
||||
|
||||
type PopoverCloseProps = React.ComponentProps<typeof PopoverPrimitive.Close>;
|
||||
|
||||
function PopoverClose(props: PopoverCloseProps) {
|
||||
return <PopoverPrimitive.Close data-slot="popover-close" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverPortal,
|
||||
PopoverContent,
|
||||
PopoverAnchor,
|
||||
PopoverClose,
|
||||
PopoverArrow,
|
||||
usePopover,
|
||||
type PopoverProps,
|
||||
type PopoverTriggerProps,
|
||||
type PopoverPortalProps,
|
||||
type PopoverContentProps,
|
||||
type PopoverAnchorProps,
|
||||
type PopoverCloseProps,
|
||||
type PopoverArrowProps,
|
||||
type PopoverContextType,
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Dialog as SheetPrimitive } from 'radix-ui';
|
||||
import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type SheetContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const [SheetProvider, useSheet] =
|
||||
getStrictContext<SheetContextType>('SheetContext');
|
||||
|
||||
type SheetProps = React.ComponentProps<typeof SheetPrimitive.Root>;
|
||||
|
||||
function Sheet(props: SheetProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props.open,
|
||||
defaultValue: props.defaultOpen,
|
||||
onChange: props.onOpenChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<SheetProvider value={{ isOpen, setIsOpen }}>
|
||||
<SheetPrimitive.Root
|
||||
data-slot="sheet"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</SheetProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetTriggerProps = React.ComponentProps<typeof SheetPrimitive.Trigger>;
|
||||
|
||||
function SheetTrigger(props: SheetTriggerProps) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type SheetCloseProps = React.ComponentProps<typeof SheetPrimitive.Close>;
|
||||
|
||||
function SheetClose(props: SheetCloseProps) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
|
||||
type SheetPortalProps = React.ComponentProps<typeof SheetPrimitive.Portal>;
|
||||
|
||||
function SheetPortal(props: SheetPortalProps) {
|
||||
const { isOpen } = useSheet();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<SheetPrimitive.Portal forceMount data-slot="sheet-portal" {...props} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetOverlayProps = Omit<
|
||||
React.ComponentProps<typeof SheetPrimitive.Overlay>,
|
||||
'asChild' | 'forceMount'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function SheetOverlay({
|
||||
transition = { duration: 0.2, ease: 'easeInOut' },
|
||||
...props
|
||||
}: SheetOverlayProps) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay asChild forceMount>
|
||||
<motion.div
|
||||
key="sheet-overlay"
|
||||
data-slot="sheet-overlay"
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</SheetPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
type Side = 'top' | 'bottom' | 'left' | 'right';
|
||||
|
||||
type SheetContentProps = React.ComponentProps<typeof SheetPrimitive.Content> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
side?: Side;
|
||||
};
|
||||
|
||||
function SheetContent({
|
||||
side = 'right',
|
||||
transition = { type: 'spring', stiffness: 150, damping: 22 },
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: SheetContentProps) {
|
||||
const axis = side === 'left' || side === 'right' ? 'x' : 'y';
|
||||
|
||||
const offscreen: Record<Side, { x?: string; y?: string; opacity: number }> = {
|
||||
right: { x: '100%', opacity: 0 },
|
||||
left: { x: '-100%', opacity: 0 },
|
||||
top: { y: '-100%', opacity: 0 },
|
||||
bottom: { y: '100%', opacity: 0 },
|
||||
};
|
||||
|
||||
const positionStyle: Record<Side, React.CSSProperties> = {
|
||||
right: { insetBlock: 0, right: 0 },
|
||||
left: { insetBlock: 0, left: 0 },
|
||||
top: { insetInline: 0, top: 0 },
|
||||
bottom: { insetInline: 0, bottom: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<SheetPrimitive.Content asChild forceMount {...props}>
|
||||
<motion.div
|
||||
key="sheet-content"
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
initial={offscreen[side]}
|
||||
animate={{ [axis]: 0, opacity: 1 }}
|
||||
exit={offscreen[side]}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
...positionStyle[side],
|
||||
...style,
|
||||
}}
|
||||
transition={transition}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</SheetPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetHeaderProps = React.ComponentProps<'div'>;
|
||||
|
||||
function SheetHeader(props: SheetHeaderProps) {
|
||||
return <div data-slot="sheet-header" {...props} />;
|
||||
}
|
||||
|
||||
type SheetFooterProps = React.ComponentProps<'div'>;
|
||||
|
||||
function SheetFooter(props: SheetFooterProps) {
|
||||
return <div data-slot="sheet-footer" {...props} />;
|
||||
}
|
||||
|
||||
type SheetTitleProps = React.ComponentProps<typeof SheetPrimitive.Title>;
|
||||
|
||||
function SheetTitle(props: SheetTitleProps) {
|
||||
return <SheetPrimitive.Title data-slot="sheet-title" {...props} />;
|
||||
}
|
||||
|
||||
type SheetDescriptionProps = React.ComponentProps<
|
||||
typeof SheetPrimitive.Description
|
||||
>;
|
||||
|
||||
function SheetDescription(props: SheetDescriptionProps) {
|
||||
return (
|
||||
<SheetPrimitive.Description data-slot="sheet-description" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
useSheet,
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
type SheetProps,
|
||||
type SheetPortalProps,
|
||||
type SheetOverlayProps,
|
||||
type SheetTriggerProps,
|
||||
type SheetCloseProps,
|
||||
type SheetContentProps,
|
||||
type SheetHeaderProps,
|
||||
type SheetFooterProps,
|
||||
type SheetTitleProps,
|
||||
type SheetDescriptionProps,
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Switch as SwitchPrimitives } from 'radix-ui';
|
||||
import {
|
||||
motion,
|
||||
type TargetAndTransition,
|
||||
type VariantLabels,
|
||||
type HTMLMotionProps,
|
||||
type LegacyAnimationControls,
|
||||
} from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type SwitchContextType = {
|
||||
isChecked: boolean;
|
||||
setIsChecked: (isChecked: boolean) => void;
|
||||
isPressed: boolean;
|
||||
setIsPressed: (isPressed: boolean) => void;
|
||||
};
|
||||
|
||||
const [SwitchProvider, useSwitch] =
|
||||
getStrictContext<SwitchContextType>('SwitchContext');
|
||||
|
||||
type SwitchProps = Omit<
|
||||
React.ComponentProps<typeof SwitchPrimitives.Root>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'button'>;
|
||||
|
||||
function Switch(props: SwitchProps) {
|
||||
// Destructure Radix-only props so they don't leak onto the motion.button DOM element
|
||||
const {
|
||||
checked,
|
||||
defaultChecked,
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
required,
|
||||
name,
|
||||
value,
|
||||
form,
|
||||
...motionProps
|
||||
} = props;
|
||||
|
||||
const [isPressed, setIsPressed] = React.useState(false);
|
||||
const [isChecked, setIsChecked] = useControlledState({
|
||||
value: checked,
|
||||
defaultValue: defaultChecked,
|
||||
onChange: onCheckedChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<SwitchProvider
|
||||
value={{ isChecked, setIsChecked, isPressed, setIsPressed }}
|
||||
>
|
||||
<SwitchPrimitives.Root
|
||||
checked={checked}
|
||||
defaultChecked={defaultChecked}
|
||||
onCheckedChange={setIsChecked}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
name={name}
|
||||
value={value}
|
||||
form={form}
|
||||
asChild
|
||||
>
|
||||
<motion.button
|
||||
data-slot="switch"
|
||||
whileTap="tap"
|
||||
initial={false}
|
||||
onTapStart={() => setIsPressed(true)}
|
||||
onTapCancel={() => setIsPressed(false)}
|
||||
onTap={() => setIsPressed(false)}
|
||||
{...motionProps}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
</SwitchProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type SwitchThumbProps = Omit<
|
||||
React.ComponentProps<typeof SwitchPrimitives.Thumb>,
|
||||
'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'> & {
|
||||
pressedAnimation?:
|
||||
| TargetAndTransition
|
||||
| VariantLabels
|
||||
| boolean
|
||||
| LegacyAnimationControls;
|
||||
};
|
||||
|
||||
function SwitchThumb({
|
||||
pressedAnimation,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: SwitchThumbProps) {
|
||||
const { isPressed } = useSwitch();
|
||||
|
||||
return (
|
||||
<SwitchPrimitives.Thumb asChild>
|
||||
<motion.div
|
||||
data-slot="switch-thumb"
|
||||
whileTap="tab"
|
||||
layout
|
||||
transition={transition}
|
||||
animate={isPressed ? pressedAnimation : undefined}
|
||||
{...props}
|
||||
/>
|
||||
</SwitchPrimitives.Thumb>
|
||||
);
|
||||
}
|
||||
|
||||
type SwitchIconPosition = 'left' | 'right' | 'thumb';
|
||||
|
||||
type SwitchIconProps = HTMLMotionProps<'div'> & {
|
||||
position: SwitchIconPosition;
|
||||
};
|
||||
|
||||
function SwitchIcon({
|
||||
position,
|
||||
transition = { type: 'spring', bounce: 0 },
|
||||
...props
|
||||
}: SwitchIconProps) {
|
||||
const { isChecked } = useSwitch();
|
||||
|
||||
const isAnimated = React.useMemo(() => {
|
||||
if (position === 'right') return !isChecked;
|
||||
if (position === 'left') return isChecked;
|
||||
if (position === 'thumb') return true;
|
||||
return false;
|
||||
}, [position, isChecked]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot={`switch-${position}-icon`}
|
||||
animate={isAnimated ? { scale: 1, opacity: 1 } : { scale: 0, opacity: 0 }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Switch,
|
||||
SwitchThumb,
|
||||
SwitchIcon,
|
||||
useSwitch,
|
||||
type SwitchProps,
|
||||
type SwitchThumbProps,
|
||||
type SwitchIconProps,
|
||||
type SwitchIconPosition,
|
||||
type SwitchContextType,
|
||||
};
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Tabs as TabsPrimitive } from 'radix-ui';
|
||||
import {
|
||||
motion,
|
||||
AnimatePresence,
|
||||
type HTMLMotionProps,
|
||||
type Transition,
|
||||
} from 'motion/react';
|
||||
|
||||
import {
|
||||
Highlight,
|
||||
HighlightItem,
|
||||
type HighlightProps,
|
||||
type HighlightItemProps,
|
||||
} from '@/components/animate-ui/primitives/effects/highlight';
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
import {
|
||||
AutoHeight,
|
||||
type AutoHeightProps,
|
||||
} from '@/components/animate-ui/primitives/effects/auto-height';
|
||||
|
||||
type TabsContextType = {
|
||||
value: string | undefined;
|
||||
setValue: TabsProps['onValueChange'];
|
||||
};
|
||||
|
||||
const [TabsProvider, useTabs] =
|
||||
getStrictContext<TabsContextType>('TabsContext');
|
||||
|
||||
type TabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
|
||||
|
||||
function Tabs(props: TabsProps) {
|
||||
const [value, setValue] = useControlledState({
|
||||
value: props.value,
|
||||
defaultValue: props.defaultValue,
|
||||
onChange: props.onValueChange,
|
||||
});
|
||||
|
||||
return (
|
||||
<TabsProvider value={{ value, setValue }}>
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
{...props}
|
||||
onValueChange={setValue}
|
||||
/>
|
||||
</TabsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsHighlightProps = Omit<HighlightProps, 'controlledItems' | 'value'>;
|
||||
|
||||
function TabsHighlight({
|
||||
transition = { type: 'spring', stiffness: 200, damping: 25 },
|
||||
...props
|
||||
}: TabsHighlightProps) {
|
||||
const { value } = useTabs();
|
||||
|
||||
return (
|
||||
<Highlight
|
||||
data-slot="tabs-highlight"
|
||||
controlledItems
|
||||
value={value}
|
||||
transition={transition}
|
||||
click={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
|
||||
|
||||
function TabsList(props: TabsListProps) {
|
||||
return <TabsPrimitive.List data-slot="tabs-list" {...props} />;
|
||||
}
|
||||
|
||||
type TabsHighlightItemProps = HighlightItemProps & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
function TabsHighlightItem(props: TabsHighlightItemProps) {
|
||||
return <HighlightItem data-slot="tabs-highlight-item" {...props} />;
|
||||
}
|
||||
|
||||
type TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger>;
|
||||
|
||||
function TabsTrigger(props: TabsTriggerProps) {
|
||||
return <TabsPrimitive.Trigger data-slot="tabs-trigger" {...props} />;
|
||||
}
|
||||
|
||||
type TabsContentProps = React.ComponentProps<typeof TabsPrimitive.Content> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function TabsContent({
|
||||
value,
|
||||
forceMount,
|
||||
transition = { duration: 0.5, ease: 'easeInOut' },
|
||||
...props
|
||||
}: TabsContentProps) {
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
<TabsPrimitive.Content asChild forceMount={forceMount} value={value}>
|
||||
<motion.div
|
||||
data-slot="tabs-content"
|
||||
layout
|
||||
layoutDependency={value}
|
||||
initial={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, filter: 'blur(4px)' }}
|
||||
transition={transition}
|
||||
{...props}
|
||||
/>
|
||||
</TabsPrimitive.Content>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsContentsAutoProps = AutoHeightProps & {
|
||||
mode?: 'auto-height';
|
||||
children: React.ReactNode;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
type TabsContentsLayoutProps = Omit<HTMLMotionProps<'div'>, 'transition'> & {
|
||||
mode: 'layout';
|
||||
children: React.ReactNode;
|
||||
transition?: Transition;
|
||||
};
|
||||
|
||||
type TabsContentsProps = TabsContentsAutoProps | TabsContentsLayoutProps;
|
||||
|
||||
const defaultTransition: Transition = {
|
||||
type: 'spring',
|
||||
stiffness: 200,
|
||||
damping: 30,
|
||||
};
|
||||
|
||||
function isAutoMode(props: TabsContentsProps): props is TabsContentsAutoProps {
|
||||
return !('mode' in props) || props.mode === 'auto-height';
|
||||
}
|
||||
|
||||
function TabsContents(props: TabsContentsProps) {
|
||||
const { value } = useTabs();
|
||||
|
||||
if (isAutoMode(props)) {
|
||||
const { transition = defaultTransition, ...autoProps } = props;
|
||||
|
||||
return (
|
||||
<AutoHeight
|
||||
data-slot="tabs-contents"
|
||||
deps={[value]}
|
||||
transition={transition}
|
||||
{...autoProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { transition = defaultTransition, style, ...layoutProps } = props;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
data-slot="tabs-contents"
|
||||
layout="size"
|
||||
layoutDependency={value}
|
||||
style={{ overflow: 'hidden', ...style }}
|
||||
transition={{ layout: transition }}
|
||||
{...layoutProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Tabs,
|
||||
TabsHighlight,
|
||||
TabsHighlightItem,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
TabsContents,
|
||||
type TabsProps,
|
||||
type TabsHighlightProps,
|
||||
type TabsHighlightItemProps,
|
||||
type TabsListProps,
|
||||
type TabsTriggerProps,
|
||||
type TabsContentProps,
|
||||
type TabsContentsProps,
|
||||
};
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Tooltip as TooltipPrimitive } from 'radix-ui';
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
type SpringOptions,
|
||||
type HTMLMotionProps,
|
||||
type MotionValue,
|
||||
} from 'motion/react';
|
||||
|
||||
import { getStrictContext } from '@/lib/get-strict-context';
|
||||
import { useControlledState } from '@/hooks/use-controlled-state';
|
||||
|
||||
type TooltipContextType = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
x: MotionValue<number>;
|
||||
y: MotionValue<number>;
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
const [LocalTooltipProvider, useTooltip] =
|
||||
getStrictContext<TooltipContextType>('TooltipContext');
|
||||
|
||||
type TooltipProviderProps = React.ComponentProps<
|
||||
typeof TooltipPrimitive.Provider
|
||||
>;
|
||||
|
||||
function TooltipProvider(props: TooltipProviderProps) {
|
||||
return <TooltipPrimitive.Provider data-slot="tooltip-provider" {...props} />;
|
||||
}
|
||||
|
||||
type TooltipProps = React.ComponentProps<typeof TooltipPrimitive.Root> & {
|
||||
followCursor?: boolean | 'x' | 'y';
|
||||
followCursorSpringOptions?: SpringOptions;
|
||||
};
|
||||
|
||||
function Tooltip({
|
||||
followCursor = false,
|
||||
followCursorSpringOptions = { stiffness: 200, damping: 17 },
|
||||
...props
|
||||
}: TooltipProps) {
|
||||
const [isOpen, setIsOpen] = useControlledState({
|
||||
value: props?.open,
|
||||
defaultValue: props?.defaultOpen,
|
||||
onChange: props?.onOpenChange,
|
||||
});
|
||||
const x = useMotionValue(0);
|
||||
const y = useMotionValue(0);
|
||||
|
||||
return (
|
||||
<LocalTooltipProvider
|
||||
value={{
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
x,
|
||||
y,
|
||||
followCursor,
|
||||
followCursorSpringOptions,
|
||||
}}
|
||||
>
|
||||
<TooltipPrimitive.Root
|
||||
data-slot="tooltip"
|
||||
{...props}
|
||||
onOpenChange={setIsOpen}
|
||||
/>
|
||||
</LocalTooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipTriggerProps = React.ComponentProps<
|
||||
typeof TooltipPrimitive.Trigger
|
||||
>;
|
||||
|
||||
function TooltipTrigger({ onMouseMove, ...props }: TooltipTriggerProps) {
|
||||
const { x, y, followCursor } = useTooltip();
|
||||
|
||||
const handleMouseMove = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onMouseMove?.(event);
|
||||
|
||||
const target = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
if (followCursor === 'x' || followCursor === true) {
|
||||
const eventOffsetX = event.clientX - target.left;
|
||||
const offsetXFromCenter = (eventOffsetX - target.width / 2) / 2;
|
||||
x.set(offsetXFromCenter);
|
||||
}
|
||||
|
||||
if (followCursor === 'y' || followCursor === true) {
|
||||
const eventOffsetY = event.clientY - target.top;
|
||||
const offsetYFromCenter = (eventOffsetY - target.height / 2) / 2;
|
||||
y.set(offsetYFromCenter);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
data-slot="tooltip-trigger"
|
||||
onMouseMove={handleMouseMove}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipPortalProps = Omit<
|
||||
React.ComponentProps<typeof TooltipPrimitive.Portal>,
|
||||
'forceMount'
|
||||
>;
|
||||
|
||||
function TooltipPortal(props: TooltipPortalProps) {
|
||||
const { isOpen } = useTooltip();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<TooltipPrimitive.Portal
|
||||
forceMount
|
||||
data-slot="tooltip-portal"
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipContentProps = Omit<
|
||||
React.ComponentProps<typeof TooltipPrimitive.Content>,
|
||||
'forceMount' | 'asChild'
|
||||
> &
|
||||
HTMLMotionProps<'div'>;
|
||||
|
||||
function TooltipContent({
|
||||
onEscapeKeyDown,
|
||||
onPointerDownOutside,
|
||||
side,
|
||||
sideOffset,
|
||||
align,
|
||||
alignOffset,
|
||||
avoidCollisions,
|
||||
collisionBoundary,
|
||||
collisionPadding,
|
||||
arrowPadding,
|
||||
sticky,
|
||||
hideWhenDetached,
|
||||
style,
|
||||
transition = { type: 'spring', stiffness: 300, damping: 25 },
|
||||
...props
|
||||
}: TooltipContentProps) {
|
||||
const { x, y, followCursor, followCursorSpringOptions } = useTooltip();
|
||||
const translateX = useSpring(x, followCursorSpringOptions);
|
||||
const translateY = useSpring(y, followCursorSpringOptions);
|
||||
|
||||
return (
|
||||
<TooltipPrimitive.Content
|
||||
asChild
|
||||
forceMount
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
avoidCollisions={avoidCollisions}
|
||||
collisionBoundary={collisionBoundary}
|
||||
collisionPadding={collisionPadding}
|
||||
arrowPadding={arrowPadding}
|
||||
sticky={sticky}
|
||||
hideWhenDetached={hideWhenDetached}
|
||||
onEscapeKeyDown={onEscapeKeyDown}
|
||||
onPointerDownOutside={onPointerDownOutside}
|
||||
>
|
||||
<motion.div
|
||||
key="popover-content"
|
||||
data-slot="popover-content"
|
||||
initial={{ opacity: 0, scale: 0.5 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.5 }}
|
||||
transition={transition}
|
||||
style={{
|
||||
x:
|
||||
followCursor === 'x' || followCursor === true
|
||||
? translateX
|
||||
: undefined,
|
||||
y:
|
||||
followCursor === 'y' || followCursor === true
|
||||
? translateY
|
||||
: undefined,
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Content>
|
||||
);
|
||||
}
|
||||
|
||||
type TooltipArrowProps = React.ComponentProps<typeof TooltipPrimitive.Arrow>;
|
||||
|
||||
function TooltipArrow(props: TooltipArrowProps) {
|
||||
return <TooltipPrimitive.Arrow data-slot="tooltip-arrow" {...props} />;
|
||||
}
|
||||
|
||||
export {
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipPortal,
|
||||
TooltipContent,
|
||||
TooltipArrow,
|
||||
useTooltip,
|
||||
type TooltipProviderProps,
|
||||
type TooltipProps,
|
||||
type TooltipTriggerProps,
|
||||
type TooltipPortalProps,
|
||||
type TooltipContentProps,
|
||||
type TooltipArrowProps,
|
||||
type TooltipContextType,
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useMotionValue, useSpring, type SpringOptions } 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;
|
||||
} & UseIsInViewOptions;
|
||||
|
||||
function CountingNumber({
|
||||
ref,
|
||||
number,
|
||||
fromNumber = 0,
|
||||
padStart = false,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
decimalSeparator = '.',
|
||||
transition = { stiffness: 90, damping: 50 },
|
||||
decimalPlaces = 0,
|
||||
delay = 0,
|
||||
initiallyStable = false,
|
||||
...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;
|
||||
}
|
||||
|
||||
localRef.current.textContent = formatted;
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [springVal, decimals, padStart, number, decimalSeparator, localRef]);
|
||||
|
||||
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;
|
||||
}
|
||||
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 };
|
||||
@@ -0,0 +1,353 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import {
|
||||
useSpring,
|
||||
useTransform,
|
||||
motion,
|
||||
useMotionValue,
|
||||
type MotionValue,
|
||||
type SpringOptions,
|
||||
type HTMLMotionProps,
|
||||
} from 'motion/react';
|
||||
import useMeasure from 'react-use-measure';
|
||||
|
||||
import {
|
||||
useIsInView,
|
||||
type UseIsInViewOptions,
|
||||
} from '@/hooks/use-is-in-view';
|
||||
|
||||
type SlidingNumberRollerProps = {
|
||||
prevValue: number;
|
||||
value: number;
|
||||
place: number;
|
||||
transition: SpringOptions;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
function SlidingNumberRoller({
|
||||
prevValue,
|
||||
value,
|
||||
place,
|
||||
transition,
|
||||
delay = 0,
|
||||
}: SlidingNumberRollerProps) {
|
||||
const startNumber = Math.floor(prevValue / place) % 10;
|
||||
const targetNumber = Math.floor(value / place) % 10;
|
||||
const animatedValue = useSpring(startNumber, transition);
|
||||
|
||||
React.useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
animatedValue.set(targetNumber);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [targetNumber, animatedValue, delay]);
|
||||
|
||||
const [measureRef, { height }] = useMeasure();
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={measureRef}
|
||||
data-slot="sliding-number-roller"
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-block',
|
||||
width: '1ch',
|
||||
overflowX: 'visible',
|
||||
overflowY: 'clip',
|
||||
lineHeight: 1,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
<span style={{ visibility: 'hidden' }}>0</span>
|
||||
{Array.from({ length: 10 }, (_, i) => (
|
||||
<SlidingNumberDisplay
|
||||
key={i}
|
||||
motionValue={animatedValue}
|
||||
number={i}
|
||||
height={height}
|
||||
transition={transition}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberDisplayProps = {
|
||||
motionValue: MotionValue<number>;
|
||||
number: number;
|
||||
height: number;
|
||||
transition: SpringOptions;
|
||||
};
|
||||
|
||||
function SlidingNumberDisplay({
|
||||
motionValue,
|
||||
number,
|
||||
height,
|
||||
transition,
|
||||
}: SlidingNumberDisplayProps) {
|
||||
const y = useTransform(motionValue, (latest) => {
|
||||
if (!height) return 0;
|
||||
const currentNumber = latest % 10;
|
||||
const offset = (10 + number - currentNumber) % 10;
|
||||
let translateY = offset * height;
|
||||
if (offset > 5) translateY -= 10 * height;
|
||||
return translateY;
|
||||
});
|
||||
|
||||
if (!height) {
|
||||
return (
|
||||
<span style={{ visibility: 'hidden', position: 'absolute' }}>
|
||||
{number}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
data-slot="sliding-number-display"
|
||||
style={{
|
||||
y,
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
transition={{ ...transition, type: 'spring' }}
|
||||
>
|
||||
{number}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
type SlidingNumberProps = Omit<HTMLMotionProps<'span'>, 'children'> & {
|
||||
number: number;
|
||||
fromNumber?: number;
|
||||
onNumberChange?: (number: number) => void;
|
||||
padStart?: boolean;
|
||||
decimalSeparator?: string;
|
||||
decimalPlaces?: number;
|
||||
thousandSeparator?: string;
|
||||
transition?: SpringOptions;
|
||||
delay?: number;
|
||||
initiallyStable?: boolean;
|
||||
} & UseIsInViewOptions;
|
||||
|
||||
function SlidingNumber({
|
||||
ref,
|
||||
number,
|
||||
fromNumber,
|
||||
onNumberChange,
|
||||
inView = false,
|
||||
inViewMargin = '0px',
|
||||
inViewOnce = true,
|
||||
padStart = false,
|
||||
decimalSeparator = '.',
|
||||
decimalPlaces = 0,
|
||||
thousandSeparator,
|
||||
transition = { stiffness: 200, damping: 20, mass: 0.4 },
|
||||
delay = 0,
|
||||
initiallyStable = false,
|
||||
...props
|
||||
}: SlidingNumberProps) {
|
||||
const { ref: localRef, isInView } = useIsInView(
|
||||
ref as React.Ref<HTMLElement>,
|
||||
{
|
||||
inView,
|
||||
inViewOnce,
|
||||
inViewMargin,
|
||||
},
|
||||
);
|
||||
|
||||
const initialNumeric = Math.abs(Number(number));
|
||||
const prevNumberRef = React.useRef<number>(
|
||||
initiallyStable ? initialNumeric : 0,
|
||||
);
|
||||
|
||||
const hasAnimated = fromNumber !== undefined;
|
||||
|
||||
const motionVal = useMotionValue(
|
||||
initiallyStable ? initialNumeric : (fromNumber ?? 0),
|
||||
);
|
||||
const springVal = useSpring(motionVal, { stiffness: 90, damping: 50 });
|
||||
|
||||
const skippedInitialWhenStable = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hasAnimated) return;
|
||||
if (initiallyStable && !skippedInitialWhenStable.current) {
|
||||
skippedInitialWhenStable.current = true;
|
||||
return;
|
||||
}
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (isInView) motionVal.set(number);
|
||||
}, delay);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [hasAnimated, initiallyStable, isInView, number, motionVal, delay]);
|
||||
|
||||
const [effectiveNumber, setEffectiveNumber] = React.useState<number>(
|
||||
initiallyStable ? initialNumeric : 0,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hasAnimated) {
|
||||
const inferredDecimals =
|
||||
typeof decimalPlaces === 'number' && decimalPlaces >= 0
|
||||
? decimalPlaces
|
||||
: (() => {
|
||||
const s = String(number);
|
||||
const idx = s.indexOf('.');
|
||||
return idx >= 0 ? s.length - idx - 1 : 0;
|
||||
})();
|
||||
|
||||
const factor = Math.pow(10, inferredDecimals);
|
||||
|
||||
const unsubscribe = springVal.on('change', (latest: number) => {
|
||||
const newValue =
|
||||
inferredDecimals > 0
|
||||
? Math.round(latest * factor) / factor
|
||||
: Math.round(latest);
|
||||
|
||||
if (effectiveNumber !== newValue) {
|
||||
setEffectiveNumber(newValue);
|
||||
onNumberChange?.(newValue);
|
||||
}
|
||||
});
|
||||
return () => unsubscribe();
|
||||
} else {
|
||||
setEffectiveNumber(
|
||||
initiallyStable ? initialNumeric : !isInView ? 0 : initialNumeric,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
hasAnimated,
|
||||
springVal,
|
||||
isInView,
|
||||
number,
|
||||
decimalPlaces,
|
||||
onNumberChange,
|
||||
effectiveNumber,
|
||||
initiallyStable,
|
||||
initialNumeric,
|
||||
]);
|
||||
|
||||
const formatNumber = React.useCallback(
|
||||
(num: number) =>
|
||||
decimalPlaces != null ? num.toFixed(decimalPlaces) : num.toString(),
|
||||
[decimalPlaces],
|
||||
);
|
||||
|
||||
const numberStr = formatNumber(effectiveNumber);
|
||||
const [newIntStrRaw, newDecStrRaw = ''] = numberStr.split('.');
|
||||
|
||||
const finalIntLength = padStart
|
||||
? Math.max(
|
||||
Math.floor(Math.abs(number)).toString().length,
|
||||
newIntStrRaw.length,
|
||||
)
|
||||
: newIntStrRaw.length;
|
||||
|
||||
const newIntStr = padStart
|
||||
? newIntStrRaw.padStart(finalIntLength, '0')
|
||||
: newIntStrRaw;
|
||||
|
||||
const prevFormatted = formatNumber(prevNumberRef.current);
|
||||
const [prevIntStrRaw = '', prevDecStrRaw = ''] = prevFormatted.split('.');
|
||||
const prevIntStr = padStart
|
||||
? prevIntStrRaw.padStart(finalIntLength, '0')
|
||||
: prevIntStrRaw;
|
||||
|
||||
const adjustedPrevInt = React.useMemo(() => {
|
||||
return prevIntStr.length > finalIntLength
|
||||
? prevIntStr.slice(-finalIntLength)
|
||||
: prevIntStr.padStart(finalIntLength, '0');
|
||||
}, [prevIntStr, finalIntLength]);
|
||||
|
||||
const adjustedPrevDec = React.useMemo(() => {
|
||||
if (!newDecStrRaw) return '';
|
||||
return prevDecStrRaw.length > newDecStrRaw.length
|
||||
? prevDecStrRaw.slice(0, newDecStrRaw.length)
|
||||
: prevDecStrRaw.padEnd(newDecStrRaw.length, '0');
|
||||
}, [prevDecStrRaw, newDecStrRaw]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isInView || initiallyStable) {
|
||||
prevNumberRef.current = effectiveNumber;
|
||||
}
|
||||
}, [effectiveNumber, isInView, initiallyStable]);
|
||||
|
||||
const intPlaces = React.useMemo(
|
||||
() =>
|
||||
Array.from({ length: finalIntLength }, (_, i) =>
|
||||
Math.pow(10, finalIntLength - i - 1),
|
||||
),
|
||||
[finalIntLength],
|
||||
);
|
||||
const decPlaces = React.useMemo(
|
||||
() =>
|
||||
newDecStrRaw
|
||||
? Array.from({ length: newDecStrRaw.length }, (_, i) =>
|
||||
Math.pow(10, newDecStrRaw.length - i - 1),
|
||||
)
|
||||
: [],
|
||||
[newDecStrRaw],
|
||||
);
|
||||
|
||||
const newDecValue = newDecStrRaw ? parseInt(newDecStrRaw, 10) : 0;
|
||||
const prevDecValue = adjustedPrevDec ? parseInt(adjustedPrevDec, 10) : 0;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={localRef}
|
||||
data-slot="sliding-number"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{isInView && Number(number) < 0 && (
|
||||
<span style={{ marginRight: '0.25rem' }}>-</span>
|
||||
)}
|
||||
|
||||
{intPlaces.map((place, idx) => {
|
||||
const digitsToRight = intPlaces.length - idx - 1;
|
||||
const isSeparatorPosition =
|
||||
typeof thousandSeparator !== 'undefined' &&
|
||||
digitsToRight > 0 &&
|
||||
digitsToRight % 3 === 0;
|
||||
|
||||
return (
|
||||
<React.Fragment key={`int-${place}`}>
|
||||
<SlidingNumberRoller
|
||||
prevValue={parseInt(adjustedPrevInt, 10)}
|
||||
value={parseInt(newIntStr ?? '0', 10)}
|
||||
place={place}
|
||||
transition={transition}
|
||||
/>
|
||||
{isSeparatorPosition && <span>{thousandSeparator}</span>}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{newDecStrRaw && (
|
||||
<>
|
||||
<span>{decimalSeparator}</span>
|
||||
{decPlaces.map((place) => (
|
||||
<SlidingNumberRoller
|
||||
key={`dec-${place}`}
|
||||
prevValue={prevDecValue}
|
||||
value={newDecValue}
|
||||
place={place}
|
||||
transition={transition}
|
||||
delay={delay}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</motion.span>
|
||||
);
|
||||
}
|
||||
|
||||
export { SlidingNumber, type SlidingNumberProps };
|
||||
Reference in New Issue
Block a user