{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "cascade-select",
  "title": "Cascade Select",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/cascade-select/CascadeSelect.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { CascadeOption } from './types'\n\n  export interface CascadeSelectProps {\n    /** Selected value path (one entry per level). Two-way bindable (`bind:value`). */\n    value?: string[] | null\n    options: CascadeOption[]\n    placeholder?: string\n    searchable?: boolean\n    clearable?: boolean\n    disabled?: boolean\n    loading?: boolean\n    size?: 'sm' | 'default' | 'lg'\n    separator?: string\n    searchPlaceholder?: string\n    emptyText?: string\n    class?: string\n    onchange?: (value: string[] | null, path: CascadeOption[]) => void\n    onclear?: () => void\n    /** The trigger button, via `bind:ref`. */\n    ref?: HTMLButtonElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { Check, ChevronDown, ChevronRight, Loader2, Search, X } from '@lucide/svelte'\n  import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover'\n  import { cn } from '$lib/utils'\n\n  let {\n    value = $bindable<string[] | null | undefined>(undefined),\n    options,\n    placeholder = 'Select...',\n    searchable = true,\n    clearable = true,\n    disabled = false,\n    loading = false,\n    size = 'default',\n    separator = ' / ',\n    searchPlaceholder = 'Search...',\n    emptyText = 'No options.',\n    class: className,\n    onchange,\n    onclear,\n    ref = $bindable(null),\n  }: CascadeSelectProps = $props()\n\n  let isOpen = $state(false)\n  let activePath = $state<number[]>([])\n  let search = $state('')\n\n  // Sync active path with value when opened\n  let prevOpen = false\n  $effect(() => {\n    if (isOpen !== prevOpen) {\n      prevOpen = isOpen\n      if (isOpen) {\n        activePath = value?.length ? findPathIndices(options, value) : []\n      } else {\n        search = ''\n      }\n    }\n  })\n\n  function findPathIndices(opts: CascadeOption[], values: string[]): number[] {\n    const indices: number[] = []\n    let current = opts\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\n  function getOptionsAtLevel(level: number): CascadeOption[] {\n    let current = options\n    for (let i = 0; i < level; i++) {\n      const idx = activePath[i]\n      if (idx == null || !current[idx]?.children?.length) return []\n      current = current[idx].children!\n    }\n    return current\n  }\n\n  function selectAtLevel(level: number, index: number) {\n    const option = getOptionsAtLevel(level)[index]\n    if (option?.disabled) return\n    const next = [...activePath]\n    next[level] = index\n    next.splice(level + 1)\n    activePath = next\n\n    // If leaf node, emit the value\n    if (!option?.children?.length) {\n      const path = buildPathFromIndices(next)\n      const values = path.map((p) => p.value)\n      value = values\n      onchange?.(values, path)\n      isOpen = false\n    }\n  }\n\n  function buildPathFromIndices(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\n  const selectedPath = $derived.by((): CascadeOption[] => {\n    if (!value?.length) return []\n    return buildPathFromIndices(findPathIndices(options, value))\n  })\n\n  const displayLabel = $derived(\n    selectedPath.length === 0 ? placeholder : selectedPath.map((p) => p.label).join(separator),\n  )\n\n  const hasValue = $derived(selectedPath.length > 0)\n\n  function clearAll(event?: Event) {\n    event?.stopPropagation()\n    if (disabled) return\n    onclear?.()\n    value = null\n    onchange?.(null, [])\n    activePath = []\n  }\n\n  function onTriggerKeydown(e: KeyboardEvent) {\n    // Escape clears the value when the popover is closed (open Escape is\n    // handled by Popover). Replaces the old nested clear button for keyboard.\n    if (e.key === 'Escape' && !isOpen && clearable && hasValue && !disabled) {\n      e.preventDefault()\n      clearAll()\n    }\n  }\n\n  // Search: flatten the tree and match\n  const searchResults = $derived.by(() => {\n    const q = 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(options, [], [])\n    return results\n  })\n\n  function selectSearchResult(result: { path: CascadeOption[]; values: string[] }) {\n    value = result.values\n    onchange?.(result.values, result.path)\n    isOpen = false\n    search = ''\n  }\n\n  const 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\n  const triggerClasses = $derived(\n    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[size],\n      className,\n    ),\n  )\n\n  const levels = $derived.by(() => {\n    const result: { options: CascadeOption[]; level: number }[] = [{ options, level: 0 }]\n    for (let i = 0; i < activePath.length; i++) {\n      const idx = activePath[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  // Match the panel width to the trigger. The Svelte popover port has no\n  // `--reka-popover-trigger-width` var, so measure the trigger directly.\n  let triggerWidth = $state<number | null>(null)\n  $effect(() => {\n    const el = ref\n    if (!el) return\n    const update = () => {\n      triggerWidth = el.offsetWidth\n    }\n    update()\n    const ro = new ResizeObserver(update)\n    ro.observe(el)\n    return () => ro.disconnect()\n  })\n</script>\n\n<Popover bind:open={isOpen}>\n  <PopoverTrigger>\n    {#snippet child({ props }: { props: Record<string, unknown> })}\n      <button\n        {...props}\n        bind:this={ref}\n        type=\"button\"\n        role=\"combobox\"\n        aria-expanded={isOpen}\n        aria-haspopup=\"listbox\"\n        disabled={disabled || loading}\n        data-uipkge\n        data-slot=\"cascade-select\"\n        class={triggerClasses}\n        onkeydown={onTriggerKeydown}\n      >\n        <span class={cn('flex-1 truncate text-left', hasValue ? 'text-foreground' : 'text-muted-foreground')}>\n          {displayLabel}\n        </span>\n        <span class=\"flex shrink-0 items-center gap-1\">\n          {#if loading}\n            <Loader2 class=\"text-muted-foreground size-4 animate-spin\" />\n          {:else if clearable && hasValue && !disabled}\n            <!-- Decorative clear affordance (not a nested button). Nested\n                 interactive content inside the combobox button is invalid HTML\n                 and confuses assistive tech. Click clears; Escape also clears\n                 when the popover is closed (see onkeydown on the trigger). -->\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              onclick={(e) => clearAll(e)}\n            >\n              <X class=\"size-4\" />\n            </span>\n          {:else}\n            <ChevronDown\n              class={cn('text-muted-foreground size-4 shrink-0 transition-transform duration-200', isOpen && 'rotate-180')}\n            />\n          {/if}\n        </span>\n      </button>\n    {/snippet}\n  </PopoverTrigger>\n\n  <PopoverContent class=\"p-0\" align=\"start\" sideOffset={4}>\n    <div class=\"flex max-h-80 flex-col\" style={triggerWidth ? `width: ${triggerWidth}px` : undefined}>\n      {#if searchable}\n        <div class=\"border-b p-2\">\n          <div class=\"relative\">\n            <Search class=\"text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2\" />\n            <input\n              bind:value={search}\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      {/if}\n\n      {#if loading}\n        <div class=\"text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm\">\n          <Loader2 class=\"size-4 animate-spin\" />\n          Loading...\n        </div>\n      {:else if searchResults}\n        <!-- Search results -->\n        <div class=\"flex-1 overflow-y-auto p-1\">\n          {#if searchResults.length === 0}\n            <div class=\"text-muted-foreground py-6 text-center text-sm\">\n              {emptyText}\n            </div>\n          {:else}\n            {#each searchResults as result, i (i)}\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                onclick={() => selectSearchResult(result)}\n              >\n                <span class=\"flex-1 truncate\">{result.path.map((p) => p.label).join(separator)}</span>\n              </button>\n            {/each}\n          {/if}\n        </div>\n      {:else}\n        <!-- Cascading panels — horizontal scroll -->\n        <div class=\"flex flex-1 overflow-x-auto overflow-y-hidden\">\n          {#each levels as lvl (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              {#each lvl.options as opt, idx (opt.value)}\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 focus-visible:outline-none',\n                    'disabled:cursor-not-allowed disabled:opacity-50',\n                    activePath[lvl.level] === idx && 'bg-accent text-accent-foreground font-medium',\n                  )}\n                  onclick={() => selectAtLevel(lvl.level, idx)}\n                >\n                  <span class=\"flex-1 truncate\">{opt.label}</span>\n                  {#if activePath[lvl.level] === idx && !opt.children?.length}\n                    <Check class=\"size-4 shrink-0\" />\n                  {:else if opt.children?.length}\n                    <ChevronRight class=\"text-muted-foreground size-3.5 shrink-0\" />\n                  {/if}\n                </button>\n              {/each}\n            </div>\n          {/each}\n        </div>\n      {/if}\n    </div>\n  </PopoverContent>\n</Popover>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/cascade-select/CascadeSelect.svelte"
    },
    {
      "path": "packages/registry-svelte/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/lib/components/ui/cascade-select/types.ts"
    },
    {
      "path": "packages/registry-svelte/components/cascade-select/index.ts",
      "content": "export { default as CascadeSelect, type CascadeSelectProps } from './CascadeSelect.svelte'\nexport type { CascadeOption } from './types'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/cascade-select/index.ts"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/svelte/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"
  ]
}