可访问的树控件需要怎样处理键盘和层级状态?
树控件不是给列表加 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 的关系。
<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 稳定。
不要把所有方向键都无条件拦截。树节点如果是链接或包含可编辑控件,要明确树导航与原生操作如何协调;文本输入法、浏览器查找和辅助技术快捷键也不能被粗暴吞掉。
一个最小的焦点移动实现
下面只演示静态、单选树的可见节点导航。真实项目还要处理展开状态、动态加载和业务选择;函数不会把任意后代元素当成树节点:
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 契约。