{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cascade-select",
  "title": "Cascade Select",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/cascade-select/CascadeSelect.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { Check, ChevronDown, ChevronRight, Loader2, Search, X } from 'lucide-react'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { cn } from '@/lib/utils'\nimport type { CascadeOption } from './types'\n\nexport interface CascadeSelectProps {\n  value?: string[] | null\n  defaultValue?: string[] | null\n  onValueChange?: (value: string[] | null) => void\n  onChange?: (value: string[] | null, path: CascadeOption[]) => void\n  onClear?: () => void\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  className?: string\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 CascadeSelect = React.forwardRef<HTMLButtonElement, CascadeSelectProps>(\n  (\n    {\n      value: modelValue,\n      defaultValue = null,\n      onValueChange,\n      onChange,\n      onClear,\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      className,\n    },\n    ref,\n  ) => {\n    const isControlled = modelValue !== undefined\n    const [internalValue, setInternalValue] = React.useState<string[] | null>(defaultValue)\n    const currentValue = isControlled ? modelValue : internalValue\n\n    const [isOpen, setIsOpen] = React.useState(false)\n    const [activePath, setActivePath] = React.useState<number[]>([])\n    const [search, setSearch] = React.useState('')\n\n    // Sync active path with value when opened\n    React.useEffect(() => {\n      if (isOpen && currentValue?.length) {\n        setActivePath(findPathIndices(options, currentValue))\n      } else if (isOpen) {\n        setActivePath([])\n      }\n      if (!isOpen) setSearch('')\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [isOpen])\n\n    function commitValue(values: string[] | null, path: CascadeOption[]) {\n      if (!isControlled) setInternalValue(values)\n      onValueChange?.(values)\n      onChange?.(values, path)\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      setActivePath(next)\n\n      // If leaf node, emit the value\n      if (!option?.children?.length) {\n        const path = buildPathFromIndices(options, next)\n        const values = path.map((p) => p.value)\n        commitValue(values, path)\n        setIsOpen(false)\n      }\n    }\n\n    const selectedPath = React.useMemo<CascadeOption[]>(() => {\n      if (!currentValue?.length) return []\n      return buildPathFromIndices(options, findPathIndices(options, currentValue))\n    }, [currentValue, options])\n\n    const displayLabel = React.useMemo(() => {\n      if (selectedPath.length === 0) return placeholder\n      return selectedPath.map((p) => p.label).join(separator)\n    }, [selectedPath, placeholder, separator])\n\n    const hasValue = selectedPath.length > 0\n\n    function clearAll(event?: React.MouseEvent | React.KeyboardEvent) {\n      event?.stopPropagation()\n      if (disabled) return\n      onClear?.()\n      commitValue(null, [])\n      setActivePath([])\n    }\n\n    // Search: flatten the tree and match\n    const searchResults = React.useMemo<{ path: CascadeOption[]; values: string[] }[] | null>(() => {\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    }, [search, options])\n\n    function selectSearchResult(result: { path: CascadeOption[]; values: string[] }) {\n      commitValue(result.values, result.path)\n      setIsOpen(false)\n      setSearch('')\n    }\n\n    const levels = React.useMemo<{ options: CascadeOption[]; level: number }[]>(() => {\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    }, [activePath, options])\n\n    const triggerClasses = 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    return (\n      <Popover open={isOpen} onOpenChange={setIsOpen}>\n        <PopoverTrigger asChild>\n          <button\n            ref={ref}\n            type=\"button\"\n            role=\"combobox\"\n            aria-expanded={isOpen}\n            disabled={disabled || loading}\n            data-uipkge=\"\"\n            data-slot=\"cascade-select\"\n            className={triggerClasses}\n          >\n            <span className={cn('flex-1 truncate text-left', hasValue ? 'text-foreground' : 'text-muted-foreground')}>\n              {displayLabel}\n            </span>\n            <span className=\"flex shrink-0 items-center gap-1\">\n              {loading ? (\n                <Loader2 className=\"text-muted-foreground size-4 animate-spin\" />\n              ) : clearable && hasValue && !disabled ? (\n                <span\n                  role=\"button\"\n                  tabIndex={0}\n                  aria-label=\"Clear\"\n                  className=\"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                  onClick={(e) => clearAll(e)}\n                  onKeyDown={(e) => {\n                    if (e.key === 'Enter' || e.key === ' ') {\n                      e.preventDefault()\n                      clearAll(e)\n                    }\n                  }}\n                >\n                  <X className=\"size-4\" />\n                </span>\n              ) : (\n                <ChevronDown\n                  className={cn(\n                    'text-muted-foreground size-4 shrink-0 transition-transform duration-200',\n                    isOpen && 'rotate-180',\n                  )}\n                />\n              )}\n            </span>\n          </button>\n        </PopoverTrigger>\n\n        <PopoverContent\n          className=\"p-0\"\n          align=\"start\"\n          sideOffset={4}\n          style={{ width: 'var(--radix-popover-trigger-width)' }}\n        >\n          <div className=\"flex max-h-80 flex-col\">\n            {searchable && (\n              <div className=\"border-b p-2\">\n                <div className=\"relative\">\n                  <Search className=\"text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2\" />\n                  <input\n                    value={search}\n                    onChange={(e) => setSearch(e.target.value)}\n                    placeholder={searchPlaceholder}\n                    aria-label=\"Search options\"\n                    className=\"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            {loading ? (\n              <div className=\"text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm\">\n                <Loader2 className=\"size-4 animate-spin\" />\n                Loading...\n              </div>\n            ) : searchResults ? (\n              <div className=\"flex-1 overflow-y-auto p-1\">\n                {searchResults.length === 0 ? (\n                  <div className=\"text-muted-foreground py-6 text-center text-sm\">{emptyText}</div>\n                ) : (\n                  searchResults.map((result, i) => (\n                    <button\n                      key={i}\n                      type=\"button\"\n                      className=\"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 className=\"flex-1 truncate\">{result.path.map((p) => p.label).join(separator)}</span>\n                    </button>\n                  ))\n                )}\n              </div>\n            ) : (\n              <div className=\"flex flex-1 overflow-x-auto overflow-y-hidden\">\n                {levels.map((lvl) => (\n                  <div\n                    key={lvl.level}\n                    className=\"max-w-56 min-w-44 shrink-0 overflow-y-auto border-r p-1 last:border-r-0\"\n                  >\n                    {lvl.options.map((opt, idx) => (\n                      <button\n                        key={opt.value}\n                        type=\"button\"\n                        disabled={opt.disabled}\n                        className={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 className=\"flex-1 truncate\">{opt.label}</span>\n                        {activePath[lvl.level] === idx && !opt.children?.length ? (\n                          <Check className=\"size-4 shrink-0\" />\n                        ) : opt.children?.length ? (\n                          <ChevronRight className=\"text-muted-foreground size-3.5 shrink-0\" />\n                        ) : null}\n                      </button>\n                    ))}\n                  </div>\n                ))}\n              </div>\n            )}\n          </div>\n        </PopoverContent>\n      </Popover>\n    )\n  },\n)\nCascadeSelect.displayName = 'CascadeSelect'\n\nexport { CascadeSelect }\n",
      "type": "registry:ui",
      "target": "~/components/ui/cascade-select/CascadeSelect.tsx"
    },
    {
      "path": "packages/registry-react/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": "~/components/ui/cascade-select/types.ts"
    },
    {
      "path": "packages/registry-react/components/cascade-select/index.ts",
      "content": "export { CascadeSelect, type CascadeSelectProps } from './CascadeSelect'\nexport { type CascadeOption } from './types'\n",
      "type": "registry:ui",
      "target": "~/components/ui/cascade-select/index.ts"
    }
  ],
  "dependencies": [
    "lucide-react"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/react/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"
  ]
}