Tree 树形列表

支持逐级展开、父子联动勾选和半选状态的独立树形列表。

  • 分组 A
  • 节点 A.1
  • 分组 A.2
  • 节点 A.2.1
  • 节点 A.2.2
  • 分组 B
  • 节点 B.1
  • 节点 B.2
'use client'

import { useState } from 'react'
import { Tree, type TreeValue } from '@hina-ui/react'
import { nodes } from './data'

export default function Demo() {
  const [checked, setChecked] = useState<TreeValue[]>(['a-2-1'])

  return (
    <Tree
      value={checked}
      onValueChange={value => setChecked(value as TreeValue[])}
      multiple
      items={nodes}
      defaultExpanded={['a', 'a-2', 'b']}
      aria-label="树形勾选"
      className="w-80 max-w-full"
    />
  )
}
tsx

用法

import { Tree, type TreeNode, type TreeValue } from '@hina-ui/react'
ts

Tree 直接渲染树形列表;需要带触发器的单选浮层时,使用 TreeSelect。通过 items 提供节点,children 定义下一级。点击展开箭头只改变展开状态,点击节点文字或按空格、Enter 改变选择。

默认单选,value 为节点的 value,再次点击当前节点会取消选择,并以 null 调用 onValueChange。

  • 分组 A
  • 节点 A.1

选中值:—

'use client'

import { useState } from 'react'
import { Stack, Text, Tree, type TreeValue } from '@hina-ui/react'
import { nodes } from './data'

export default function Demo() {
  const [selected, setSelected] = useState<TreeValue | null>(null)

  return (
    <Stack className="w-80 max-w-full">
      <Tree
        value={selected}
        onValueChange={value => setSelected(value as TreeValue | null)}
        items={nodes}
        defaultExpanded={['a']}
        aria-label="单选树"
      />
      <Text size="sm" tone="muted">
        选中值:{selected ?? '—'}
      </Text>
    </Stack>
  )
}
tsx

示例

多选勾选与半选

设置 multiple 显示勾选框,value / onValueChange 改为 TreeValue[]。勾选父节点会选中所有可用后代,取消父节点会清除这些选择;点击半选父节点会补全勾选。部分后代被选中时,父节点显示横线;全部可用子节点选中后,父节点显示勾选。

状态按完整树计算,折叠不会清除选择或改变半选结果。onValueChange 收到所有完整勾选节点的值,包含自动勾选的父节点;半选节点不写入数组。已知节点按树的前序排列,未出现在当前 items 中的值会保留。

外部回填可以只传叶子值,父节点状态会自动推导;传入父节点值会勾选它的可用后代。组件不会仅因初始化、展开或数据刷新而调用 onValueChange。勾选框沿用 Checkbox 的视觉与动效。

  • 分组 A
  • 节点 A.1
  • 分组 A.2
  • 节点 A.2.1
  • 节点 A.2.2
  • 分组 B
  • 节点 B.1
  • 节点 B.2

绑定值:a-2-1

'use client'

import { useState } from 'react'
import { Button, Inline, Stack, Text, Tree, type TreeValue } from '@hina-ui/react'
import { nodes } from './data'

export default function Demo() {
  const [checked, setChecked] = useState<TreeValue[]>(['a-2-1'])

  return (
    <Stack className="w-96 max-w-full">
      <Tree
        value={checked}
        onValueChange={value => setChecked(value as TreeValue[])}
        multiple
        items={nodes}
        defaultExpanded={['a', 'a-2', 'b']}
        aria-label="父子联动勾选"
      />
      <Inline>
        <Button
          size="sm"
          variant="soft"
          tone="neutral"
          onClick={() => setChecked(nodes.map(node => node.value))}
        >
          全选
        </Button>
        <Button size="sm" variant="ghost" tone="neutral" onClick={() => setChecked([])}>
          清空
        </Button>
      </Inline>
      <Text size="sm" tone="muted" className="break-all">
        绑定值:{checked.join(', ') || '—'}
      </Text>
    </Stack>
  )
}
tsx

控制展开

defaultExpanded 设置初始展开节点,expanded / onExpandedChange 可在外部控制展开状态。它们都使用节点的原始 value;传入 expanded 时优先使用受控值,包含空数组。没有子节点或 children: [] 的节点不会显示展开箭头。

  • 分组 A
  • 节点 A.1
'use client'

import { useState } from 'react'
import { Button, Inline, Stack, Tree, type TreeValue } from '@hina-ui/react'
import { nodes } from './data'

export default function Demo() {
  const [expanded, setExpanded] = useState<TreeValue[]>(['a'])
  const [checked, setChecked] = useState<TreeValue[]>(['a-2-1'])

  return (
    <Stack className="w-80 max-w-full">
      <Inline>
        <Button
          size="sm"
          variant="soft"
          tone="neutral"
          onClick={() => setExpanded(['a', 'a-2', 'b'])}
        >
          展开全部
        </Button>
        <Button size="sm" variant="ghost" tone="neutral" onClick={() => setExpanded([])}>
          收起全部
        </Button>
      </Inline>
      <Tree
        value={checked}
        onValueChange={value => setChecked(value as TreeValue[])}
        expanded={expanded}
        onExpandedChange={setExpanded}
        multiple
        items={nodes}
        aria-label="受控展开"
      />
    </Stack>
  )
}
tsx

自定义节点

renderNode 属性替换节点文字区域,renderTrailing 属性位于行尾,两者都接收原节点及 selected、indeterminate、expanded、disabled 状态。selected 仅表示完整选中;半选时为 false,indeterminate 为 true。展开箭头和勾选框由组件保留。

示例通过 Tag 显示完整选中和部分选中状态。

  • 分组 A部分选中
  • 节点 A.1
  • 分组 A.2部分选中
  • 节点 A.2.1已选中
  • 节点 A.2.2
  • 分组 B
  • 节点 B.1
  • 节点 B.2
'use client'

import { useState } from 'react'
import { File, Folder, FolderOpen } from 'lucide-react'
import { Inline, Tag, Text, Tree, type TreeValue } from '@hina-ui/react'
import { nodes } from './data'

export default function Demo() {
  const [checked, setChecked] = useState<TreeValue[]>(['a-2-1'])

  return (
    <Tree
      value={checked}
      onValueChange={value => setChecked(value as TreeValue[])}
      multiple
      items={nodes}
      defaultExpanded={['a', 'a-2', 'b']}
      aria-label="自定义节点"
      className="w-96 max-w-full"
      renderNode={({ node, expanded }) => (
        <Inline as="span" wrap={false} gap="sm">
          {node.children?.length && expanded ? (
            <FolderOpen className="text-muted size-4 shrink-0" aria-hidden="true" />
          ) : node.children?.length ? (
            <Folder className="text-muted size-4 shrink-0" aria-hidden="true" />
          ) : (
            <File className="text-muted size-4 shrink-0" aria-hidden="true" />
          )}
          <Text as="span" size="sm">
            {node.label}
          </Text>
        </Inline>
      )}
      renderTrailing={({ selected, indeterminate }) =>
        indeterminate ? (
          <Tag size="sm" tone="neutral">
            部分选中
          </Tag>
        ) : selected ? (
          <Tag size="sm">已选中</Tag>
        ) : null
      }
    />
  )
}
tsx

禁用

节点的 disabled 会禁用该节点及其子树,阻止点击、键盘选择和展开。父子勾选联动跳过禁用子树,父节点的全选、半选判断也不计入它们;原有绑定值保留。只有禁用子节点的可用父节点,可以单独勾选。

组件的 disabled 禁用整棵树,已有选择仍可见。FormField 的禁用、校验状态、标题和描述关联会自动传递到树上。

节点禁用

  • 根节点
  • 可用节点
  • 禁用节点
  • 禁用子树中的节点

整体禁用

  • 根节点
  • 可用节点
import { Stack, Text, Tree, type TreeNode } from '@hina-ui/react'

const items: TreeNode[] = [
  {
    value: 'root',
    label: '根节点',
    children: [
      { value: 'available', label: '可用节点' },
      {
        value: 'disabled',
        label: '禁用节点',
        disabled: true,
        children: [{ value: 'child', label: '禁用子树中的节点' }],
      },
    ],
  },
]

export default function Demo() {
  return (
    <Stack className="w-80 max-w-full">
      <Text size="sm" tone="muted">
        节点禁用
      </Text>
      <Tree multiple items={items} defaultExpanded={['root', 'disabled']} aria-label="节点禁用" />
      <Text size="sm" tone="muted">
        整体禁用
      </Text>
      <Tree
        multiple
        disabled
        items={items}
        value={['available']}
        defaultExpanded={['root']}
        aria-label="整体禁用"
      />
    </Stack>
  )
}
tsx

滚动

组件自身不限制高度,也不添加面板背景或边框。需要限制可见高度时,用 ScrollArea 包裹树。

  • 分组
  • 节点 1
  • 节点 2
  • 节点 3
  • 节点 4
  • 节点 5
  • 节点 6
  • 节点 7
  • 节点 8
  • 节点 9
  • 节点 10
  • 节点 11
  • 节点 12
  • 节点 13
  • 节点 14
  • 节点 15
  • 节点 16
  • 节点 17
  • 节点 18
  • 节点 19
  • 节点 20
  • 节点 21
  • 节点 22
  • 节点 23
  • 节点 24
  • 节点 25
  • 节点 26
  • 节点 27
  • 节点 28
  • 节点 29
  • 节点 30
  • 节点 31
  • 节点 32
  • 节点 33
  • 节点 34
  • 节点 35
  • 节点 36
  • 节点 37
  • 节点 38
  • 节点 39
  • 节点 40
'use client'

import { useState } from 'react'
import { ScrollArea, Tree, type TreeNode, type TreeValue } from '@hina-ui/react'

const items: TreeNode[] = [
  {
    value: 'root',
    label: '分组',
    children: Array.from({ length: 40 }, (_, index) => ({
      value: index,
      label: `节点 ${index + 1}`,
    })),
  },
]

export default function Demo() {
  const [checked, setChecked] = useState<TreeValue[]>([])

  return (
    <ScrollArea className="h-64 w-80 max-w-full">
      <Tree
        value={checked}
        onValueChange={value => setChecked(value as TreeValue[])}
        multiple
        items={items}
        defaultExpanded={['root']}
        aria-label="可滚动树"
      />
    </ScrollArea>
  )
}
tsx

空状态

没有节点时显示语言包中的空状态文字,可通过 empty 属性替换。空状态使用 role="status",不会产生一个没有节点的 role="tree"。

暂无节点

还没有节点

import { Stack, Text, Tree } from '@hina-ui/react'

export default function Demo() {
  return (
    <Stack className="w-80 max-w-full">
      <Tree items={[]} aria-label="默认空状态" />
      <Tree
        items={[]}
        aria-label="自定义空状态"
        empty={
          <Text size="sm" tone="muted">
            还没有节点
          </Text>
        }
      />
    </Stack>
  )
}
tsx

虚拟滚动

virtualize 按需渲染可见范围附近的条目,与 VirtualList 共用测量与滚动底层。默认关闭;可传 { estimateSize, overscan } 调整预估行高和两侧预渲染数量,行高会按实际内容测量。键盘导航覆盖完整数据,禁用项会跳过。只对展开后的可见节点进行虚拟化,父子导航与勾选状态不依赖节点是否挂载。maxHeight 默认 320px,仅开启虚拟化时生效,可传数字或 CSS 长度。条目离开渲染范围后会卸载;renderNode 与 renderTrailing 渲染的内容中需要持久保留的状态应按唯一 value 存在外部。

已选: 1

'use client'

import { useState } from 'react'
import { Stack, Text, Tree, type TreeValue } from '@hina-ui/react'

const options = Array.from({ length: 10000 }, (_, index) => ({
  value: index,
  label: `条目 ${String(index + 1).padStart(5, '0')}`,
  disabled: index % 97 === 0,
}))
const items = [{ value: 'root', label: '全部节点', children: options }]

export default function Demo() {
  const [selected, setSelected] = useState<TreeValue[]>([7890])

  return (
    <Stack gap="sm" className="w-80 max-w-full">
      <Tree
        value={selected}
        onValueChange={value => setSelected(value as TreeValue[])}
        items={items}
        defaultExpanded={['root']}
        virtualize={{ estimateSize: 36, overscan: 6 }}
        multiple
        maxHeight={320}
        aria-label="一万项"
      />
      <Text size="sm" tone="muted">
        已选: {selected.length}
      </Text>
    </Stack>
  )
}
tsx

键盘与无障碍

  • 树为 role="tree",节点为 role="treeitem",保留层级、同级位置和展开状态。使用 aria-label、aria-labelledby 或 FormField 提供名称。
  • 单选用 aria-selected 表达选择;多选用 aria-checked,半选为 mixed。勾选框是节点状态的视觉表现,不会增加额外的 Tab 停靠点。
  • 上下方向键移动焦点,Home / End 到达首尾可用节点;空格和 Enter 改变选择。
  • 向行尾方向的箭头展开节点或进入子级,向行首方向的箭头收起节点或返回父级;RTL 下左右键互换。禁用节点不进入键盘焦点序列。

API

Props 与受控状态

属性
类型
默认值
说明
items
TreeNode[]
—
必填。树节点
virtualize
VirtualizeOptions
false
虚拟滚动;预估行高按内容,overscan 6
maxHeight
number | string
320
虚拟滚动视口的最大高度
multiple
boolean
false
启用父子联动的多选勾选
value
TreeValue | TreeValue[] | null
—
单选值或多选值数组,可受控
defaultExpanded
TreeValue[]
[]
初始展开节点
expanded
TreeValue[]
—
展开节点,可受控
disabled
boolean
false
禁用整棵树
invalid
boolean
false
标记校验失败
className
string
—
追加到树根节点的类名

内容属性

属性
参数
说明
renderNode
TreeNodeSlot
节点内容
renderTrailing
TreeNodeSlot
行尾内容,不提供时不占位
empty
—
空状态内容

节点类型

export type TreeValue = string | number

export interface TreeNode {
  value: TreeValue
  label: string
  description?: string
  disabled?: boolean
  children?: TreeNode[]
}

export interface TreeNodeSlot {
  node: TreeNode
  selected: boolean
  indeterminate: boolean
  expanded: boolean
  disabled: boolean
}
ts

value 必须在整棵树中唯一,数字 1 与字符串 '1' 是不同的值。description 默认显示在节点文字下方;disabled 渲染函数参数包含从祖先和组件继承的禁用状态。

type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts