TreeSelect 树形选择器

从树形层级中选择一项。

'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"
    />
  )
}
tsx

用法

import { TreeSelect } from '@hina-ui/react'
ts

树形选择框的触发器与 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>
  )
}
tsx

示例

默认展开

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"
    />
  )
}
tsx

定制内容

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>
      )}
    />
  )
}
tsx

尺寸

三档尺寸与输入框相同。

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>
  )
}
tsx

形态

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>
  )
}
tsx

状态

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>
  )
}
tsx

在表单中

放进 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>
  )
}
tsx

虚拟滚动

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>
  )
}
tsx

行为

  • 点击节点前的箭头只展开或者收起,点击节点本身则选中它并关闭浮层。
  • 键盘上下方向键移动焦点,右方向键展开、左方向键收起,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[]
}
ts
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts