Tree Select
tree-select ui 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
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-select.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-select.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/tree-select.json $ bunx 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