wui
组件

树选择 TreeSelect

将树状层级结构与下拉搜索浮层融为一体,用于在复杂父子关系中精确检索并选取目标节点。

第三方依赖 · lucide-react第三方依赖 · motion

基础用法

最简单的树选择用法。点击输入框展开下拉树形结构,可自由展开节点并选择:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/tree-select
安装基础依赖与动效库
pnpm add radix-ui motion lucide-react clsx tailwind-merge
复制组件源码到 components/ui/tree-select.tsx
components/ui/tree-select.tsx
"use client"

import * as React from "react"
import { ChevronDownIcon, SearchIcon, XIcon } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

import { cn } from "@/lib/utils"
import { Input } from "@/components/ui/input"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Tree, type TreeNode } from "@/components/ui/tree"

export interface TreeSelectProps extends Omit<
  React.ComponentProps<"button">,
  "value" | "defaultValue" | "onChange"
> {
  /** 树形选项数据。 */
  items: TreeNode[]
  /** 受控模式下的选中值。 */
  value?: string
  /** 非受控模式下的初始选中值。 */
  defaultValue?: string
  /** 选中值变化或清空时触发。 */
  onValueChange?: (value: string, node?: TreeNode) => void
  /** 未选中时显示的文本。@default "请选择" */
  placeholder?: string
  /** 是否允许清空已选值。@default true */
  clearable?: boolean
  /** 是否显示搜索框。@default true */
  searchable?: boolean
  /** 搜索框占位文本。@default "搜索节点" */
  searchPlaceholder?: string
  /** 没有匹配节点时显示的文本。@default "没有匹配的节点" */
  emptyText?: React.ReactNode
  /** 应用于浮层面板的额外类名。 */
  contentClassName?: string
  /** 自定义节点搜索逻辑。 */
  filterNode?: (query: string, node: TreeNode) => boolean
}

function findNode(items: TreeNode[], value: string): TreeNode | undefined {
  for (const node of items) {
    if (node.value === value) return node
    const match = node.children ? findNode(node.children, value) : undefined
    if (match) return match
  }
}

function findAncestors(
  items: TreeNode[],
  value: string,
  trail: string[] = []
): string[] | undefined {
  for (const node of items) {
    if (node.value === value) return trail
    const match = node.children
      ? findAncestors(node.children, value, [...trail, node.value])
      : undefined
    if (match) return match
  }
}

function defaultFilterNode(query: string, node: TreeNode) {
  return typeof node.label === "string"
    ? node.label.toLocaleLowerCase().includes(query.toLocaleLowerCase())
    : false
}

function filterNodes(
  items: TreeNode[],
  query: string,
  filterNode: (query: string, node: TreeNode) => boolean
): TreeNode[] {
  if (!query.trim()) return items
  return items.flatMap((node) => {
    if (filterNode(query.trim(), node)) return [node]
    const children = node.children
      ? filterNodes(node.children, query, filterNode)
      : []
    return children.length ? [{ ...node, children }] : []
  })
}

function collectParentValues(items: TreeNode[]): string[] {
  return items.flatMap((node) => [
    ...(node.children?.length ? [node.value] : []),
    ...(node.children ? collectParentValues(node.children) : []),
  ])
}

/** 由 Popover、Input 与 Tree 组合而成的可搜索树形选择器。 */
function TreeSelect({
  className,
  items,
  value,
  defaultValue = "",
  onValueChange,
  placeholder = "请选择",
  clearable = true,
  searchable = true,
  searchPlaceholder = "搜索节点",
  emptyText = "没有匹配的节点",
  contentClassName,
  filterNode = defaultFilterNode,
  disabled,
  ...props
}: TreeSelectProps) {
  const reduceMotion = useReducedMotion()
  const [open, setOpen] = React.useState(false)
  const [query, setQuery] = React.useState("")
  const [expanded, setExpanded] = React.useState<string[]>([])
  const [internalValue, setInternalValue] = React.useState(defaultValue)
  const selectedValue = value ?? internalValue
  const selectedNode = findNode(items, selectedValue)
  const filteredItems = React.useMemo(
    () => filterNodes(items, query, filterNode),
    [filterNode, items, query]
  )
  const searchExpanded = React.useMemo(
    () => (query.trim() ? collectParentValues(filteredItems) : undefined),
    [filteredItems, query]
  )

  function changeValue(nextValue: string, node?: TreeNode) {
    if (value === undefined) setInternalValue(nextValue)
    onValueChange?.(nextValue, node)
  }

  function changeOpen(nextOpen: boolean) {
    setOpen(nextOpen)
    if (nextOpen) setExpanded(findAncestors(items, selectedValue) ?? [])
    else setQuery("")
  }

  return (
    <Popover open={open} onOpenChange={changeOpen}>
      <div
        data-slot="tree-select"
        data-disabled={disabled || undefined}
        className={cn(
          "border-input bg-background shadow-xs focus-within:border-ring focus-within:ring-ring/30 has-[button[aria-invalid=true]]:border-destructive has-[button[aria-invalid=true]]:ring-[3px] has-[button[aria-invalid=true]]:ring-destructive/20 flex h-10 w-full min-w-56 items-center rounded-md border transition-[border-color,box-shadow] duration-200 focus-within:ring-[3px] data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50",
          className
        )}
      >
        <PopoverTrigger asChild>
          <button
            type="button"
            data-slot="tree-select-trigger"
            role="combobox"
            aria-expanded={open}
            disabled={disabled}
            className="flex h-full min-w-0 flex-1 items-center gap-2 rounded-l-md px-3 text-left text-sm outline-none"
            {...props}
          >
            <span className="relative flex min-w-0 flex-1 overflow-hidden">
              <AnimatePresence initial={false} mode="popLayout">
                <motion.span
                  key={selectedNode?.value ?? "__placeholder"}
                  data-placeholder={!selectedNode || undefined}
                  className="data-[placeholder=true]:text-muted-foreground min-w-0 flex-1 truncate"
                  initial={reduceMotion ? false : { opacity: 0, y: 6 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={reduceMotion ? undefined : { opacity: 0, y: -6 }}
                  transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
                >
                  {selectedNode?.label ?? placeholder}
                </motion.span>
              </AnimatePresence>
            </span>
            <ChevronDownIcon
              className={cn(
                "text-muted-foreground size-4 shrink-0 transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]",
                open && "rotate-180"
              )}
            />
          </button>
        </PopoverTrigger>
        <AnimatePresence initial={false}>
          {clearable && selectedNode ? (
            <motion.button
              type="button"
              data-slot="tree-select-clear"
              aria-label="清空选择"
              disabled={disabled}
              className="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 mr-2 flex size-6 shrink-0 items-center justify-center rounded-sm outline-none transition-colors focus-visible:ring-2"
              initial={reduceMotion ? false : { opacity: 0, scale: 0.6 }}
              animate={{ opacity: 1, scale: 1 }}
              exit={reduceMotion ? undefined : { opacity: 0, scale: 0.6 }}
              transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
              onClick={() => changeValue("")}
            >
              <XIcon className="size-3.5" />
            </motion.button>
          ) : null}
        </AnimatePresence>
      </div>
      <PopoverContent
        data-slot="tree-select-content"
        align="start"
        className={cn(
          "w-[var(--radix-popover-trigger-width)] min-w-64 origin-(--radix-popover-content-transform-origin) p-1.5",
          contentClassName
        )}
      >
        {searchable ? (
          <Input
            data-slot="tree-select-search"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
            placeholder={searchPlaceholder}
            aria-label={searchPlaceholder}
            size="sm"
            startContent={<SearchIcon />}
            wrapperClassName="mb-1.5"
            autoFocus
          />
        ) : null}
        <div className="max-h-72 overflow-y-auto overscroll-contain">
          {filteredItems.length ? (
            <Tree
              items={filteredItems}
              value={selectedValue}
              expanded={searchExpanded ?? expanded}
              onExpandedChange={searchExpanded ? undefined : setExpanded}
              onValueChange={(nextValue, node) => {
                changeValue(nextValue, node)
                changeOpen(false)
              }}
              aria-label="树形选项"
            />
          ) : (
            <div
              data-slot="tree-select-empty"
              className="text-muted-foreground px-3 py-8 text-center text-sm"
            >
              {emptyText}
            </div>
          )}
        </div>
      </PopoverContent>
    </Popover>
  )
}

export { TreeSelect }

属性 Props

TreeSelect 支持以下配置属性,并继承原生 <button> 的 HTML 属性:

属性类型默认值说明
itemsTreeNode[]—树形选项数据源,支持任意深度的多级嵌套与图标定义。
valuestring—受控模式下的当前选中节点的 `value` 标识符。
defaultValuestring""非受控模式下的初始选中节点标识值。
onValueChange(value: string, node?: TreeNode) => void—选中节点发生改变或清空选择时触发的回调函数,返回选中值及完整的节点对象。
placeholderstring"请选择"未选中任何节点时触发框内显示的占位提示文本。
clearablebooleantrue当已有选中值时,是否在触发框右侧显示快速一键清空按钮。
searchablebooleantrue是否在下拉浮层顶部显示实时节点搜索过滤框。
searchPlaceholderstring"搜索节点"下拉搜索过滤输入框内的占位提示文本。
filterNode(query: string, node: TreeNode) => boolean—自定义节点搜索过滤规则函数,默认按节点 label 包含匹配。
emptyTextReact.ReactNode"没有匹配的节点"当搜索无匹配项或树数据为空时展示的空状态提示内容。
disabledbooleanfalse是否完全禁用树选择器的展开与选择交互。
contentClassNamestring—应用于下拉弹出层浮动面板容器的额外 CSS 类名。
classNamestring—应用于触发器外层容器的额外 CSS 类名。

TreeNode 数据结构

items 树节点类型定义:

export interface TreeNode {
  /** 节点的稳定唯一标识值 */
  value: string
  /** 节点在树中展示的文本或 React 节点 */
  label: React.ReactNode
  /** 子节点列表 */
  children?: TreeNode[]
  /** 是否禁用此节点的展开与选择 */
  disabled?: boolean
  /** 节点名称前展示的图标元素 */
  icon?: React.ReactNode
}

事件 Events

属性类型默认值说明
onValueChange(value: string, node?: TreeNode) => void—用户点击某个节点或点击清空按钮时触发。清空时 value 为空字符串,node 为 undefined。
onFocus(event: React.FocusEvent<HTMLButtonElement>) => void—触发器获得焦点时触发。
onBlur(event: React.FocusEvent<HTMLButtonElement>) => void—触发器失去焦点时触发。

使用场景与设计规范

TreeSelect 适用于从具有层次组织结构(如公司部门、知识库分类树、文件系统目录)中选取单个目标节点的业务场景。

  • TreeSelect vs Cascader vs Tree:
    • TreeSelect(树选择):在紧凑表单中使用,浮层展开为树,注重从树中定位单点值。
    • Cascader(级联选择):多列横向展开,强制体现层级路径关系(如省市区)。
    • Tree(树形控件):常驻页面主体布局中,支持大面积展示、多选复选框、拖拽排序等深交互。
  • 搜索自动展开祖先链:用户在 TreeSelect 中搜索时,组件会自动过滤出匹配的节点并自动展开其上层的所有父级节点路径,确保用户直观理解搜索结果的上下文。
  • 一键清空机制:非必填表单字段建议保留 clearable={true},方便用户快速取消选择而无需重新展开树进行反选。

场景示例

拼音与首字母多维检索

通过 filterNode 接入自定义别名映射字典,支持拼音或业务首字母极速检索:

Loading…

企业组织架构与审批指派

配合节点自定义图标(icon)呈现具有层级分工的企业部门树,提供专业清晰的层级视觉反馈:

Loading…

文件系统目录与清空重置

在代码管理或云存储目录场景中,结合 clearable 属性实现自由关联与随时清空重置:

Loading…

节点禁用与不可选权限

当用户缺少特定分支的操作权限(如只读管理员分支)时,可单独对节点设置 disabled: true:

Loading…

无障碍与交互 Accessibility

  • ARIA 规范:触发按钮具有 role="combobox"、aria-expanded 与 aria-haspopup="dialog";内部树结构遵循 WAI-ARIA tree 与 treeitem 无障碍规范。
  • 键盘导航:
    • Tab:在表单字段间切换焦点至触发框。
    • Enter / Space:展开树选择浮层,光标自动聚焦至内部搜索框。
    • ↑ / ↓:在树节点列表中上下移动聚焦。
    • →:展开当前聚焦的父节点。
    • ←:收起当前聚焦的父节点,或返回上层父级。
    • Esc:收起浮层并将焦点送回触发按钮。
  • 清空操作无障碍:清空按钮具有独立的 aria-label="清空选择",支持独立键盘 Tab 聚焦与激活。
  • 打开即定位:打开浮层时自动展开已选节点的所有祖先,已选项始终可见;搜索时自动展开匹配路径。
  • 动效:浮层从触发器方向展开,触发器中的值在切换时上移淡入,清空按钮缩放出现,箭头平滑旋转;均遵循 prefers-reduced-motion。