{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "cascade-select",
  "title": "Cascade Select",
  "type": "registry:ui",
  "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"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/cascade-select/cascade-select.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { Check, ChevronDown, ChevronRight, Loader2, Search, X } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\nimport { computePosition } from '../../lib/position'\n\nexport interface CascadeOption {\n  value: string\n  label: string\n  disabled?: boolean\n  children?: CascadeOption[]\n  [key: string]: unknown\n}\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 = {\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\nconst jsonConverter = {\n  fromAttribute: (v: string | null) => {\n    if (!v) return undefined\n    try {\n      return JSON.parse(v)\n    } catch {\n      return undefined\n    }\n  },\n  toAttribute: (v: unknown) => (v ? JSON.stringify(v) : null),\n}\n\nconst trueUnlessFalse = {\n  fromAttribute: (v: string | null) => v !== 'false',\n}\n\n/**\n * <uip-cascade-select> — the registry CascadeSelect as a web component.\n *\n * Form-associated: submits `name[]=val` per path segment with its <form>.\n */\nexport class UipCascadeSelect extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  static styles = [tailwind, css`:host { display: block; width: 100%; }`]\n\n  static properties = {\n    value: { converter: jsonConverter },\n    defaultValue: { attribute: 'default-value', converter: jsonConverter },\n    options: { converter: jsonConverter },\n    placeholder: {},\n    searchable: { converter: trueUnlessFalse },\n    clearable: { converter: trueUnlessFalse },\n    disabled: { type: Boolean, reflect: true },\n    loading: { type: Boolean, reflect: true },\n    size: { reflect: true },\n    separator: {},\n    searchPlaceholder: { attribute: 'search-placeholder' },\n    emptyText: { attribute: 'empty-text' },\n    name: { reflect: true },\n    accessibleLabel: { attribute: 'aria-label' },\n    isOpen: { state: true },\n    activePath: { state: true },\n    search: { state: true },\n    pos: { state: true },\n  }\n\n  value: string[] | null = null\n  defaultValue: string[] | null = null\n  options: CascadeOption[] = []\n  placeholder = 'Select...'\n  searchable = true\n  clearable = true\n  disabled = false\n  loading = false\n  size: 'sm' | 'default' | 'lg' = 'default'\n  separator = ' / '\n  searchPlaceholder = 'Search...'\n  emptyText = 'No options.'\n  name?: string\n  accessibleLabel?: string\n\n  private isOpen = false\n  private activePath: number[] = []\n  private search = ''\n  private pos: Record<string, string> = {}\n  private initialValue: string[] | null = null\n  private internals = this.attachInternals()\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', 'cascade-select')\n    if (this.defaultValue !== undefined && this.getAttribute('value') === null) {\n      this.value = this.defaultValue\n    }\n    this.initialValue = this.value\n    document.addEventListener('pointerdown', this.onDocumentPointerDown)\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    document.removeEventListener('pointerdown', this.onDocumentPointerDown)\n  }\n\n  private onDocumentPointerDown = (e: PointerEvent) => {\n    if (!this.isOpen) return\n    const path = e.composedPath()\n    if (!path.includes(this)) {\n      this.close()\n    }\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value') || changed.has('name')) {\n      if (!this.name || !this.value) {\n        this.internals.setFormValue(null)\n      } else {\n        const fd = new FormData()\n        this.value.forEach((v) => fd.append(`${this.name}[]`, v))\n        this.internals.setFormValue(fd)\n      }\n    }\n  }\n\n  formResetCallback() {\n    this.commitValue(this.initialValue, [])\n    this.activePath = []\n  }\n\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  private get trigger(): HTMLElement | null {\n    return this.renderRoot?.querySelector('[role=combobox]')\n  }\n\n  private get popoverEl(): HTMLElement | null {\n    return this.renderRoot?.querySelector('[data-slot=cascade-popover]')\n  }\n\n  private toggleOpen() {\n    if (this.disabled || this.loading) return\n    if (this.isOpen) this.close()\n    else this.open()\n  }\n\n  private open() {\n    this.isOpen = true\n    if (this.value?.length) {\n      this.activePath = findPathIndices(this.options, this.value)\n    } else {\n      this.activePath = []\n    }\n    this.search = ''\n    this.place()\n  }\n\n  private close() {\n    this.isOpen = false\n    this.search = ''\n  }\n\n  private place() {\n    this.updateComplete.then(() => {\n      const t = this.trigger\n      const p = this.popoverEl\n      if (!t || !p) return\n      const { style } = computePosition(t, p, { side: 'bottom', align: 'start', sideOffset: 4, matchWidth: true })\n      this.pos = style\n    })\n  }\n\n  private commitValue(values: string[] | null, path: CascadeOption[]) {\n    this.value = values\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(\n      new CustomEvent('value-change', {\n        detail: { value: values, path },\n        bubbles: true,\n        composed: true,\n      }),\n    )\n  }\n\n  private getOptionsAtLevel(level: number): CascadeOption[] {\n    let current = this.options\n    for (let i = 0; i < level; i++) {\n      const idx = this.activePath[i]\n      if (idx == null || !current[idx]?.children?.length) return []\n      current = current[idx]!.children!\n    }\n    return current\n  }\n\n  private selectAtLevel(level: number, index: number) {\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 = next\n\n    // If leaf node, emit value and close\n    if (!option?.children?.length) {\n      const path = buildPathFromIndices(this.options, next)\n      const values = path.map((p) => p.value)\n      this.commitValue(values, path)\n      this.close()\n    }\n  }\n\n  private clearAll(e?: Event) {\n    e?.stopPropagation()\n    if (this.disabled) return\n    this.commitValue(null, [])\n    this.activePath = []\n    this.dispatchEvent(new CustomEvent('clear', { bubbles: true, composed: true }))\n  }\n\n  private get searchResults(): { path: CascadeOption[]; values: string[] }[] | null {\n    const q = this.search.trim().toLowerCase()\n    if (!q) return null\n    const results: { path: CascadeOption[]; values: string[] }[] = []\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        }\n        if (opt.children?.length) {\n          walk(opt.children, newPath, newValues)\n        }\n      }\n    }\n    walk(this.options, [], [])\n    return results\n  }\n\n  private selectSearchResult(result: { path: CascadeOption[]; values: string[] }) {\n    this.commitValue(result.values, result.path)\n    this.close()\n  }\n\n  render() {\n    const selectedPath =\n      this.value?.length ? buildPathFromIndices(this.options, findPathIndices(this.options, this.value)) : []\n    const displayLabel =\n      selectedPath.length > 0 ? selectedPath.map((p) => p.label).join(this.separator) : this.placeholder\n    const hasVal = selectedPath.length > 0\n\n    const levels: { options: CascadeOption[]; level: number }[] = [{ options: this.options, level: 0 }]\n    for (let i = 0; i < this.activePath.length; i++) {\n      const idx = this.activePath[i]\n      const current = levels[i]!.options\n      if (idx == null || !current[idx]?.children?.length) break\n      levels.push({ options: current[idx]!.children!, level: i + 1 })\n    }\n\n    const searchResults = this.searchResults\n\n    return html`\n      <div part=\"base\" class=\"relative w-full\">\n        <button\n          part=\"trigger\"\n          type=\"button\"\n          role=\"combobox\"\n          aria-expanded=${this.isOpen ? 'true' : 'false'}\n          aria-haspopup=\"listbox\"\n          aria-label=${this.accessibleLabel ?? nothing}\n          ?disabled=${this.disabled || this.loading}\n          data-slot=\"cascade-select\"\n          class=${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 text-foreground',\n            sizeClasses[this.size] ?? sizeClasses.default,\n          )}\n          @click=${this.toggleOpen}\n        >\n          <span class=${cn('flex-1 truncate text-left', hasVal ? 'text-foreground' : 'text-muted-foreground')}>\n            ${displayLabel}\n          </span>\n          <span class=\"flex shrink-0 items-center gap-1\">\n            ${this.loading\n              ? icon(Loader2, 'loader-2', 'text-muted-foreground size-4 animate-spin')\n              : this.clearable && hasVal && !this.disabled\n                ? html`\n                    <span\n                      role=\"button\"\n                      tabindex=\"0\"\n                      aria-label=\"Clear selection\"\n                      class=\"text-muted-foreground hover:text-foreground flex size-4 items-center justify-center rounded transition-colors cursor-pointer\"\n                      @click=${(e: Event) => this.clearAll(e)}\n                    >\n                      ${icon(X, 'x', 'size-4')}\n                    </span>\n                  `\n                : html`\n                    <span class=${cn('text-muted-foreground size-4 shrink-0 transition-transform duration-200', this.isOpen && 'rotate-180')}>\n                      ${icon(ChevronDown, 'chevron-down', 'size-4')}\n                    </span>\n                  `}\n          </span>\n        </button>\n\n        ${this.isOpen\n          ? html`\n              <div\n                part=\"content\"\n                data-slot=\"cascade-popover\"\n                style=${styleMap(this.pos)}\n                class=\"bg-popover text-popover-foreground fixed inset-auto m-0 z-50 max-h-80 min-w-44 overflow-hidden rounded-md border shadow-md\"\n              >\n                <div class=\"flex max-h-80 flex-col\">\n                  ${this.searchable\n                    ? html`\n                        <div class=\"border-b p-2\">\n                          <div class=\"relative\">\n                            <span class=\"text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2 pointer-events-none\">\n                              ${icon(Search, 'search', 'size-4')}\n                            </span>\n                            <input\n                              .value=${this.search}\n                              placeholder=${this.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] text-foreground placeholder:text-muted-foreground\"\n                              @input=${(e: Event) => (this.search = (e.target as HTMLInputElement).value)}\n                            />\n                          </div>\n                        </div>\n                      `\n                    : nothing}\n\n                  ${this.loading\n                    ? html`\n                        <div class=\"text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm\">\n                          ${icon(Loader2, 'loader-2', 'size-4 animate-spin')}\n                          Loading...\n                        </div>\n                      `\n                    : searchResults\n                      ? html`\n                          <div class=\"flex-1 overflow-y-auto p-1 max-h-64\">\n                            ${searchResults.length === 0\n                              ? html`<div class=\"text-muted-foreground py-6 text-center text-sm\">${this.emptyText}</div>`\n                              : searchResults.map(\n                                  (result, i) => html`\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\"\n                                      @click=${() => this.selectSearchResult(result)}\n                                    >\n                                      <span class=\"flex-1 truncate\">\n                                        ${result.path.map((p) => p.label).join(this.separator)}\n                                      </span>\n                                    </button>\n                                  `,\n                                )}\n                          </div>\n                        `\n                      : html`\n                          <div class=\"flex flex-1 overflow-x-auto overflow-y-hidden\">\n                            ${levels.map(\n                              (lvl) => html`\n                                <div class=\"max-w-56 min-w-44 shrink-0 overflow-y-auto border-r p-1 last:border-r-0 max-h-72\">\n                                  ${lvl.options.map(\n                                    (opt, idx) => html`\n                                      <button\n                                        type=\"button\"\n                                        ?disabled=${opt.disabled}\n                                        class=${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',\n                                          'disabled:cursor-not-allowed disabled:opacity-50',\n                                          this.activePath[lvl.level] === idx && 'bg-accent text-accent-foreground font-medium',\n                                        )}\n                                        @click=${() => this.selectAtLevel(lvl.level, idx)}\n                                      >\n                                        <span class=\"flex-1 truncate\">${opt.label}</span>\n                                        ${this.activePath[lvl.level] === idx && !opt.children?.length\n                                          ? icon(Check, 'check', 'size-4 shrink-0')\n                                          : opt.children?.length\n                                            ? icon(ChevronRight, 'chevron-right', 'text-muted-foreground size-3.5 shrink-0')\n                                            : nothing}\n                                      </button>\n                                    `,\n                                  )}\n                                </div>\n                              `,\n                            )}\n                          </div>\n                        `}\n                </div>\n              </div>\n            `\n          : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-cascade-select') || customElements.define('uip-cascade-select', UipCascadeSelect)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-cascade-select': UipCascadeSelect\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/cascade-select/cascade-select.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
