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

227 lines
5.6 KiB
TypeScript

'use client';
import * as React from 'react';
import { motion, type Transition } from 'motion/react';
import {
TabGroup as TabGroupPrimitive,
TabList as TabListPrimitive,
Tab as TabPrimitive,
TabPanels as TabPanelsPrimitive,
TabPanel as TabPanelPrimitive,
type TabGroupProps as TabGroupPrimitiveProps,
type TabListProps as TabListPrimitiveProps,
type TabProps as TabPrimitiveProps,
type TabPanelsProps as TabPanelsPrimitiveProps,
type TabPanelProps as TabPanelPrimitiveProps,
} from '@headlessui/react';
import { cn } from '@workspace/ui/lib/utils';
import {
MotionHighlight,
MotionHighlightItem,
} from '@/registry/effects/motion-highlight';
type TabGroupProps<TTag extends React.ElementType = 'div'> =
TabGroupPrimitiveProps<TTag> & {
className?: string;
as?: TTag;
};
function TabGroup<TTag extends React.ElementType = 'div'>({
className,
...props
}: TabGroupProps<TTag>) {
return (
<TabGroupPrimitive
data-slot="tab-group"
className={cn('flex flex-col gap-2', className)}
{...props}
/>
);
}
type TabListProps<TTag extends React.ElementType = 'div'> =
TabListPrimitiveProps<TTag> & {
as?: TTag;
className?: string;
activeClassName?: string;
transition?: Transition;
};
function TabList<TTag extends React.ElementType = 'div'>({
children,
className,
activeClassName,
transition = {
type: 'spring',
stiffness: 200,
damping: 25,
},
...props
}: TabListProps<TTag>) {
return (
<TabListPrimitive
data-slot="tab-list"
className={cn(
'bg-muted text-muted-foreground inline-flex h-10 w-fit items-center justify-center rounded-lg p-[4px]',
className,
)}
{...props}
>
{(bag) => (
<MotionHighlight
controlledItems
className={cn('rounded-sm bg-background shadow-sm', activeClassName)}
value={bag.selectedIndex.toString()}
transition={transition}
>
{typeof children === 'function' ? children(bag) : children}
</MotionHighlight>
)}
</TabListPrimitive>
);
}
type TabProps<TTag extends React.ElementType = 'button'> = Omit<
TabPrimitiveProps<TTag>,
'children'
> &
Required<Pick<TabPrimitiveProps<TTag>, 'children'>> & {
index: number;
className?: string;
as?: TTag;
};
function Tab<TTag extends React.ElementType = 'button'>(props: TabProps<TTag>) {
const { children, className, index, as = 'button', ...rest } = props;
return (
<MotionHighlightItem value={index.toString()} className="size-full">
<TabPrimitive
data-slot="tabs-trigger"
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-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-selected:text-foreground z-[1]',
className,
)}
as={as as React.ElementType}
{...rest}
>
{children}
</TabPrimitive>
</MotionHighlightItem>
);
}
type TabPanelProps<TTag extends React.ElementType = typeof motion.div> = Omit<
TabPanelPrimitiveProps<TTag>,
'transition'
> & {
children: React.ReactNode;
className?: string;
as?: TTag;
transition?: Transition;
};
function TabPanel<TTag extends React.ElementType = typeof motion.div>(
props: TabPanelProps<TTag>,
) {
const {
className,
as = motion.div,
transition = {
duration: 0.5,
ease: 'easeInOut',
},
...rest
} = props;
return (
<TabPanelPrimitive
data-slot="tabs-content"
className={cn('flex-1 outline-none', className)}
layout
initial={{ opacity: 0, y: -10, filter: 'blur(4px)' }}
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, y: 10, filter: 'blur(4px)' }}
transition={transition}
as={as as React.ElementType}
{...rest}
/>
);
}
type TabPanelsProps<TTag extends React.ElementType = typeof motion.div> = Omit<
TabPanelsPrimitiveProps<TTag>,
'transition'
> & {
className?: string;
as?: TTag;
transition?: Transition;
};
function TabPanels<TTag extends React.ElementType = typeof motion.div>(
props: TabPanelsProps<TTag>,
) {
const {
children,
className,
as = motion.div,
transition = { type: 'spring', stiffness: 200, damping: 25 },
...rest
} = props;
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [height, setHeight] = React.useState(0);
React.useEffect(() => {
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
const newHeight = entries[0]?.contentRect.height ?? 0;
requestAnimationFrame(() => {
setHeight(newHeight);
});
});
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, [children]);
React.useLayoutEffect(() => {
if (containerRef.current) {
const initialHeight = containerRef.current.getBoundingClientRect().height;
setHeight(initialHeight);
}
}, [children]);
return (
<TabPanelsPrimitive
data-slot="tabs-contents"
layout
animate={{ height: height }}
transition={transition}
as={as as React.ElementType}
className={className}
{...rest}
>
<div ref={containerRef}>{children}</div>
</TabPanelsPrimitive>
);
}
export {
TabGroup,
TabList,
Tab,
TabPanel,
TabPanels,
type TabGroupProps,
type TabListProps,
type TabProps,
type TabPanelProps,
type TabPanelsProps,
};