前端知库

可访问的树控件需要怎样处理键盘和层级状态?

树控件不是给列表加 role=tree 就结束了,需要同步层级、展开状态、选中状态和 roving tabindex,并处理懒加载、焦点恢复和读屏反馈。

参考答案

简答

  • 只需要展示静态目录时,原生链接和标题结构更简单;需要方向键展开、折叠和选择时才实现 tree 模式。
  • 容器用 role="tree",节点用 treeitem,子节点容器用 group,有子树的节点用 aria-expanded 表达展开状态。
  • aria-level 表达逻辑层级,叶节点不应设置 aria-expanded。
  • 通常使用 roving tabindex:只有当前节点 tabindex="0",方向键移动时更新焦点。

文件管理器、组织架构和权限目录都有父子层级。用户不仅要看见缩进,还要能用键盘展开、收起、移动和选择节点;读屏用户还要知道当前节点的层级、展开状态以及是否有子节点。

如果只是展示一棵静态目录,原生链接和标题结构往往更简单。只有当用户需要像树一样用方向键操作、展开折叠和选择节点时,才需要实现 Tree View 模式。role="tree" 不会自动提供任何键盘行为。

先把节点语义和状态分开

典型结构是容器使用 role="tree",可操作节点使用 role="treeitem",子节点容器使用 role="group"。拥有子节点的节点要表达 aria-expanded;可多选树还要明确 aria-multiselectable 与 aria-selected 的关系。

HTML
<ul id="file-tree" role="tree" aria-label="项目文件">
  <li role="treeitem" aria-expanded="true" aria-level="1" tabindex="0">
    src
    <ul role="group">
      <li role="treeitem" aria-level="2" tabindex="-1">main.ts</li>
      <li role="treeitem" aria-level="2" tabindex="-1">styles.css</li>
    </ul>
  </li>
</ul>

aria-level 表达节点的逻辑层级,不一定等于 DOM 的嵌套深度;如果 DOM 结构已经完整,浏览器可能推断部分层级,但动态或虚拟树仍应按实现需要明确提供。aria-posinset 和 aria-setsize 在无法从 DOM 推断同级集合时也需要正确更新,不能用固定数字填充。

aria-expanded="false" 表示节点有可展开的子树但当前收起;叶节点通常不应伪造这个属性。节点是否被选中、是否有焦点和是否正在加载,也不是同一个状态,不能用一个 selected 布尔值承担全部含义。

键盘行为要先选定模式

单选树常见的按键规则如下:

按键典型行为
ArrowDown / ArrowUp移动到下一个或上一个可见节点
ArrowRight收起节点时展开;已展开时进入第一个可见子节点
ArrowLeft已展开时收起;已收起时移动到父节点
Home / End移到当前树的第一个或最后一个可见节点
Enter执行节点的默认操作或选择节点,具体含义要统一
*在支持该模式时展开当前层级的节点
字符键按可见名称定位节点,可选但要明确支持范围
Tab进入树时聚焦当前节点,离开树时离开整个控件

树控件通常使用 roving tabindex:只有当前焦点节点是 tabindex="0",其他可见节点为 -1。方向键移动时更新 DOM focus 和 tabindex,不能让几十个节点同时进入页面 Tab 顺序。另一种方案是让焦点留在树容器,通过 aria-activedescendant 指向活动节点,但这要求活动节点始终存在且 ID 稳定。

不要把所有方向键都无条件拦截。树节点如果是链接或包含可编辑控件,要明确树导航与原生操作如何协调;文本输入法、浏览器查找和辅助技术快捷键也不能被粗暴吞掉。

一个最小的焦点移动实现

下面只演示静态、单选树的可见节点导航。真实项目还要处理展开状态、动态加载和业务选择;函数不会把任意后代元素当成树节点:

JavaScript
function bindTreeNavigation(tree) {
  const controller = new AbortController();

  function items() {
    return [...tree.querySelectorAll('[role="treeitem"]')]
      .filter((item) => !item.closest('[hidden]'));
  }

  function focusItem(item) {
    const visible = items();
    if (!visible.includes(item)) return;
    visible.forEach((node) => node.setAttribute('tabindex', node === item ? '0' : '-1'));
    item.focus();
  }

  tree.addEventListener('keydown', (event) => {
    const current = event.target.closest('[role="treeitem"]');
    if (!current || !tree.contains(current)) return;

    const visible = items();
    const index = visible.indexOf(current);
    if (index < 0) return;

    let next;
    if (event.key === 'ArrowDown') next = visible[index + 1];
    if (event.key === 'ArrowUp') next = visible[index - 1];
    if (event.key === 'Home') next = visible[0];
    if (event.key === 'End') next = visible.at(-1);
    if (!next) return;

    event.preventDefault();
    focusItem(next);
  }, { signal: controller.signal });

  return () => controller.abort();
}

示例通过 [hidden] 排除收起子树,但真实项目可能使用 aria-hidden、虚拟化或完全移除节点;仅设置透明度和 pointer-events:none 不会让内容从可访问性树中消失。节点列表发生变化后,还要检查当前焦点节点是否仍存在,必要时把焦点移到父节点或合理的相邻节点。

选择、聚焦和激活是三件事

树可能是导航树、单选树、多选树或文件选择器。导航树的 Enter 可能打开链接;文件树的 Enter 可能进入编辑;多选树还要定义是否使用 Ctrl/Command、Shift 或 aria-selected 的锚点范围。不能把“获得焦点”自动当成“已选中”,也不能让鼠标和键盘使用不同的选择规则。

如果树节点有复选框,优先让复选框承担可操作语义,并明确父节点的全选、部分选中和禁用状态。把复选框视觉上画在 treeitem 里,却让整个节点同时拥有互相冲突的点击和键盘行为,会让读屏和指针操作都难以预测。

拖放排序、上下文菜单和批量操作也应提供键盘路径。只支持鼠标拖动的树不能称为完整的可访问文件管理器。

异步子节点要处理过期结果

点击展开目录后,子节点可能需要请求服务器。加载中、空目录、加载失败和已展开不是同一个状态;“正在加载”不应伪装成可以被 Enter 选中的文件节点。

请求要绑定节点 ID 和当前展开代次。用户快速收起、切换账号或重新加载目录后,旧响应不能把子节点写回新状态。取消请求可以节省资源,但仍需要版本检查,因为并非所有数据源都支持取消。

树的权限也不能由隐藏节点代替。服务端返回的目录和文件仍需按当前用户授权;前端没有显示某节点不代表接口不会被直接调用。懒加载错误应保留重试入口,并让用户知道是目录加载失败,而不是把整个树变成空白。

虚拟树和无障碍信息要同步

大型树可能只渲染可见节点,但活动节点必须真实存在,aria-activedescendant 不能指向已经卸载的元素。滚动到目标节点时,应先更新窗口、等待节点挂载,再设置焦点或活动后代。

如果 aria-setsize、aria-posinset 或层级信息来自服务端,插入、删除和过滤后必须重新计算。把当前屏幕上的 20 个节点都写成 posinset=1..20,会让用户误以为整棵树只有 20 项。

验收不能只看 DOM 属性

至少要用键盘完成进入树、上下移动、展开、收起、选择和离开;再测试空目录、错误重试、异步竞态、长名称、重复名称、禁用节点、窄屏和文字放大。用常用读屏器确认树名称、节点层级、展开状态和选择状态是否能被理解。

自动化检查可以发现缺少角色、重复 ID 或无法聚焦的节点,但不能替代真实读屏路径。若原生列表、链接和按钮已经能完成任务,优先保留原生语义;只有确实需要树导航时,才承担完整 Tree View 契约。