{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs",
  "type": "registry:ui",
  "title": "Tabs",
  "description": "같은 자리에서 여러 화면을 갈아 보여주는 탐색 컴포넌트입니다.",
  "registryDependencies": [
    "@atom/tokens",
    "@atom/lib-bem",
    "@atom/lib-cn",
    "@atom/lib-context",
    "@atom/lib-use-controllable-state"
  ],
  "files": [
    {
      "path": "poc/design-system/implement/src/components/tabs.tsx",
      "content": "\"use client\";\n\nimport {\n  type HTMLAttributes,\n  type KeyboardEvent,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { mod } from \"../lib/bem\";\nimport { cn } from \"../lib/cn\";\nimport { createStrictContext } from \"../lib/context\";\nimport { useControllableState } from \"../lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------- */\n/*  Tabs (components/tabs.md, styles/atom/elements/tabs.css)                  */\n/*    <Tabs aria-label=\"처리 상태\" items={[                                    */\n/*      { value: \"all\", label: \"전체\", count: 128 },                           */\n/*      { value: \"ing\", label: \"진행중\" },                                     */\n/*    ]}>                                                                     */\n/*      <Tabs.Panel value=\"all\">…</Tabs.Panel>                                */\n/*      <Tabs.Panel value=\"ing\">…</Tabs.Panel>                                */\n/*    </Tabs>                                                                 */\n/* -------------------------------------------------------------------------- */\n\nexport type TabsDirection = \"horizontal\" | \"vertical\";\nexport type TabsLayout = \"fit\" | \"scroll\";\nexport type TabsSize = \"small\" | \"medium\";\nexport type TabsActivationMode = \"automatic\" | \"manual\";\nexport type TabsItem = {\n  value: string;\n  label: ReactNode;\n  /** 라벨 뒤 개수. 0 이면 표시하지 않습니다. */\n  count?: number;\n  /** aria-disabled — 포커스는 받되 선택되지 않습니다. */\n  disabled?: boolean;\n};\n\nconst B = \"atom-tabs\";\n\ntype TabsContextValue = {\n  value: string | undefined;\n  tabId: (value: string) => string | undefined;\n  panelId: (value: string) => string | undefined;\n  registerPanel: (value: string) => () => void;\n};\n\nconst [TabsContext, useTabsContext] = createStrictContext<TabsContextValue>(\"Tabs\");\n\nexport type TabsProps = Omit<HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\"> & {\n  ref?: Ref<HTMLDivElement>;\n  /** 선택된 탭의 값입니다. 주면 controlled 입니다. */\n  value?: string;\n  /** uncontrolled 일 때 처음 선택할 탭입니다. 없으면 첫 번째(비활성이 아닌) 탭입니다. */\n  defaultValue?: string;\n  /** 탭 목록입니다. */\n  items: TabsItem[];\n  /** 레일을 위에 둘지 시작 방향 옆에 둘지 정합니다. */\n  direction?: TabsDirection;\n  /** 균등 분할과 라벨 폭 + 가로 스크롤 중 하나입니다. */\n  layout?: TabsLayout;\n  /** Trigger 높이와 폰트 크기를 결정합니다. */\n  size?: TabsSize;\n  /** 화살표 이동만으로 전환할지, Enter 로 확정할지입니다. */\n  activationMode?: TabsActivationMode;\n  /** 탭이 바뀔 때 호출됩니다. */\n  onValueChange?: (value: string) => void;\n  /** `role=\"tablist\"` 의 이름입니다(무엇을 고르는 탭인지). List 에 붙습니다. */\n  \"aria-label\"?: string;\n  /** `role=\"tablist\"` 의 이름을 다른 요소에서 가져올 때. List 에 붙습니다. */\n  \"aria-labelledby\"?: string;\n};\n\nfunction TabsRoot({\n  ref,\n  value: valueProp,\n  defaultValue,\n  items,\n  direction = \"horizontal\",\n  layout = \"fit\",\n  size = \"medium\",\n  activationMode = \"automatic\",\n  onValueChange,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledby,\n  className,\n  children,\n  ...rest\n}: TabsProps) {\n  const baseId = useId();\n  const [value, setValue] = useControllableState<string | undefined>({\n    value: valueProp,\n    defaultValue: defaultValue ?? items.find((i) => !i.disabled)?.value,\n    onChange: (v) => {\n      if (v !== undefined) onValueChange?.(v);\n    },\n  });\n  const [panels, setPanels] = useState<ReadonlySet<string>>(() => new Set());\n  const triggers = useRef<(HTMLButtonElement | null)[]>([]);\n  const vertical = direction === \"vertical\";\n\n  const indexOf = useCallback((v: string) => items.findIndex((i) => i.value === v), [items]);\n  const tabId = useCallback(\n    (v: string) => {\n      const i = indexOf(v);\n      return i < 0 ? undefined : `${baseId}-tab-${i}`;\n    },\n    [baseId, indexOf],\n  );\n  const panelId = useCallback(\n    (v: string) => {\n      const i = indexOf(v);\n      return i < 0 ? undefined : `${baseId}-panel-${i}`;\n    },\n    [baseId, indexOf],\n  );\n  const registerPanel = useCallback((v: string) => {\n    setPanels((prev) => new Set(prev).add(v));\n    return () =>\n      setPanels((prev) => {\n        const next = new Set(prev);\n        next.delete(v);\n        return next;\n      });\n  }, []);\n\n  const select = (item: TabsItem) => {\n    if (item.disabled || item.value === value) return;\n    setValue(item.value);\n  };\n\n  // roving tabindex — 선택된 탭만 0. 선택이 없으면 첫 번째 탭.\n  const selectedIndex = value === undefined ? -1 : indexOf(value);\n  const tabbable = selectedIndex >= 0 ? selectedIndex : 0;\n\n  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {\n    const current = triggers.current.findIndex((el) => el === e.target);\n    if (current < 0) return;\n    const n = items.length;\n    const prevKey = vertical ? \"ArrowUp\" : \"ArrowLeft\";\n    const nextKey = vertical ? \"ArrowDown\" : \"ArrowRight\";\n    let target: number;\n    switch (e.key) {\n      case prevKey:\n        target = (current - 1 + n) % n;\n        break;\n      case nextKey:\n        target = (current + 1) % n;\n        break;\n      case \"Home\":\n        target = 0;\n        break;\n      case \"End\":\n        target = n - 1;\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    triggers.current[target]?.focus();\n    const item = items[target];\n    if (activationMode === \"automatic\" && item) select(item);\n  };\n\n  return (\n    <TabsContext value={{ value, tabId, panelId, registerPanel }}>\n      <div\n        {...rest}\n        ref={ref}\n        className={cn(\n          B,\n          mod(B, \"direction\", direction, \"horizontal\"),\n          mod(B, \"layout\", layout, \"fit\"),\n          mod(B, \"size\", size, \"medium\"),\n          className,\n        )}\n      >\n        <div\n          className={`${B}__list`}\n          role=\"tablist\"\n          aria-label={ariaLabel}\n          aria-labelledby={ariaLabelledby}\n          aria-orientation={vertical ? \"vertical\" : undefined}\n          onKeyDown={onKeyDown}\n        >\n          {items.map((item, i) => {\n            const selected = item.value === value;\n            return (\n              <button\n                key={item.value}\n                ref={(el) => {\n                  triggers.current[i] = el;\n                }}\n                type=\"button\"\n                role=\"tab\"\n                id={tabId(item.value)}\n                className={`${B}__trigger`}\n                aria-selected={selected}\n                aria-controls={panels.has(item.value) ? panelId(item.value) : undefined}\n                aria-disabled={item.disabled ? \"true\" : undefined}\n                tabIndex={i === tabbable ? 0 : -1}\n                onClick={() => select(item)}\n              >\n                <span className={`${B}__label`} data-label={typeof item.label === \"string\" ? item.label : undefined}>\n                  {item.label}\n                </span>\n                {item.count != null && item.count !== 0 && <span className={`${B}__count`}>{item.count}</span>}\n              </button>\n            );\n          })}\n        </div>\n        {children}\n      </div>\n    </TabsContext>\n  );\n}\n\nexport type TabsPanelProps = HTMLAttributes<HTMLDivElement> & {\n  ref?: Ref<HTMLDivElement>;\n  /** 연결할 탭의 값(items[].value) 입니다. */\n  value: string;\n};\n\n/** `role=\"tabpanel\"`. 선택되지 않은 패널은 `hidden` 을 답니다. */\nfunction TabsPanel({ ref, value, className, children, ...rest }: TabsPanelProps) {\n  const ctx = useTabsContext(\"Tabs.Panel\");\n  const { registerPanel } = ctx;\n  useLayoutEffect(() => registerPanel(value), [registerPanel, value]);\n  const selected = ctx.value === value;\n  return (\n    <div\n      tabIndex={0}\n      {...rest}\n      ref={ref}\n      role=\"tabpanel\"\n      id={ctx.panelId(value)}\n      aria-labelledby={ctx.tabId(value)}\n      hidden={!selected}\n      className={cn(`${B}__panel`, className)}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport const Tabs = Object.assign(TabsRoot, { Panel: TabsPanel });\n",
      "type": "registry:ui",
      "target": "components/ui/tabs.tsx"
    },
    {
      "path": "poc/design-system/implement/src/styles/atom/elements/tabs.css",
      "content": "/* ============================================================================\n   Element — Tabs\n   ----------------------------------------------------------------------------\n   A tablist over one panel area. Only the selected panel is in the document;\n   the rest carry `hidden`. The selected tab is marked three ways at once —\n   colour, weight and the 2px indicator — so the signal survives greyscale.\n\n   The two layouts are the real choice here. `fit` splits the rail evenly and\n   holds only while every label fits on one line; `scroll` lets each tab take\n   its label's width and pushes the overflow onto a horizontal rail. Labels\n   never wrap — `nowrap` on the trigger keeps the rail one line tall. Picking `fit` for a set that will\n   grow is what breaks tab bars.\n\n   atom-tabs\n     --direction_{horizontal|vertical}       horizontal is the default\n     --layout_{fit|scroll}                   fit is the default\n     --size_{small|medium}                   medium is the default\n     __list      role=\"tablist\"\n     __trigger   role=\"tab\"      [aria-selected] [aria-disabled] [data-hover]\n     __label     wraps the text — required for ellipsis and for the bold\n                 width reservation (see below)\n     __count     trailing number\n     __panel     role=\"tabpanel\" — [hidden] on every unselected one\n   ========================================================================== */\n\n.atom-tabs {\n  border-radius: var(--atom-radius-r3) var(--atom-radius-r3) 0 0;\n  background: var(--atom-bg-layer);\n  overflow: hidden;\n}\n\n/* --- list ---------------------------------------------------------------- */\n/* The divider is an inset shadow rather than a border so it does not add to the\n   list's height — the rail height must stay exactly the trigger height. */\n.atom-tabs__list {\n  display: flex;\n  box-shadow: inset 0 -1px 0 var(--atom-border-subtle);\n}\n\n/* --- trigger ------------------------------------------------------------- */\n.atom-tabs__trigger {\n  position: relative;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: var(--atom-x1);\n  flex: 1;\n  min-width: 0;\n  height: 44px;\n  padding-inline: var(--atom-x2);\n  font-size: 14px;\n  color: var(--atom-fg-neutral-muted);\n  white-space: nowrap;\n  overflow: hidden;\n  transition: color var(--atom-duration-fast) ease;\n}\n\n.atom-tabs__label {\n  display: block;\n  min-width: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n}\n\n/* Selecting a tab switches it to 600, which in `scroll` would resize the tab and\n   shift every tab after it. The label reserves its own bold width up front with\n   a zero-height copy, so the rail never moves. Needs `data-label` to match the\n   text; without it the copy is empty and only `fit` stays safe. */\n.atom-tabs__label::after {\n  content: attr(data-label);\n  display: block;\n  height: 0;\n  overflow: hidden;\n  font-weight: 600;\n  visibility: hidden;\n}\n\n.atom-tabs__count {\n  flex-shrink: 0;\n  font-variant-numeric: tabular-nums;\n  opacity: 0.7;\n}\n\n/* --- indicator ----------------------------------------------------------- */\n/* The indicator lives on each trigger, so it cannot slide between tabs — that\n   needs one shared bar and JS to measure the target. What it can do is grow\n   from the centre instead of appearing, which reads as a move without claiming\n   to be one. base.css already collapses this under prefers-reduced-motion. */\n.atom-tabs__trigger::after {\n  content: \"\";\n  position: absolute;\n  inset: auto var(--atom-x2) 0 var(--atom-x2);\n  height: 2px;\n  border-radius: 2px 2px 0 0;\n  background: var(--atom-fg-neutral);\n  opacity: 0;\n  transform: scaleX(0.4);\n  transition: opacity var(--atom-duration-fast) ease,\n    transform var(--atom-duration-base) var(--atom-ease-out);\n}\n\n/* --- layouts ------------------------------------------------------------- */\n/* `fit` is the default and needs no rule — flex: 1 above is it. */\n.atom-tabs--layout_scroll .atom-tabs__list {\n  overflow-x: auto;\n  scrollbar-width: none;\n}\n\n.atom-tabs--layout_scroll .atom-tabs__list::-webkit-scrollbar {\n  display: none;\n}\n\n.atom-tabs--layout_scroll .atom-tabs__trigger {\n  flex: 0 0 auto;\n  padding-inline: var(--atom-x4);\n}\n\n/* --- sizes --------------------------------------------------------------- */\n.atom-tabs--size_small .atom-tabs__trigger {\n  height: 36px;\n  font-size: 13px;\n}\n\n.atom-tabs--size_small.atom-tabs--layout_scroll .atom-tabs__trigger {\n  padding-inline: var(--atom-x3);\n}\n\n/* --- states -------------------------------------------------------------- */\n.atom-tabs__trigger[aria-selected=\"true\"] {\n  color: var(--atom-fg-neutral);\n  font-weight: 600;\n}\n\n.atom-tabs__trigger[aria-selected=\"true\"]::after {\n  opacity: 1;\n  transform: none;\n}\n\n/* `data-hover` is a documentation hook: it renders the hover treatment in a\n   demo or screenshot, where there is no pointer to produce it. */\n.atom-tabs__trigger:hover:not([aria-selected=\"true\"]):not([aria-disabled=\"true\"]):not([disabled]),\n.atom-tabs__trigger[data-hover]:not([aria-selected=\"true\"]):not([aria-disabled=\"true\"]):not([disabled]) {\n  color: var(--atom-fg-neutral);\n}\n\n/* The shared :focus-visible outline sits outside the trigger, where it lands on\n   top of the list divider and reads as a broken line. Pulled inside instead. */\n.atom-tabs__trigger:focus-visible {\n  outline: 2px solid var(--atom-fg-brand);\n  outline-offset: -3px;\n  border-radius: var(--atom-radius-r2);\n}\n\n.atom-tabs__trigger[aria-disabled=\"true\"],\n.atom-tabs__trigger[disabled] {\n  opacity: 0.4;\n  cursor: not-allowed;\n}\n\n/* --- direction ----------------------------------------------------------- */\n/* Horizontal is the default and needs no rule. Vertical turns the rail 90°: the\n   list becomes a column at the inline start, its divider moves to the inline\n   end, and the panel sits beside the rail instead of under it.\n\n   Placed after the states block on purpose — the indicator rules below have to\n   land after their horizontal counterparts to win the tie. */\n.atom-tabs--direction_vertical {\n  display: flex;\n  align-items: stretch;\n}\n\n.atom-tabs--direction_vertical .atom-tabs__list {\n  flex: 0 0 auto;\n  flex-direction: column;\n  min-width: 140px;\n  box-shadow: inset -1px 0 0 var(--atom-border-subtle);\n}\n\n/* A column has no spare width to distribute, so triggers keep their content\n   height and fill the rail. That leaves `--layout_fit` and `--layout_scroll`\n   differing here only in whether the rail scrolls — see the Guidelines. */\n.atom-tabs--direction_vertical .atom-tabs__trigger {\n  flex: 0 0 auto;\n  justify-content: flex-start;\n  height: auto;\n  min-height: 44px;\n  padding-block: var(--atom-x2);\n  padding-inline: var(--atom-x4);\n  text-align: left;\n}\n\n.atom-tabs--direction_vertical.atom-tabs--size_small .atom-tabs__trigger {\n  min-height: 36px;\n  padding-inline: var(--atom-x3);\n}\n\n/* The bar swaps axis with the rail: leading edge, full height, growing from the\n   centre vertically instead of horizontally. */\n.atom-tabs--direction_vertical .atom-tabs__trigger::after {\n  inset: var(--atom-x1) auto var(--atom-x1) 0;\n  width: 2px;\n  height: auto;\n  border-radius: 0 2px 2px 0;\n  transform: scaleY(0.4);\n}\n\n.atom-tabs--direction_vertical .atom-tabs__trigger[aria-selected=\"true\"]::after {\n  transform: none;\n}\n\n.atom-tabs--direction_vertical.atom-tabs--layout_scroll .atom-tabs__list {\n  overflow-x: hidden;\n  overflow-y: auto;\n}\n\n.atom-tabs--direction_vertical .atom-tabs__panel {\n  flex: 1;\n  min-width: 0;\n}\n\n/* --- panel --------------------------------------------------------------- */\n/* Height follows the content — a fixed panel is what makes a tab switch feel\n   like a page reload when the two panels differ in length. */\n.atom-tabs__panel {\n  display: block;\n  padding: var(--atom-x4);\n}\n\n.atom-tabs__panel[hidden] {\n  display: none;\n}\n",
      "type": "registry:file",
      "target": "styles/atom/elements/tabs.css"
    }
  ],
  "categories": [
    "navigation"
  ],
  "meta": {
    "version": "0.1.0",
    "kind": "component",
    "status": "stable",
    "specSource": "measured",
    "parts": [
      "Tabs",
      "Tabs.Panel"
    ],
    "docs": "poc/design-system/components/tabs.md"
  }
}
