"use client"; import { Children, Fragment, cloneElement, createRef, isValidElement, type KeyboardEvent, type ReactElement, type ReactNode, type RefObject, } from "react"; import { cx } from "../../utils"; import type { FoxTabItemProps } from "../fox-tab-item"; export type FoxTabType = "line" | "fill"; export type FoxTabSize = "lg" | "md"; export interface FoxTabProps { /** `FoxTabItem`들. */ children?: ReactNode; /** `line`은 밑줄로, `fill`은 색을 채워 고른 탭을 표시한다. */ type?: FoxTabType; /** `lg`는 폭을 고르게 나눠 가지고, `md`는 글자만큼만 차지하고 나란히 선다. */ size?: FoxTabSize; /** 고른 탭의 `value`. */ value?: string; onValueChange?: (value: string) => void; /** 목록을 읽어 줄 이름. */ label?: string; /** 참이면 렌더하지 않는다(DOM에 남지 않는다). */ hidden?: boolean; /** 배치 조정용. */ className?: string; } type ItemElement = ReactElement; /** 프래그먼트 안까지 훑어 탭을 모은다. */ function collect(children: ReactNode): ItemElement[] { const items: ItemElement[] = []; Children.forEach(children, (child) => { if (!isValidElement(child)) { return; } if (child.type === Fragment) { const fragment = child as ReactElement<{ children?: ReactNode }>; items.push(...collect(fragment.props.children)); return; } items.push(child as ItemElement); }); return items; } /** * @fox 탭 묶음. 어느 탭이 골라졌는지는 이 컴포넌트가 소유한다. * * 항목이 `href`를 하나라도 가지면 주소가 바뀌는 탭으로 보고 탭 역할(`role="tablist"`)을 * 붙이지 않는다 — 같은 화면 안의 전환이 아니라 이동이기 때문이다. 그 밖에는 ARIA 탭 * 규약대로 좌우 화살표·Home·End로 옮길 수 있고 탭키는 고른 탭에만 닿는다. * * ⚠️ 스타일은 이 파일이 import하지 않는다 — 호스트 앱이 `@use "@fox/styles/components"` * (또는 개별 파티셜)로 한 번 불러와야 한다. */ export function FoxTab({ children, type = "line", size = "lg", value, onValueChange, label = "탭", hidden = false, className, }: FoxTabProps) { if (hidden) { return null; } const items = collect(children); const tabRole = !items.some((item) => item.props.href); const refs = items.map(() => createRef()); // 잠긴 탭은 건너뛴다 — 옮겨 가도 고를 수 없어 막다른 길이 된다. const move = (from: number, step: number) => { const count = items.length; for (let hop = 1; hop <= count; hop += 1) { const next = (from + step * hop + count * count) % count; if (!items[next].props.disabled) { refs[next].current?.focus(); return; } } }; const navigate = (index: number) => (event: KeyboardEvent) => { if (!tabRole) { return; } if (event.key === "ArrowRight") { event.preventDefault(); move(index, 1); } else if (event.key === "ArrowLeft") { event.preventDefault(); move(index, -1); } else if (event.key === "Home") { event.preventDefault(); move(-1, 1); } else if (event.key === "End") { event.preventDefault(); move(0, -1); } }; return (
{items.map((item, index) => cloneElement(item, { key: item.key ?? item.props.value, active: item.props.value === value, tabRole, onSelect: onValueChange, onNavigate: navigate(index), ref: refs[index] as RefObject, }) )}
); }