2025-08-20 04:12:49 -06:00

291 lines
7.1 KiB
TypeScript

'use client';
import * as React from 'react';
import { motion, type Transition, type HTMLMotionProps } from 'motion/react';
import { cn } from '@workspace/ui/lib/utils';
import {
MotionHighlight,
MotionHighlightItem,
} from '@/registry/effects/motion-highlight';
type TabsContextType<T extends string> = {
activeValue: T;
handleValueChange: (value: T) => void;
registerTrigger: (value: T, node: HTMLElement | null) => void;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TabsContext = React.createContext<TabsContextType<any> | undefined>(
undefined,
);
function useTabs<T extends string = string>(): TabsContextType<T> {
const context = React.useContext(TabsContext);
if (!context) {
throw new Error('useTabs must be used within a TabsProvider');
}
return context;
}
type BaseTabsProps = React.ComponentProps<'div'> & {
children: React.ReactNode;
};
type UnControlledTabsProps<T extends string = string> = BaseTabsProps & {
defaultValue?: T;
value?: never;
onValueChange?: never;
};
type ControlledTabsProps<T extends string = string> = BaseTabsProps & {
value: T;
onValueChange?: (value: T) => void;
defaultValue?: never;
};
type TabsProps<T extends string = string> =
| UnControlledTabsProps<T>
| ControlledTabsProps<T>;
function Tabs<T extends string = string>({
defaultValue,
value,
onValueChange,
children,
className,
...props
}: TabsProps<T>) {
const [activeValue, setActiveValue] = React.useState<T | undefined>(
defaultValue ?? undefined,
);
const triggersRef = React.useRef(new Map<string, HTMLElement>());
const initialSet = React.useRef(false);
const isControlled = value !== undefined;
React.useEffect(() => {
if (
!isControlled &&
activeValue === undefined &&
triggersRef.current.size > 0 &&
!initialSet.current
) {
const firstTab = Array.from(triggersRef.current.keys())[0];
setActiveValue(firstTab as T);
initialSet.current = true;
}
}, [activeValue, isControlled]);
const registerTrigger = (value: string, node: HTMLElement | null) => {
if (node) {
triggersRef.current.set(value, node);
if (!isControlled && activeValue === undefined && !initialSet.current) {
setActiveValue(value as T);
initialSet.current = true;
}
} else {
triggersRef.current.delete(value);
}
};
const handleValueChange = (val: T) => {
if (!isControlled) setActiveValue(val);
else onValueChange?.(val);
};
return (
<TabsContext.Provider
value={{
activeValue: (value ?? activeValue)!,
handleValueChange,
registerTrigger,
}}
>
<div
data-slot="tabs"
className={cn('flex flex-col gap-2', className)}
{...props}
>
{children}
</div>
</TabsContext.Provider>
);
}
type TabsListProps = React.ComponentProps<'div'> & {
children: React.ReactNode;
activeClassName?: string;
transition?: Transition;
};
function TabsList({
children,
className,
activeClassName,
transition = {
type: 'spring',
stiffness: 200,
damping: 25,
},
...props
}: TabsListProps) {
const { activeValue } = useTabs();
return (
<MotionHighlight
controlledItems
className={cn('rounded-sm bg-background shadow-sm', activeClassName)}
value={activeValue}
transition={transition}
>
<div
role="tablist"
data-slot="tabs-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg p-[4px]',
className,
)}
{...props}
>
{children}
</div>
</MotionHighlight>
);
}
type TabsTriggerProps = HTMLMotionProps<'button'> & {
value: string;
children: React.ReactNode;
};
function TabsTrigger({
ref,
value,
children,
className,
...props
}: TabsTriggerProps) {
const { activeValue, handleValueChange, registerTrigger } = useTabs();
const localRef = React.useRef<HTMLButtonElement | null>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLButtonElement);
React.useEffect(() => {
registerTrigger(value, localRef.current);
return () => registerTrigger(value, null);
}, [value, registerTrigger]);
return (
<MotionHighlightItem value={value} className="size-full">
<motion.button
ref={localRef}
data-slot="tabs-trigger"
role="tab"
whileTap={{ scale: 0.95 }}
onClick={() => handleValueChange(value)}
data-state={activeValue === value ? 'active' : 'inactive'}
className={cn(
'inline-flex cursor-pointer items-center size-full justify-center whitespace-nowrap rounded-sm px-2 py-1 text-sm font-medium ring-offset-background transition-transform 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]:text-foreground z-[1]',
className,
)}
{...props}
>
{children}
</motion.button>
</MotionHighlightItem>
);
}
type TabsContentsProps = React.ComponentProps<'div'> & {
children: React.ReactNode;
transition?: Transition;
};
function TabsContents({
children,
className,
transition = {
type: 'spring',
stiffness: 300,
damping: 30,
bounce: 0,
restDelta: 0.01,
},
...props
}: TabsContentsProps) {
const { activeValue } = useTabs();
const childrenArray = React.Children.toArray(children);
const activeIndex = childrenArray.findIndex(
(child): child is React.ReactElement<{ value: string }> =>
React.isValidElement(child) &&
typeof child.props === 'object' &&
child.props !== null &&
'value' in child.props &&
child.props.value === activeValue,
);
return (
<div
data-slot="tabs-contents"
className={cn('overflow-hidden', className)}
{...props}
>
<motion.div
className="flex -mx-2"
animate={{ x: activeIndex * -100 + '%' }}
transition={transition}
>
{childrenArray.map((child, index) => (
<div key={index} className="w-full shrink-0 px-2">
{child}
</div>
))}
</motion.div>
</div>
);
}
type TabsContentProps = HTMLMotionProps<'div'> & {
value: string;
children: React.ReactNode;
};
function TabsContent({
children,
value,
className,
...props
}: TabsContentProps) {
const { activeValue } = useTabs();
const isActive = activeValue === value;
return (
<motion.div
role="tabpanel"
data-slot="tabs-content"
className={cn('overflow-hidden', className)}
initial={{ filter: 'blur(0px)' }}
animate={{ filter: isActive ? 'blur(0px)' : 'blur(4px)' }}
exit={{ filter: 'blur(0px)' }}
transition={{ type: 'spring', stiffness: 200, damping: 25 }}
{...props}
>
{children}
</motion.div>
);
}
export {
Tabs,
TabsList,
TabsTrigger,
TabsContents,
TabsContent,
useTabs,
type TabsContextType,
type TabsProps,
type TabsListProps,
type TabsTriggerProps,
type TabsContentsProps,
type TabsContentProps,
};