Angular @angular/aria/tree:构建符合 ARIA 规范的可访问树形控件完全指南
本文基于 Angular 仓库中 adev/src/content/guide/aria/tree.md 官方指南,系统讲解 @angular/aria/tree 包提供的 Tree、TreeItem、TreeItemGroup 指令:如何渲染层级数据、配置单选/多选/导航模式、处理禁用项与焦点策略,以及如何使用 TreeHarness 编写组件测试。读完本文,你可以直接复制仓库中的示例代码构建一个键盘导航完备、屏幕阅读器友好的树形控件。
概述:Tree 组件解决什么问题
Tree(树形控件)用于展示层级数据:节点可以展开以显示子节点,也可以折叠以隐藏它们。用户通过方向键导航、展开/折叠节点,并可选择性地选中条目用于导航或数据选取场景。@angular/aria/tree 把 W3C ARIA 树形模式(APG Treeview Pattern)的复杂交互逻辑全部封装进指令,开发者只需要声明模板结构,即可自动获得完整的键盘导航、焦点管理和 ARIA 语义。
在仓库中,该组件的使用范例集中位于 tree 示例目录,按场景分为 nav(导航)、single-select(单选)、multi-select(多选)、single-select-follow-focus(选中跟随焦点)、disabled-focusable(可聚焦的禁用项)五个子目录,每个场景又提供 basic 与 retro 两套视觉实现。
适用场景:什么时候用 Tree,什么时候不用
官方指南给出了明确的选型边界:
适合使用 Tree 的场景:
- 构建文件系统导航
- 展示文件夹与文档层级
- 创建嵌套菜单结构
- 显示组织架构图
- 浏览任意层级数据
- 实现带嵌套片段的站点导航
应避免使用 Tree 的场景:
| 场景 | 应使用的组件 | 指南位置 |
|---|---|---|
| 展示扁平列表 | Listbox | listbox.md |
| 展示数据表格 | Grid | grid.md |
| 简单下拉选择 | Select | select.md |
| 面包屑导航 | breadcrumb 模式 | — |
这一边界的意义在于:Tree 自带方向键漫游、Shift 范围选择等交互,用在扁平数据上反而增加认知成本。
核心功能特性
从官方指南声明的特性列表看,Tree 组件提供以下能力:
- 层级导航:嵌套树结构,支持展开与折叠
- 选择模式:单选或多选,支持显式选择(explicit)或焦点跟随(follow-focus)两种行为
- 选中跟随焦点:焦点变化时自动选中,可选项
- 键盘导航:方向键、Home、End 键以及 type-ahead(按键前缀搜索)
- 展开/折叠:右/左方向键或 Enter 切换父节点
- 禁用项:禁用特定节点,并可控其焦点行为
- 焦点模式:支持 roving tabindex 或 activedescendant 两种焦点策略
- RTL 支持:面向从右向左书写语言的导航方向适配
最小示例:模板结构三要素
所有示例共享同一套模板结构,由三个指令协作完成(以 single-select/basic/app.html 为基准):
ngTree:挂在外层<ul>上,构成role="tree"容器,通过模板引用变量#tree="ngTree"获取实例;ngTreeItem:挂在每个<li>或<a>上,声明[parent](所属容器)、[value](业务值)、[label](显示文本)、[disabled]、[(expanded)](双向绑定展开状态);ngTreeItemGroup:挂在<ul role="group">内部的<ng-template>上,通过[ownedBy]关联到父ngTreeItem,声明哪组子节点归属于哪个节点。
子节点通过 @if (node.children) 条件渲染,并借助递归 ng-template 自引用实现任意深度嵌套:
<ul ngTree #tree="ngTree" [(value)]="selected" class="basic-tree">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: nodes, parent: tree}"
/>
</ul>
<ng-template #treeNodes let-nodes="nodes" let-parent="parent">
@for (node of nodes; track node.value) {
<li
ngTreeItem
[parent]="parent"
[value]="node.value"
[label]="node.name"
[disabled]="node.disabled"
[(expanded)]="node.expanded"
#treeItem="ngTreeItem"
>
<span aria-hidden="true" class="material-symbols-outlined expand-icon" translate="no">{{
node.children ? 'chevron_right' : ''
}}</span>
{{ node.name }}
<span aria-hidden="true" class="material-symbols-outlined selected-icon" translate="no">check</span>
</li>
@if (node.children) {
<ul role="group">
<ng-template ngTreeItemGroup [ownedBy]="treeItem" #group="ngTreeItemGroup">
<ng-template
[ngTemplateOutlet]="treeNodes"
[ngTemplateOutletContext]="{nodes: node.children, parent: group}"
/>
</ng-template>
</ul>
}
}
</ng-template>
注意两个可访问性细节:装饰性图标 span 均标记 aria-hidden="true",避免屏幕阅读器读出图标字形名;每个节点的可见文本({{ node.name }})与 [label] 绑定保持一致,确保辅助技术读取内容与视觉内容一致。
对应的数据模型与组件类见 single-select/basic/app.ts:
import {Component, signal} from '@angular/core';
import {NgTemplateOutlet} from '@angular/common';
import {Tree, TreeItem, TreeItemGroup} from '@angular/aria/tree';
type TreeNode = {
name: string;
value: string;
children?: TreeNode[];
disabled?: boolean;
expanded?: boolean;
};
@Component({
selector: 'app-root',
templateUrl: 'app.html',
styleUrl: 'app.css',
imports: [Tree, TreeItem, TreeItemGroup, NgTemplateOutlet],
})
export class App {
readonly nodes: TreeNode[] = [
{
name: 'public',
value: 'public',
children: [
{name: 'index.html', value: 'public/index.html'},
{name: 'favicon.ico', value: 'public/favicon.ico'},
{name: 'styles.css', value: 'public/styles.css'},
],
expanded: true,
},
// …… 省略 src、angular.json、package.json 等其余节点,
// 完整数据见仓库文件
];
readonly selected = signal(['angular.json']);
}
要点:组件以 standalone 风格直接在 imports 中声明 Tree、TreeItem、TreeItemGroup 三个指令;选中值用 signal<string[]> 保存,通过 [(value)] 与树双向绑定,即使单选场景也是字符串数组,统一了 API 心智模型。
场景一:导航树(Navigation Tree)
导航树用于「点击条目触发动作而非选中」的场景,典型如邮件客户端左侧的收件箱/文件夹导航。完整示例见 nav/basic。
与普通选择树的差异在容器声明处只多了一个属性:
<ul ngTree #tree="ngTree" [nav]="true" [(value)]="selected" class="basic-tree">
设置 [nav]="true" 即开启导航模式。此时组件使用 aria-current 标记当前页,而不是用 selection 语义——这是 ARIA 规范对导航类树的要求,屏幕阅读器会将其播报为"当前所在页"。
导航树示例还有两个值得注意的写法(见 nav/basic/app.html):
- 条目使用语义化的
<a href="#...">元素而非<li>,并用(click)="$event.preventDefault()"拦截默认跳转,使其成为可访问的原生链接; - 父节点(含 children 的节点)显式设置
[selectable]="!node.children",即只有叶子节点可被选中,符合「导航到具体页面」的语义。
场景二:单选(Single Selection)
当用户需要从树中选定一项时,保持 [multi]="false"(默认值)即为单选模式。用户按空格键选中当前焦点条目。示例位于 single-select/basic 与 single-select/retro(后者为复古视觉风格,结构相同)。
单选模式的交互约定:方向键只移动焦点;空格确认选中;[(value)] 信号在选中变更时同步更新。上文「最小示例」中的完整代码即该场景实现。
场景三:多选(Multi-Selection)
将 [multi]="true" 设置到树上即允许多选(示例见 multi-select/basic):
<ul ngTree #tree="ngTree" [multi]="true" [(value)]="selected" class="basic-tree">
多选下的两种操作方式:
- 用户按空格逐项选中/取消;
- 按住 Shift 配合方向键做范围选择(从当前焦点延伸选区)。
场景四:选中跟随焦点(Selection Follows Focus)
在导航类场景中,可以让选中自动跟随焦点移动,省去每步空格确认。将 selectionMode 设为 "follow" 即可(示例见 single-select-follow-focus/basic):
<ul ngTree #tree="ngTree" [(value)]="selected" selectionMode="follow" class="basic-tree">
此时用户用方向键移动焦点的过程中,选中值自动更新。这一模式与「显式选择」(默认,需空格确认)构成指南中提到的 selection mode 两极,选型原则是:数据选取场景用显式选择,页面/记录导航场景用 follow。
场景五:禁用项(Disabled Tree Items)
通过节点的 [disabled] 输入禁用特定树节点(在上文数据模型中即 disabled?: boolean,single-select 示例里 src/styles.css 节点即被禁用)。真正的设计决策在于:禁用项能否获得焦点? 这由树容器上的 softDisabled 属性控制(示例见 disabled-focusable/basic):
| 配置 | 行为 |
|---|---|
[softDisabled]="true" |
禁用项可以接收焦点,但无法被激活或选中("软禁用") |
[softDisabled]="false" |
键盘导航直接跳过禁用项,焦点不会停留 |
选型提示:当禁用原因用户需要感知(如"该文件无权限,但你能看到它")时用软禁用;当禁用项对用户毫无信息量时用硬跳过,减少无意义停顿。
测试:使用 TreeHarness 验证树行为
Angular Aria 为 Tree 提供了组件 harness,可脱离 DOM 断言层级结构与交互效果。官方指南给出的完整测试示例如下(引用自 tree.md):
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {HarnessLoader} from '@angular/cdk/testing';
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
import {TreeHarness} from '@angular/aria/tree/testing';
import {MyTreeComponent} from './my-tree'; // Your component
describe('MyTreeComponent', () => {
let fixture: ComponentFixture<MyTreeComponent>;
let loader: HarnessLoader;
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [MyTreeComponent],
});
fixture = TestBed.createComponent(MyTreeComponent);
await fixture.whenStable();
loader = TestbedHarnessEnvironment.loader(fixture);
});
it('should navigate and expand tree items', async () => {
const tree = await loader.getHarness(TreeHarness);
// Get top-level structure representation
expect(await tree.getTreeStructure()).toEqual({
children: [{text: 'public'}, {text: 'src'}, {text: 'package.json'}],
});
// Get all items (currently visible)
const items = await tree.getItems();
expect(items.length).toBe(3);
// Expand the first item ('public')
expect(await items[0].isExpanded()).toBe(false);
await items[0].click();
expect(await items[0].isExpanded()).toBe(true);
// Verifying tree structure updates after expansion
expect(await tree.getTreeStructure()).toEqual({
children: [
{
text: 'public',
children: [{text: 'index.html'}, {text: 'styles.css'}],
},
{text: 'src'},
{text: 'package.json'},
],
});
});
});
从该测试可以读出 TreeHarness 的核心 API 面:
getTreeStructure():返回整棵树的嵌套结构快照(文本 + children),是断言层级正确性的主力方法;getItems():获取当前可见的条目(折叠节点的子级不在其中),因此展开前后条目数量会变化;- 单个 item 提供
isExpanded()、click()等方法,可模拟用户展开操作后再复查结构。
harness 基于 @angular/cdk/testing 的 HarnessLoader 体系,配合 TestbedHarnessEnvironment.loader(fixture) 即可在 TestBed 中获取。
API 参考与延伸阅读
指南列出的三个核心 API(其在线文档页位于 Angular 官方站点,仓库内的 API 元数据快照可见 aria-tree.json):
Tree— 树容器指令(ngTree),承载multi、nav、selectionMode、softDisabled、value等输入;TreeItem— 树节点指令(ngTreeItem),承载parent、value、label、disabled、expanded、selectable等输入;TreeItemGroup— 子节点组指令(ngTreeItemGroup),通过ownedBy将一组子节点挂靠到父节点。
同系列指南还包括 accordion.md、grid.md、listbox.md、menubar.md、select.md、tabs.md 等组件,可对照选型;总览见 overview.md。
小结
@angular/aria/tree 的价值在于把 ARIA 树形模式中容易出错的细节——方向键漫游、Shift 范围选择、展开/折叠焦点回退、aria-current 与 selection 语义的区分、禁用项焦点策略——全部内建到三个轻量指令里。开发者只负责提供 nodes 数据与递归模板,即可获得一个键盘与屏幕阅读器双友好的层级控件;配合 TreeHarness,这些行为还能被单元测试逐条锁定。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00