Tree 树形列表

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

  • 分组 A
  • 节点 A.1
  • 分组 A.2
  • 节点 A.2.1
  • 节点 A.2.2
  • 分组 B
  • 节点 B.1
  • 节点 B.2
<script setup lang="ts">
  import { ref } from 'vue'
  import { Tree, type TreeValue } from '@hina-ui/vue'
  import { nodes } from './data'

  const checked = ref<TreeValue[]>(['a-2-1'])
</script>

<template>
  <Tree
    v-model="checked"
    multiple
    :items="nodes"
    :default-expanded="['a', 'a-2', 'b']"
    aria-label="树形勾选"
    class="w-80 max-w-full"
  />
</template>
vue

用法

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

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

默认单选,v-model 为节点的 value,再次点击当前节点会取消选择并写入 null。

  • 分组 A
  • 节点 A.1

选中值:—

<script setup lang="ts">
  import { ref } from 'vue'
  import { Stack, Text, Tree, type TreeValue } from '@hina-ui/vue'
  import { nodes } from './data'

  const selected = ref<TreeValue | null>(null)
</script>

<template>
  <Stack class="w-80 max-w-full">
    <Tree v-model="selected" :items="nodes" :default-expanded="['a']" aria-label="单选树" />
    <Text size="sm" tone="muted">选中值:{{ selected ?? '—' }}</Text>
  </Stack>
</template>
vue

示例

多选勾选与半选

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

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

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

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

绑定值:a-2-1

<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Inline, Stack, Text, Tree, type TreeValue } from '@hina-ui/vue'
  import { nodes } from './data'

  const checked = ref<TreeValue[]>(['a-2-1'])
</script>

<template>
  <Stack class="w-96 max-w-full">
    <Tree
      v-model="checked"
      multiple
      :items="nodes"
      :default-expanded="['a', 'a-2', 'b']"
      aria-label="父子联动勾选"
    />
    <Inline>
      <Button
        size="sm"
        variant="soft"
        tone="neutral"
        @click="checked = nodes.map(node => node.value)"
      >
        全选
      </Button>
      <Button size="sm" variant="ghost" tone="neutral" @click="checked = []">清空</Button>
    </Inline>
    <Text size="sm" tone="muted" class="break-all">绑定值:{{ checked.join(', ') || '—' }}</Text>
  </Stack>
</template>
vue

控制展开

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

  • 分组 A
  • 节点 A.1
<script setup lang="ts">
  import { ref } from 'vue'
  import { Button, Inline, Stack, Tree, type TreeValue } from '@hina-ui/vue'
  import { nodes } from './data'

  const expanded = ref<TreeValue[]>(['a'])
  const checked = ref<TreeValue[]>(['a-2-1'])
</script>

<template>
  <Stack class="w-80 max-w-full">
    <Inline>
      <Button size="sm" variant="soft" tone="neutral" @click="expanded = ['a', 'a-2', 'b']">
        展开全部
      </Button>
      <Button size="sm" variant="ghost" tone="neutral" @click="expanded = []">收起全部</Button>
    </Inline>
    <Tree
      v-model="checked"
      v-model:expanded="expanded"
      multiple
      :items="nodes"
      aria-label="受控展开"
    />
  </Stack>
</template>
vue

自定义节点

node 插槽替换节点文字区域,trailing 插槽位于行尾,两者都接收原节点及 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
<script setup lang="ts">
  import { ref } from 'vue'
  import { File, Folder, FolderOpen } from '@lucide/vue'
  import { Inline, Tag, Text, Tree, type TreeValue } from '@hina-ui/vue'
  import { nodes } from './data'

  const checked = ref<TreeValue[]>(['a-2-1'])
</script>

<template>
  <Tree
    v-model="checked"
    multiple
    :items="nodes"
    :default-expanded="['a', 'a-2', 'b']"
    aria-label="自定义节点"
    class="w-96 max-w-full"
  >
    <template #node="{ node, expanded }">
      <Inline as="span" :wrap="false" gap="sm">
        <FolderOpen
          v-if="node.children?.length && expanded"
          class="text-muted size-4 shrink-0"
          aria-hidden="true"
        />
        <Folder
          v-else-if="node.children?.length"
          class="text-muted size-4 shrink-0"
          aria-hidden="true"
        />
        <File v-else class="text-muted size-4 shrink-0" aria-hidden="true" />
        <Text as="span" size="sm">{{ node.label }}</Text>
      </Inline>
    </template>
    <template #trailing="{ selected, indeterminate }">
      <Tag v-if="indeterminate" size="sm" tone="neutral">部分选中</Tag>
      <Tag v-else-if="selected" size="sm">已选中</Tag>
    </template>
  </Tree>
</template>
vue

禁用

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

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

节点禁用

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

整体禁用

  • 根节点
  • 可用节点
<script setup lang="ts">
  import { Stack, Text, Tree, type TreeNode } from '@hina-ui/vue'

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

<template>
  <Stack class="w-80 max-w-full">
    <Text size="sm" tone="muted">节点禁用</Text>
    <Tree multiple :items="items" :default-expanded="['root', 'disabled']" aria-label="节点禁用" />
    <Text size="sm" tone="muted">整体禁用</Text>
    <Tree
      multiple
      disabled
      :items="items"
      :model-value="['available']"
      :default-expanded="['root']"
      aria-label="整体禁用"
    />
  </Stack>
</template>
vue

滚动

组件自身不限制高度,也不添加面板背景或边框。需要限制可见高度时,用 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
<script setup lang="ts">
  import { ref } from 'vue'
  import { ScrollArea, Tree, type TreeNode, type TreeValue } from '@hina-ui/vue'

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

<template>
  <ScrollArea class="h-64 w-80 max-w-full">
    <Tree
      v-model="checked"
      multiple
      :items="items"
      :default-expanded="['root']"
      aria-label="可滚动树"
    />
  </ScrollArea>
</template>
vue

空状态

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

暂无节点

还没有节点

<script setup lang="ts">
  import { Stack, Text, Tree } from '@hina-ui/vue'
</script>

<template>
  <Stack class="w-80 max-w-full">
    <Tree :items="[]" aria-label="默认空状态" />
    <Tree :items="[]" aria-label="自定义空状态">
      <template #empty><Text size="sm" tone="muted">还没有节点</Text></template>
    </Tree>
  </Stack>
</template>
vue

虚拟滚动

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

已选: 1

<script setup lang="ts">
  import { ref } from 'vue'
  import { Tree, Stack, Text } from '@hina-ui/vue'

  const selected = ref<Array<string | number>>([7890])
  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 }]
</script>

<template>
  <Stack gap="sm" class="w-80 max-w-full">
    <Tree
      v-model="selected"
      :items="items"
      :default-expanded="['root']"
      :virtualize="{ estimateSize: 36, overscan: 6 }"
      multiple
      :max-height="320"
      aria-label="一万项"
    />
    <Text size="sm" tone="muted">已选: {{ selected.length }}</Text>
  </Stack>
</template>
vue

键盘与无障碍

  • 树为 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
启用父子联动的多选勾选
modelValue
TreeValue | TreeValue[] | null
—
单选值或多选值数组,支持 v-model
defaultExpanded
TreeValue[]
[]
初始展开节点
expanded
TreeValue[]
—
展开节点,支持 v-model:expanded
disabled
boolean
false
禁用整棵树
invalid
boolean
false
标记校验失败
class
string
—
追加到树根节点的类名

插槽

插槽
参数
说明
node
TreeNodeSlot
节点内容
trailing
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