mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-12 17:57:50 +02:00
refactor: animate tabs
This commit is contained in:
@@ -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<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,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<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,
|
||||
};
|
||||
+179
-18
@@ -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<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>;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
TabsListProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="tabs-list"
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
@@ -22,12 +86,23 @@ const TabsList = React.forwardRef<
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
type TabsHighlightItemProps = HighlightItemProps & {
|
||||
value: string;
|
||||
};
|
||||
|
||||
function TabsHighlightItem(props: TabsHighlightItemProps) {
|
||||
return <HighlightItem data-slot="tabs-highlight-item" {...props} />;
|
||||
}
|
||||
|
||||
type TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger>;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
TabsTriggerProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
@@ -37,19 +112,105 @@ const TabsTrigger = React.forwardRef<
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
type TabsContentProps = React.ComponentProps<typeof TabsPrimitive.Content> &
|
||||
HTMLMotionProps<"div">;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
function TabsContent({
|
||||
value,
|
||||
forceMount,
|
||||
transition = { duration: 0.5, ease: "easeInOut" },
|
||||
className,
|
||||
...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}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TabsPrimitive.Content>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
type TabsContentsAutoProps = React.ComponentProps<typeof AutoHeight> & {
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user