262 lines
6.8 KiB
TypeScript
262 lines
6.8 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
|
|
import { motion, type Transition, type HTMLMotionProps } from 'motion/react'
|
|
|
|
import { cn } from '@/lib/utils'
|
|
import { MotionHighlight, MotionHighlightItem } from '@/components/ui/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('bg-background rounded-sm 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'
|
|
onClick={() => handleValueChange(value)}
|
|
data-state={activeValue === value ? 'active' : 'inactive'}
|
|
className={cn(
|
|
'ring-offset-background focus-visible:ring-ring data-[state=active]:text-foreground z-[1] inline-flex size-full cursor-pointer items-center justify-center rounded-sm px-2 py-1 text-sm font-medium whitespace-nowrap transition-transform focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',
|
|
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='-mx-2 flex' 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(2px)' }}
|
|
exit={{ filter: 'blur(0px)' }}
|
|
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
)
|
|
}
|
|
|
|
export {
|
|
Tabs,
|
|
TabsList,
|
|
TabsTrigger,
|
|
TabsContents,
|
|
TabsContent,
|
|
useTabs,
|
|
type TabsContextType,
|
|
type TabsProps,
|
|
type TabsListProps,
|
|
type TabsTriggerProps,
|
|
type TabsContentsProps,
|
|
type TabsContentProps
|
|
}
|