{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs",
  "type": "registry:ui",
  "title": "Tabs",
  "description": "Fluent 2-styled tabs with shadcn APIs. Built on @base-ui/react's Tabs for roving-tabindex keyboard navigation and a layout-measured sliding active-tab indicator; the list is a transparent hairline row with an animated brand underline (not shadcn's muted pill).",
  "author": "graundtech <https://github.com/graundtech/fluent2-react-kit>",
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://fluent2-react-kit.graund.io/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/tabs.tsx",
      "type": "registry:ui",
      "target": "components/ui/tabs.tsx",
      "content": "\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport type { ComponentProps } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Tabs — Fluent 2-styled, shadcn-API tabs.\n *\n * ## Base UI mapping (conventions §9)\n * Roving-tabindex focus, arrow-key navigation, and (per divergence 3 below)\n * the sliding active-tab indicator are genuine behavior/layout-measurement\n * that plain markup can't express, so the four parts wrap `@base-ui/react/tabs`\n * (namespace import as `TabsPrimitive`, matching the actual\n * `export * as Tabs from \"./index.parts.js\"` shape in node_modules — the same\n * pattern `select.tsx`/`radio-group.tsx` use):\n *\n * | Exported (shadcn name) | Base UI primitive     |\n * | ----------------------- | --------------------- |\n * | `Tabs`                  | `Tabs.Root`            |\n * | `TabsList`               | `Tabs.List` (+ `Tabs.Indicator` appended) |\n * | `TabsTrigger`            | `Tabs.Tab`              |\n * | `TabsContent`            | `Tabs.Panel`            |\n *\n * `\"use client\"` is required here — unlike `radio-group.tsx`, this wrapper\n * itself needs it, not just the primitive parts. `Tabs.Indicator` measures the\n * active tab's DOM layout (`getBoundingClientRect`/`offsetLeft`) inside a\n * `React.useEffect` to drive the sliding-underline CSS vars (divergence 3), so\n * the component genuinely participates in client-side behavior beyond just\n * rendering a client-boundary child — matching the reasoning that already\n * governs `select.tsx`/`checkbox.tsx` for a different trigger (icons there;\n * layout measurement here).\n *\n * ## Divergences from the shadcn/Radix Tabs API (all deliberate)\n * 1. **`TabsList` is a transparent underline bar, not a muted pill.** shadcn's\n *    reference `TabsList` is a segmented control: `bg-muted` rounded box with\n *    each `TabsTrigger` becoming a filled `bg-background` pill when active.\n *    Fluent 2's `TabList` has no pill — it's a transparent row of labels over a\n *    hairline `border-b`, with a 2px brand bar that slides under the active\n *    label. This implementation follows Fluent, not shadcn's segmented look:\n *    `TabsList` carries `border-b border-border` (the resting hairline) and no\n *    background; `TabsTrigger` is unstyled at rest beyond text color (muted →\n *    foreground on hover, foreground + semibold when active via\n *    `data-[active]:`), never a filled pill.\n * 2. **Active state uses Base UI's `data-active`, not shadcn's `data-state`\n *    value pair.** Radix's `Tabs.Trigger` exposes `data-state=\"active\"|\n *    \"inactive\"`; Base UI's `Tabs.Tab` instead exposes `data-active` as a plain\n *    presence attribute (see `TabsTabDataAttributes` in node_modules — boolean\n *    presence, not a value pair, the same model `radio-group.tsx`'s\n *    `data-checked` uses). Styled here via the bracketed\n *    `data-[active]:` form per conventions §4.\n * 3. **The sliding indicator uses `Tabs.Indicator`'s CSS-var position API**\n *    (`--active-tab-left`/`--active-tab-width`, from `TabsIndicatorCssVars` in\n *    node_modules), not a custom `ref`-measured span. `Tabs.Indicator` renders\n *    a `<span>` inside `TabsList` with those vars set as inline custom\n *    properties (re-measured via a `ResizeObserver`-backed listener whenever\n *    the active tab or list layout changes) and `hidden` until a non-zero size\n *    is available. This wrapper positions the span with\n *    `left-[var(--active-tab-left)] w-[var(--active-tab-width)]` and animates\n *    moves with `transition-[left,width] duration-normal ease-ease` — the\n *    Fluent sliding-underline motion, confirmed working. **jsdom caveat:**\n *    jsdom has no layout engine, so `offsetWidth`/`getBoundingClientRect` are\n *    always `0`; `Tabs.Indicator` treats that as \"not yet measured\" and stays\n *    `hidden`, so the indicator element exists in the test DOM\n *    (`data-slot=\"tabs-indicator\"`) but the sliding motion itself is only\n *    observable in a real browser (see the Select/Dialog popup-motion note in\n *    conventions §3.5 for the same class of jsdom limitation applied to\n *    motion). The component test asserts the indicator renders and tracks\n *    `data-activation-direction`, not the pixel position.\n * 4. **Default activation is manual (Enter/Space), not automatic-on-arrow.**\n *    Radix/shadcn Tabs activates on arrow-key focus move (`automatic`). Base\n *    UI's `Tabs.List` defaults `activateOnFocus` to `false` — the\n *    WAI-ARIA-recommended \"manual activation\" pattern: arrow keys move focus\n *    among tabs, and the focused tab is only selected on `Enter`/`Space` (or\n *    click). This wrapper does not override that default, so the kit ships\n *    Base UI's more accessible default rather than papering over it to match\n *    Radix bit-for-bit; callers who want automatic activation can pass\n *    `activateOnFocus` to `TabsList` themselves (it's forwarded straight\n *    through as an ordinary prop).\n *\n * ## data-slot note\n * Every part renders a real DOM element, so every part gets a `data-slot`:\n * `tabs`, `tabs-list`, `tabs-indicator`, `tabs-trigger`, `tabs-content`.\n */\n\nfunction Tabs({\n  className,\n  ...props\n}: ComponentProps<typeof TabsPrimitive.Root>) {\n  return (\n    <TabsPrimitive.Root\n      data-slot=\"tabs\"\n      className={cn(\"flex flex-col gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\n/**\n * List — transparent row of tab labels over a hairline bottom border, with\n * the brand sliding indicator (divergence 3) appended as the last child so it\n * paints above the hairline.\n */\nfunction TabsList({\n  className,\n  children,\n  ...props\n}: ComponentProps<typeof TabsPrimitive.List>) {\n  return (\n    <TabsPrimitive.List\n      data-slot=\"tabs-list\"\n      className={cn(\n        \"relative flex items-center gap-1 border-b border-border\",\n        className\n      )}\n      {...props}\n    >\n      {children}\n      <TabsPrimitive.Indicator\n        data-slot=\"tabs-indicator\"\n        // Fluent's Medium indicator is a 3px rounded pill inset 12px from\n        // each edge of the active tab — not an edge-to-edge bar (Figma\n        // validation pass 2, node 9116:18476).\n        className=\"absolute bottom-0 left-[calc(var(--active-tab-left)+12px)] h-[3px] w-[calc(var(--active-tab-width)-24px)] rounded-full bg-primary transition-[left,width] duration-normal ease-ease\"\n      />\n    </TabsPrimitive.List>\n  );\n}\n\n/**\n * Trigger — muted label at rest, brand-adjacent foreground + semibold when\n * active (divergence 2: `data-[active]:`, not shadcn's filled pill). Focus\n * uses the generic offset-ring recipe (conventions §4) — a tab is a\n * selectable control, not a typed-into field, so it doesn't get the bottom\n * brand-accent treatment `Input`/`Select` use.\n */\nfunction TabsTrigger({\n  className,\n  ...props\n}: ComponentProps<typeof TabsPrimitive.Tab>) {\n  return (\n    <TabsPrimitive.Tab\n      data-slot=\"tabs-trigger\"\n      className={cn(\n        // layout\n        // py-3 + 20px line-height = Fluent's 44px Medium tab height (Figma\n        // validation pass 2, node 9116:18471).\n        \"cursor-pointer px-3 py-3 text-sm font-medium whitespace-nowrap select-none\",\n        // motion\n        \"outline-none transition-colors duration-fast ease-ease\",\n        // rest / hover — muted until active\n        \"text-muted-foreground hover:text-foreground\",\n        // active — Fluent foreground + semibold (divergence 2)\n        \"data-[active]:text-foreground data-[active]:font-semibold\",\n        // focus — generic offset-ring recipe (conventions §4)\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        // disabled — opacity-based (conventions §4). Unlike `SelectTrigger`,\n        // Base UI's `Tabs.Tab` never sets the native `disabled` attribute —\n        // it stays a focusable `<button>` per the WAI-ARIA \"disabled but\n        // reachable\" tab pattern and only exposes `aria-disabled`/\n        // `data-disabled` (see `TabsTab.js`), so this is `data-[disabled]:`\n        // only; a plain `disabled:` selector would never match.\n        \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\n/**\n * Content — the panel shown for the active tab. Base UI's `Tabs.Panel`\n * unmounts inactive panels from the accessibility tree (`hidden`) and only\n * the active one is focusable/rendered visible, so this wrapper is just\n * spacing + a focus ring for when a panel itself receives focus (e.g. no\n * focusable content inside it).\n */\nfunction TabsContent({\n  className,\n  ...props\n}: ComponentProps<typeof TabsPrimitive.Panel>) {\n  return (\n    <TabsPrimitive.Panel\n      data-slot=\"tabs-content\"\n      className={cn(\n        \"pt-4 outline-none\",\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        className\n      )}\n      {...props}\n    />\n  );\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent };\n"
    }
  ],
  "docs": "Fluent 2's TabList is a transparent underline control, not shadcn's muted-pill segmented control — `TabsList` renders no background/pill, only a hairline `border-b` plus a 2px brand bar (`Tabs.Indicator`) that slides under the active `TabsTrigger`. The indicator's sliding motion relies on real layout (`getBoundingClientRect`), so it only animates in a browser, not in a zero-layout test environment. Base UI's `Tabs.List` also defaults to *manual* tab activation (arrow keys move focus; `Enter`/`Space` or click selects) rather than Radix's automatic-on-arrow-focus — pass `activateOnFocus` on `TabsList` to opt back into automatic activation. See `tabs.tsx`'s doc comment for the full divergence list."
}
