{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "tabs",
  "title": "Tabs",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/tabs/tabs.component.ts",
      "content": "import {\n  AfterViewInit,\n  Component,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  Input,\n  OnChanges,\n  OnDestroy,\n  Output,\n  SimpleChanges,\n  booleanAttribute,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport { uniqueId } from '@/ui/popper/popper'\nimport { tabsListVariants, tabsTriggerVariants, type TabsListVariants, type TabsTriggerVariants } from './tabs.variants'\n\nexport type TabsOrientation = 'horizontal' | 'vertical'\nexport type TabsListVariant = NonNullable<TabsListVariants['variant']>\nexport type TabsTriggerSize = NonNullable<TabsTriggerVariants['size']>\nexport type TabsActivationMode = 'automatic' | 'manual'\n\n/**\n * Angular port of UIPKGE Tabs with Radix Tabs behaviour: controlled `value` or\n * uncontrolled `defaultValue` + `valueChange`, `orientation`, `dir` and\n * `activationMode` (automatic selects on focus, manual needs Enter / Space / click).\n * The list is a roving-focus group (arrows per orientation, Home / End, looping,\n * disabled triggers skipped) and renders the same sliding `tabs-indicator` span as\n * React, positioned via ResizeObserver + MutationObserver. Inactive panels are hidden\n * and unmounted unless force-mounted. Class strings match React.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-tabs, [ui-tabs]',\n  standalone: true,\n  host: {\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"tabs\"',\n    '[attr.dir]': 'dir',\n    '[attr.data-orientation]': 'orientation',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiTabsComponent {\n  @Input('class') className?: string\n  @Input() value?: string\n  @Input() defaultValue?: string\n  @Input() orientation: TabsOrientation = 'horizontal'\n  @Input() dir: 'ltr' | 'rtl' = 'ltr'\n  @Input() activationMode: TabsActivationMode = 'automatic'\n  @Output() valueChange = new EventEmitter<string>()\n\n  readonly baseId = uniqueId('tabs')\n  private readonly _value = signal<string | null>(null)\n\n  get activeValue(): string | undefined {\n    if (this.value !== undefined) return this.value\n    return this._value() ?? this.defaultValue\n  }\n\n  select(v: string): void {\n    if (v === this.activeValue) return\n    this._value.set(v)\n    this.valueChange.emit(v)\n  }\n\n  triggerId(value: string): string {\n    return `${this.baseId}-trigger-${value}`\n  }\n\n  contentId(value: string): string {\n    return `${this.baseId}-content-${value}`\n  }\n\n  get hostClass(): string {\n    return cn('flex w-full', this.orientation === 'vertical' ? 'flex-row gap-4' : 'flex-col gap-2', this.className)\n  }\n}\n\nconst INDICATOR_EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-tabs-list, [ui-tabs-list]',\n  standalone: true,\n  host: {\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"tabs-list\"',\n    '[attr.data-animated]': 'animated ? \"true\" : \"false\"',\n    '[attr.role]': '\"tablist\"',\n    '[attr.aria-orientation]': 'tabs.orientation',\n    '[attr.data-orientation]': 'tabs.orientation',\n    '[attr.dir]': 'tabs.dir',\n    // Radix RovingFocusGroup: the list is the tab stop and forwards focus to the active tab.\n    '[attr.tabindex]': 'tabbingBackOut() ? -1 : 0',\n    '[class]': 'hostClass',\n    '(mousedown)': 'clickFocus = true',\n    '(focus)': 'onFocus($event)',\n    '(focusout)': 'tabbingBackOut.set(false)',\n  },\n  template: `@if (animated) {\n      <span data-slot=\"tabs-indicator\" aria-hidden=\"true\" [class]=\"indicatorClass\" [style]=\"indicatorStyle()\"></span>\n    }\n    <ng-content />`,\n})\nexport class UiTabsListComponent implements AfterViewInit, OnChanges, OnDestroy {\n  readonly tabs = inject(UiTabsComponent)\n  readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  @Input('class') className?: string\n  @Input() variant: TabsListVariant = 'segmented'\n  /** Styling orientation; defaults to the Tabs root's orientation. */\n  @Input() orientation?: TabsOrientation\n  /** Sliding active indicator (default true). When false, the active surface paints on the trigger. */\n  @Input({ transform: booleanAttribute }) animated = true\n  @Input({ transform: booleanAttribute }) loop = true\n\n  readonly indicatorStyle = signal<Record<string, string>>({ opacity: '0' })\n  readonly tabbingBackOut = signal(false)\n  /** Roving tab stop: the trigger that was focused last (Radix currentTabStopId). */\n  readonly currentTabStop = signal<string | null>(null)\n  clickFocus = false\n  private firstPosition = true\n  private stopObserving: (() => void) | null = null\n  private viewReady = false\n\n  get effectiveOrientation(): TabsOrientation {\n    return this.orientation ?? this.tabs.orientation\n  }\n\n  get indicatorClass(): string {\n    if (this.variant === 'pill')\n      return 'pointer-events-none absolute top-0 left-0 z-0 rounded-full bg-primary shadow-xs will-change-transform'\n    if (this.variant === 'underline')\n      return 'pointer-events-none absolute top-0 left-0 z-0 bg-foreground will-change-transform'\n    return 'pointer-events-none absolute top-0 left-0 z-0 rounded-sm bg-background shadow-xs will-change-transform'\n  }\n\n  ngAfterViewInit(): void {\n    this.viewReady = true\n    this.observe()\n  }\n\n  ngOnChanges(changes: SimpleChanges): void {\n    if (this.viewReady && (changes['animated'] || changes['variant'] || changes['orientation'])) {\n      // Wait for the indicator span to (un)render before measuring.\n      queueMicrotask(() => this.observe())\n    }\n  }\n\n  ngOnDestroy(): void {\n    this.stopObserving?.()\n  }\n\n  /** Bind observers once per animated / variant / orientation (not per render), like React. */\n  private observe(): void {\n    this.stopObserving?.()\n    this.stopObserving = null\n    this.firstPosition = true\n    if (!this.animated) {\n      this.indicatorStyle.set({ opacity: '0' })\n      return\n    }\n    this.updateIndicator()\n    const root = this.el\n    const triggers = () => root.querySelectorAll('[data-slot=\"tabs-trigger\"]')\n    const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => this.updateIndicator()) : null\n    if (ro) {\n      ro.observe(root)\n      triggers().forEach((t) => ro.observe(t))\n    }\n    const mo =\n      typeof MutationObserver !== 'undefined'\n        ? new MutationObserver((mutations) => {\n            if (ro && mutations.some((m) => m.type === 'childList')) triggers().forEach((t) => ro.observe(t))\n            this.updateIndicator()\n          })\n        : null\n    mo?.observe(root, { attributes: true, attributeFilter: ['data-state'], subtree: true, childList: true })\n    this.stopObserving = () => {\n      ro?.disconnect()\n      mo?.disconnect()\n    }\n  }\n\n  private transition(): string {\n    const reduced =\n      typeof window !== 'undefined' &&\n      typeof window.matchMedia === 'function' &&\n      window.matchMedia('(prefers-reduced-motion: reduce)').matches\n    if (reduced || this.firstPosition) return 'none'\n    return `transform 220ms ${INDICATOR_EASE}, width 220ms ${INDICATOR_EASE}, height 220ms ${INDICATOR_EASE}`\n  }\n\n  updateIndicator(): void {\n    if (!this.animated) return\n    const root = this.el\n    const active = root.querySelector<HTMLElement>('[data-slot=\"tabs-trigger\"][data-state=\"active\"]')\n    if (!active) {\n      this.indicatorStyle.set({ 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.transition()\n    const thickness = 2\n    let box: { width: number; height: number; x: number; y: number }\n    if (this.variant === 'underline' && this.effectiveOrientation === 'vertical') {\n      box = { width: thickness, height: activeRect.height, x: listRect.width - thickness, y: top }\n    } else if (this.variant === 'underline') {\n      box = { width: activeRect.width, height: thickness, x: left, y: listRect.height - thickness }\n    } else {\n      box = { width: activeRect.width, height: activeRect.height, x: left, y: top }\n    }\n    this.indicatorStyle.set({\n      width: `${box.width}px`,\n      height: `${box.height}px`,\n      transform: `translate3d(${box.x}px, ${box.y}px, 0)`,\n      opacity: '1',\n      transition,\n    })\n    this.firstPosition = false\n  }\n\n  /** Enabled triggers of this list, in DOM order. */\n  focusableTriggers(): HTMLElement[] {\n    return [...this.el.querySelectorAll<HTMLElement>('[data-slot=\"tabs-trigger\"]')].filter(\n      (t) => t.closest('[data-slot=\"tabs-list\"]') === this.el && !t.hasAttribute('data-disabled'),\n    )\n  }\n\n  /** Keyboard focus landing on the list itself moves to the active tab (then the tab stop, then the first). */\n  onFocus(event: FocusEvent): void {\n    const fromClick = this.clickFocus\n    this.clickFocus = false\n    if (event.target !== this.el || fromClick || this.tabbingBackOut()) return\n    const items = this.focusableTriggers()\n    const active = items.find((t) => t.getAttribute('data-state') === 'active')\n    const stop = this.currentTabStop()\n    const current = stop === null ? undefined : items.find((t) => t.id === this.tabs.triggerId(stop))\n    ;(active ?? current ?? items[0])?.focus()\n  }\n\n  /** Radix RovingFocusGroup arrow / Home / End / PageUp / PageDown handling. */\n  onTriggerKeydown(event: KeyboardEvent, trigger: HTMLElement): void {\n    if (event.key === 'Tab' && event.shiftKey) {\n      this.tabbingBackOut.set(true)\n      return\n    }\n    if (event.target !== trigger || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return\n    const vertical = this.tabs.orientation === 'vertical'\n    const rtl = this.tabs.dir === 'rtl'\n    const key =\n      !vertical && rtl ? ({ ArrowLeft: 'ArrowRight', ArrowRight: 'ArrowLeft' }[event.key] ?? event.key) : event.key\n    let intent: 'first' | 'last' | 'prev' | 'next' | null = null\n    if (key === 'Home' || key === 'PageUp') intent = 'first'\n    else if (key === 'End' || key === 'PageDown') intent = 'last'\n    else if ((!vertical && key === 'ArrowLeft') || (vertical && key === 'ArrowUp')) intent = 'prev'\n    else if ((!vertical && key === 'ArrowRight') || (vertical && key === 'ArrowDown')) intent = 'next'\n    if (!intent) return\n    event.preventDefault()\n    const items = this.focusableTriggers()\n    const i = items.indexOf(trigger)\n    let target: HTMLElement | undefined\n    if (intent === 'first') target = items[0]\n    else if (intent === 'last') target = items[items.length - 1]\n    else {\n      const next = intent === 'next' ? i + 1 : i - 1\n      target = this.loop ? items[(next + items.length) % items.length] : items[next]\n    }\n    target?.focus()\n  }\n\n  get hostClass(): string {\n    return cn(\n      'group/list relative',\n      tabsListVariants({ variant: this.variant, orientation: this.effectiveOrientation }),\n      this.className,\n    )\n  }\n}\n\n/** Use `<button ui-tabs-trigger>` (Radix renders a button); other hosts get role=\"tab\" + tabindex. */\n@Directive({\n  selector: 'ui-tabs-trigger, [ui-tabs-trigger]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': 'dataSlot',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.type]': 'isNativeButton ? \"button\" : null',\n    '[attr.role]': '\"tab\"',\n    '[attr.id]': 'tabs.triggerId(value)',\n    '[attr.aria-selected]': 'isSelected',\n    '[attr.aria-controls]': 'tabs.contentId(value)',\n    '[attr.tabindex]': 'list.currentTabStop() === value ? 0 : -1',\n    '[attr.disabled]': 'isNativeButton && disabled ? \"\" : null',\n    '[attr.data-state]': 'isSelected ? \"active\" : \"inactive\"',\n    '[attr.data-disabled]': 'disabled ? \"\" : null',\n    '[attr.data-orientation]': 'tabs.orientation',\n    '[class]': 'hostClass',\n    '(mousedown)': 'onMousedown($event)',\n    '(keydown)': 'onKeydown($event)',\n    '(focus)': 'onFocus()',\n  },\n})\nexport class UiTabsTriggerComponent {\n  // Radix Slot: the trigger's data-slot overrides a wrapped component's own (ui-button's\n  // \"button\"), but a data-slot written on the element itself wins.\n  readonly dataSlot: string = inject(ElementRef).nativeElement.getAttribute('data-slot') ?? 'tabs-trigger'\n  readonly tabs = inject(UiTabsComponent)\n  readonly list = inject(UiTabsListComponent)\n  private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  readonly isNativeButton = this.el.tagName === 'BUTTON'\n  @Input('class') className?: string\n  @Input({ required: true }) value!: string\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input() size?: TabsTriggerSize\n  @Input() variant?: TabsListVariant\n  /** Styling orientation; defaults to the Tabs root's orientation. */\n  @Input() orientation?: TabsOrientation\n\n  get isSelected(): boolean {\n    return this.tabs.activeValue === this.value\n  }\n\n  /** Radix selects on left mouse-down (not click); disabled tabs never take focus. */\n  onMousedown(event: MouseEvent): void {\n    if (this.disabled) {\n      event.preventDefault()\n      return\n    }\n    this.list.currentTabStop.set(this.value)\n    if (event.button === 0 && !event.ctrlKey) this.tabs.select(this.value)\n    else event.preventDefault()\n  }\n\n  onKeydown(event: KeyboardEvent): void {\n    if (!this.disabled && (event.key === ' ' || event.key === 'Enter')) {\n      if (!this.isNativeButton) event.preventDefault()\n      this.tabs.select(this.value)\n      return\n    }\n    this.list.onTriggerKeydown(event, this.el)\n  }\n\n  onFocus(): void {\n    this.list.currentTabStop.set(this.value)\n    if (!this.isSelected && !this.disabled && this.tabs.activationMode !== 'manual') this.tabs.select(this.value)\n  }\n\n  get hostClass(): string {\n    return cn(\n      tabsTriggerVariants({\n        size: this.size,\n        variant: this.variant,\n        orientation: this.orientation ?? this.tabs.orientation,\n      }),\n      this.className,\n    )\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-tabs-content, [ui-tabs-content]',\n  standalone: true,\n  host: {\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"tabs-content\"',\n    '[attr.id]': 'tabs.contentId(value)',\n    '[attr.role]': '\"tabpanel\"',\n    '[attr.aria-labelledby]': 'tabs.triggerId(value)',\n    '[attr.tabindex]': '0',\n    '[attr.data-state]': 'isSelected ? \"active\" : \"inactive\"',\n    '[attr.data-orientation]': 'tabs.orientation',\n    '[attr.hidden]': 'present ? null : \"\"',\n    '[style.animation-duration]': 'mountAnimationPrevented() ? \"0s\" : null',\n    '[class]': 'hostClass',\n  },\n  template: `@if (present) {\n    <ng-content />\n  }`,\n})\nexport class UiTabsContentComponent implements AfterViewInit {\n  readonly tabs = inject(UiTabsComponent)\n  @Input('class') className?: string\n  @Input({ required: true }) value!: string\n  @Input({ transform: booleanAttribute }) forceMount = false\n\n  /** Radix: the tab that is active on first paint does not play its enter animation. */\n  readonly mountAnimationPrevented = signal(true)\n\n  get isSelected(): boolean {\n    return this.tabs.activeValue === this.value\n  }\n\n  get present(): boolean {\n    return this.forceMount || this.isSelected\n  }\n\n  ngAfterViewInit(): void {\n    const release = () => this.mountAnimationPrevented.set(false)\n    if (typeof requestAnimationFrame === 'function') requestAnimationFrame(release)\n    else release()\n  }\n\n  get hostClass(): string {\n    return cn(\n      'block',\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      this.className,\n    )\n  }\n}\n\nexport { tabsListVariants, tabsTriggerVariants, type TabsListVariants, type TabsTriggerVariants }\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/tabs/tabs.component.ts"
    },
    {
      "path": "packages/registry-angular/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:ui",
      "target": "~/src/app/components/ui/tabs/tabs.variants.ts"
    },
    {
      "path": "packages/registry-angular/components/tabs/index.ts",
      "content": "export {\n  UiTabsComponent,\n  UiTabsListComponent,\n  UiTabsTriggerComponent,\n  UiTabsContentComponent,\n  tabsListVariants,\n  tabsTriggerVariants,\n  type TabsListVariants,\n  type TabsTriggerVariants,\n  type TabsOrientation,\n  type TabsListVariant,\n  type TabsTriggerSize,\n  type TabsActivationMode,\n} from './tabs.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/tabs/index.ts"
    }
  ],
  "dependencies": [
    "class-variance-authority"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/popper.json"
  ],
  "description": "Tab navigation with content panels — pick one panel at a time. Segmented, pill or underline variants with a sliding active indicator, horizontal or vertical. Radix Tabs behaviour: roving arrow-key focus, automatic or manual activation, inactive panels unmounted.",
  "categories": [
    "navigation"
  ]
}