{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tabs",
  "title": "Tabs",
  "type": "registry:ui",
  "description": "Horizontal tab navigation with content panels — pick one panel at a time. Underline or pills variants. Built on Radix UI with full keyboard navigation.",
  "categories": [
    "navigation"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/tabs/tabs.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { tabsListVariants, tabsTriggerVariants } from './tabs.variants'\n\ntype Variant = 'segmented' | 'pill' | 'underline'\ntype Orientation = 'horizontal' | 'vertical'\ntype Size = 'default' | 'sm' | 'lg'\n\ninterface Tab {\n  value: string\n  disabled: boolean\n  trigger: Element\n  content?: Element\n}\n\nlet uid = 0\n\n// React's TabsContent string, through cn() like React (twMerge drops the\n// overridden ring-2).\nconst contentClass = cn(\n  'ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 motion-safe:data-[state=active]:animate-in motion-safe:data-[state=active]:fade-in-0 motion-safe:data-[state=active]:blur-in-2 motion-safe:data-[state=active]:slide-in-from-bottom-1 motion-safe:data-[state=active]:ease-emphasized flex-1 focus-visible:ring-2 focus-visible:ring-[3px] focus-visible:outline-none motion-safe:data-[state=active]:duration-200',\n)\n\n/**\n * <uip-tabs> — the registry Tabs (Tabs + TabsList + TabsTrigger + TabsContent)\n * as ONE web component.\n *\n * Each tab's aria-controls points at its panel and each panel's\n * aria-labelledby at its tab; those ids must live in one shadow root, so the\n * triggers and panels are flat light-DOM children paired by `data-value`,\n * which the element slots into its own tablist / tabpanel markup (manual slot\n * assignment):\n *\n *   <uip-tabs default-value=\"account\">\n *     <span slot=\"trigger\" data-value=\"account\">Account</span>\n *     <span slot=\"trigger\" data-value=\"team\" data-disabled>Team</span>\n *     <div slot=\"content\" data-value=\"account\">…</div>\n *   </uip-tabs>\n *\n * Tabs order is trigger order; a trigger's element is laid out with\n * `display: contents` so its icon + text become the trigger's flex items\n * (React's `gap-1.5` / `[&_svg]` layout).\n *\n * Props (React names across the parts): value, default-value, orientation,\n * activation-mode ('automatic' | 'manual'), and TabsList's variant,\n * animated (default true; `animated=\"false\"` turns the sliding indicator\n * off) and TabsTrigger's size. React's per-part `className` → style the\n * shadow parts from outside: `part=\"root\" | \"list\" | \"trigger\" | \"content\"\n * | \"indicator\"`, e.g. `class=\"[&::part(list)]:w-48\"`.\n *\n * Keyboard (Radix): roving tabindex on the tabs; Left/Right (Up/Down when\n * vertical) move and wrap, Home/End jump, disabled tabs are skipped; focus\n * activates in automatic mode, Enter/Space in manual mode.\n * Events: `input`, `change` and `value-change` (detail: { value }).\n */\nexport class UipTabs extends LitElement {\n  static shadowRootOptions: ShadowRootInit = { ...LitElement.shadowRootOptions, slotAssignment: 'manual' }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    value: { reflect: true },\n    defaultValue: { attribute: 'default-value' },\n    orientation: { reflect: true },\n    activationMode: { attribute: 'activation-mode' },\n    variant: { reflect: true },\n    size: { reflect: true },\n    animated: { converter: (v: string | null) => v !== 'false' },\n    tabs: { state: true },\n  }\n\n  value = ''\n  defaultValue?: string\n  orientation: Orientation = 'horizontal'\n  activationMode: 'automatic' | 'manual' = 'automatic'\n  variant: Variant = 'segmented'\n  size: Size = 'default'\n  animated = true\n  private tabs: Tab[] = []\n  private firstPosition = true\n  private readonly uidBase = `uip-tabs-${++uid}`\n  private childObserver?: MutationObserver\n  private resizeObserver?: ResizeObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'tabs')\n    if (!this.value && this.defaultValue) this.value = this.defaultValue\n    this.readTabs()\n    this.childObserver = new MutationObserver(() => this.readTabs())\n    this.childObserver.observe(this, {\n      childList: true,\n      attributes: true,\n      attributeFilter: ['slot', 'data-value', 'data-disabled'],\n    })\n    if (typeof ResizeObserver !== 'undefined') {\n      this.resizeObserver = new ResizeObserver(() => this.updateIndicator())\n      this.updateComplete.then(() => this.observeSizes())\n    }\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.childObserver?.disconnect()\n    this.resizeObserver?.disconnect()\n  }\n\n  willUpdate(changed: Map<string, unknown>) {\n    this.setAttribute('data-orientation', this.orientation)\n    if (changed.has('variant') || changed.has('orientation') || changed.has('animated')) this.firstPosition = true\n  }\n\n  private readTabs() {\n    const kids = [...this.children]\n    this.tabs = kids\n      .filter((k) => k.getAttribute('slot') === 'trigger')\n      .map((trigger) => {\n        const value = trigger.getAttribute('data-value') ?? ''\n        return {\n          value,\n          disabled: trigger.hasAttribute('data-disabled'),\n          trigger,\n          content: kids.find((k) => k.getAttribute('slot') === 'content' && k.getAttribute('data-value') === value),\n        }\n      })\n    this.updateComplete.then(() => this.observeSizes())\n  }\n\n  private get list() {\n    return this.renderRoot?.querySelector<HTMLElement>('[role=tablist]')\n  }\n\n  private observeSizes() {\n    const ro = this.resizeObserver\n    const list = this.list\n    if (!ro || !list) return\n    ro.disconnect()\n    ro.observe(list)\n    list.querySelectorAll('[role=tab]').forEach((t) => ro.observe(t))\n  }\n\n  private motionSafeTransition() {\n    if (matchMedia('(prefers-reduced-motion: reduce)').matches) return 'none'\n    return this.firstPosition\n      ? 'none'\n      : 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1), height 220ms cubic-bezier(0.22, 1, 0.36, 1)'\n  }\n\n  /**\n   * Same geometry as React's TabsList indicator. Written straight to the\n   * indicator's style (runtime values, like React's inline style) so a\n   * measurement never triggers another render.\n   */\n  private set indicatorStyle(style: Record<string, string | number>) {\n    const el = this.renderRoot?.querySelector<HTMLElement>('[data-slot=tabs-indicator]')\n    if (!el) return\n    el.removeAttribute('style')\n    for (const [k, v] of Object.entries(style)) el.style.setProperty(k, String(v))\n  }\n\n  private updateIndicator() {\n    if (!this.animated) return\n    const root = this.list\n    if (!root) return\n    const active = root.querySelector<HTMLElement>('[data-slot=\"tabs-trigger\"][data-state=\"active\"]')\n    if (!active) {\n      this.indicatorStyle = { opacity: 0 }\n      return\n    }\n    const listRect = root.getBoundingClientRect()\n    const activeRect = active.getBoundingClientRect()\n    const left = activeRect.left - listRect.left + root.scrollLeft\n    const top = activeRect.top - listRect.top + root.scrollTop\n    const transition = this.motionSafeTransition()\n    if (this.variant === 'underline') {\n      const thickness = 2\n      this.indicatorStyle =\n        this.orientation === 'vertical'\n          ? {\n              width: `${thickness}px`,\n              height: `${activeRect.height}px`,\n              transform: `translate3d(${listRect.width - thickness}px, ${top}px, 0)`,\n              opacity: 1,\n              transition,\n            }\n          : {\n              width: `${activeRect.width}px`,\n              height: `${thickness}px`,\n              transform: `translate3d(${left}px, ${listRect.height - thickness}px, 0)`,\n              opacity: 1,\n              transition,\n            }\n    } else {\n      this.indicatorStyle = {\n        width: `${activeRect.width}px`,\n        height: `${activeRect.height}px`,\n        transform: `translate3d(${left}px, ${top}px, 0)`,\n        opacity: 1,\n        transition,\n      }\n    }\n    this.firstPosition = false\n  }\n\n  private select(value: string) {\n    if (value === this.value) return\n    this.value = value\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('value-change', { detail: { value }, bubbles: true, composed: true }))\n  }\n\n  private onKeyDown(e: KeyboardEvent, i: number) {\n    const vertical = this.orientation === 'vertical'\n    const prev = vertical ? 'ArrowUp' : 'ArrowLeft'\n    const next = vertical ? 'ArrowDown' : 'ArrowRight'\n    if (this.activationMode === 'manual' && (e.key === 'Enter' || e.key === ' ')) {\n      e.preventDefault()\n      this.select(this.tabs[i].value)\n      return\n    }\n    const enabled = this.tabs.map((t, j) => (t.disabled ? -1 : j)).filter((j) => j >= 0)\n    const pos = enabled.indexOf(i)\n    const n = enabled.length\n    let to: number | undefined\n    if (e.key === next) to = enabled[(pos + 1) % n]\n    else if (e.key === prev) to = enabled[(pos - 1 + n) % n]\n    else if (e.key === 'Home') to = enabled[0]\n    else if (e.key === 'End') to = enabled[n - 1]\n    if (to === undefined) return\n    e.preventDefault()\n    this.focusTab(to)\n  }\n\n  // Roving tabindex: the focused tab becomes the tab stop.\n  private focusIndex = -1\n  private focusTab(i: number) {\n    this.focusIndex = i\n    if (this.activationMode === 'automatic') this.select(this.tabs[i].value)\n    else this.requestUpdate()\n    this.updateComplete.then(() => this.renderRoot.querySelector<HTMLElement>(`#${this.uidBase}-trigger-${i}`)?.focus())\n  }\n\n  protected updated() {\n    this.tabs.forEach((t, i) => {\n      this.renderRoot.querySelector<HTMLSlotElement>(`slot[data-part=\"trigger\"][data-index=\"${i}\"]`)?.assign(t.trigger)\n      if (t.content)\n        this.renderRoot.querySelector<HTMLSlotElement>(`slot[data-part=\"content\"][data-index=\"${i}\"]`)?.assign(t.content)\n    })\n    this.updateIndicator()\n  }\n\n  render() {\n    const o = this.orientation\n    const v = this.variant\n    const activeIndex = this.tabs.findIndex((t) => t.value === this.value)\n    // Tab stop: the focused tab (manual mode), else the active one, else the first enabled.\n    const stop =\n      this.focusIndex >= 0 && this.activationMode === 'manual'\n        ? this.focusIndex\n        : activeIndex >= 0\n          ? activeIndex\n          : this.tabs.findIndex((t) => !t.disabled)\n    const indicatorClass =\n      v === 'pill'\n        ? 'pointer-events-none absolute top-0 left-0 z-0 rounded-full bg-primary shadow-xs will-change-transform'\n        : v === 'underline'\n          ? 'pointer-events-none absolute top-0 left-0 z-0 bg-foreground will-change-transform'\n          : 'pointer-events-none absolute top-0 left-0 z-0 rounded-sm bg-background shadow-xs will-change-transform'\n    return html`<div\n      part=\"root\"\n      data-orientation=${o}\n      dir=\"ltr\"\n      class=${cn('flex w-full', o === 'vertical' ? 'flex-row gap-4' : 'flex-col gap-2')}\n    >\n      <div\n        part=\"list\"\n        role=\"tablist\"\n        aria-orientation=${o}\n        data-orientation=${o}\n        data-slot=\"tabs-list\"\n        data-animated=${this.animated ? 'true' : 'false'}\n        class=${cn('group/list relative', tabsListVariants({ variant: v, orientation: o }))}\n      >\n        ${this.animated\n          ? html`<span\n              part=\"indicator\"\n              data-slot=\"tabs-indicator\"\n              aria-hidden=\"true\"\n              class=${indicatorClass}\n              style=\"opacity: 0\"\n            ></span>`\n          : nothing}\n        ${this.tabs.map((t, i) => {\n          const active = i === activeIndex\n          return html`<button\n            part=\"trigger\"\n            type=\"button\"\n            role=\"tab\"\n            id=${`${this.uidBase}-trigger-${i}`}\n            aria-selected=${active ? 'true' : 'false'}\n            aria-controls=${`${this.uidBase}-content-${i}`}\n            data-state=${active ? 'active' : 'inactive'}\n            ?data-disabled=${t.disabled}\n            ?disabled=${t.disabled}\n            data-orientation=${o}\n            data-slot=\"tabs-trigger\"\n            tabindex=${i === stop ? 0 : -1}\n            class=${cn(tabsTriggerVariants({ size: this.size, variant: v, orientation: o }))}\n            @mousedown=${(e: MouseEvent) => {\n              // Radix activates on mousedown (left button, no ctrl).\n              if (t.disabled || e.button !== 0 || e.ctrlKey) return\n              this.focusIndex = i\n              this.select(t.value)\n            }}\n            @focus=${() => {\n              this.focusIndex = i\n              if (this.activationMode === 'automatic' && !t.disabled) this.select(t.value)\n            }}\n            @keydown=${(e: KeyboardEvent) => this.onKeyDown(e, i)}\n          >\n            <slot data-part=\"trigger\" data-index=${i} class=\"[&::slotted(*)]:contents\"></slot>\n          </button>`\n        })}\n      </div>\n      ${this.tabs.map(\n        // Every tab gets a panel so its aria-controls resolves (Radix always sets\n        // it); a tab without content keeps an empty panel hidden, like React\n        // rendering no TabsContent.\n        (t, i) => html`<div\n          part=\"content\"\n          role=\"tabpanel\"\n          id=${`${this.uidBase}-content-${i}`}\n          aria-labelledby=${`${this.uidBase}-trigger-${i}`}\n          data-state=${i === activeIndex ? 'active' : 'inactive'}\n          data-orientation=${o}\n          data-slot=\"tabs-content\"\n          tabindex=\"0\"\n          ?hidden=${i !== activeIndex || !t.content}\n          class=${contentClass}\n        >\n          <slot data-part=\"content\" data-index=${i}></slot>\n        </div>`,\n      )}\n    </div>`\n  }\n}\n\ncustomElements.get('uip-tabs') || customElements.define('uip-tabs', UipTabs)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-tabs': UipTabs\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/tabs/tabs.ts"
    },
    {
      "path": "packages/elements-lit/src/components/tabs/tabs.variants.ts",
      "content": "import type { VariantProps } from 'class-variance-authority'\nimport { cva } from 'class-variance-authority'\n\n/**\n * Variant definitions live in their own file (rather than the package\n * `index.ts`) so consuming Vue SFCs can import without creating a circular\n * dependency through the index. See card.variants.ts for the canonical\n * example + the SSR symptom that motivated the split.\n */\n\nexport const tabsListVariants = cva(\n  // Scrollable when triggers overflow. Centering uses trigger auto margins\n  // instead of justify-center so an overflowing list stays scrollable from its\n  // start edge (justify-center + overflow makes leading items unreachable).\n  // The scrollbar is hidden; scrolling still works via wheel/touch/keyboard.\n  'inline-flex h-9 max-w-full items-center overflow-x-auto rounded-lg bg-muted p-1 text-muted-foreground overscroll-x-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [&>[data-slot=tabs-trigger]:first-child]:ml-auto [&>[data-slot=tabs-trigger]:last-child]:mr-auto',\n  {\n    variants: {\n      variant: {\n        // Solid muted track with rounded inset triggers — the default look.\n        segmented: 'gap-1 rounded-lg bg-muted p-1',\n        // Transparent track with rounded-full pill triggers.\n        pill: 'h-auto gap-2 bg-transparent p-0',\n        // Bottom-border bar (horizontal) or right-border bar (vertical), with\n        // an underline on the active trigger.\n        underline: 'h-auto gap-0 bg-transparent p-0',\n      },\n      orientation: {\n        horizontal: 'flex-row',\n        vertical: 'h-auto flex-col items-stretch',\n      },\n    },\n    compoundVariants: [\n      {\n        variant: 'underline',\n        orientation: 'horizontal',\n        // Neutralize the base auto-margin centering; this variant aligns start.\n        class:\n          'w-full justify-start border-b border-border [&>[data-slot=tabs-trigger]:first-child]:ml-0 [&>[data-slot=tabs-trigger]:last-child]:mr-0',\n      },\n      {\n        variant: 'underline',\n        orientation: 'vertical',\n        class: 'border-r border-border',\n      },\n    ],\n    defaultVariants: {\n      variant: 'segmented',\n      orientation: 'horizontal',\n    },\n  },\n)\n\nexport const tabsTriggerVariants = cva(\n  // z-10 keeps label above the sliding indicator; active surface paints on the indicator\n  // when the parent list has data-animated=\"true\". Static active chrome restores when\n  // data-animated=\"false\" (see group-data variants below).\n  'relative z-10 inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap font-medium ring-offset-background [&_svg]:shrink-0 transition-colors duration-200 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50',\n  {\n    variants: {\n      variant: {\n        // Active bg/shadow live on TabsList indicator so it can slide between triggers.\n        segmented:\n          'rounded-md data-[state=active]:text-foreground group-data-[animated=false]/list:data-[state=active]:bg-background group-data-[animated=false]/list:data-[state=active]:shadow-xs',\n        pill: 'rounded-full border border-border bg-transparent data-[state=active]:border-transparent data-[state=active]:text-primary-foreground group-data-[animated=false]/list:data-[state=active]:bg-primary group-data-[animated=false]/list:data-[state=active]:border-primary',\n        // Underline bar slides on TabsList indicator; keep transparent border for layout stability.\n        underline:\n          'rounded-none border-b-2 border-transparent -mb-px data-[state=active]:text-foreground group-data-[animated=false]/list:data-[state=active]:border-foreground',\n      },\n      size: {\n        default: 'h-7 px-3 text-sm',\n        sm: 'h-6 px-2.5 text-xs',\n        lg: 'h-8 px-4 text-sm',\n      },\n      orientation: {\n        horizontal: '',\n        vertical: 'w-full justify-start',\n      },\n    },\n    compoundVariants: [\n      {\n        variant: 'underline',\n        orientation: 'vertical',\n        class: 'border-b-0 border-r-2 -mr-px group-data-[animated=false]/list:data-[state=active]:border-foreground',\n      },\n    ],\n    defaultVariants: {\n      variant: 'segmented',\n      size: 'default',\n      orientation: 'horizontal',\n    },\n  },\n)\n\nexport type TabsListVariants = VariantProps<typeof tabsListVariants>\n\nexport type TabsTriggerVariants = VariantProps<typeof tabsTriggerVariants>\n",
      "type": "registry:file",
      "target": "src/uip/components/tabs/tabs.variants.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority",
    "lit"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
