{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "vertical-tabs",
  "title": "Vertical Tabs",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/vertical-tabs/vertical-tabs.component.ts",
      "content": "import {\n  type AfterViewInit,\n  Component,\n  Directive,\n  ElementRef,\n  EventEmitter,\n  Input,\n  type OnChanges,\n  type OnDestroy,\n  Output,\n  type SimpleChanges,\n  booleanAttribute,\n  inject,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\n\nexport type VerticalTabsActivationMode = 'automatic' | 'manual'\n\nlet idCounter = 0\n\n/**\n * Angular port of UIPKGE VerticalTabs (React: Radix Tabs locked to `orientation=\"vertical\"`).\n * Root holds controlled `value` / uncontrolled `defaultValue` + `valueChange`, `dir` and\n * `activationMode`. The list is a Radix roving-focus group (ArrowUp / ArrowDown, Home / End,\n * looping, disabled triggers skipped) with the React sliding `vertical-tabs-indicator`\n * (muted surface + primary rail) positioned via ResizeObserver + MutationObserver; with\n * `animated=false` the trigger paints the active chrome itself. Inactive panels are hidden and\n * unmounted unless force-mounted. DOM, aria and class strings match React.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-vertical-tabs, [ui-vertical-tabs]',\n  standalone: true,\n  host: {\n    '[attr.dir]': 'dir',\n    '[attr.data-orientation]': '\"vertical\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"vertical-tabs\"',\n    '[class]': 'hostClass',\n  },\n  template: `<ng-content />`,\n})\nexport class UiVerticalTabsComponent {\n  @Input('class') className?: string\n  @Input() value?: string\n  @Input() defaultValue?: string\n  @Input() dir: 'ltr' | 'rtl' = 'ltr'\n  @Input() activationMode: VerticalTabsActivationMode = 'automatic'\n  /** Radix `onValueChange`. */\n  @Output() valueChange = new EventEmitter<string>()\n\n  readonly baseId = `vertical-tabs-${++idCounter}`\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 gap-6', this.className)\n  }\n}\n\nconst EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-vertical-tabs-list, [ui-vertical-tabs-list]',\n  standalone: true,\n  host: {\n    '[attr.role]': '\"tablist\"',\n    '[attr.aria-orientation]': '\"vertical\"',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"vertical-tabs-list\"',\n    '[attr.data-animated]': 'animated ? \"true\" : \"false\"',\n    '[class]': 'hostClass',\n    // Radix RovingFocusGroup: the list is the tab stop and forwards focus to the active tab.\n    '[attr.tabindex]': 'tabbingBackOut() ? -1 : 0',\n    '[attr.data-orientation]': '\"vertical\"',\n    '[attr.dir]': 'tabs.dir',\n    '[style.outline]': '\"none\"',\n    '(mousedown)': 'clickFocus = true',\n    '(focus)': 'onFocus($event)',\n    '(focusout)': 'tabbingBackOut.set(false)',\n  },\n  template: `@if (animated) {\n      <span\n        data-slot=\"vertical-tabs-indicator\"\n        aria-hidden=\"true\"\n        class=\"bg-muted pointer-events-none absolute top-0 left-0 z-0 rounded-md will-change-transform\"\n        [style]=\"indicatorStyle()\"\n        ><span class=\"bg-primary absolute inset-y-1 left-0 w-0.5 rounded-full\"></span\n      ></span>\n    }\n    <ng-content />`,\n})\nexport class UiVerticalTabsListComponent implements AfterViewInit, OnChanges, OnDestroy {\n  readonly tabs = inject(UiVerticalTabsComponent)\n  readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement\n  @Input('class') className?: string\n  /** Sliding active indicator (default true). When false, active chrome paints on the trigger. */\n  @Input({ transform: booleanAttribute }) animated = true\n  /** Radix `loop`: arrow keys wrap from the last trigger to the first. */\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 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 hostClass(): string {\n    return cn('group/list border-border relative flex w-56 shrink-0 flex-col gap-0.5 border-r pr-3', this.className)\n  }\n\n  ngAfterViewInit(): void {\n    this.viewReady = true\n    this.observe()\n  }\n\n  ngOnChanges(changes: SimpleChanges): void {\n    // Wait for the indicator span to (un)render before measuring.\n    if (this.viewReady && changes['animated']) queueMicrotask(() => this.observe())\n  }\n\n  ngOnDestroy(): void {\n    this.stopObserving?.()\n  }\n\n  /** Bind observers once per `animated` flip (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=\"vertical-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 ${EASE}, width 220ms ${EASE}, height 220ms ${EASE}`\n  }\n\n  updateIndicator(): void {\n    if (!this.animated) return\n    const root = this.el\n    const active = root.querySelector<HTMLElement>('[data-slot=\"vertical-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    // Full active surface slides (muted pill); the primary rail is nested so it stays inset-y-1.\n    this.indicatorStyle.set({\n      width: `${activeRect.width}px`,\n      height: `${activeRect.height}px`,\n      transform: `translate3d(${left}px, ${top}px, 0)`,\n      opacity: '1',\n      transition: this.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=\"vertical-tabs-trigger\"]')].filter(\n      (t) => !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 (vertical): ArrowUp / ArrowDown, Home / End / PageUp / PageDown. */\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 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 (key === 'ArrowUp') intent = 'prev'\n    else if (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\n/** Non-interactive group heading inside the list (React `label` prop, rendered as the text). */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-vertical-tabs-section, [ui-vertical-tabs-section]',\n  standalone: true,\n  host: {\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"vertical-tabs-section\"',\n    '[class]': 'hostClass',\n  },\n  template: `{{ label }}`,\n})\nexport class UiVerticalTabsSectionComponent {\n  @Input({ required: true }) label!: string\n  @Input('class') className?: string\n\n  get hostClass(): string {\n    return cn(\n      'block text-muted-foreground mt-3 mb-1 px-2 text-xs font-medium tracking-wider uppercase first:mt-0',\n      this.className,\n    )\n  }\n}\n\n/** Use `<button ui-vertical-tabs-trigger>` (Radix renders a button); other hosts get role=\"tab\" + tabindex. */\n@Directive({\n  selector: 'ui-vertical-tabs-trigger, [ui-vertical-tabs-trigger]',\n  standalone: true,\n  host: {\n    '[attr.data-slot]': 'dataSlot',\n    '[attr.type]': 'isNativeButton ? \"button\" : null',\n    '[attr.role]': '\"tab\"',\n    '[attr.aria-selected]': 'isSelected',\n    '[attr.aria-controls]': 'tabs.contentId(value)',\n    '[attr.data-state]': 'isSelected ? \"active\" : \"inactive\"',\n    '[attr.data-disabled]': 'disabled ? \"\" : null',\n    '[attr.disabled]': 'isNativeButton && disabled ? \"\" : null',\n    '[attr.id]': 'tabs.triggerId(value)',\n    '[attr.data-uipkge]': '\"\"',\n    '[class]': 'hostClass',\n    '[attr.tabindex]': 'list.currentTabStop() === value ? 0 : -1',\n    '[attr.data-orientation]': '\"vertical\"',\n    '(mousedown)': 'onMousedown($event)',\n    '(keydown)': 'onKeydown($event)',\n    '(focus)': 'onFocus()',\n  },\n})\nexport class UiVerticalTabsTriggerComponent {\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') ?? 'vertical-tabs-trigger'\n  readonly tabs = inject(UiVerticalTabsComponent)\n  readonly list = inject(UiVerticalTabsListComponent)\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\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      // z-10 keeps the label above the sliding indicator; static active chrome restores when\n      // the list has data-animated=\"false\" (group-data variants below).\n      'group/trigger text-muted-foreground relative z-10 flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium transition-[color,background-color] duration-150',\n      'hover:bg-muted/60 hover:text-foreground',\n      'focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none',\n      'disabled:pointer-events-none disabled:opacity-50',\n      'data-[state=active]:text-foreground',\n      'group-data-[animated=false]/list:data-[state=active]:bg-muted',\n      \"before:bg-primary before:pointer-events-none before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full before:opacity-0 before:content-['']\",\n      'group-data-[animated=false]/list:data-[state=active]:before:opacity-100',\n      '[&>svg,&>lucide-icon>svg]:size-4 [&>svg,&>lucide-icon>svg]:shrink-0',\n      this.className,\n    )\n  }\n}\n\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-vertical-tabs-content, [ui-vertical-tabs-content]',\n  standalone: true,\n  host: {\n    '[attr.data-state]': 'isSelected ? \"active\" : \"inactive\"',\n    '[attr.data-orientation]': '\"vertical\"',\n    '[attr.role]': '\"tabpanel\"',\n    '[attr.aria-labelledby]': 'tabs.triggerId(value)',\n    '[attr.hidden]': 'present ? null : \"\"',\n    '[attr.id]': 'tabs.contentId(value)',\n    '[attr.tabindex]': '0',\n    '[attr.data-uipkge]': '\"\"',\n    '[attr.data-slot]': '\"vertical-tabs-content\"',\n    '[class]': 'hostClass',\n    '[style.animation-duration]': 'mountAnimationPrevented() && isSelected ? \"0s\" : null',\n  },\n  template: `@if (present) {\n    <ng-content />\n  }`,\n})\nexport class UiVerticalTabsContentComponent implements AfterViewInit {\n  readonly tabs = inject(UiVerticalTabsComponent)\n  @Input('class') className?: string\n  @Input({ required: true }) value!: string\n  @Input({ transform: booleanAttribute }) forceMount = false\n\n  /** Radix: the tab 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 ring-offset-background focus-visible:ring-ring/50 flex-1 focus-visible:ring-2 focus-visible:outline-none',\n      this.className,\n    )\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/vertical-tabs/vertical-tabs.component.ts"
    },
    {
      "path": "packages/registry-angular/components/vertical-tabs/index.ts",
      "content": "export {\n  UiVerticalTabsComponent,\n  UiVerticalTabsListComponent,\n  UiVerticalTabsSectionComponent,\n  UiVerticalTabsTriggerComponent,\n  UiVerticalTabsContentComponent,\n  type VerticalTabsActivationMode,\n} from './vertical-tabs.component'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/vertical-tabs/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Settings-page navigation pattern — labels stack on the left, content panel on the right. Radix Tabs semantics locked to vertical orientation, with section headings and a sliding active indicator.",
  "categories": [
    "navigation"
  ]
}