{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "cascade-select",
  "title": "Cascade Select",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-angular/components/cascade-select/cascade-select.component.ts",
      "content": "import {\n  AfterViewInit,\n  Component,\n  ElementRef,\n  EventEmitter,\n  Input,\n  Output,\n  ViewChild,\n  booleanAttribute,\n  signal,\n  ChangeDetectionStrategy,\n} from '@angular/core'\nimport { cn } from '@/lib/utils'\nimport {\n  UiPopoverComponent,\n  UiPopoverContentComponent,\n  UiPopoverTriggerComponent,\n} from '@/ui/popover/popover.component'\nimport type { CascadeOption } from './types'\n\nexport type { CascadeOption } from './types'\nexport type CascadeSelectSize = 'sm' | 'default' | 'lg'\n\nfunction findPathIndices(options: CascadeOption[], values: string[]): number[] {\n  const indices: number[] = []\n  let current = options\n  for (const val of values) {\n    const idx = current.findIndex((o) => o.value === val)\n    if (idx === -1) return indices\n    indices.push(idx)\n    const next = current[idx].children\n    if (!next?.length) break\n    current = next\n  }\n  return indices\n}\n\nfunction buildPathFromIndices(options: CascadeOption[], indices: number[]): CascadeOption[] {\n  const path: CascadeOption[] = []\n  let current = options\n  for (const idx of indices) {\n    if (idx == null || !current[idx]) break\n    const opt = current[idx]\n    path.push(opt)\n    if (!opt.children?.length) break\n    current = opt.children\n  }\n  return path\n}\n\nconst sizeClasses: Record<CascadeSelectSize, string> = {\n  sm: 'h-8 text-xs px-2.5',\n  default: 'h-9 text-sm px-3',\n  lg: 'h-11 text-base px-4',\n}\n\ninterface SearchResult {\n  path: CascadeOption[]\n  values: string[]\n}\n\n/**\n * Angular port of UIPKGE CascadeSelect, 1:1 with the React component: a Popover whose\n * trigger is a combobox button showing the selected path, and whose content is either one\n * column per level (clicking a parent opens the next column, clicking a leaf commits the\n * path and closes) or, while searching, a flat list of matching leaf paths. Escape on the\n * closed trigger clears. `value` / `defaultValue` / `valueChange` work controlled or\n * uncontrolled like React. Class strings copied verbatim from React.\n */\n@Component({\n  changeDetection: ChangeDetectionStrategy.Eager,\n  selector: 'ui-cascade-select, [ui-cascade-select]',\n  standalone: true,\n  imports: [UiPopoverComponent, UiPopoverTriggerComponent, UiPopoverContentComponent],\n  // Block (not `contents`) so parent spacing utilities reach it like React's trigger button.\n  host: { '[class]': '\"block\"' },\n  template: `\n    <ui-popover [open]=\"isOpen()\" (openChange)=\"setOpen($event)\">\n      <button\n        #triggerEl\n        ui-popover-trigger\n        type=\"button\"\n        role=\"combobox\"\n        [attr.aria-expanded]=\"isOpen()\"\n        [disabled]=\"disabled || loading\"\n        data-uipkge=\"\"\n        data-slot=\"cascade-select\"\n        [class]=\"triggerClasses\"\n        (keydown)=\"onTriggerKeyDown($event)\"\n      >\n        <span [class]=\"labelClass\">{{ displayLabel }}</span>\n        <span class=\"flex shrink-0 items-center gap-1\">\n          @if (loading) {\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"24\"\n              height=\"24\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              class=\"lucide lucide-loader-circle text-muted-foreground size-4 animate-spin\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"M21 12a9 9 0 1 1-6.219-8.56\" />\n            </svg>\n          } @else if (clearable && hasValue && !disabled) {\n            <!-- Decorative clear affordance -- not a nested interactive control. -->\n            <span\n              aria-hidden=\"true\"\n              class=\"text-muted-foreground hover:text-foreground flex size-4 items-center justify-center rounded transition-colors\"\n              (click)=\"clearAll($event)\"\n            >\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                width=\"24\"\n                height=\"24\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                class=\"lucide lucide-x size-4\"\n                aria-hidden=\"true\"\n              >\n                <path d=\"M18 6 6 18\" />\n                <path d=\"m6 6 12 12\" />\n              </svg>\n            </span>\n          } @else {\n            <svg\n              xmlns=\"http://www.w3.org/2000/svg\"\n              width=\"24\"\n              height=\"24\"\n              viewBox=\"0 0 24 24\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              stroke-width=\"2\"\n              stroke-linecap=\"round\"\n              stroke-linejoin=\"round\"\n              [attr.class]=\"chevronClass\"\n              aria-hidden=\"true\"\n            >\n              <path d=\"m6 9 6 6 6-6\" />\n            </svg>\n          }\n        </span>\n      </button>\n\n      <ui-popover-content class=\"p-0\" align=\"start\" [sideOffset]=\"4\" (openAutoFocus)=\"onPanelOpen()\">\n        <div #body class=\"flex max-h-80 flex-col\">\n          @if (searchable) {\n            <div class=\"border-b p-2\">\n              <div class=\"relative\">\n                <svg\n                  xmlns=\"http://www.w3.org/2000/svg\"\n                  width=\"24\"\n                  height=\"24\"\n                  viewBox=\"0 0 24 24\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  stroke-width=\"2\"\n                  stroke-linecap=\"round\"\n                  stroke-linejoin=\"round\"\n                  class=\"lucide lucide-search text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2\"\n                  aria-hidden=\"true\"\n                >\n                  <path d=\"m21 21-4.34-4.34\" />\n                  <circle cx=\"11\" cy=\"11\" r=\"8\" />\n                </svg>\n                <input\n                  [value]=\"search()\"\n                  (input)=\"search.set($any($event.target).value)\"\n                  [placeholder]=\"searchPlaceholder\"\n                  aria-label=\"Search options\"\n                  class=\"border-input focus-visible:ring-ring/50 h-9 w-full rounded-md border bg-transparent pl-8 text-sm shadow-xs outline-none focus-visible:ring-[3px]\"\n                />\n              </div>\n            </div>\n          }\n\n          @if (loading) {\n            <div class=\"text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm\">\n              <svg\n                xmlns=\"http://www.w3.org/2000/svg\"\n                width=\"24\"\n                height=\"24\"\n                viewBox=\"0 0 24 24\"\n                fill=\"none\"\n                stroke=\"currentColor\"\n                stroke-width=\"2\"\n                stroke-linecap=\"round\"\n                stroke-linejoin=\"round\"\n                class=\"lucide lucide-loader-circle size-4 animate-spin\"\n                aria-hidden=\"true\"\n              >\n                <path d=\"M21 12a9 9 0 1 1-6.219-8.56\" />\n              </svg>\n              Loading...\n            </div>\n          } @else if (searchResults; as results) {\n            <div class=\"flex-1 overflow-y-auto p-1\">\n              @if (results.length === 0) {\n                <div class=\"text-muted-foreground py-6 text-center text-sm\">{{ emptyText }}</div>\n              } @else {\n                @for (result of results; track $index) {\n                  <button\n                    type=\"button\"\n                    class=\"hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 flex w-full items-center gap-1.5 rounded-sm px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:outline-none\"\n                    (click)=\"selectSearchResult(result)\"\n                  >\n                    <span class=\"flex-1 truncate\">{{ pathLabel(result.path) }}</span>\n                  </button>\n                }\n              }\n            </div>\n          } @else {\n            <div class=\"flex flex-1 overflow-x-auto overflow-y-hidden\">\n              @for (lvl of levels; track lvl.level) {\n                <div class=\"max-w-56 min-w-44 shrink-0 overflow-y-auto border-r p-1 last:border-r-0\">\n                  @for (opt of lvl.options; track opt.value; let idx = $index) {\n                    <button\n                      type=\"button\"\n                      [disabled]=\"opt.disabled\"\n                      [class]=\"optionClass(lvl.level, idx)\"\n                      (click)=\"selectAtLevel(lvl.level, idx)\"\n                    >\n                      <span class=\"flex-1 truncate\">{{ opt.label }}</span>\n                      @if (activePath()[lvl.level] === idx && !opt.children?.length) {\n                        <svg\n                          xmlns=\"http://www.w3.org/2000/svg\"\n                          width=\"24\"\n                          height=\"24\"\n                          viewBox=\"0 0 24 24\"\n                          fill=\"none\"\n                          stroke=\"currentColor\"\n                          stroke-width=\"2\"\n                          stroke-linecap=\"round\"\n                          stroke-linejoin=\"round\"\n                          class=\"lucide lucide-check size-4 shrink-0\"\n                          aria-hidden=\"true\"\n                        >\n                          <path d=\"M20 6 9 17l-5-5\" />\n                        </svg>\n                      } @else if (opt.children?.length) {\n                        <svg\n                          xmlns=\"http://www.w3.org/2000/svg\"\n                          width=\"24\"\n                          height=\"24\"\n                          viewBox=\"0 0 24 24\"\n                          fill=\"none\"\n                          stroke=\"currentColor\"\n                          stroke-width=\"2\"\n                          stroke-linecap=\"round\"\n                          stroke-linejoin=\"round\"\n                          class=\"lucide lucide-chevron-right text-muted-foreground size-3.5 shrink-0\"\n                          aria-hidden=\"true\"\n                        >\n                          <path d=\"m9 18 6-6-6-6\" />\n                        </svg>\n                      }\n                    </button>\n                  }\n                </div>\n              }\n            </div>\n          }\n        </div>\n      </ui-popover-content>\n    </ui-popover>\n  `,\n})\nexport class UiCascadeSelectComponent implements AfterViewInit {\n  /** Controlled selected path (pair with `valueChange`). Leave unset for uncontrolled use. */\n  @Input() value?: string[] | null\n  @Input() defaultValue: string[] | null = null\n  @Input() options: CascadeOption[] = []\n  @Input() placeholder = 'Select...'\n  @Input({ transform: booleanAttribute }) searchable = true\n  @Input({ transform: booleanAttribute }) clearable = true\n  @Input({ transform: booleanAttribute }) disabled = false\n  @Input({ transform: booleanAttribute }) loading = false\n  @Input() size: CascadeSelectSize = 'default'\n  @Input() separator = ' / '\n  @Input() searchPlaceholder = 'Search...'\n  @Input() emptyText = 'No options.'\n  @Input('class') className?: string\n\n  /** React `onValueChange`. */\n  @Output() valueChange = new EventEmitter<string[] | null>()\n  /** React `onChange(value, path)`. */\n  @Output() change = new EventEmitter<{ value: string[] | null; path: CascadeOption[] }>()\n  /** React `onClear`. */\n  @Output() clear = new EventEmitter<void>()\n\n  @ViewChild('triggerEl', { static: true }) private triggerEl?: ElementRef<HTMLButtonElement>\n  @ViewChild('body', { static: true }) private body?: ElementRef<HTMLElement>\n\n  readonly isOpen = signal(false)\n  readonly activePath = signal<number[]>([])\n  readonly search = signal('')\n  private readonly internalValue = signal<string[] | null | undefined>(undefined)\n\n  ngAfterViewInit(): void {\n    // Radix Slot: the child's props win over PopoverTrigger's (Angular host bindings would win).\n    const el = this.triggerEl?.nativeElement\n    el?.setAttribute('data-slot', 'cascade-select')\n    el?.setAttribute('aria-haspopup', 'listbox')\n  }\n\n  get currentValue(): string[] | null {\n    if (this.value !== undefined) return this.value\n    const v = this.internalValue()\n    return v === undefined ? this.defaultValue : v\n  }\n\n  setOpen(open: boolean): void {\n    this.isOpen.set(open)\n    // Sync the active path with the value when opened; clear the search when closed.\n    if (open) {\n      const v = this.currentValue\n      this.activePath.set(v?.length ? findPathIndices(this.options, v) : [])\n    } else {\n      this.search.set('')\n    }\n  }\n\n  /** Content width tracks the trigger (React passes `style` to PopoverContent). */\n  onPanelOpen(): void {\n    const panel = this.body?.nativeElement.parentElement\n    if (panel) panel.style.width = 'var(--radix-popover-trigger-width)'\n  }\n\n  private commitValue(values: string[] | null, path: CascadeOption[]): void {\n    if (this.value === undefined) this.internalValue.set(values)\n    this.valueChange.emit(values)\n    this.change.emit({ value: values, path })\n  }\n\n  private getOptionsAtLevel(level: number): CascadeOption[] {\n    let current = this.options\n    const active = this.activePath()\n    for (let i = 0; i < level; i++) {\n      const idx = active[i]\n      if (idx == null || !current[idx]?.children?.length) return []\n      current = current[idx].children!\n    }\n    return current\n  }\n\n  selectAtLevel(level: number, index: number): void {\n    const option = this.getOptionsAtLevel(level)[index]\n    if (option?.disabled) return\n    const next = [...this.activePath()]\n    next[level] = index\n    next.splice(level + 1)\n    this.activePath.set(next)\n    // Leaf node: emit the value and close.\n    if (!option?.children?.length) {\n      const path = buildPathFromIndices(this.options, next)\n      this.commitValue(\n        path.map((p) => p.value),\n        path,\n      )\n      this.setOpen(false)\n    }\n  }\n\n  get selectedPath(): CascadeOption[] {\n    const v = this.currentValue\n    if (!v?.length) return []\n    return buildPathFromIndices(this.options, findPathIndices(this.options, v))\n  }\n\n  get displayLabel(): string {\n    const path = this.selectedPath\n    if (path.length === 0) return this.placeholder\n    return path.map((p) => p.label).join(this.separator)\n  }\n\n  get hasValue(): boolean {\n    return this.selectedPath.length > 0\n  }\n\n  pathLabel(path: CascadeOption[]): string {\n    return path.map((p) => p.label).join(this.separator)\n  }\n\n  clearAll(event?: Event): void {\n    event?.stopPropagation()\n    if (this.disabled) return\n    this.clear.emit()\n    this.commitValue(null, [])\n    this.activePath.set([])\n  }\n\n  onTriggerKeyDown(e: KeyboardEvent): void {\n    // Escape clears when closed (open Escape is handled by Popover).\n    if (e.key === 'Escape' && !this.isOpen() && this.clearable && this.hasValue && !this.disabled) {\n      e.preventDefault()\n      this.clearAll(e)\n    }\n  }\n\n  /** Search: flatten the tree and match leaf labels. */\n  get searchResults(): SearchResult[] | null {\n    const q = this.search().trim().toLowerCase()\n    if (!q) return null\n    const results: SearchResult[] = []\n    const walk = (opts: CascadeOption[], path: CascadeOption[], values: string[]) => {\n      for (const opt of opts) {\n        const newPath = [...path, opt]\n        const newValues = [...values, opt.value]\n        if (opt.label.toLowerCase().includes(q) && !opt.children?.length)\n          results.push({ path: newPath, values: newValues })\n        if (opt.children?.length) walk(opt.children, newPath, newValues)\n      }\n    }\n    walk(this.options, [], [])\n    return results\n  }\n\n  selectSearchResult(result: SearchResult): void {\n    this.commitValue(result.values, result.path)\n    this.setOpen(false)\n    this.search.set('')\n  }\n\n  get levels(): { options: CascadeOption[]; level: number }[] {\n    const result: { options: CascadeOption[]; level: number }[] = [{ options: this.options, level: 0 }]\n    const active = this.activePath()\n    for (let i = 0; i < active.length; i++) {\n      const idx = active[i]\n      const current = result[i].options\n      if (idx == null || !current[idx]?.children?.length) break\n      result.push({ options: current[idx].children!, level: i + 1 })\n    }\n    return result\n  }\n\n  get triggerClasses(): string {\n    return cn(\n      'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-transparent text-sm shadow-xs transition-[color,box-shadow] outline-none',\n      'hover:border-ring/50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',\n      'disabled:cursor-not-allowed disabled:opacity-50',\n      sizeClasses[this.size],\n      this.className,\n    )\n  }\n\n  get labelClass(): string {\n    return cn('flex-1 truncate text-left', this.hasValue ? 'text-foreground' : 'text-muted-foreground')\n  }\n\n  get chevronClass(): string {\n    return cn(\n      'lucide lucide-chevron-down',\n      'text-muted-foreground size-4 shrink-0 transition-transform duration-200',\n      this.isOpen() && 'rotate-180',\n    )\n  }\n\n  optionClass(level: number, idx: number): string {\n    return cn(\n      'flex w-full items-center justify-between gap-1.5 rounded-sm px-2 py-1.5 text-left text-sm outline-none',\n      'hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none',\n      'disabled:cursor-not-allowed disabled:opacity-50',\n      this.activePath()[level] === idx && 'bg-accent text-accent-foreground font-medium',\n    )\n  }\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/cascade-select/cascade-select.component.ts"
    },
    {
      "path": "packages/registry-angular/components/cascade-select/types.ts",
      "content": "export interface CascadeOption {\n  value: string\n  label: string\n  disabled?: boolean\n  children?: CascadeOption[]\n  [key: string]: unknown\n}\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/cascade-select/types.ts"
    },
    {
      "path": "packages/registry-angular/components/cascade-select/index.ts",
      "content": "export { UiCascadeSelectComponent, type CascadeSelectSize } from './cascade-select.component'\nexport type { CascadeOption } from './types'\n",
      "type": "registry:ui",
      "target": "~/src/app/components/ui/cascade-select/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/angular/popover.json"
  ],
  "description": "Hierarchical cascading select where each level selection determines the next level options. Displays the selected path as labels. Supports search, clearable, disabled, and loading states.",
  "categories": [
    "control",
    "form"
  ]
}