UIPackage
Menu

Framework

Change language

Boilerplate repo

Tree Select

tree-select ui
Boilerplate repo

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

Loading interactive previews…

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.8 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.
        // Expand-on-match is applied in an effect — never setState inside useMemo.
        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
              }
            }
            return anyMatch
          }
          walk(data)
          return visible
        }, [search, data])
    
        // Auto-expand ancestors of search matches.
        React.useEffect(() => {
          if (!filteredIds || filteredIds.size === 0) return
          const q = search.trim().toLowerCase()
          if (!q) return
          setExpandedIds((prev) => {
            const next = new Set(prev)
            let changed = false
            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) {
                  anyMatch = true
                  if (childMatch && !next.has(n.value)) {
                    next.add(n.value)
                    changed = true
                  }
                }
              }
              return anyMatch
            }
            walk(data)
            return changed ? next : prev
          })
        }, [filteredIds, 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>
                ) : data.length === 0 || (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 7.5 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
      parentValue?: 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
    }
    
    function getTreeRows(from: HTMLElement): HTMLElement[] {
      const tree = from.closest('[role="tree"]')
      if (!tree) return []
      return Array.from(tree.querySelectorAll<HTMLElement>('[data-tree-row]:not([data-disabled="true"])'))
    }
    
    function focusRow(row: HTMLElement | null | undefined) {
      row?.focus()
    }
    
    export const TreeSelectNode = React.memo(
      React.forwardRef<HTMLDivElement, TreeSelectNodeProps>(
        (
          { node, depth, multiple, expandedIds, selectedValues, filteredIds, parentValue = null, 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 isSelected = !multiple ? selectedValues.has(node.value) : isFullyChecked || isChecked
    
          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)
          }
    
          function handleRowKeydown(e: React.KeyboardEvent) {
            if (node.disabled) return
            const target = e.currentTarget as HTMLElement
    
            if (e.key === 'Enter' || e.key === ' ') {
              e.preventDefault()
              handleSelect()
              return
            }
    
            if (e.key === 'ArrowRight') {
              e.preventDefault()
              if (hasChildren && !isExpanded) {
                onToggle(node)
              } else if (hasChildren && isExpanded) {
                const rows = getTreeRows(target)
                const idx = rows.indexOf(target)
                if (idx >= 0 && idx < rows.length - 1) focusRow(rows[idx + 1])
              }
              return
            }
    
            if (e.key === 'ArrowLeft') {
              e.preventDefault()
              if (hasChildren && isExpanded) {
                onToggle(node)
              } else if (parentValue) {
                const tree = target.closest('[role="tree"]')
                const parent = tree?.querySelector<HTMLElement>(
                  `[data-tree-row][data-tree-id="${CSS.escape(parentValue)}"]`,
                )
                focusRow(parent)
              }
              return
            }
    
            if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
              e.preventDefault()
              const rows = getTreeRows(target)
              const idx = rows.indexOf(target)
              if (idx < 0) return
              focusRow(e.key === 'ArrowDown' ? rows[idx + 1] : rows[idx - 1])
              return
            }
    
            if (e.key === 'Home') {
              e.preventDefault()
              focusRow(getTreeRows(target)[0])
              return
            }
    
            if (e.key === 'End') {
              e.preventDefault()
              const rows = getTreeRows(target)
              focusRow(rows[rows.length - 1])
            }
          }
    
          if (!isVisible) return null
    
          return (
            <div ref={ref} role="treeitem" aria-expanded={hasChildren ? isExpanded : undefined} aria-selected={isSelected}>
              <div
                data-tree-row
                data-tree-id={node.value}
                data-tree-parent={parentValue ?? undefined}
                data-disabled={node.disabled ? 'true' : undefined}
                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}
                onClick={handleSelect}
                onKeyDown={handleRowKeydown}
              >
                {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'}
                    tabIndex={-1}
                    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}
                      parentValue={node.value}
                      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