Toolbar Pattern 详解:构建无障碍的工具栏组件
Toolbar(工具栏)是一种用于组合一组控件的容器,例如按钮、菜单按钮或复选框。本文基于 W3C WAI-ARIA Toolbar Pattern 规范,详解如何构建无障碍的工具栏组件。
一、Toolbar 的定义与核心概念
1.1 什么是 Toolbar
Toolbar 是一种控件分组容器,具有以下特征:
- 将一组相关控件(按钮、菜单、复选框等)视觉上分组
- 通过 role="toolbar" 向屏幕阅读器用户传达分组的存在和目的
- 减少键盘 Tab 序列中的停靠点数量
- 使用方向键在控件之间导航
- 通常包含 3 个或更多控件
1.2 核心术语
| 术语 | 说明 |
|---|---|
| Toolbar Container | 工具栏容器,包含所有控件 |
| Control | 工具栏内的控件(按钮、菜单等) |
| Roving Tabindex | 流动 Tab 索引,管理工具栏内的焦点 |
| Orientation | 工具栏方向(水平或垂直) |
1┌─────────────────────────────────────────────────────────────┐ 2│ │ 3│ ┌─────────────────────────────────────────────────────┐ │ 4│ │ │ │ 5│ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ │ 6│ │ │ Bold │ │Italic│ │Under-│ │Align │ │Font │ │ │ 7│ │ │ B │ │ I │ │line U│ │Left │ │Size │ │ │ 8│ │ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │ │ 9│ │ │ │ 10│ │ role="toolbar" │ │ 11│ │ aria-label="Formatting" │ │ 12│ │ │ │ 13│ │ Tab: Enter/Exit Toolbar │ │ 14│ │ ← → : Move Focus Between Controls │ │ 15│ │ │ │ 16│ └─────────────────────────────────────────────────────┘ │ 17│ │ 18└─────────────────────────────────────────────────────────────┘ 19
1.3 典型应用场景
- 文本编辑器工具栏:粗体、斜体、下划线、对齐等按钮
- 富文本编辑器:字体选择、颜色选择、插入链接等
- 媒体播放器:播放、暂停、音量、进度控制
- 绘图工具:画笔、橡皮擦、颜色选择器
- 表格操作:插入行、删除列、合并单元格
二、WAI-ARIA 角色与属性
2.1 基本角色
Toolbar 使用 role="toolbar" 标记容器。
1<div 2 role="toolbar" 3 aria-label="格式工具栏"> 4 <button aria-pressed="false">粗体</button> 5 <button aria-pressed="false">斜体</button> 6 <button>下划线</button> 7</div> 8
2.2 必需属性
| 属性 | 说明 | 示例值 |
|---|---|---|
| role="toolbar" | 标记工具栏容器 | - |
| aria-label / aria-labelledby | 工具栏标签 | "格式工具栏" |
2.3 可选属性
| 属性 | 说明 | 示例值 |
|---|---|---|
| aria-orientation | 工具栏方向 | "horizontal"(默认), "vertical" |
2.4 属性详解
aria-orientation
用于指定工具栏的方向:
1<!-- 水平工具栏(默认) --> 2<div 3 role="toolbar" 4 aria-label="格式工具栏"> 5 ... 6</div> 7 8<!-- 垂直工具栏 --> 9<div 10 role="toolbar" 11 aria-label="侧边工具栏" 12 aria-orientation="vertical"> 13 ... 14</div> 15
注意:
- 默认方向为水平(
horizontal) - 垂直工具栏需要显式设置
aria-orientation="vertical" - 方向影响键盘导航的行为
三、键盘交互规范
3.1 基本键盘交互
| 按键 | 功能 |
|---|---|
| Tab | 将焦点移入工具栏 |
| Shift + Tab | 将焦点移出工具栏 |
| Left Arrow | 将焦点移到上一个控件(水平工具栏) |
| Right Arrow | 将焦点移到下一个控件(水平工具栏) |
| Up Arrow | 将焦点移到上一个控件(垂直工具栏) |
| Down Arrow | 将焦点移到下一个控件(垂直工具栏) |
| Home(可选) | 将焦点移到第一个控件 |
| End(可选) | 将焦点移到最后一个控件 |
3.2 焦点管理
首次进入工具栏
- 焦点设置到第一个非禁用控件
再次进入工具栏
- 焦点可选地设置到之前获得焦点的控件
- 否则,焦点设置到第一个非禁用控件
水平工具栏导航
1┌─────────────────────────────────────────────────────────┐ 2│ │ 3│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │ 4│ │ Bold │ ←→ │Italic│ ←→ │Under-│ ←→ │Font │ │ 5│ │ B │ │ I │ │line U│ │Size │ │ 6│ └──────┘ └──────┘ └──────┘ └──────┘ │ 7│ │ 8│ Left Arrow: Previous Control │ 9│ Right Arrow: Next Control │ 10│ (Optional: Focus Wraps from First/Last) │ 11│ │ 12└─────────────────────────────────────────────────────────┘ 13
垂直工具栏导航
1┌─────────────────────────┐ 2│ │ 3│ ┌──────┐ │ 4│ │ Bold │ │ 5│ │ B │ │ 6│ └──────┘ │ 7│ ↑↓ │ 8│ ┌──────┐ │ 9│ │Italic│ │ 10│ │ I │ │ 11│ └──────┘ │ 12│ ↑↓ │ 13│ ┌──────┐ │ 14│ │Under-│ │ 15│ │line U│ │ 16│ └──────┘ │ 17│ │ 18│ Up Arrow: Previous │ 19│ Down Arrow: Next │ 20│ │ 21└─────────────────────────┘ 22
3.3 方向键的复用
在水平工具栏中:
- Left/Right Arrow:在控件间导航
- Up/Down Arrow:可以复用 Left/Right Arrow 的功能,或保留给需要垂直方向键操作的控件(如 Spinbutton)
在垂直工具栏中:
- Up/Down Arrow:在控件间导航
- Left/Right Arrow:可以复用 Up/Down Arrow 的功能,或保留给需要水平方向键操作的控件(如 Slider)
3.4 禁用控件的焦点
通常,禁用控件在键盘导航中不可聚焦。但在某些情况下,如果发现功能至关重要,可以让禁用控件可聚焦,以便屏幕阅读器用户了解其存在。
四、实现方式
4.1 基础 Toolbar 结构
1<div 2 role="toolbar" 3 aria-label="文本格式工具栏"> 4 <button 5 id="bold-btn" 6 aria-pressed="false" 7 tabindex="0"> 8 粗体 9 </button> 10 <button 11 id="italic-btn" 12 aria-pressed="false" 13 tabindex="-1"> 14 斜体 15 </button> 16 <button 17 id="underline-btn" 18 tabindex="-1"> 19 下划线 20 </button> 21 <button 22 id="align-left-btn" 23 aria-pressed="true" 24 tabindex="-1"> 25 左对齐 26 </button> 27 <button 28 id="align-center-btn" 29 aria-pressed="false" 30 tabindex="-1"> 31 居中对齐 32 </button> 33</div> 34
4.2 JavaScript 实现
1class Toolbar { 2 constructor(element) { 3 this.toolbar = element; 4 this.controls = Array.from( 5 this.toolbar.querySelectorAll( 6 'button, [role="button"], input, select, [role="checkbox"], [role="radio"]', 7 ), 8 ); 9 this.orientation = 10 this.toolbar.getAttribute('aria-orientation') || 'horizontal'; 11 12 this.init(); 13 } 14 15 init() { 16 // 键盘事件 17 this.toolbar.addEventListener('keydown', this.handleKeyDown.bind(this)); 18 19 // 焦点管理 20 this.controls.forEach((control, index) => { 21 control.addEventListener('focus', () => this.setFocusedControl(index)); 22 }); 23 } 24 25 handleKeyDown(e) { 26 const currentIndex = this.controls.indexOf(document.activeElement); 27 28 if (currentIndex === -1) return; 29 30 let nextIndex = -1; 31 32 switch (e.key) { 33 case 'ArrowLeft': 34 if (this.orientation === 'horizontal') { 35 e.preventDefault(); 36 nextIndex = this.getPreviousIndex(currentIndex); 37 } 38 break; 39 case 'ArrowRight': 40 if (this.orientation === 'horizontal') { 41 e.preventDefault(); 42 nextIndex = this.getNextIndex(currentIndex); 43 } 44 break; 45 case 'ArrowUp': 46 if (this.orientation === 'vertical') { 47 e.preventDefault(); 48 nextIndex = this.getPreviousIndex(currentIndex); 49 } 50 break; 51 case 'ArrowDown': 52 if (this.orientation === 'vertical') { 53 e.preventDefault(); 54 nextIndex = this.getNextIndex(currentIndex); 55 } 56 break; 57 case 'Home': 58 e.preventDefault(); 59 nextIndex = 0; 60 break; 61 case 'End': 62 e.preventDefault(); 63 nextIndex = this.controls.length - 1; 64 break; 65 } 66 67 if (nextIndex !== -1) { 68 this.focusControl(nextIndex); 69 } 70 } 71 72 getPreviousIndex(currentIndex) { 73 // 循环到末尾 74 return currentIndex === 0 ? this.controls.length - 1 : currentIndex - 1; 75 } 76 77 getNextIndex(currentIndex) { 78 // 循环到开头 79 return currentIndex === this.controls.length - 1 ? 0 : currentIndex + 1; 80 } 81 82 focusControl(index) { 83 const control = this.controls[index]; 84 if (control && !control.disabled) { 85 control.focus(); 86 } 87 } 88 89 setFocusedControl(index) { 90 // 更新 tabindex,实现 roving tabindex 91 this.controls.forEach((control, i) => { 92 control.setAttribute('tabindex', i === index ? '0' : '-1'); 93 }); 94 } 95} 96 97// 初始化 98const toolbar = document.querySelector('[role="toolbar"]'); 99new Toolbar(toolbar); 100
4.3 富文本编辑器 Toolbar 示例
1<div 2 role="toolbar" 3 aria-label="富文本编辑器工具栏"> 4 <!-- 文本样式 --> 5 <button 6 id="bold" 7 aria-pressed="false" 8 tabindex="0"> 9 <span aria-hidden="true">B</span> 10 <span class="sr-only">粗体</span> 11 </button> 12 <button 13 id="italic" 14 aria-pressed="false" 15 tabindex="-1"> 16 <span aria-hidden="true">I</span> 17 <span class="sr-only">斜体</span> 18 </button> 19 <button 20 id="underline" 21 aria-pressed="false" 22 tabindex="-1"> 23 <span aria-hidden="true">U</span> 24 <span class="sr-only">下划线</span> 25 </button> 26 27 <!-- 分隔符 --> 28 <span 29 role="separator" 30 aria-orientation="vertical"></span> 31 32 <!-- 对齐方式 --> 33 <button 34 id="align-left" 35 aria-pressed="true" 36 tabindex="-1"> 37 <span class="sr-only">左对齐</span> 38 </button> 39 <button 40 id="align-center" 41 aria-pressed="false" 42 tabindex="-1"> 43 <span class="sr-only">居中对齐</span> 44 </button> 45 <button 46 id="align-right" 47 aria-pressed="false" 48 tabindex="-1"> 49 <span class="sr-only">右对齐</span> 50 </button> 51 52 <!-- 分隔符 --> 53 <span 54 role="separator" 55 aria-orientation="vertical"></span> 56 57 <!-- 字体大小 --> 58 <label for="font-size">字体大小</label> 59 <select 60 id="font-size" 61 tabindex="-1"> 62 <option value="12">12px</option> 63 <option value="14">14px</option> 64 <option 65 value="16" 66 selected> 67 16px 68 </option> 69 <option value="18">18px</option> 70 </select> 71</div> 72
4.4 垂直 Toolbar 示例
1<div 2 role="toolbar" 3 aria-label="绘图工具栏" 4 aria-orientation="vertical"> 5 <button 6 id="brush" 7 aria-pressed="true" 8 tabindex="0"> 9 <span class="sr-only">画笔</span> 10 </button> 11 <button 12 id="eraser" 13 aria-pressed="false" 14 tabindex="-1"> 15 <span class="sr-only">橡皮擦</span> 16 </button> 17 <button 18 id="line" 19 aria-pressed="false" 20 tabindex="-1"> 21 <span class="sr-only">直线</span> 22 </button> 23 <button 24 id="rectangle" 25 aria-pressed="false" 26 tabindex="-1"> 27 <span class="sr-only">矩形</span> 28 </button> 29 <button 30 id="circle" 31 aria-pressed="false" 32 tabindex="-1"> 33 <span class="sr-only">圆形</span> 34 </button> 35</div> 36
五、最佳实践
5.1 控件数量
仅在包含 3 个或更多控件时使用 Toolbar:
1<!-- 好的示例:3 个以上控件 --> 2<div 3 role="toolbar" 4 aria-label="格式工具栏"> 5 <button>粗体</button> 6 <button>斜体</button> 7 <button>下划线</button> 8</div> 9 10<!-- 不好的示例:控件太少,不需要 Toolbar --> 11<div 12 role="toolbar" 13 aria-label="操作"> 14 <button>保存</button> 15</div> 16
5.2 避免冲突的控件
避免包含需要与 Toolbar 导航方向冲突的控件:
1<!-- 错误:水平工具栏中包含需要左右方向键的 Spinbutton --> 2<!-- 问题:用户按 Left/Right 时,Toolbar 和 Spinbutton 都要响应,冲突! --> 3<div 4 role="toolbar" 5 aria-label="错误示例"> 6 <button>加粗</button> 7 <button>斜体</button> 8 <div 9 role="spinbutton" 10 aria-label="字体大小" 11 aria-valuenow="16"> 12 16px 13 </div> 14 <!-- 冲突! --> 15</div> 16 17<!-- 正确:避免使用方向键冲突的控件,或改用不冲突的替代方案 --> 18<div 19 role="toolbar" 20 aria-label="正确示例"> 21 <button>加粗</button> 22 <button>斜体</button> 23 <!-- 用 select 替代 spinbutton:select 只有展开后才占用方向键,平时不冲突 --> 24 <select aria-label="字体大小"> 25 <option>12px</option> 26 <option selected>16px</option> 27 <option>20px</option> 28 </select> 29</div> 30
5.3 使用 Roving Tabindex
通过 Roving Tabindex 管理焦点,确保只有一个控件在 Tab 序列中:
1// 初始化时,只有第一个控件 tabindex="0" 2// 其他控件 tabindex="-1" 3 4// 当焦点移动时,更新 tabindex 5setFocusedControl(index) { 6 this.controls.forEach((control, i) => { 7 control.setAttribute('tabindex', i === index ? '0' : '-1'); 8 }); 9} 10
5.4 提供快捷键
在需要快速访问工具栏的应用中,提供快捷键:
1// 从文本区域快速跳转到工具栏 2document.addEventListener('keydown', (e) => { 3 if (e.altKey && e.key === 'f10') { 4 e.preventDefault(); 5 const toolbar = document.querySelector('[role="toolbar"]'); 6 const firstControl = toolbar.querySelector('[tabindex="0"]'); 7 firstControl.focus(); 8 } 9}); 10
5.5 视觉与语义一致
确保工具栏的视觉呈现与 ARIA 语义一致:
1<!-- 视觉上是分组,语义上也是分组 --> 2<div 3 role="toolbar" 4 aria-label="对齐工具栏" 5 class="toolbar-group"> 6 <button aria-pressed="true">左对齐</button> 7 <button aria-pressed="false">居中对齐</button> 8 <button aria-pressed="false">右对齐</button> 9</div> 10
5.6 处理禁用控件
1<!-- 禁用控件通常不可聚焦 --> 2<button disabled>不可用</button> 3 4<!-- 但在需要发现功能时,可以让其可聚焦 --> 5<button 6 aria-disabled="true" 7 tabindex="-1"> 8 即将推出 9</button> 10
六、常见错误
6.1 忘记设置 role="toolbar"
1<!-- 错误 --> 2<div class="toolbar"> 3 <button>粗体</button> 4 <button>斜体</button> 5</div> 6 7<!-- 正确 --> 8<div 9 role="toolbar" 10 aria-label="格式工具栏"> 11 <button>粗体</button> 12 <button>斜体</button> 13</div> 14
6.2 所有控件都 tabindex="0"
1<!-- 错误:所有控件都在 Tab 序列中 --> 2<div role="toolbar"> 3 <button tabindex="0">按钮 1</button> 4 <button tabindex="0">按钮 2</button> 5 <button tabindex="0">按钮 3</button> 6</div> 7 8<!-- 正确:只有一个控件在 Tab 序列中 --> 9<div role="toolbar"> 10 <button tabindex="0">按钮 1</button> 11 <button tabindex="-1">按钮 2</button> 12 <button tabindex="-1">按钮 3</button> 13</div> 14
6.3 控件太少使用 Toolbar
1<!-- 错误:只有 2 个控件 --> 2<div 3 role="toolbar" 4 aria-label="操作"> 5 <button>保存</button> 6 <button>取消</button> 7</div> 8 9<!-- 正确:直接放置,不使用 Toolbar --> 10<button>保存</button> 11<button>取消</button> 12
6.4 方向键冲突
1<!-- 错误:水平工具栏中包含需要左右方向键的控件 --> 2<div 3 role="toolbar" 4 aria-label="错误示例"> 5 <button>按钮</button> 6 <div 7 role="spinbutton" 8 aria-label="字体大小"> 9 16 10 </div> 11 <!-- 冲突! --> 12</div> 13
七、Toolbar vs 其他组件
7.1 Toolbar vs Menu
| 特性 | Toolbar | Menu |
|---|---|---|
| 结构 | 平级控件 | 层级结构 |
| 交互 | 直接操作 | 选择后执行 |
| 持久性 | 始终可见 | 通常需要触发 |
| 典型用例 | 格式工具栏 | 下拉菜单 |
7.2 Toolbar vs Button Group
| 特性 | Toolbar | Button Group |
|---|---|---|
| 键盘导航 | 方向键 | Tab 键 |
| 焦点管理 | Roving Tabindex | 独立焦点 |
| Tab 序列 | 一个停靠点 | 多个停靠点 |
| 适用场景 | 频繁操作的工具 | 相关操作的分组 |
八、总结
构建无障碍的 Toolbar 组件需要关注:
- 正确的角色:使用
role="toolbar" - 标签:使用
aria-label或aria-labelledby - 方向:使用
aria-orientation指定方向 - 焦点管理:使用 Roving Tabindex 减少 Tab 停靠点
- 键盘导航:方向键在控件间移动焦点
- 控件数量:仅在 3 个以上控件时使用
- 避免冲突:避免包含与导航方向冲突的控件
- 快捷键:在需要快速访问时提供快捷键
遵循 W3C Toolbar Pattern 规范,我们能够创建既实用又无障碍的工具栏组件,为所有用户提供高效的操作体验。
文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。
《构建无障碍组件之Toolbar Pattern》 是转载文章,点击查看原文。