From 0b43c6776b761dd1de57c8d8cd78b6144b396e08 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:14:42 +0400 Subject: [PATCH] refactor: animate tabs --- src/components/ui/auto-height.tsx | 55 +++ src/components/ui/highlight.tsx | 640 ++++++++++++++++++++++++++++++ src/components/ui/tabs.tsx | 197 ++++++++- src/hooks/use-auto-height.tsx | 101 +++++ src/lib/slot.tsx | 98 +++++ 5 files changed, 1073 insertions(+), 18 deletions(-) create mode 100644 src/components/ui/auto-height.tsx create mode 100644 src/components/ui/highlight.tsx create mode 100644 src/hooks/use-auto-height.tsx create mode 100644 src/lib/slot.tsx diff --git a/src/components/ui/auto-height.tsx b/src/components/ui/auto-height.tsx new file mode 100644 index 0000000..ba16e7f --- /dev/null +++ b/src/components/ui/auto-height.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { + type HTMLMotionProps, + type LegacyAnimationControls, + motion, + type TargetAndTransition, + type Transition, +} from "motion/react"; +import type * as React from "react"; + +import { useAutoHeight } from "@/hooks/use-auto-height"; +import { Slot, type WithAsChild } from "@/lib/slot"; + +type AutoHeightProps = WithAsChild< + { + children: React.ReactNode; + deps?: React.DependencyList; + animate?: TargetAndTransition | LegacyAnimationControls; + transition?: Transition; + } & Omit, "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(deps); + + const Comp = asChild ? Slot : motion.div; + + return ( + +
{children}
+
+ ); +} + +export { AutoHeight, type AutoHeightProps }; diff --git a/src/components/ui/highlight.tsx b/src/components/ui/highlight.tsx new file mode 100644 index 0000000..bd19448 --- /dev/null +++ b/src/components/ui/highlight.tsx @@ -0,0 +1,640 @@ +"use client"; + +import { AnimatePresence, motion, type Transition } from "motion/react"; +import * as React from "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 = { + 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 | undefined +>(undefined); + +function useHighlight(): HighlightContextType { + const context = React.useContext(HighlightContext); + if (!context) { + throw new Error("useHighlight must be used within a HighlightProvider"); + } + return context as unknown as HighlightContextType; +} + +type BaseHighlightProps = { + as?: T; + ref?: React.Ref; + 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; + containerClassName?: string; + forceUpdateBounds?: boolean; +}; + +type ControlledParentModeHighlightProps = + BaseHighlightProps & + ParentModeHighlightProps & { + mode: "parent"; + controlledItems: true; + children: React.ReactNode; + }; + +type ControlledChildrenModeHighlightProps = + BaseHighlightProps & { + mode?: "children" | undefined; + controlledItems: true; + children: React.ReactNode; + }; + +type UncontrolledParentModeHighlightProps = + BaseHighlightProps & + ParentModeHighlightProps & { + mode: "parent"; + controlledItems?: false; + itemsClassName?: string; + children: React.ReactElement | React.ReactElement[]; + }; + +type UncontrolledChildrenModeHighlightProps< + T extends React.ElementType = "div", +> = BaseHighlightProps & { + mode?: "children"; + controlledItems?: false; + itemsClassName?: string; + children: React.ReactElement | React.ReactElement[]; +}; + +type HighlightProps = + | ControlledParentModeHighlightProps + | ControlledChildrenModeHighlightProps + | UncontrolledParentModeHighlightProps + | UncontrolledChildrenModeHighlightProps; + +function Highlight({ + ref, + ...props +}: HighlightProps) { + 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(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( + value ?? defaultValue ?? null, + ); + const [boundsState, setBoundsState] = React.useState(null); + const [activeClassNameState, setActiveClassNameState] = + React.useState(""); + + 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( + `[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 ( + + + {boundsState && ( + + )} + + {children} + + ); + } + + return children; + }; + + return ( + + {enabled + ? controlledItems + ? render(children) + : render( + React.Children.map(children, (child, index) => ( + + {child} + + )), + ) + : children} + + ); +} + +function getNonOverridingDataAttributes( + element: React.ReactElement, + dataAttributes: Record, +): Record { + return Object.keys(dataAttributes).reduce>( + (acc, key) => { + if ((element.props as Record)[key] === undefined) { + acc[key] = dataAttributes[key]; + } + return acc; + }, + {}, + ); +} + +type ExtendedChildProps = React.ComponentProps<"div"> & { + id?: string; + ref?: React.Ref; + "data-active"?: string; + "data-value"?: string; + "data-disabled"?: boolean; + "data-highlight"?: boolean; + "data-slot"?: string; +}; + +type HighlightItemProps = + React.ComponentProps & { + 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({ + ref, + as, + children, + id, + value, + className, + style, + transition, + disabled = false, + activeClassName, + exitDelay, + asChild = false, + forceUpdateBounds, + ...props +}: HighlightItemProps) { + 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; + 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(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) => { + setActiveValue(childValue); + element.props.onMouseEnter?.(e); + }, + onMouseLeave: (e: React.MouseEvent) => { + setActiveValue(null); + element.props.onMouseLeave?.(e); + }, + } + : click + ? { + onClick: (e: React.MouseEvent) => { + 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, + }, + <> + + {isActive && !isDisabled && ( + + )} + + + + {children} + + , + ); + } + + return React.cloneElement(element, { + ref: refCallback, + ...getNonOverridingDataAttributes(element, { + ...dataAttributes, + "data-slot": "motion-highlight-item", + }), + ...commonHandlers, + }); + } + + return enabled ? ( + + {mode === "children" && ( + + {isActive && !isDisabled && ( + + )} + + )} + + {React.cloneElement(element, { + style: { position: "relative", zIndex: 1 }, + className: element.props.className, + ...getNonOverridingDataAttributes(element, { + ...dataAttributes, + "data-slot": "motion-highlight-item", + }), + })} + + ) : ( + children + ); +} + +export { + Highlight, + HighlightItem, + useHighlight, + type HighlightProps, + type HighlightItemProps, +}; diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 32b65c0..dc56ffb 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -1,18 +1,82 @@ "use client"; import * as TabsPrimitive from "@radix-ui/react-tabs"; +import { + AnimatePresence, + type HTMLMotionProps, + motion, + type Transition, +} from "motion/react"; import * as React from "react"; +import { AutoHeight } from "@/components/ui/auto-height"; +import { + Highlight, + HighlightItem, + type HighlightItemProps, + type HighlightProps, +} from "@/components/ui/highlight"; +import { useControlledState } from "@/hooks/use-controlled-state"; +import { getStrictContext } from "@/lib/get-strict-context"; import { cn } from "@/lib/utils"; -const Tabs = TabsPrimitive.Root; +type TabsContextType = { + value: string | undefined; + setValue: TabsProps["onValueChange"]; +}; + +const [TabsProvider, useTabs] = + getStrictContext("TabsContext"); + +type TabsProps = React.ComponentProps; + +function Tabs(props: TabsProps) { + const [value, setValue] = useControlledState({ + value: props.value, + defaultValue: props.defaultValue, + onChange: props.onValueChange, + }); + + return ( + + + + ); +} + +type TabsHighlightProps = Omit; + +function TabsHighlight({ + transition = { type: "spring", stiffness: 200, damping: 25 }, + ...props +}: TabsHighlightProps) { + const { value } = useTabs(); + + return ( + + ); +} + +type TabsListProps = React.ComponentProps; const TabsList = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + TabsListProps >(({ className, ...props }, ref) => ( ; +} + +type TabsTriggerProps = React.ComponentProps; + const TabsTrigger = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + TabsTriggerProps >(({ className, ...props }, ref) => ( , - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -TabsContent.displayName = TabsPrimitive.Content.displayName; +type TabsContentProps = React.ComponentProps & + HTMLMotionProps<"div">; -export { Tabs, TabsList, TabsTrigger, TabsContent }; +function TabsContent({ + value, + forceMount, + transition = { duration: 0.5, ease: "easeInOut" }, + className, + ...props +}: TabsContentProps) { + return ( + + + + + + ); +} + +type TabsContentsAutoProps = React.ComponentProps & { + mode?: "auto-height"; + children: React.ReactNode; + transition?: Transition; +}; + +type TabsContentsLayoutProps = Omit, "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 ( + + ); + } + + const { transition = defaultTransition, style, ...layoutProps } = props; + + return ( + + ); +} + +export { + Tabs, + TabsHighlight, + TabsHighlightItem, + TabsList, + TabsTrigger, + TabsContent, + TabsContents, + type TabsProps, + type TabsHighlightProps, + type TabsHighlightItemProps, + type TabsListProps, + type TabsTriggerProps, + type TabsContentProps, + type TabsContentsProps, +}; diff --git a/src/hooks/use-auto-height.tsx b/src/hooks/use-auto-height.tsx new file mode 100644 index 0000000..91845d3 --- /dev/null +++ b/src/hooks/use-auto-height.tsx @@ -0,0 +1,101 @@ +"use client"; + +import * as React from "react"; + +type AutoHeightOptions = { + includeParentBox?: boolean; + includeSelfBox?: boolean; +}; + +export function useAutoHeight( + deps: React.DependencyList = [], + options: AutoHeightOptions = { + includeParentBox: true, + includeSelfBox: false, + }, +) { + const ref = React.useRef(null); + const roRef = React.useRef(null); + const [height, setHeight] = React.useState(0); + + const measure = React.useCallback(() => { + const el = ref.current; + if (!el) return 0; + + const base = el.getBoundingClientRect().height || 0; + + let extra = 0; + + if (options.includeParentBox && el.parentElement) { + const cs = getComputedStyle(el.parentElement); + const paddingY = + (parseFloat(cs.paddingTop || "0") || 0) + + (parseFloat(cs.paddingBottom || "0") || 0); + const borderY = + (parseFloat(cs.borderTopWidth || "0") || 0) + + (parseFloat(cs.borderBottomWidth || "0") || 0); + const isBorderBox = cs.boxSizing === "border-box"; + if (isBorderBox) { + extra += paddingY + borderY; + } + } + + if (options.includeSelfBox) { + const cs = getComputedStyle(el); + const paddingY = + (parseFloat(cs.paddingTop || "0") || 0) + + (parseFloat(cs.paddingBottom || "0") || 0); + const borderY = + (parseFloat(cs.borderTopWidth || "0") || 0) + + (parseFloat(cs.borderBottomWidth || "0") || 0); + const isBorderBox = cs.boxSizing === "border-box"; + if (isBorderBox) { + extra += paddingY + borderY; + } + } + + const dpr = + typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1; + const total = Math.ceil((base + extra) * dpr) / dpr; + + return total; + }, [options.includeParentBox, options.includeSelfBox]); + + React.useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + + setHeight(measure()); + + if (roRef.current) { + roRef.current.disconnect(); + roRef.current = null; + } + + const ro = new ResizeObserver(() => { + const next = measure(); + requestAnimationFrame(() => setHeight(next)); + }); + + ro.observe(el); + if (options.includeParentBox && el.parentElement) { + ro.observe(el.parentElement); + } + + roRef.current = ro; + + return () => { + ro.disconnect(); + roRef.current = null; + }; + }, [...deps, measure, options.includeParentBox]); + + React.useLayoutEffect(() => { + if (height === 0) { + const next = measure(); + if (next !== 0) setHeight(next); + } + }, [height, measure]); + + return { ref, height } as const; +} diff --git a/src/lib/slot.tsx b/src/lib/slot.tsx new file mode 100644 index 0000000..05c6b7f --- /dev/null +++ b/src/lib/slot.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { type HTMLMotionProps, isMotionComponent, motion } from "motion/react"; +import * as React from "react"; +import { cn } from "@/lib/utils"; + +type AnyProps = Record; + +type DOMMotionProps = Omit< + HTMLMotionProps, + "ref" +> & { ref?: React.Ref }; + +type WithAsChild = + | (Base & { asChild: true; children: React.ReactElement }) + | (Base & { asChild?: false | undefined }); + +type SlotProps = { + children?: React.ReactElement; +} & DOMMotionProps; + +function mergeRefs( + ...refs: (React.Ref | undefined)[] +): React.RefCallback { + return (node) => { + refs.forEach((ref) => { + if (!ref) return; + if (typeof ref === "function") { + ref(node); + } else { + (ref as React.RefObject).current = node; + } + }); + }; +} + +function mergeProps( + childProps: AnyProps, + slotProps: DOMMotionProps, +): 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({ + children, + ref, + ...props +}: SlotProps) { + const isAlreadyMotion = React.useMemo(() => { + if (!React.isValidElement(children)) return false; + return ( + typeof children.type === "object" && + children.type !== null && + isMotionComponent(children.type) + ); + }, [children]); + + const Base = React.useMemo(() => { + if (!React.isValidElement(children)) return motion.div; + return isAlreadyMotion + ? (children.type as React.ElementType) + : motion.create(children.type as React.ElementType); + }, [isAlreadyMotion, children]); + + if (!React.isValidElement(children)) return null; + + const { ref: childRef, ...childProps } = children.props as AnyProps; + + const mergedProps = mergeProps(childProps, props); + + return ( + , ref)} /> + ); +} + +export { + Slot, + type SlotProps, + type WithAsChild, + type DOMMotionProps, + type AnyProps, +};