- 分组 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"
/>
)
}
用法
import { Tree, type TreeNode, type TreeValue } from '@hina-ui/react'
Tree 直接渲染树形列表;需要带触发器的单选浮层时,使用 TreeSelect。通过 items 提供节点,children 定义下一级。点击展开箭头只改变展开状态,点击节点文字或按空格、Enter 改变选择。
默认单选,value 为节点的 value,再次点击当前节点会取消选择,并以 null 调用 onValueChange。
- 分组 A
- 节点 A.1
- 分组 A.2
- 分组 B
选中值:—
'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>
)
}
示例
多选勾选与半选
设置 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>
)
}
控制展开
defaultExpanded 设置初始展开节点,expanded / onExpandedChange 可在外部控制展开状态。它们都使用节点的原始 value;传入 expanded 时优先使用受控值,包含空数组。没有子节点或 children: [] 的节点不会显示展开箭头。
- 分组 A
- 节点 A.1
- 分组 A.2
- 分组 B
'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>
)
}
自定义节点
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
}
/>
)
}
禁用
节点的 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>
)
}
滚动
组件自身不限制高度,也不添加面板背景或边框。需要限制可见高度时,用 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>
)
}
空状态
没有节点时显示语言包中的空状态文字,可通过 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>
)
}
虚拟滚动
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>
)
}
键盘与无障碍
- 树为
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
}
value 必须在整棵树中唯一,数字 1 与字符串 '1' 是不同的值。description 默认显示在节点文字下方;disabled 渲染函数参数包含从祖先和组件继承的禁用状态。
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }