首页
/ Angular @angular/aria/tree:构建符合 ARIA 规范的可访问树形控件完全指南

Angular @angular/aria/tree:构建符合 ARIA 规范的可访问树形控件完全指南

2026-09-06 14:29:34作者:宗隆裙

本文基于 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(可聚焦的禁用项)五个子目录,每个场景又提供 basicretro 两套视觉实现。

适用场景:什么时候用 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 为基准):

  1. ngTree:挂在外层 <ul> 上,构成 role="tree" 容器,通过模板引用变量 #tree="ngTree" 获取实例;
  2. ngTreeItem:挂在每个 <li><a> 上,声明 [parent](所属容器)、[value](业务值)、[label](显示文本)、[disabled][(expanded)](双向绑定展开状态);
  3. 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 中声明 TreeTreeItemTreeItemGroup 三个指令;选中值用 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/basicsingle-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/testingHarnessLoader 体系,配合 TestbedHarnessEnvironment.loader(fixture) 即可在 TestBed 中获取。

API 参考与延伸阅读

指南列出的三个核心 API(其在线文档页位于 Angular 官方站点,仓库内的 API 元数据快照可见 aria-tree.json):

  • Tree — 树容器指令(ngTree),承载 multinavselectionModesoftDisabledvalue 等输入;
  • TreeItem — 树节点指令(ngTreeItem),承载 parentvaluelabeldisabledexpandedselectable 等输入;
  • TreeItemGroup — 子节点组指令(ngTreeItemGroup),通过 ownedBy 将一组子节点挂靠到父节点。

同系列指南还包括 accordion.mdgrid.mdlistbox.mdmenubar.mdselect.mdtabs.md 等组件,可对照选型;总览见 overview.md

小结

@angular/aria/tree 的价值在于把 ARIA 树形模式中容易出错的细节——方向键漫游、Shift 范围选择、展开/折叠焦点回退、aria-current 与 selection 语义的区分、禁用项焦点策略——全部内建到三个轻量指令里。开发者只负责提供 nodes 数据与递归模板,即可获得一个键盘与屏幕阅读器双友好的层级控件;配合 TreeHarness,这些行为还能被单元测试逐条锁定。

登录后查看全文
热门项目推荐
相关项目推荐