'use client'
import { useState } from 'react'
import { TreeSelect, type TreeSelectValue } from '@hina-ui/react'
import { regions } from './data'
export default function Demo() {
const [region, setRegion] = useState<TreeSelectValue>('kyoto')
return (
<TreeSelect
value={region}
onValueChange={setRegion}
items={regions}
aria-label="地区"
className="w-64"
/>
)
}
用法
import { TreeSelect } from '@hina-ui/react'
树形选择框的触发器与 Select 相同,浮层里是可以逐层展开的树。items 提供节点,每个节点是 { value, label },带 children 的节点可以展开;value / onValueChange 绑定选中节点的值,任何一级的节点都可以选中。
当前值:无
'use client'
import { useState } from 'react'
import { Stack, Text, TreeSelect, type TreeSelectValue } from '@hina-ui/react'
import { regions } from './data'
export default function Demo() {
const [region, setRegion] = useState<TreeSelectValue>(null)
return (
<Stack className="w-64">
<TreeSelect
value={region}
onValueChange={setRegion}
items={regions}
placeholder="选择地区"
aria-label="地区"
/>
<Text tone="muted">当前值:{region ?? '无'}</Text>
</Stack>
)
}
示例
默认展开
defaultExpanded 列出打开时默认展开的节点。已选节点所在的路径总会自动展开。
'use client'
import { useState } from 'react'
import { TreeSelect, type TreeSelectValue } from '@hina-ui/react'
import { regions } from './data'
export default function Demo() {
const [region, setRegion] = useState<TreeSelectValue>(null)
return (
<TreeSelect
value={region}
onValueChange={setRegion}
items={regions}
defaultExpanded={['jp', 'kanto']}
placeholder="选择地区"
aria-label="地区"
className="w-64"
/>
)
}
搜索
searchable 在浮层顶部显示搜索框,按节点的 label 过滤,忽略大小写及重音差异。匹配节点的祖先路径会保留并展开,其他节点隐藏;清空搜索恢复搜索前的展开状态,已选值保持不变。
search / onSearchChange 绑定搜索文本,searchPlaceholder 设置搜索框提示文字。关闭浮层时清空搜索。搜索框中按 ↓ 或 ↑ 进入树的首个或末个可用节点,在首个节点按 ↑ 返回搜索框;Esc 先清空非空搜索,再次按下关闭浮层。
当前值:无
搜索:无
'use client'
import { useState } from 'react'
import { Stack, Text, TreeSelect, type TreeSelectValue } from '@hina-ui/react'
import { regions } from './data'
export default function Demo() {
const [region, setRegion] = useState<TreeSelectValue>(null)
const [search, setSearch] = useState('')
return (
<Stack className="w-64">
<TreeSelect
value={region}
onValueChange={setRegion}
search={search}
onSearchChange={setSearch}
items={regions}
searchable
searchPlaceholder="搜索节点"
aria-label="地区"
/>
<Text tone="muted">当前值:{region ?? '无'}</Text>
<Text tone="muted">搜索:{search || '无'}</Text>
</Stack>
)
}
定制内容
renderNode 属性定制每个节点的内容。
'use client'
import { useState } from 'react'
import { Inline, Text, TreeSelect, type TreeSelectNode, type TreeSelectValue } from '@hina-ui/react'
const departments: TreeSelectNode[] = [
{
value: 'product',
label: '产品部',
description: '12 人',
children: [
{ value: 'design', label: '设计组', description: '5 人' },
{ value: 'research', label: '用研组', description: '3 人' },
],
},
{
value: 'engineering',
label: '工程部',
description: '28 人',
children: [
{ value: 'web', label: '前端组', description: '9 人' },
{ value: 'api', label: '后端组', description: '11 人' },
],
},
]
export default function Demo() {
const [dept, setDept] = useState<TreeSelectValue>(null)
return (
<TreeSelect
value={dept}
onValueChange={setDept}
items={departments}
placeholder="选择部门"
aria-label="部门"
className="w-72"
renderNode={({ node }) => (
<Inline as="span" gap="sm" wrap={false} justify="between">
<Text as="span">{node.label}</Text>
<Text as="span" size="xs" tone="muted">
{node.description}
</Text>
</Inline>
)}
/>
)
}
尺寸
三档尺寸与输入框相同。
import { Stack, TreeSelect } from '@hina-ui/react'
import { regions } from './data'
export default function Demo() {
return (
<Stack className="w-64">
<TreeSelect size="sm" items={regions} value="tokyo" aria-label="小号" />
<TreeSelect size="md" items={regions} value="tokyo" aria-label="中号" />
<TreeSelect size="lg" items={regions} value="tokyo" aria-label="大号" />
</Stack>
)
}
形态
primary 直接放在页面底色上,带边框与阴影;secondary 放在卡片等表面内,只有一层浅色底。
import { Card, Stack, TreeSelect } from '@hina-ui/react'
import { regions } from './data'
export default function Demo() {
return (
<Stack className="w-64">
<TreeSelect items={regions} placeholder="直接放在页面上" aria-label="页面上的选择框" />
<Card>
<TreeSelect
variant="secondary"
items={regions}
placeholder="放在卡片内"
aria-label="卡片内的选择框"
/>
</Card>
</Stack>
)
}
状态
invalid 标出校验未通过,disabled 禁用整个选择框,节点上的 disabled 只禁用该节点。
import { Stack, TreeSelect, type TreeSelectNode } from '@hina-ui/react'
const items: TreeSelectNode[] = [
{
value: 'jp',
label: '日本',
children: [
{ value: 'tokyo', label: '东京' },
{ value: 'osaka', label: '大阪', disabled: true },
],
},
]
export default function Demo() {
return (
<Stack className="w-64">
<TreeSelect invalid items={items} placeholder="请选择地区" aria-label="校验未通过" />
<TreeSelect disabled items={items} value="tokyo" aria-label="已禁用" />
<TreeSelect
items={items}
defaultExpanded={['jp']}
placeholder="含禁用节点"
aria-label="含禁用节点"
/>
</Stack>
)
}
在表单中
放进 FormField 后,标签指向触发器,错误信息由字段渲染并关联到它;校验规则与提交交给 Form。
'use client'
import { useState } from 'react'
import * as v from 'valibot'
import { Button, Form, FormField, Text, TreeSelect, type TreeSelectValue } from '@hina-ui/react'
import { regions } from './data'
const schema = v.object({
department: v.string('请选择地区'),
})
export default function Demo() {
const [values, setValues] = useState({ department: null as TreeSelectValue })
const [saved, setSaved] = useState('')
async function save(data: unknown) {
await new Promise(resolve => setTimeout(resolve, 600))
setSaved(JSON.stringify(data))
}
return (
<Form values={values} rules={schema} className="w-80" onSubmit={save}>
{({ submitting }) => (
<>
<FormField name="department" label="所在地区" required>
<TreeSelect
value={values.department}
onValueChange={department => setValues({ ...values, department })}
items={regions}
/>
</FormField>
<Button type="submit" loading={submitting} className="self-start">
保存
</Button>
{saved && (
<Text tone="muted" size="sm">
已保存:{saved}
</Text>
)}
</>
)}
</Form>
)
}
虚拟滚动
virtualize 按需渲染可见范围附近的条目,与 VirtualList 共用测量与滚动底层。默认关闭;可传 { estimateSize, overscan } 调整预估行高和两侧预渲染数量,行高会按实际内容测量。键盘导航覆盖完整数据,禁用项会跳过。搜索仍处理完整数据。只对展开后的可见节点进行虚拟化,父子导航与勾选状态不依赖节点是否挂载。条目离开渲染范围后会卸载;renderNode 渲染的内容中需要持久保留的状态应按唯一 value 存在外部。
已选: 7890
'use client'
import { useState } from 'react'
import { Stack, Text, TreeSelect, type TreeSelectValue } 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<TreeSelectValue>(7890)
return (
<Stack gap="sm" className="w-64 max-w-full">
<TreeSelect
value={selected}
onValueChange={setSelected}
items={items}
defaultExpanded={['root']}
virtualize={{ estimateSize: 36, overscan: 6 }}
searchable
aria-label="一万项"
/>
<Text size="sm" tone="muted">
已选: {selected}
</Text>
</Stack>
)
}
行为
- 点击节点前的箭头只展开或者收起,点击节点本身则选中它并关闭浮层。
- 键盘上下方向键移动焦点,右方向键展开、左方向键收起,Enter 或者空格选中。
- 浮层贴着触发器展开,宽度与触发器相同,树超出高度时在浮层内滚动。
- 打开期间页面锁定滚动,点击外部或者按 Esc 关闭。
无障碍
- 触发器为
role="combobox",树为role="tree",节点为role="treeitem"并带aria-level、aria-expanded与aria-selected。 - 应当配合
label元素或者aria-label提供名称。invalid同时设置aria-invalid。
API
Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
value | string | number | null | — | 选中节点的值 |
items | TreeSelectNode[] | — | 节点,见下方类型 |
virtualize | VirtualizeOptions | false | 虚拟滚动;预估行高按内容,overscan 6 |
placeholder | string | 语言包 | 无值时显示的文字 |
searchable | boolean | false | 显示搜索框并启用节点过滤 |
search | string | '' | 搜索文本,可受控 |
searchPlaceholder | string | 语言包 | 搜索框提示文字与无障碍名称 |
defaultExpanded | Array<string | number> | [] | 打开时默认展开的节点 |
open | boolean | false | 浮层是否打开,可受控 |
variant | 'primary' | 'secondary' | 'primary' | 形态 |
size | 'sm' | 'md' | 'lg' | 'md' | 尺寸 |
invalid | boolean | false | 是否校验未通过 |
disabled | boolean | false | 是否禁用 |
className | string | — | 追加至触发器的类名 |
内容属性
| 属性 | 参数 | 说明 |
|---|---|---|
renderNode | { node: TreeSelectNode } | 每个节点的内容 |
回调
| 回调 | 参数 | 说明 |
|---|---|---|
onValueChange | value: string | number | 选中值变化 |
onOpenChange | open: boolean | 浮层开合变化 |
onSearchChange | search: string | 搜索文本变化 |
类型
interface TreeSelectNode {
value: string | number
label: string
description?: string
disabled?: boolean
children?: TreeSelectNode[]
}
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }