'use client';/** * @title Tabs * @description 한 화면 안에서 콘텐츠를 탭 단위로 나눠 전환할 수 있는 컴포넌트예요. */import * as React from 'react';import { Tabs as TabsPrimitive } from 'radix-ui';import { cva } from 'class-variance-authority';import { cn } from '@/lib/utils';// SEED Rootage spec: tablist.yaml, tab.yaml / qvism recipe: tabs.tstype TabsTriggerLayout = 'fill' | 'hug';type TabsSize = 'small' | 'medium';type TabsContentLayout = 'fill' | 'hug';interface TabsContextValue { triggerLayout: TabsTriggerLayout; size: TabsSize; stickyList: boolean;}const TabsContext = React.createContext<TabsContextValue>({ triggerLayout: 'fill', size: 'small', stickyList: false,});const tabsListVariants = cva( [ 'relative isolate flex flex-nowrap items-stretch overflow-x-auto', '[scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden', // inset box-shadow 로 하단 선을 그려 레이아웃 이동을 막아요. 'bg-bg-layer-default shadow-[inset_0_-1px_0_var(--rb-color-stroke-neutral-muted)]', ], { variants: { triggerLayout: { fill: 'justify-around px-0', hug: 'justify-start px-spacing-x-global-gutter', }, size: { small: 'min-h-[40px]', medium: 'min-h-[44px]', }, stickyList: { true: 'sticky top-0 z-1', false: '', }, }, defaultVariants: { triggerLayout: 'fill', size: 'small', stickyList: false }, },);const tabsTriggerVariants = cva( [ 'relative box-border flex cursor-pointer items-end justify-center whitespace-nowrap border-none bg-transparent', 'text-fg-neutral-subtle data-[state=active]:text-fg-neutral', 'disabled:cursor-not-allowed disabled:text-fg-disabled', // SEED scaleScope: self 'enabled:active:scale-[0.95] motion-reduce:enabled:active:scale-100', 'transition-[scale,outline-color,color] duration-(--rb-duration-pressed-scale) ease-pressed-scale', // 포커스 링 position: inside 'outline-2 -outline-offset-2 outline-transparent focus-visible:outline-stroke-focus-ring', // 하이드레이션 전(인디케이터 측정 전)에는 선택된 탭 아래에 선을 그려요. 'group-data-[indicator=pending]/tabs-list:data-[state=active]:after:absolute group-data-[indicator=pending]/tabs-list:data-[state=active]:after:bottom-0 group-data-[indicator=pending]/tabs-list:data-[state=active]:after:h-[2px] group-data-[indicator=pending]/tabs-list:data-[state=active]:after:bg-fg-neutral', ], { variants: { triggerLayout: { fill: 'flex-1 group-data-[indicator=pending]/tabs-list:data-[state=active]:after:inset-x-spacing-x-global-gutter', hug: 'group-data-[indicator=pending]/tabs-list:data-[state=active]:after:inset-x-0', }, size: { small: 'min-h-[40px] px-x2_5 py-x2_5 text-t4 font-bold', medium: 'min-h-[44px] px-x2_5 py-x2_5 text-t5 font-bold', }, }, defaultVariants: { triggerLayout: 'fill', size: 'small' }, },);export interface TabsRootProps extends React.ComponentProps<typeof TabsPrimitive.Root> { /** 탭 아이템을 꽉 채울지(fill) 라벨 길이에 맞출지(hug) 정해요. */ triggerLayout?: TabsTriggerLayout; /** 콘텐츠 영역이 남은 높이를 채울지(fill) 콘텐츠 높이에 맞출지(hug) 정해요. */ contentLayout?: TabsContentLayout; size?: TabsSize; /** 스크롤해도 탭 목록이 화면 위에 붙어 있어요. */ stickyList?: boolean;}function TabsRoot({ className, triggerLayout = 'fill', contentLayout = 'hug', size = 'small', stickyList = false, style, ...props}: TabsRootProps) { const context = React.useMemo(() => ({ triggerLayout, size, stickyList }), [triggerLayout, size, stickyList]); return ( <TabsContext.Provider value={context}> <TabsPrimitive.Root data-slot="tabs" data-trigger-layout={triggerLayout} data-size={size} className={cn('relative', contentLayout === 'fill' ? 'flex h-full flex-col' : 'block', className)} style={{ ['--tabs-list-height' as string]: size === 'medium' ? '44px' : '40px', ...style }} {...props} /> </TabsContext.Provider> );}/** 선택된 트리거의 위치·너비를 측정해 리스트에 CSS 변수(--indicator-left/width)로 기록해요. */function useTabsIndicator(listRef: React.RefObject<HTMLDivElement | null>, triggerSelector: string) { React.useLayoutEffect(() => { const list = listRef.current; if (!list) return; let frame = 0; const update = () => { const active = list.querySelector<HTMLElement>(`${triggerSelector}[data-state="active"]`); if (!active) { list.dataset.indicator = 'pending'; return; } list.style.setProperty('--indicator-left', `${active.offsetLeft}px`); list.style.setProperty('--indicator-width', `${active.offsetWidth}px`); if (list.dataset.indicator !== 'ready' && list.dataset.indicator !== 'measured') { list.dataset.indicator = 'measured'; // 첫 위치를 잡은 다음 프레임부터 이동 애니메이션을 켜요. cancelAnimationFrame(frame); frame = requestAnimationFrame(() => { list.dataset.indicator = 'ready'; }); } }; update(); const mutation = new MutationObserver(update); mutation.observe(list, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-state'] }); const resize = new ResizeObserver(update); resize.observe(list); list.querySelectorAll(triggerSelector).forEach((el) => resize.observe(el)); return () => { mutation.disconnect(); resize.disconnect(); cancelAnimationFrame(frame); }; }, [listRef, triggerSelector]);}export interface TabsListProps extends React.ComponentProps<typeof TabsPrimitive.List> {}function TabsList({ className, children, ref, ...props }: TabsListProps) { const { triggerLayout, size, stickyList } = React.useContext(TabsContext); const listRef = React.useRef<HTMLDivElement>(null); useTabsIndicator(listRef, '[data-slot="tabs-trigger"]'); const mergedRef = React.useCallback( (node: HTMLDivElement | null) => { listRef.current = node; if (typeof ref === 'function') ref(node); else if (ref) ref.current = node; }, [ref], ); return ( <TabsPrimitive.List ref={mergedRef} data-slot="tabs-list" data-indicator="pending" className={cn('group/tabs-list', tabsListVariants({ triggerLayout, size, stickyList }), className)} {...props} > {children} <span aria-hidden data-slot="tabs-indicator" className={cn( 'pointer-events-none absolute bottom-0 z-1 border-b-2 border-fg-neutral will-change-[left,width]', triggerLayout === 'fill' ? 'left-[calc(var(--indicator-left,0px)+var(--rb-dimension-spacing-x-global-gutter))] w-[calc(var(--indicator-width,0px)-2*var(--rb-dimension-spacing-x-global-gutter))]' : 'left-(--indicator-left,0px) w-(--indicator-width,0px)', 'group-data-[indicator=pending]/tabs-list:hidden', 'group-data-[indicator=ready]/tabs-list:transition-[left,width] group-data-[indicator=ready]/tabs-list:duration-(--rb-duration-d4) group-data-[indicator=ready]/tabs-list:ease-easing', 'motion-reduce:transition-none', )} /> </TabsPrimitive.List> );}export interface TabsTriggerProps extends React.ComponentProps<typeof TabsPrimitive.Trigger> { /** 라벨 오른쪽 위에 새 콘텐츠를 알리는 Notification Badge(점)를 보여줘요. */ notification?: boolean;}function TabsTrigger({ className, children, notification = false, ...props }: TabsTriggerProps) { const { triggerLayout, size } = React.useContext(TabsContext); return ( <TabsPrimitive.Trigger data-slot="tabs-trigger" className={cn(tabsTriggerVariants({ triggerLayout, size }), className)} {...props} > {notification ? ( <span className="relative"> {children} <span aria-hidden data-slot="tabs-notification" className="absolute top-0 -right-[8px] size-[6px] rounded-full bg-bg-brand-solid" /> </span> ) : ( children )} </TabsPrimitive.Trigger> );}export interface TabsContentProps extends React.ComponentProps<typeof TabsPrimitive.Content> {}function TabsContent({ className, ...props }: TabsContentProps) { return ( <TabsPrimitive.Content data-slot="tabs-content" className={cn( 'min-w-0 overflow-x-hidden overflow-y-auto outline-none', 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-stroke-focus-ring', 'data-[state=inactive]:hidden', className, )} {...props} /> );}export { TabsRoot, TabsList, TabsTrigger, TabsContent, tabsListVariants, tabsTriggerVariants };