/** * @title Tabs * @description 같은 맥락 안에서 서로 관련된 콘텐츠 묶음을 오가며 볼 수 있게 해주는 탭이에요. */import * as React from 'react';import { Tabs as TabsPrimitive } from 'radix-ui';import { cn } from '@/lib/utils';// Atlassian @atlaskit/tabs — components/tabs, tab-list, tab, tab-panel 스펙type TabsInternalContextValue = { selected: number; /** 한 번이라도 선택된 패널 (shouldUnmountTabPanelOnChange=false 일 때 마운트 유지) */ visited: Set<number>; shouldUnmountTabPanelOnChange: boolean;};const TabsInternalContext = React.createContext<TabsInternalContextValue | null>(null);const TabIndexContext = React.createContext<number | null>(null);const TabPanelIndexContext = React.createContext<number | null>(null);function useTabsInternal(name: string) { const ctx = React.useContext(TabsInternalContext); if (!ctx) throw new Error(`Steel Tabs: ${name} 는 Tabs 안에서 써야 해요.`); return ctx;}export interface TabsProps extends Omit<React.ComponentProps<typeof TabsPrimitive.Root>, 'value' | 'defaultValue' | 'onValueChange' | 'onChange' | 'id'> { /** 탭·패널 id 를 만드는 데 쓰는 고유 id (접근성용, 필수) */ id: string; /** 제어 모드에서 선택된 탭 index */ selected?: number; /** 처음 선택될 탭 index. 기본 0 */ defaultSelected?: number; /** 탭이 바뀔 때 새 index 와 함께 호출돼요. */ onChange?: (index: number) => void; /** true 면 선택되지 않은 패널을 언마운트해요. 기본은 한 번 본 패널을 숨긴 채 유지해요. */ shouldUnmountTabPanelOnChange?: boolean; testId?: string;}/** 첫 자식은 TabList, 이어지는 자식은 TabPanel 이에요. */function Tabs({ id, selected: selectedProp, defaultSelected, onChange, shouldUnmountTabPanelOnChange = false, testId, className, children, ...props}: TabsProps) { const [selectedState, setSelectedState] = React.useState(selectedProp ?? defaultSelected ?? 0); const selected = selectedProp === undefined ? selectedState : selectedProp; // 방문한 패널 기록 (원본과 같이 렌더 중에 추가) const visitedRef = React.useRef(new Set<number>([selected])); if (!visitedRef.current.has(selected)) visitedRef.current.add(selected); const handleValueChange = React.useCallback( (value: string) => { const index = Number(value); onChange?.(index); setSelectedState(index); }, [onChange], ); const childrenArray = React.Children.toArray(children).filter(Boolean); const [tabList, ...tabPanels] = childrenArray; return ( <TabsInternalContext.Provider value={{ selected, visited: visitedRef.current, shouldUnmountTabPanelOnChange }} > <TabsPrimitive.Root data-slot="tabs" data-testid={testId} id={id} value={String(selected)} onValueChange={handleValueChange} activationMode="automatic" className={cn( 'flex min-h-0 max-w-full grow basis-full flex-col', '[&>[role=tabpanel]]:flex [&>[role=tabpanel]]:min-h-0 [&>[role=tabpanel]]:grow [&>[hidden]]:hidden', className, )} {...props} > {tabList} {tabPanels.map((panel, index) => ( <TabPanelIndexContext.Provider key={index} value={index}> {panel} </TabPanelIndexContext.Provider> ))} </TabsPrimitive.Root> </TabsInternalContext.Provider> );}export interface TabListProps extends React.ComponentProps<typeof TabsPrimitive.List> {}/* ------------------------------------------------------------------------------------------------- * 선택 인디케이터 모션 (platform-dst-motion-uplift-tab ON) * 선택이 바뀌면 새 탭의 밑줄은 바뀐 방향으로 8px 미끄러지며 나타나고(150ms), * 이전 탭의 밑줄은 같은 방향으로 8px 미끄러지며 사라져요(100ms). * -----------------------------------------------------------------------------------------------*/type TabMotionDirection = 'left' | 'right';type TabMotionState = 'entering' | 'exiting' | 'visible';type TabMotionSelection = { direction: TabMotionDirection; exits: Map<number, TabMotionDirection>; isReducedMotion: boolean; selected: number; selectedState: TabMotionState;};type TabMotionAttributes = { 'data-motion-direction'?: TabMotionDirection; 'data-motion-state'?: TabMotionState;};const TabMotionContext = React.createContext<((index: number) => TabMotionAttributes) | null>(null);const getSelectionDirection = (previous: number, next: number): TabMotionDirection => next > previous ? 'right' : 'left';function reconcileMotionSelection( current: TabMotionSelection, selected: number, isReducedMotion: boolean,): TabMotionSelection { if (isReducedMotion) { if (current.isReducedMotion && current.selected === selected && current.selectedState === 'visible' && current.exits.size === 0) { return current; } return { direction: current.selected === selected ? current.direction : getSelectionDirection(current.selected, selected), exits: new Map(), isReducedMotion: true, selected, selectedState: 'visible', }; } const base = current.isReducedMotion ? { ...current, isReducedMotion: false } : current; if (base.selected === selected) return base; const direction = getSelectionDirection(base.selected, selected); const exits = new Map(base.exits); exits.set(base.selected, direction); exits.delete(selected); return { direction, exits, isReducedMotion: false, selected, selectedState: 'entering' };}const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';function usePrefersReducedMotion() { return React.useSyncExternalStore( (onChange) => { if (typeof window === 'undefined' || !window.matchMedia) return () => {}; const mql = window.matchMedia(REDUCED_MOTION_QUERY); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, () => (typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(REDUCED_MOTION_QUERY).matches : false), () => false, );}/** Tab 들을 묶는 목록. 아래 1px 구분선을 그려요. */function TabList({ className, children, onAnimationEndCapture, ...props }: TabListProps) { const { selected } = useTabsInternal('TabList'); const tabs = React.Children.toArray(children).filter(Boolean); const isReducedMotion = usePrefersReducedMotion(); // 처음 그릴 때도 선택된 밑줄은 entering(오른쪽 방향)으로 시작해요 (원본과 같음) const [motionSelection, setMotionSelection] = React.useState<TabMotionSelection>(() => ({ direction: 'right', exits: new Map(), isReducedMotion, selected, selectedState: isReducedMotion ? 'visible' : 'entering', })); const rendered = reconcileMotionSelection(motionSelection, selected, isReducedMotion); if (rendered !== motionSelection) setMotionSelection(rendered); const getTabMotionAttributes = React.useCallback( (index: number): TabMotionAttributes => { if (index === rendered.selected) { return { 'data-motion-direction': rendered.direction, 'data-motion-state': rendered.selectedState }; } const exitDirection = rendered.exits.get(index); if (exitDirection !== undefined) return { 'data-motion-direction': exitDirection, 'data-motion-state': 'exiting' }; return {}; }, [rendered], ); const handleAnimationEnd = (event: React.AnimationEvent<HTMLDivElement>) => { onAnimationEndCapture?.(event); const target = event.target; if (!(target instanceof HTMLElement) || event.pseudoElement !== '::after' || !target.matches('[role="tab"]')) return; const index = Number(target.getAttribute('data-tab-index')); const state = target.getAttribute('data-motion-state'); if (!Number.isInteger(index)) return; setMotionSelection((current) => { const next = reconcileMotionSelection(current, selected, isReducedMotion); if (state === 'entering' && index === next.selected && next.selectedState === 'entering') { return { ...next, selectedState: 'visible' }; } if (state === 'exiting' && next.exits.has(index)) { const exits = new Map(next.exits); exits.delete(index); return { ...next, exits }; } return next; }); }; return ( <TabMotionContext.Provider value={getTabMotionAttributes}> <TabsPrimitive.List data-slot="tab-list" loop onAnimationEndCapture={handleAnimationEnd} className={cn( 'relative flex p-ds-0 font-ds-medium ms-ds-negative-100', // 하단 구분선: border(1px), 왼쪽은 탭 안쪽 여백(8px) 만큼 들여서 시작 "before:pointer-events-none before:absolute before:end-0 before:bottom-0 before:start-ds-100 before:h-(--ds-border-width) before:bg-ds-border before:content-['']", className, )} {...props} > {tabs.map((tab, index) => ( <TabIndexContext.Provider key={index} value={index}> {tab} </TabIndexContext.Provider> ))} </TabsPrimitive.List> </TabMotionContext.Provider> );}export interface TabProps extends Omit<React.ComponentProps<typeof TabsPrimitive.Trigger>, 'value'> { testId?: string;}/** 탭 하나. 선택되면 2px 밑줄(border.selected)과 선택 글자색이 적용돼요. */function Tab({ className, children, testId, ...props }: TabProps) { const index = React.useContext(TabIndexContext); const getTabMotionAttributes = React.useContext(TabMotionContext); if (index === null) throw new Error('Steel Tabs: Tab 은 TabList 안에서 써야 해요.'); const motionAttributes = getTabMotionAttributes?.(index) ?? {}; return ( <TabsPrimitive.Trigger data-slot="tab" data-testid={testId} data-tab-index={index} {...motionAttributes} value={String(index)} className={cn( 'relative m-0 min-w-0 cursor-pointer overflow-hidden rounded-ds-medium border-0 bg-transparent px-ds-100 py-ds-075', 'ds-font-body font-ds-medium truncate text-ds-text-subtle', // 글자색·투명도 전환: motion.tab (150ms) 'transition-[color,opacity] duration-(--ds-motion-tab-duration) ease-(--ds-motion-tab-curve) motion-reduce:transition-none', // 포커스 링: Focusable isInset 'outline-2 -outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', // hover·press 중립 밑줄(:before, 2px border): 투명도로 서서히 나타나요 "before:pointer-events-none before:absolute before:end-ds-100 before:bottom-0 before:start-ds-100 before:h-0 before:border-b-(length:--ds-border-width-selected) before:border-solid before:border-ds-border before:opacity-0 before:content-['']", 'before:transition-opacity before:duration-(--ds-motion-tab-duration) before:ease-(--ds-motion-tab-curve) motion-reduce:before:transition-none', 'hover:before:opacity-100 active:before:opacity-100', // 선택 인디케이터(:after, 2px border.selected): 선택된 탭과 사라지는 중인 탭에만 그려요 "after:pointer-events-none after:absolute after:end-ds-100 after:bottom-0 after:start-ds-100 after:z-[1] after:h-0 after:border-b-(length:--ds-border-width-selected) after:border-solid after:border-transparent after:content-['']", 'data-[state=active]:after:border-ds-border-selected data-[motion-state=exiting]:after:border-ds-border-selected', // press: 선택 안 된 탭은 글자색 text 'data-[state=inactive]:active:text-ds-text', // selected: text.selected (hover·press 에도 유지) 'data-[state=active]:text-ds-text-selected', // 인디케이터 모션 — right(뒤쪽 탭으로 이동): 시작 쪽에서 8px 미끄러져 들어오고, 이전 밑줄은 끝 쪽으로 8px 나가요 'data-[motion-state=entering]:after:animate-in data-[motion-state=entering]:after:fade-in-0 data-[motion-state=entering]:after:fill-mode-backwards', 'data-[motion-state=exiting]:after:animate-out data-[motion-state=exiting]:after:fade-out-0 data-[motion-state=exiting]:after:fill-mode-forwards', 'data-[motion-state=entering]:after:duration-(--ds-motion-tab-indicator-enter-left-duration) data-[motion-state=entering]:after:ease-(--ds-motion-tab-indicator-enter-left-curve)', 'data-[motion-state=exiting]:after:duration-(--ds-motion-tab-indicator-exit-left-duration) data-[motion-state=exiting]:after:ease-(--ds-motion-tab-indicator-exit-left-curve)', 'data-[motion-state=entering]:data-[motion-direction=right]:after:[--tw-enter-translate-x:-8px] data-[motion-state=entering]:data-[motion-direction=left]:after:[--tw-enter-translate-x:8px]', 'data-[motion-state=exiting]:data-[motion-direction=right]:after:[--tw-exit-translate-x:8px] data-[motion-state=exiting]:data-[motion-direction=left]:after:[--tw-exit-translate-x:-8px]', // RTL 은 좌우를 뒤집어요 'rtl:data-[motion-state=entering]:data-[motion-direction=right]:after:[--tw-enter-translate-x:8px] rtl:data-[motion-state=entering]:data-[motion-direction=left]:after:[--tw-enter-translate-x:-8px]', 'rtl:data-[motion-state=exiting]:data-[motion-direction=right]:after:[--tw-exit-translate-x:-8px] rtl:data-[motion-state=exiting]:data-[motion-direction=left]:after:[--tw-exit-translate-x:8px]', 'motion-reduce:after:animate-none', className, )} {...props} > {/* platform_dst_tabs_remove_line_clamp: 안쪽 줄 제한 없이 탭 자체에서 말줄임 */} <span>{children}</span> </TabsPrimitive.Trigger> );}export interface TabPanelProps extends Omit<React.ComponentProps<typeof TabsPrimitive.Content>, 'value' | 'forceMount'> { testId?: string;}/** 탭 콘텐츠. 기본은 한 번 열린 패널을 숨긴 채 유지해요. */function TabPanel({ className, children, testId, ...props }: TabPanelProps) { const index = React.useContext(TabPanelIndexContext); const { selected, visited, shouldUnmountTabPanelOnChange } = useTabsInternal('TabPanel'); if (index === null) throw new Error('Steel Tabs: TabPanel 은 Tabs 의 직접 자식이어야 해요.'); const isSelected = index === selected; const keepMounted = !shouldUnmountTabPanelOnChange && visited.has(index); if (!isSelected && !keepMounted) return null; return ( <TabsPrimitive.Content data-slot="tab-panel" data-testid={testId} value={String(index)} forceMount={keepMounted ? true : undefined} hidden={!isSelected} tabIndex={isSelected ? 0 : -1} className={cn('outline-2 -outline-offset-2 outline-transparent focus-visible:outline-ds-border-focused', className)} {...props} > {children} </TabsPrimitive.Content> );}export { Tabs, TabList, Tab, TabPanel };