{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tree-select",
  "title": "Tree Select",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-react/components/tree-select/TreeSelect.tsx",
      "content": "'use client'\n\nimport * as React from 'react'\nimport { ChevronDown, Loader2, Search, X } from 'lucide-react'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { cn } from '@/lib/utils'\nimport { treeSelectTriggerVariants } from './tree-select.variants'\nimport { TreeSelectNode } from './TreeSelectNode'\nimport type { TreeSelectNode as TreeNode } from './types'\n\nexport interface TreeSelectProps {\n  value?: string | string[] | null\n  defaultValue?: string | string[] | null\n  data: TreeNode[]\n  multiple?: boolean\n  placeholder?: string\n  searchable?: boolean\n  disabled?: boolean\n  loading?: boolean\n  clearable?: boolean\n  defaultExpandAll?: boolean\n  size?: 'sm' | 'default' | 'lg'\n  emptyText?: string\n  searchPlaceholder?: string\n  className?: string\n  onValueChange?: (value: string | string[] | null) => void\n  onChange?: (value: string | string[] | null) => void\n  onSelect?: (node: TreeNode) => void\n  onClear?: () => void\n}\n\nfunction collectAllExpandable(nodes: TreeNode[]): string[] {\n  const ids: string[] = []\n  const walk = (list: TreeNode[]) => {\n    for (const n of list) {\n      if (n.children?.length) {\n        ids.push(n.value)\n        walk(n.children)\n      }\n    }\n  }\n  walk(nodes)\n  return ids\n}\n\nfunction findNode(nodes: TreeNode[], value: string): TreeNode | undefined {\n  for (const n of nodes) {\n    if (n.value === value) return n\n    if (n.children) {\n      const found = findNode(n.children, value)\n      if (found) return found\n    }\n  }\n  return undefined\n}\n\nfunction findLabels(nodes: TreeNode[], values: string[]): string[] {\n  return values.map((v) => findNode(nodes, v)?.label ?? v)\n}\n\nfunction collectLeafValues(node: TreeNode): string[] {\n  if (!node.children?.length) return [node.value]\n  const vals: string[] = []\n  for (const c of node.children) vals.push(...collectLeafValues(c))\n  return vals\n}\n\nconst TreeSelect = React.forwardRef<HTMLButtonElement, TreeSelectProps>(\n  (\n    {\n      value,\n      defaultValue,\n      data,\n      multiple = false,\n      placeholder = 'Select...',\n      searchable = true,\n      disabled = false,\n      loading = false,\n      clearable = true,\n      defaultExpandAll = false,\n      size = 'default',\n      emptyText = 'No results found.',\n      searchPlaceholder = 'Search...',\n      className,\n      onValueChange,\n      onChange,\n      onSelect,\n      onClear,\n    },\n    ref,\n  ) => {\n    const isControlled = value !== undefined\n    const [internalValue, setInternalValue] = React.useState<string | string[] | null>(\n      defaultValue ?? null,\n    )\n    const currentValue = isControlled ? value : internalValue\n\n    const [isOpen, setIsOpen] = React.useState(false)\n    const [search, setSearch] = React.useState('')\n    const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => {\n      if (defaultExpandAll) return new Set(collectAllExpandable(data))\n      return new Set()\n    })\n\n    // Re-collect expandable when defaultExpandAll toggles or data changes.\n    React.useEffect(() => {\n      if (defaultExpandAll) setExpandedIds(new Set(collectAllExpandable(data)))\n    }, [defaultExpandAll, data])\n\n    // Clear search when popover closes.\n    React.useEffect(() => {\n      if (!isOpen) setSearch('')\n    }, [isOpen])\n\n    const selectedValues = React.useMemo<Set<string>>(() => {\n      if (currentValue == null) return new Set()\n      if (Array.isArray(currentValue)) return new Set(currentValue)\n      return new Set([currentValue])\n    }, [currentValue])\n\n    const displayLabel = React.useMemo(() => {\n      if (multiple) {\n        const vals = Array.isArray(currentValue) ? currentValue : []\n        if (vals.length === 0) return placeholder\n        const labels = findLabels(data, vals)\n        if (labels.length <= 3) return labels.join(', ')\n        return `${labels.slice(0, 3).join(', ')} +${labels.length - 3}`\n      }\n      if (currentValue == null) return placeholder\n      const node = findNode(data, currentValue as string)\n      return node?.label ?? String(currentValue)\n    }, [multiple, currentValue, placeholder, data])\n\n    const hasValue = React.useMemo(() => {\n      if (multiple) return Array.isArray(currentValue) && currentValue.length > 0\n      return currentValue != null\n    }, [multiple, currentValue])\n\n    // Search filtering: a node is visible if it or any descendant matches.\n    const filteredIds = React.useMemo<Set<string> | null>(() => {\n      const q = search.trim().toLowerCase()\n      if (!q) return null\n      const visible = new Set<string>()\n      const walk = (nodes: TreeNode[]): boolean => {\n        let anyMatch = false\n        for (const n of nodes) {\n          const selfMatch = n.label.toLowerCase().includes(q)\n          let childMatch = false\n          if (n.children?.length) {\n            childMatch = walk(n.children)\n          }\n          if (selfMatch || childMatch) {\n            visible.add(n.value)\n            anyMatch = true\n            // Auto-expand parents of matches\n            if (childMatch) {\n              setExpandedIds((prev) => {\n                if (prev.has(n.value)) return prev\n                const next = new Set(prev)\n                next.add(n.value)\n                return next\n              })\n            }\n          }\n        }\n        return anyMatch\n      }\n      walk(data)\n      return visible\n    }, [search, data])\n\n    function commit(next: string | string[] | null) {\n      if (!isControlled) setInternalValue(next)\n      onValueChange?.(next)\n      onChange?.(next)\n    }\n\n    function toggleNode(node: TreeNode) {\n      if (node.disabled) return\n      setExpandedIds((prev) => {\n        const next = new Set(prev)\n        if (next.has(node.value)) next.delete(node.value)\n        else next.add(node.value)\n        return next\n      })\n    }\n\n    function selectNode(node: TreeNode) {\n      if (node.disabled) return\n      if (!multiple) {\n        commit(node.value)\n        onSelect?.(node)\n        setIsOpen(false)\n        return\n      }\n      // Multi-select: toggle. For parent nodes, toggle all leaf descendants.\n      const current = Array.isArray(currentValue) ? [...currentValue] : []\n      const leaves = collectLeafValues(node)\n      const allSelected = leaves.every((v) => current.includes(v))\n      let next: string[]\n      if (allSelected) {\n        next = current.filter((v) => !leaves.includes(v))\n      } else {\n        next = [...current, ...leaves.filter((v) => !current.includes(v))]\n      }\n      commit(next)\n      onSelect?.(node)\n    }\n\n    function clearAll(event?: React.MouseEvent | React.KeyboardEvent) {\n      event?.stopPropagation()\n      if (disabled) return\n      onClear?.()\n      if (multiple) {\n        commit([])\n      } else {\n        commit(null)\n      }\n    }\n\n    const triggerClasses = cn(treeSelectTriggerVariants({ size }), className)\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=\"tree-select\"\n            className={triggerClasses}\n          >\n            <span\n              className={cn('flex-1 truncate text-left', hasValue ? 'text-foreground' : 'text-muted-foreground')}\n            >\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 tree\"\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            ) : filteredIds && filteredIds.size === 0 ? (\n              <div className=\"text-muted-foreground py-6 text-center text-sm\">{emptyText}</div>\n            ) : (\n              <div className=\"flex-1 overflow-y-auto p-1\" role=\"tree\">\n                {data.map((node) => (\n                  <TreeSelectNode\n                    key={node.value}\n                    node={node}\n                    depth={0}\n                    multiple={multiple}\n                    expandedIds={expandedIds}\n                    selectedValues={selectedValues}\n                    filteredIds={filteredIds}\n                    onToggle={toggleNode}\n                    onSelect={selectNode}\n                  />\n                ))}\n              </div>\n            )}\n\n            {multiple && Array.isArray(currentValue) && currentValue.length > 0 && (\n              <div className=\"flex items-center justify-between border-t px-2 py-1.5 text-xs\">\n                <span className=\"text-muted-foreground\">{currentValue.length} selected</span>\n                <button\n                  type=\"button\"\n                  className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded focus-visible:ring-2 focus-visible:outline-none\"\n                  onClick={() => clearAll()}\n                >\n                  Clear all\n                </button>\n              </div>\n            )}\n          </div>\n        </PopoverContent>\n      </Popover>\n    )\n  },\n)\nTreeSelect.displayName = 'TreeSelect'\n\nexport { TreeSelect }\n",
      "type": "registry:ui",
      "target": "~/components/ui/tree-select/TreeSelect.tsx"
    },
    {
      "path": "packages/registry-react/components/tree-select/TreeSelectNode.tsx",
      "content": "import * as React from 'react'\nimport { ChevronRight } from 'lucide-react'\nimport { cn } from '@/lib/utils'\nimport type { TreeSelectNode } from './types'\n\nexport interface TreeSelectNodeProps {\n  node: TreeSelectNode\n  depth: number\n  multiple: boolean\n  expandedIds: Set<string>\n  selectedValues: Set<string>\n  filteredIds: Set<string> | null\n  onToggle: (node: TreeSelectNode) => void\n  onSelect: (node: TreeSelectNode) => void\n}\n\nfunction collectValues(node: TreeSelectNode): string[] {\n  const vals: string[] = []\n  const walk = (n: TreeSelectNode) => {\n    if (n.children?.length) {\n      for (const c of n.children) walk(c)\n    } else {\n      vals.push(n.value)\n    }\n  }\n  walk(node)\n  return vals\n}\n\nexport const TreeSelectNode = React.memo(\n  React.forwardRef<HTMLDivElement, TreeSelectNodeProps>(\n    ({ node, depth, multiple, expandedIds, selectedValues, filteredIds, onToggle, onSelect }, ref) => {\n      const hasChildren = !!(node.children && node.children.length)\n      const isExpanded = expandedIds.has(node.value)\n      const isChecked = React.useMemo(() => {\n        if (!multiple) return selectedValues.has(node.value)\n        if (selectedValues.has(node.value)) return true\n        if (!hasChildren) return false\n        const descendants = collectValues(node)\n        const selected = descendants.filter((v) => selectedValues.has(v))\n        return selected.length > 0 && selected.length < descendants.length\n      }, [multiple, selectedValues, node, hasChildren])\n      const isFullyChecked = React.useMemo(() => {\n        if (!multiple) return false\n        if (selectedValues.has(node.value)) return true\n        if (!hasChildren) return false\n        const descendants = collectValues(node)\n        return descendants.length > 0 && descendants.every((v) => selectedValues.has(v))\n      }, [multiple, selectedValues, node, hasChildren])\n      const isVisible = !filteredIds || filteredIds.has(node.value)\n\n      const indent = `${depth * 20 + 8}px`\n\n      function handleToggle(e: React.MouseEvent) {\n        e.stopPropagation()\n        onToggle(node)\n      }\n\n      function handleSelect() {\n        if (node.disabled) return\n        onSelect(node)\n      }\n\n      function handleCheckboxChange(e: React.ChangeEvent<HTMLInputElement>) {\n        e.stopPropagation()\n        if (node.disabled) return\n        onSelect(node)\n      }\n\n      if (!isVisible) return null\n\n      return (\n        <div ref={ref} role=\"treeitem\" aria-expanded={hasChildren ? isExpanded : undefined}>\n          <div\n            className={cn(\n              'group relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md pr-2 text-sm transition-colors',\n              'hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none',\n              node.disabled && 'cursor-not-allowed opacity-50',\n              !multiple && selectedValues.has(node.value) && 'bg-accent text-accent-foreground font-medium',\n            )}\n            style={{ paddingLeft: indent }}\n            tabIndex={node.disabled ? -1 : 0}\n            role=\"button\"\n            onClick={handleSelect}\n            onKeyDown={(e) => {\n              if (e.key === 'Enter') {\n                e.preventDefault()\n                handleSelect()\n              }\n            }}\n          >\n            {hasChildren ? (\n              <button\n                type=\"button\"\n                className={cn(\n                  'focus-visible:ring-ring flex size-4 shrink-0 items-center justify-center rounded transition-transform duration-150 focus-visible:ring-2 focus-visible:outline-none',\n                  'hover:bg-foreground/10',\n                  isExpanded && 'rotate-90',\n                )}\n                aria-label={isExpanded ? 'Collapse' : 'Expand'}\n                onClick={handleToggle}\n              >\n                <ChevronRight className=\"text-muted-foreground size-3.5\" aria-hidden=\"true\" />\n              </button>\n            ) : (\n              <span className=\"size-4 shrink-0\" />\n            )}\n\n            {multiple && (\n              <input\n                type=\"checkbox\"\n                checked={isFullyChecked || isChecked}\n                ref={(el) => {\n                  if (el) el.indeterminate = isChecked && !isFullyChecked\n                }}\n                disabled={node.disabled}\n                className=\"border-input text-primary focus:ring-ring size-3.5 shrink-0 rounded focus:ring-1\"\n                onChange={handleCheckboxChange}\n                onClick={(e) => e.stopPropagation()}\n              />\n            )}\n\n            <span className=\"flex-1 truncate\">{node.label}</span>\n          </div>\n\n          {hasChildren && isExpanded && (\n            <div role=\"group\">\n              {node.children!.map((child) => (\n                <TreeSelectNode\n                  key={child.value}\n                  node={child}\n                  depth={depth + 1}\n                  multiple={multiple}\n                  expandedIds={expandedIds}\n                  selectedValues={selectedValues}\n                  filteredIds={filteredIds}\n                  onToggle={onToggle}\n                  onSelect={onSelect}\n                />\n              ))}\n            </div>\n          )}\n        </div>\n      )\n    },\n  ),\n)\nTreeSelectNode.displayName = 'TreeSelectNode'\n",
      "type": "registry:ui",
      "target": "~/components/ui/tree-select/TreeSelectNode.tsx"
    },
    {
      "path": "packages/registry-react/components/tree-select/tree-select.variants.ts",
      "content": "import { cva } from 'class-variance-authority'\n\nexport const treeSelectTriggerVariants = cva(\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 hover:border-ring/50 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n  {\n    variants: {\n      size: {\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    defaultVariants: {\n      size: 'default',\n    },\n  },\n)\n\nexport type TreeSelectVariants = ReturnType<typeof treeSelectTriggerVariants>\n",
      "type": "registry:ui",
      "target": "~/components/ui/tree-select/tree-select.variants.ts"
    },
    {
      "path": "packages/registry-react/components/tree-select/types.ts",
      "content": "export interface TreeSelectNode {\n  value: string\n  label: string\n  disabled?: boolean\n  children?: TreeSelectNode[]\n  [key: string]: unknown\n}\n",
      "type": "registry:ui",
      "target": "~/components/ui/tree-select/types.ts"
    },
    {
      "path": "packages/registry-react/components/tree-select/index.ts",
      "content": "export { TreeSelect, type TreeSelectProps } from './TreeSelect'\nexport { TreeSelectNode, type TreeSelectNodeProps } from './TreeSelectNode'\nexport { treeSelectTriggerVariants, type TreeSelectVariants } from './tree-select.variants'\nexport type { TreeSelectNode } from './types'\n",
      "type": "registry:ui",
      "target": "~/components/ui/tree-select/index.ts"
    }
  ],
  "dependencies": [
    "lucide-react",
    "@radix-ui/react-popover",
    "@radix-ui/react-slot",
    "class-variance-authority"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/react/popover.json"
  ],
  "description": "Select from tree-structured data with expand/collapse nodes, single or multi-select with checkboxes, and search filtering. Dropdown shows a nested tree; parent selection cascades to leaf descendants.",
  "categories": [
    "control",
    "form"
  ]
}