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 Vue ->

Installation

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

Examples

Props

Name Type / Values Default Required
size
'sm''default''lg'
default optional
value string | string[] | null optional
defaultValue string | string[] | null optional
data TreeNode[] required
multiple boolean optional
placeholder string optional
searchable boolean optional
disabled boolean optional
loading boolean optional
clearable boolean optional
defaultExpandAll boolean optional
emptyText string optional
searchPlaceholder string optional
className string optional
onValueChange (value: string | string[] | null) => void optional
onChange (value: string | string[] | null) => void optional
onSelect (node: TreeNode) => void optional
onClear () => void 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
}

Files installed (5)

  • components/ui/tree-select/TreeSelect.tsx 11.1 kB
    'use client'
    
    import * as React from 'react'
    import { ChevronDown, Loader2, Search, X } from 'lucide-react'
    import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
    import { cn } from '@/lib/utils'
    import { treeSelectTriggerVariants } from './tree-select.variants'
    import { TreeSelectNode } from './TreeSelectNode'
    import type { TreeSelectNode as TreeNode } from './types'
    
    export interface TreeSelectProps {
      value?: string | string[] | null
      defaultValue?: 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
      className?: string
      onValueChange?: (value: string | string[] | null) => void
      onChange?: (value: string | string[] | null) => void
      onSelect?: (node: TreeNode) => void
      onClear?: () => void
    }
    
    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
    }
    
    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)
    }
    
    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
    }
    
    const TreeSelect = React.forwardRef<HTMLButtonElement, TreeSelectProps>(
      (
        {
          value,
          defaultValue,
          data,
          multiple = false,
          placeholder = 'Select...',
          searchable = true,
          disabled = false,
          loading = false,
          clearable = true,
          defaultExpandAll = false,
          size = 'default',
          emptyText = 'No results found.',
          searchPlaceholder = 'Search...',
          className,
          onValueChange,
          onChange,
          onSelect,
          onClear,
        },
        ref,
      ) => {
        const isControlled = value !== undefined
        const [internalValue, setInternalValue] = React.useState<string | string[] | null>(
          defaultValue ?? null,
        )
        const currentValue = isControlled ? value : internalValue
    
        const [isOpen, setIsOpen] = React.useState(false)
        const [search, setSearch] = React.useState('')
        const [expandedIds, setExpandedIds] = React.useState<Set<string>>(() => {
          if (defaultExpandAll) return new Set(collectAllExpandable(data))
          return new Set()
        })
    
        // Re-collect expandable when defaultExpandAll toggles or data changes.
        React.useEffect(() => {
          if (defaultExpandAll) setExpandedIds(new Set(collectAllExpandable(data)))
        }, [defaultExpandAll, data])
    
        // Clear search when popover closes.
        React.useEffect(() => {
          if (!isOpen) setSearch('')
        }, [isOpen])
    
        const selectedValues = React.useMemo<Set<string>>(() => {
          if (currentValue == null) return new Set()
          if (Array.isArray(currentValue)) return new Set(currentValue)
          return new Set([currentValue])
        }, [currentValue])
    
        const displayLabel = React.useMemo(() => {
          if (multiple) {
            const vals = Array.isArray(currentValue) ? currentValue : []
            if (vals.length === 0) return placeholder
            const labels = findLabels(data, vals)
            if (labels.length <= 3) return labels.join(', ')
            return `${labels.slice(0, 3).join(', ')} +${labels.length - 3}`
          }
          if (currentValue == null) return placeholder
          const node = findNode(data, currentValue as string)
          return node?.label ?? String(currentValue)
        }, [multiple, currentValue, placeholder, data])
    
        const hasValue = React.useMemo(() => {
          if (multiple) return Array.isArray(currentValue) && currentValue.length > 0
          return currentValue != null
        }, [multiple, currentValue])
    
        // Search filtering: a node is visible if it or any descendant matches.
        const filteredIds = React.useMemo<Set<string> | null>(() => {
          const q = search.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) {
                  setExpandedIds((prev) => {
                    if (prev.has(n.value)) return prev
                    const next = new Set(prev)
                    next.add(n.value)
                    return next
                  })
                }
              }
            }
            return anyMatch
          }
          walk(data)
          return visible
        }, [search, data])
    
        function commit(next: string | string[] | null) {
          if (!isControlled) setInternalValue(next)
          onValueChange?.(next)
          onChange?.(next)
        }
    
        function toggleNode(node: TreeNode) {
          if (node.disabled) return
          setExpandedIds((prev) => {
            const next = new Set(prev)
            if (next.has(node.value)) next.delete(node.value)
            else next.add(node.value)
            return next
          })
        }
    
        function selectNode(node: TreeNode) {
          if (node.disabled) return
          if (!multiple) {
            commit(node.value)
            onSelect?.(node)
            setIsOpen(false)
            return
          }
          // Multi-select: toggle. For parent nodes, toggle all leaf descendants.
          const current = Array.isArray(currentValue) ? [...currentValue] : []
          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))]
          }
          commit(next)
          onSelect?.(node)
        }
    
        function clearAll(event?: React.MouseEvent | React.KeyboardEvent) {
          event?.stopPropagation()
          if (disabled) return
          onClear?.()
          if (multiple) {
            commit([])
          } else {
            commit(null)
          }
        }
    
        const triggerClasses = cn(treeSelectTriggerVariants({ size }), className)
    
        return (
          <Popover open={isOpen} onOpenChange={setIsOpen}>
            <PopoverTrigger asChild>
              <button
                ref={ref}
                type="button"
                role="combobox"
                aria-expanded={isOpen}
                disabled={disabled || loading}
                data-uipkge=""
                data-slot="tree-select"
                className={triggerClasses}
              >
                <span
                  className={cn('flex-1 truncate text-left', hasValue ? 'text-foreground' : 'text-muted-foreground')}
                >
                  {displayLabel}
                </span>
                <span className="flex shrink-0 items-center gap-1">
                  {loading ? (
                    <Loader2 className="text-muted-foreground size-4 animate-spin" />
                  ) : clearable && hasValue && !disabled ? (
                    <span
                      role="button"
                      tabIndex={0}
                      aria-label="Clear"
                      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"
                      onClick={(e) => clearAll(e)}
                      onKeyDown={(e) => {
                        if (e.key === 'Enter' || e.key === ' ') {
                          e.preventDefault()
                          clearAll(e)
                        }
                      }}
                    >
                      <X className="size-4" />
                    </span>
                  ) : (
                    <ChevronDown
                      className={cn(
                        'text-muted-foreground size-4 shrink-0 transition-transform duration-200',
                        isOpen && 'rotate-180',
                      )}
                    />
                  )}
                </span>
              </button>
            </PopoverTrigger>
    
            <PopoverContent
              className="p-0"
              align="start"
              sideOffset={4}
              style={{ width: 'var(--radix-popover-trigger-width)' }}
            >
              <div className="flex max-h-80 flex-col">
                {searchable && (
                  <div className="border-b p-2">
                    <div className="relative">
                      <Search className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
                      <input
                        value={search}
                        onChange={(e) => setSearch(e.target.value)}
                        placeholder={searchPlaceholder}
                        aria-label="Search tree"
                        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]"
                      />
                    </div>
                  </div>
                )}
    
                {loading ? (
                  <div className="text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm">
                    <Loader2 className="size-4 animate-spin" />
                    Loading...
                  </div>
                ) : filteredIds && filteredIds.size === 0 ? (
                  <div className="text-muted-foreground py-6 text-center text-sm">{emptyText}</div>
                ) : (
                  <div className="flex-1 overflow-y-auto p-1" role="tree">
                    {data.map((node) => (
                      <TreeSelectNode
                        key={node.value}
                        node={node}
                        depth={0}
                        multiple={multiple}
                        expandedIds={expandedIds}
                        selectedValues={selectedValues}
                        filteredIds={filteredIds}
                        onToggle={toggleNode}
                        onSelect={selectNode}
                      />
                    ))}
                  </div>
                )}
    
                {multiple && Array.isArray(currentValue) && currentValue.length > 0 && (
                  <div className="flex items-center justify-between border-t px-2 py-1.5 text-xs">
                    <span className="text-muted-foreground">{currentValue.length} selected</span>
                    <button
                      type="button"
                      className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded focus-visible:ring-2 focus-visible:outline-none"
                      onClick={() => clearAll()}
                    >
                      Clear all
                    </button>
                  </div>
                )}
              </div>
            </PopoverContent>
          </Popover>
        )
      },
    )
    TreeSelect.displayName = 'TreeSelect'
    
    export { TreeSelect }
  • components/ui/tree-select/TreeSelectNode.tsx 5.2 kB
    import * as React from 'react'
    import { ChevronRight } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import type { TreeSelectNode } from './types'
    
    export interface TreeSelectNodeProps {
      node: TreeSelectNode
      depth: number
      multiple: boolean
      expandedIds: Set<string>
      selectedValues: Set<string>
      filteredIds: Set<string> | null
      onToggle: (node: TreeSelectNode) => void
      onSelect: (node: TreeSelectNode) => void
    }
    
    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
    }
    
    export const TreeSelectNode = React.memo(
      React.forwardRef<HTMLDivElement, TreeSelectNodeProps>(
        ({ node, depth, multiple, expandedIds, selectedValues, filteredIds, onToggle, onSelect }, ref) => {
          const hasChildren = !!(node.children && node.children.length)
          const isExpanded = expandedIds.has(node.value)
          const isChecked = React.useMemo(() => {
            if (!multiple) return selectedValues.has(node.value)
            if (selectedValues.has(node.value)) return true
            if (!hasChildren) return false
            const descendants = collectValues(node)
            const selected = descendants.filter((v) => selectedValues.has(v))
            return selected.length > 0 && selected.length < descendants.length
          }, [multiple, selectedValues, node, hasChildren])
          const isFullyChecked = React.useMemo(() => {
            if (!multiple) return false
            if (selectedValues.has(node.value)) return true
            if (!hasChildren) return false
            const descendants = collectValues(node)
            return descendants.length > 0 && descendants.every((v) => selectedValues.has(v))
          }, [multiple, selectedValues, node, hasChildren])
          const isVisible = !filteredIds || filteredIds.has(node.value)
    
          const indent = `${depth * 20 + 8}px`
    
          function handleToggle(e: React.MouseEvent) {
            e.stopPropagation()
            onToggle(node)
          }
    
          function handleSelect() {
            if (node.disabled) return
            onSelect(node)
          }
    
          function handleCheckboxChange(e: React.ChangeEvent<HTMLInputElement>) {
            e.stopPropagation()
            if (node.disabled) return
            onSelect(node)
          }
    
          if (!isVisible) return null
    
          return (
            <div ref={ref} role="treeitem" aria-expanded={hasChildren ? isExpanded : undefined}>
              <div
                className={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"
                onClick={handleSelect}
                onKeyDown={(e) => {
                  if (e.key === 'Enter') {
                    e.preventDefault()
                    handleSelect()
                  }
                }}
              >
                {hasChildren ? (
                  <button
                    type="button"
                    className={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'}
                    onClick={handleToggle}
                  >
                    <ChevronRight className="text-muted-foreground size-3.5" aria-hidden="true" />
                  </button>
                ) : (
                  <span className="size-4 shrink-0" />
                )}
    
                {multiple && (
                  <input
                    type="checkbox"
                    checked={isFullyChecked || isChecked}
                    ref={(el) => {
                      if (el) el.indeterminate = isChecked && !isFullyChecked
                    }}
                    disabled={node.disabled}
                    className="border-input text-primary focus:ring-ring size-3.5 shrink-0 rounded focus:ring-1"
                    onChange={handleCheckboxChange}
                    onClick={(e) => e.stopPropagation()}
                  />
                )}
    
                <span className="flex-1 truncate">{node.label}</span>
              </div>
    
              {hasChildren && isExpanded && (
                <div role="group">
                  {node.children!.map((child) => (
                    <TreeSelectNode
                      key={child.value}
                      node={child}
                      depth={depth + 1}
                      multiple={multiple}
                      expandedIds={expandedIds}
                      selectedValues={selectedValues}
                      filteredIds={filteredIds}
                      onToggle={onToggle}
                      onSelect={onSelect}
                    />
                  ))}
                </div>
              )}
            </div>
          )
        },
      ),
    )
    TreeSelectNode.displayName = 'TreeSelectNode'
  • components/ui/tree-select/tree-select.variants.ts 0.7 kB
    import { cva } from 'class-variance-authority'
    
    export const treeSelectTriggerVariants = cva(
      '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',
      {
        variants: {
          size: {
            sm: 'h-8 text-xs px-2.5',
            default: 'h-9 text-sm px-3',
            lg: 'h-11 text-base px-4',
          },
        },
        defaultVariants: {
          size: 'default',
        },
      },
    )
    
    export type TreeSelectVariants = ReturnType<typeof treeSelectTriggerVariants>
  • components/ui/tree-select/types.ts 0.1 kB
    export interface TreeSelectNode {
      value: string
      label: string
      disabled?: boolean
      children?: TreeSelectNode[]
      [key: string]: unknown
    }
  • components/ui/tree-select/index.ts 0.3 kB
    export { TreeSelect, type TreeSelectProps } from './TreeSelect'
    export { TreeSelectNode, type TreeSelectNodeProps } from './TreeSelectNode'
    export { treeSelectTriggerVariants, type TreeSelectVariants } from './tree-select.variants'
    export type { TreeSelectNode } from './types'

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