UIPackage
Menu

Tree Select

tree-select ui
Edit on GitHub

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.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-select.json
Named registry: npx shadcn-vue@latest add @uipkge/tree-select Installs to: app/components/ui/tree-select/

Examples

Props

Name Type / Values Default Required
modelValue string | string[] | null optional
data TreeNode[] required
multiple boolean false optional
placeholder string 'Select...' optional
searchable boolean true optional
disabled boolean false optional
loading boolean false optional
clearable boolean true optional
defaultExpandAll boolean false optional
size
'sm''default''lg'
'default' optional
emptyText string 'No results found.' optional
searchPlaceholder string 'Search...' optional
class HTMLAttributes['class'] optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

TreeSelectNode
interface TreeSelectNode {
  value: string
  label: string
  disabled?: boolean
  children?: TreeSelectNode[]
  [key: string]: unknown
}

npm dependencies

Includes

Files installed (4)

  • app/components/ui/tree-select/TreeSelect.vue 9.2 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, ref, watch } from 'vue'
    import { Check, ChevronDown, Loader2, Search, X } from 'lucide-vue-next'
    import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
    import { cn } from '@/lib/utils'
    import TreeSelectNode from './TreeSelectNode.vue'
    import type { TreeSelectNode as TreeNode } from './types'
    
    interface Props {
      modelValue?: string | string[] | null
      data: TreeNode[]
      multiple?: boolean
      placeholder?: string
      searchable?: boolean
      disabled?: boolean
      loading?: boolean
      clearable?: boolean
      defaultExpandAll?: boolean
      size?: 'sm' | 'default' | 'lg'
      emptyText?: string
      searchPlaceholder?: string
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      multiple: false,
      placeholder: 'Select...',
      searchable: true,
      disabled: false,
      loading: false,
      clearable: true,
      defaultExpandAll: false,
      size: 'default',
      emptyText: 'No results found.',
      searchPlaceholder: 'Search...',
    })
    
    const emits = defineEmits<{
      'update:modelValue': [value: string | string[] | null]
      select: [node: TreeNode]
      change: [value: string | string[] | null]
      clear: []
    }>()
    
    const isOpen = ref(false)
    const search = ref('')
    const expandedIds = ref<Set<string>>(new Set())
    
    function collectAllExpandable(nodes: TreeNode[]): string[] {
      const ids: string[] = []
      const walk = (list: TreeNode[]) => {
        for (const n of list) {
          if (n.children?.length) {
            ids.push(n.value)
            walk(n.children)
          }
        }
      }
      walk(nodes)
      return ids
    }
    
    watch(
      () => props.defaultExpandAll,
      (v) => {
        if (v) expandedIds.value = new Set(collectAllExpandable(props.data))
      },
      { immediate: true },
    )
    
    const selectedValues = computed<Set<string>>(() => {
      if (props.modelValue == null) return new Set()
      if (Array.isArray(props.modelValue)) return new Set(props.modelValue)
      return new Set([props.modelValue])
    })
    
    function findNode(nodes: TreeNode[], value: string): TreeNode | undefined {
      for (const n of nodes) {
        if (n.value === value) return n
        if (n.children) {
          const found = findNode(n.children, value)
          if (found) return found
        }
      }
      return undefined
    }
    
    function findLabels(nodes: TreeNode[], values: string[]): string[] {
      return values.map((v) => findNode(nodes, v)?.label ?? v)
    }
    
    const displayLabel = computed(() => {
      if (props.multiple) {
        const vals = Array.isArray(props.modelValue) ? props.modelValue : []
        if (vals.length === 0) return props.placeholder
        const labels = findLabels(props.data, vals)
        if (labels.length <= 3) return labels.join(', ')
        return `${labels.slice(0, 3).join(', ')} +${labels.length - 3}`
      }
      if (props.modelValue == null) return props.placeholder
      const node = findNode(props.data, props.modelValue as string)
      return node?.label ?? String(props.modelValue)
    })
    
    const hasValue = computed(() => {
      if (props.multiple) return Array.isArray(props.modelValue) && props.modelValue.length > 0
      return props.modelValue != null
    })
    
    // Search filtering: a node is visible if it or any descendant matches
    const filteredIds = computed<Set<string> | null>(() => {
      const q = search.value.trim().toLowerCase()
      if (!q) return null
      const visible = new Set<string>()
      const walk = (nodes: TreeNode[]): boolean => {
        let anyMatch = false
        for (const n of nodes) {
          const selfMatch = n.label.toLowerCase().includes(q)
          let childMatch = false
          if (n.children?.length) {
            childMatch = walk(n.children)
          }
          if (selfMatch || childMatch) {
            visible.add(n.value)
            anyMatch = true
            // Auto-expand parents of matches
            if (childMatch) expandedIds.value.add(n.value)
          }
        }
        return anyMatch
      }
      walk(props.data)
      return visible
    })
    
    function toggleNode(node: TreeNode) {
      if (node.disabled) return
      const next = new Set(expandedIds.value)
      if (next.has(node.value)) next.delete(node.value)
      else next.add(node.value)
      expandedIds.value = next
    }
    
    function collectLeafValues(node: TreeNode): string[] {
      if (!node.children?.length) return [node.value]
      const vals: string[] = []
      for (const c of node.children) vals.push(...collectLeafValues(c))
      return vals
    }
    
    function selectNode(node: TreeNode) {
      if (node.disabled) return
      if (!props.multiple) {
        emits('update:modelValue', node.value)
        emits('change', node.value)
        emits('select', node)
        isOpen.value = false
        return
      }
      // Multi-select: toggle. For parent nodes, toggle all leaf descendants.
      const current = Array.isArray(props.modelValue) ? [...props.modelValue] : []
      const leaves = collectLeafValues(node)
      const allSelected = leaves.every((v) => current.includes(v))
      let next: string[]
      if (allSelected) {
        next = current.filter((v) => !leaves.includes(v))
      } else {
        next = [...current, ...leaves.filter((v) => !current.includes(v))]
      }
      emits('update:modelValue', next)
      emits('change', next)
      emits('select', node)
    }
    
    function clearAll(event?: Event) {
      event?.stopPropagation()
      if (props.disabled) return
      emits('clear')
      if (props.multiple) {
        emits('update:modelValue', [])
        emits('change', [])
      } else {
        emits('update:modelValue', null)
        emits('change', null)
      }
    }
    
    watch(isOpen, (open) => {
      if (!open) search.value = ''
    })
    
    const sizeClasses = {
      sm: 'h-8 text-xs px-2.5',
      default: 'h-9 text-sm px-3',
      lg: 'h-11 text-base px-4',
    }
    
    const triggerClasses = computed(() =>
      cn(
        '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',
        sizeClasses[props.size],
        props.class,
      ),
    )
    </script>
    
    <template>
      <Popover v-model:open="isOpen">
        <PopoverTrigger as-child>
          <button
            type="button"
            role="combobox"
            :aria-expanded="isOpen"
            :disabled="disabled || loading"
            data-uipkge
            data-slot="tree-select"
            :class="triggerClasses"
          >
            <span :class="['flex-1 truncate text-left', hasValue ? 'text-foreground' : 'text-muted-foreground']">
              {{ displayLabel }}
            </span>
            <span class="flex shrink-0 items-center gap-1">
              <Loader2 v-if="loading" class="text-muted-foreground size-4 animate-spin" />
              <span
                v-else-if="clearable && hasValue && !disabled"
                role="button"
                tabindex="0"
                aria-label="Clear"
                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"
                @click.stop="clearAll"
                @keydown.enter.prevent="clearAll"
                @keydown.space.prevent="clearAll"
              >
                <X class="size-4" />
              </span>
              <ChevronDown
                v-else
                class="text-muted-foreground size-4 shrink-0 transition-transform duration-200"
                :class="isOpen ? 'rotate-180' : ''"
              />
            </span>
          </button>
        </PopoverTrigger>
    
        <PopoverContent class="p-0" align="start" :side-offset="4" :style="{ width: 'var(--reka-popover-trigger-width)' }">
          <div class="flex max-h-80 flex-col">
            <div v-if="searchable" class="border-b p-2">
              <div class="relative">
                <Search class="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
                <input
                  v-model="search"
                  :placeholder="searchPlaceholder"
                  aria-label="Search tree"
                  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]"
                />
              </div>
            </div>
    
            <div v-if="loading" class="text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm">
              <Loader2 class="size-4 animate-spin" />
              Loading...
            </div>
    
            <div v-else-if="filteredIds && filteredIds.size === 0" class="text-muted-foreground py-6 text-center text-sm">
              {{ emptyText }}
            </div>
    
            <div v-else class="flex-1 overflow-y-auto p-1" role="tree">
              <TreeSelectNode
                v-for="node in data"
                :key="node.value"
                :node="node"
                :depth="0"
                :multiple="multiple"
                :expanded-ids="expandedIds"
                :selected-values="selectedValues"
                :filtered-ids="filteredIds"
                @toggle="toggleNode"
                @select="selectNode"
              />
            </div>
    
            <div
              v-if="multiple && Array.isArray(modelValue) && modelValue.length"
              class="flex items-center justify-between border-t px-2 py-1.5 text-xs"
            >
              <span class="text-muted-foreground">{{ modelValue.length }} selected</span>
              <button
                type="button"
                class="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded focus-visible:ring-2 focus-visible:outline-none"
                @click="clearAll"
              >
                Clear all
              </button>
            </div>
          </div>
        </PopoverContent>
      </Popover>
    </template>
  • app/components/ui/tree-select/TreeSelectNode.vue 4.3 kB
    <script setup lang="ts">
    import { computed } from 'vue'
    import { ChevronRight } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import type { TreeSelectNode } from './types'
    
    interface Props {
      node: TreeSelectNode
      depth: number
      multiple: boolean
      expandedIds: Set<string>
      selectedValues: Set<string>
      filteredIds: Set<string> | null
    }
    
    const props = defineProps<Props>()
    const emit = defineEmits<{
      toggle: [node: TreeSelectNode]
      select: [node: TreeSelectNode]
    }>()
    
    const hasChildren = computed(() => !!(props.node.children && props.node.children.length))
    const isExpanded = computed(() => props.expandedIds.has(props.node.value))
    const isChecked = computed(() => {
      if (!props.multiple) return props.selectedValues.has(props.node.value)
      if (props.selectedValues.has(props.node.value)) return true
      // Indeterminate: some (not all) descendants selected
      if (!hasChildren.value) return false
      const descendants = collectValues(props.node)
      const selected = descendants.filter((v) => props.selectedValues.has(v))
      return selected.length > 0 && selected.length < descendants.length
    })
    const isFullyChecked = computed(() => {
      if (!props.multiple) return false
      if (props.selectedValues.has(props.node.value)) return true
      if (!hasChildren.value) return false
      const descendants = collectValues(props.node)
      return descendants.length > 0 && descendants.every((v) => props.selectedValues.has(v))
    })
    const isVisible = computed(() => !props.filteredIds || props.filteredIds.has(props.node.value))
    
    function collectValues(node: TreeSelectNode): string[] {
      const vals: string[] = []
      const walk = (n: TreeSelectNode) => {
        if (n.children?.length) {
          for (const c of n.children) walk(c)
        } else {
          vals.push(n.value)
        }
      }
      walk(node)
      return vals
    }
    
    function handleToggle(e: Event) {
      e.stopPropagation()
      emit('toggle', props.node)
    }
    
    function handleSelect() {
      if (props.node.disabled) return
      emit('select', props.node)
    }
    
    function handleCheckboxChange(e: Event) {
      e.stopPropagation()
      if (props.node.disabled) return
      emit('select', props.node)
    }
    
    const indent = computed(() => `${props.depth * 20 + 8}px`)
    </script>
    
    <template>
      <div v-if="isVisible" role="treeitem" :aria-expanded="hasChildren ? isExpanded : undefined">
        <div
          :class="
            cn(
              'group relative flex h-8 cursor-pointer items-center gap-1.5 rounded-md pr-2 text-sm transition-colors',
              'hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none',
              node.disabled && 'cursor-not-allowed opacity-50',
              !multiple && selectedValues.has(node.value) && 'bg-accent text-accent-foreground font-medium',
            )
          "
          :style="{ paddingLeft: indent }"
          :tabindex="node.disabled ? -1 : 0"
          role="button"
          @click="handleSelect"
          @keydown.enter.prevent="handleSelect"
        >
          <button
            v-if="hasChildren"
            type="button"
            :class="
              cn(
                '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',
                'hover:bg-foreground/10',
                isExpanded && 'rotate-90',
              )
            "
            :aria-label="isExpanded ? 'Collapse' : 'Expand'"
            @click="handleToggle"
          >
            <ChevronRight class="text-muted-foreground size-3.5" aria-hidden="true" />
          </button>
          <span v-else class="size-4 shrink-0" />
    
          <input
            v-if="multiple"
            type="checkbox"
            :checked="isFullyChecked || isChecked"
            :indeterminate.prop="isChecked && !isFullyChecked"
            :disabled="node.disabled"
            class="border-input text-primary focus:ring-ring size-3.5 shrink-0 rounded focus:ring-1"
            @change="handleCheckboxChange"
            @click.stop
          />
    
          <span class="flex-1 truncate">{{ node.label }}</span>
        </div>
    
        <div v-if="hasChildren && isExpanded" role="group">
          <TreeSelectNode
            v-for="child in node.children"
            :key="child.value"
            :node="child"
            :depth="depth + 1"
            :multiple="multiple"
            :expanded-ids="expandedIds"
            :selected-values="selectedValues"
            :filtered-ids="filteredIds"
            @toggle="emit('toggle', $event)"
            @select="emit('select', $event)"
          />
        </div>
      </div>
    </template>
  • app/components/ui/tree-select/types.ts 0.1 kB
    export interface TreeSelectNode {
      value: string
      label: string
      disabled?: boolean
      children?: TreeSelectNode[]
      [key: string]: unknown
    }
  • app/components/ui/tree-select/index.ts 0.2 kB
    export { default as TreeSelect } from './TreeSelect.vue'
    export { default as TreeSelectNode } from './TreeSelectNode.vue'
    export type { TreeSelectNode } from './types'

Raw manifest: https://uipkge.dev/r/vue/tree-select.json