{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "cascade-select",
  "title": "Cascade Select",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/cascade-select/CascadeSelect.vue",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { computed, ref, watch } from 'vue'\nimport { Check, ChevronDown, ChevronRight, Loader2, Search, X } from 'lucide-vue-next'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { cn } from '@/lib/utils'\nimport type { CascadeOption } from './types'\n\ninterface Props {\n  modelValue?: 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?: HTMLAttributes['class']\n}\n\nconst props = withDefaults(defineProps<Props>(), {\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})\n\nconst emits = defineEmits<{\n  'update:modelValue': [value: string[] | null]\n  change: [value: string[] | null, path: CascadeOption[]]\n  clear: []\n}>()\n\nconst isOpen = ref(false)\nconst activePath = ref<number[]>([])\nconst search = ref('')\n\n// Sync active path with modelValue when opened\nwatch(isOpen, (open) => {\n  if (open && props.modelValue?.length) {\n    activePath.value = findPathIndices(props.options, props.modelValue)\n  } else if (open) {\n    activePath.value = []\n  }\n  if (!open) search.value = ''\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 getOptionsAtLevel(level: number): CascadeOption[] {\n  let current = props.options\n  for (let i = 0; i < level; i++) {\n    const idx = activePath.value[i]\n    if (idx == null || !current[idx]?.children?.length) return []\n    current = current[idx].children!\n  }\n  return current\n}\n\nfunction selectAtLevel(level: number, index: number) {\n  const option = getOptionsAtLevel(level)[index]\n  if (option?.disabled) return\n  const next = [...activePath.value]\n  next[level] = index\n  next.splice(level + 1)\n  activePath.value = 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    emits('update:modelValue', values)\n    emits('change', values, path)\n    isOpen.value = false\n  }\n}\n\nfunction buildPathFromIndices(indices: number[]): CascadeOption[] {\n  const path: CascadeOption[] = []\n  let current = props.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 selectedPath = computed<CascadeOption[]>(() => {\n  if (!props.modelValue?.length) return []\n  return buildPathFromIndices(findPathIndices(props.options, props.modelValue))\n})\n\nconst displayLabel = computed(() => {\n  if (selectedPath.value.length === 0) return props.placeholder\n  return selectedPath.value.map((p) => p.label).join(props.separator)\n})\n\nconst hasValue = computed(() => selectedPath.value.length > 0)\n\nfunction clearAll(event?: Event) {\n  event?.stopPropagation()\n  if (props.disabled) return\n  emits('clear')\n  emits('update:modelValue', null)\n  emits('change', null, [])\n  activePath.value = []\n}\n\n// Search: flatten the tree and match\nconst searchResults = computed(() => {\n  const q = search.value.trim().toLowerCase()\n  if (!q) return null\n  const results: { path: CascadeOption[]; values: string[] }[] = []\n  const walk = (options: CascadeOption[], path: CascadeOption[], values: string[]) => {\n    for (const opt of options) {\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(props.options, [], [])\n  return results\n})\n\nfunction selectSearchResult(result: { path: CascadeOption[]; values: string[] }) {\n  emits('update:modelValue', result.values)\n  emits('change', result.values, result.path)\n  isOpen.value = false\n  search.value = ''\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 triggerClasses = computed(() =>\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[props.size],\n    props.class,\n  ),\n)\n\nconst levels = computed(() => {\n  const result: { options: CascadeOption[]; level: number }[] = [{ options: props.options, level: 0 }]\n  for (let i = 0; i < activePath.value.length; i++) {\n    const idx = activePath.value[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</script>\n\n<template>\n  <Popover v-model:open=\"isOpen\">\n    <PopoverTrigger as-child>\n      <button\n        type=\"button\"\n        role=\"combobox\"\n        :aria-expanded=\"isOpen\"\n        :disabled=\"disabled || loading\"\n        data-uipkge\n        data-slot=\"cascade-select\"\n        :class=\"triggerClasses\"\n      >\n        <span :class=\"['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          <Loader2 v-if=\"loading\" class=\"text-muted-foreground size-4 animate-spin\" />\n          <span\n            v-else-if=\"clearable && hasValue && !disabled\"\n            role=\"button\"\n            tabindex=\"0\"\n            aria-label=\"Clear\"\n            class=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex size-4 items-center justify-center rounded transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n            @click.stop=\"clearAll\"\n            @keydown.enter.prevent=\"clearAll\"\n            @keydown.space.prevent=\"clearAll\"\n          >\n            <X class=\"size-4\" />\n          </span>\n          <ChevronDown\n            v-else\n            class=\"text-muted-foreground size-4 shrink-0 transition-transform duration-200\"\n            :class=\"isOpen ? 'rotate-180' : ''\"\n          />\n        </span>\n      </button>\n    </PopoverTrigger>\n\n    <PopoverContent class=\"p-0\" align=\"start\" :side-offset=\"4\" :style=\"{ width: 'var(--reka-popover-trigger-width)' }\">\n      <div class=\"flex max-h-80 flex-col\">\n        <div v-if=\"searchable\" class=\"border-b p-2\">\n          <div class=\"relative\">\n            <Search class=\"text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2\" />\n            <input\n              v-model=\"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\n        <div v-if=\"loading\" 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\n        <!-- Search results -->\n        <div v-else-if=\"searchResults\" class=\"flex-1 overflow-y-auto p-1\">\n          <div v-if=\"searchResults.length === 0\" class=\"text-muted-foreground py-6 text-center text-sm\">\n            {{ emptyText }}\n          </div>\n          <button\n            v-for=\"(result, i) in searchResults\"\n            :key=\"i\"\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\">{{ result.path.map((p) => p.label).join(separator) }}</span>\n          </button>\n        </div>\n\n        <!-- Cascading panels — horizontal scroll -->\n        <div v-else class=\"flex flex-1 overflow-x-auto overflow-y-hidden\">\n          <div\n            v-for=\"lvl in levels\"\n            :key=\"lvl.level\"\n            class=\"max-w-56 min-w-44 shrink-0 overflow-y-auto border-r p-1 last:border-r-0\"\n          >\n            <button\n              v-for=\"(opt, idx) in lvl.options\"\n              :key=\"opt.value\"\n              type=\"button\"\n              :disabled=\"opt.disabled\"\n              :class=\"\n                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              \"\n              @click=\"selectAtLevel(lvl.level, idx)\"\n            >\n              <span class=\"flex-1 truncate\">{{ opt.label }}</span>\n              <Check v-if=\"activePath[lvl.level] === idx && !opt.children?.length\" class=\"size-4 shrink-0\" />\n              <ChevronRight v-else-if=\"opt.children?.length\" class=\"text-muted-foreground size-3.5 shrink-0\" />\n            </button>\n          </div>\n        </div>\n      </div>\n    </PopoverContent>\n  </Popover>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/cascade-select/CascadeSelect.vue"
    },
    {
      "path": "packages/registry-vue/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": "~/app/components/ui/cascade-select/types.ts"
    },
    {
      "path": "packages/registry-vue/components/cascade-select/index.ts",
      "content": "export { default as CascadeSelect } from './CascadeSelect.vue'\nexport type { CascadeOption } from './types'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/cascade-select/index.ts"
    }
  ],
  "dependencies": [
    "lucide-vue-next",
    "reka-ui"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/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"
  ]
}