{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "tree-select",
  "title": "Tree Select",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-vue/components/tree-select/TreeSelect.vue",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { computed, ref, watch } from 'vue'\nimport { Check, ChevronDown, Loader2, Search, X } from 'lucide-vue-next'\nimport { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'\nimport { cn } from '@/lib/utils'\nimport TreeSelectNode from './TreeSelectNode.vue'\nimport type { TreeSelectNode as TreeNode } from './types'\n\ninterface Props {\n  modelValue?: 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  class?: HTMLAttributes['class']\n}\n\nconst props = withDefaults(defineProps<Props>(), {\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})\n\nconst emits = defineEmits<{\n  'update:modelValue': [value: string | string[] | null]\n  select: [node: TreeNode]\n  change: [value: string | string[] | null]\n  clear: []\n}>()\n\nconst isOpen = ref(false)\nconst search = ref('')\nconst expandedIds = ref<Set<string>>(new Set())\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\nwatch(\n  () => props.defaultExpandAll,\n  (v) => {\n    if (v) expandedIds.value = new Set(collectAllExpandable(props.data))\n  },\n  { immediate: true },\n)\n\nconst selectedValues = computed<Set<string>>(() => {\n  if (props.modelValue == null) return new Set()\n  if (Array.isArray(props.modelValue)) return new Set(props.modelValue)\n  return new Set([props.modelValue])\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\nconst displayLabel = computed(() => {\n  if (props.multiple) {\n    const vals = Array.isArray(props.modelValue) ? props.modelValue : []\n    if (vals.length === 0) return props.placeholder\n    const labels = findLabels(props.data, vals)\n    if (labels.length <= 3) return labels.join(', ')\n    return `${labels.slice(0, 3).join(', ')} +${labels.length - 3}`\n  }\n  if (props.modelValue == null) return props.placeholder\n  const node = findNode(props.data, props.modelValue as string)\n  return node?.label ?? String(props.modelValue)\n})\n\nconst hasValue = computed(() => {\n  if (props.multiple) return Array.isArray(props.modelValue) && props.modelValue.length > 0\n  return props.modelValue != null\n})\n\n// Search filtering: a node is visible if it or any descendant matches\nconst filteredIds = computed<Set<string> | null>(() => {\n  const q = search.value.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) expandedIds.value.add(n.value)\n      }\n    }\n    return anyMatch\n  }\n  walk(props.data)\n  return visible\n})\n\nfunction toggleNode(node: TreeNode) {\n  if (node.disabled) return\n  const next = new Set(expandedIds.value)\n  if (next.has(node.value)) next.delete(node.value)\n  else next.add(node.value)\n  expandedIds.value = next\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\nfunction selectNode(node: TreeNode) {\n  if (node.disabled) return\n  if (!props.multiple) {\n    emits('update:modelValue', node.value)\n    emits('change', node.value)\n    emits('select', node)\n    isOpen.value = false\n    return\n  }\n  // Multi-select: toggle. For parent nodes, toggle all leaf descendants.\n  const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []\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  emits('update:modelValue', next)\n  emits('change', next)\n  emits('select', node)\n}\n\nfunction clearAll(event?: Event) {\n  event?.stopPropagation()\n  if (props.disabled) return\n  emits('clear')\n  if (props.multiple) {\n    emits('update:modelValue', [])\n    emits('change', [])\n  } else {\n    emits('update:modelValue', null)\n    emits('change', null)\n  }\n}\n\nwatch(isOpen, (open) => {\n  if (!open) 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</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=\"tree-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 tree\"\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        <div v-else-if=\"filteredIds && filteredIds.size === 0\" class=\"text-muted-foreground py-6 text-center text-sm\">\n          {{ emptyText }}\n        </div>\n\n        <div v-else class=\"flex-1 overflow-y-auto p-1\" role=\"tree\">\n          <TreeSelectNode\n            v-for=\"node in data\"\n            :key=\"node.value\"\n            :node=\"node\"\n            :depth=\"0\"\n            :multiple=\"multiple\"\n            :expanded-ids=\"expandedIds\"\n            :selected-values=\"selectedValues\"\n            :filtered-ids=\"filteredIds\"\n            @toggle=\"toggleNode\"\n            @select=\"selectNode\"\n          />\n        </div>\n\n        <div\n          v-if=\"multiple && Array.isArray(modelValue) && modelValue.length\"\n          class=\"flex items-center justify-between border-t px-2 py-1.5 text-xs\"\n        >\n          <span class=\"text-muted-foreground\">{{ modelValue.length }} selected</span>\n          <button\n            type=\"button\"\n            class=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded focus-visible:ring-2 focus-visible:outline-none\"\n            @click=\"clearAll\"\n          >\n            Clear all\n          </button>\n        </div>\n      </div>\n    </PopoverContent>\n  </Popover>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/tree-select/TreeSelect.vue"
    },
    {
      "path": "packages/registry-vue/components/tree-select/TreeSelectNode.vue",
      "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue'\nimport { ChevronRight } from 'lucide-vue-next'\nimport { cn } from '@/lib/utils'\nimport type { TreeSelectNode } from './types'\n\ninterface Props {\n  node: TreeSelectNode\n  depth: number\n  multiple: boolean\n  expandedIds: Set<string>\n  selectedValues: Set<string>\n  filteredIds: Set<string> | null\n}\n\nconst props = defineProps<Props>()\nconst emit = defineEmits<{\n  toggle: [node: TreeSelectNode]\n  select: [node: TreeSelectNode]\n}>()\n\nconst hasChildren = computed(() => !!(props.node.children && props.node.children.length))\nconst isExpanded = computed(() => props.expandedIds.has(props.node.value))\nconst isChecked = computed(() => {\n  if (!props.multiple) return props.selectedValues.has(props.node.value)\n  if (props.selectedValues.has(props.node.value)) return true\n  // Indeterminate: some (not all) descendants selected\n  if (!hasChildren.value) return false\n  const descendants = collectValues(props.node)\n  const selected = descendants.filter((v) => props.selectedValues.has(v))\n  return selected.length > 0 && selected.length < descendants.length\n})\nconst isFullyChecked = computed(() => {\n  if (!props.multiple) return false\n  if (props.selectedValues.has(props.node.value)) return true\n  if (!hasChildren.value) return false\n  const descendants = collectValues(props.node)\n  return descendants.length > 0 && descendants.every((v) => props.selectedValues.has(v))\n})\nconst isVisible = computed(() => !props.filteredIds || props.filteredIds.has(props.node.value))\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\nfunction handleToggle(e: Event) {\n  e.stopPropagation()\n  emit('toggle', props.node)\n}\n\nfunction handleSelect() {\n  if (props.node.disabled) return\n  emit('select', props.node)\n}\n\nfunction handleCheckboxChange(e: Event) {\n  e.stopPropagation()\n  if (props.node.disabled) return\n  emit('select', props.node)\n}\n\nconst indent = computed(() => `${props.depth * 20 + 8}px`)\n</script>\n\n<template>\n  <div v-if=\"isVisible\" role=\"treeitem\" :aria-expanded=\"hasChildren ? isExpanded : undefined\">\n    <div\n      :class=\"\n        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      \"\n      :style=\"{ paddingLeft: indent }\"\n      :tabindex=\"node.disabled ? -1 : 0\"\n      role=\"button\"\n      @click=\"handleSelect\"\n      @keydown.enter.prevent=\"handleSelect\"\n    >\n      <button\n        v-if=\"hasChildren\"\n        type=\"button\"\n        :class=\"\n          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        \"\n        :aria-label=\"isExpanded ? 'Collapse' : 'Expand'\"\n        @click=\"handleToggle\"\n      >\n        <ChevronRight class=\"text-muted-foreground size-3.5\" aria-hidden=\"true\" />\n      </button>\n      <span v-else class=\"size-4 shrink-0\" />\n\n      <input\n        v-if=\"multiple\"\n        type=\"checkbox\"\n        :checked=\"isFullyChecked || isChecked\"\n        :indeterminate.prop=\"isChecked && !isFullyChecked\"\n        :disabled=\"node.disabled\"\n        class=\"border-input text-primary focus:ring-ring size-3.5 shrink-0 rounded focus:ring-1\"\n        @change=\"handleCheckboxChange\"\n        @click.stop\n      />\n\n      <span class=\"flex-1 truncate\">{{ node.label }}</span>\n    </div>\n\n    <div v-if=\"hasChildren && isExpanded\" role=\"group\">\n      <TreeSelectNode\n        v-for=\"child in node.children\"\n        :key=\"child.value\"\n        :node=\"child\"\n        :depth=\"depth + 1\"\n        :multiple=\"multiple\"\n        :expanded-ids=\"expandedIds\"\n        :selected-values=\"selectedValues\"\n        :filtered-ids=\"filteredIds\"\n        @toggle=\"emit('toggle', $event)\"\n        @select=\"emit('select', $event)\"\n      />\n    </div>\n  </div>\n</template>\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/tree-select/TreeSelectNode.vue"
    },
    {
      "path": "packages/registry-vue/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": "~/app/components/ui/tree-select/types.ts"
    },
    {
      "path": "packages/registry-vue/components/tree-select/index.ts",
      "content": "export { default as TreeSelect } from './TreeSelect.vue'\nexport { default as TreeSelectNode } from './TreeSelectNode.vue'\nexport type { TreeSelectNode } from './types'\n",
      "type": "registry:ui",
      "target": "~/app/components/ui/tree-select/index.ts"
    }
  ],
  "dependencies": [
    "lucide-vue-next",
    "reka-ui"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/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"
  ]
}