CKEditor 4 工具栏 Toggle 按钮的无障碍实现:`aria-pressed` 状态与屏幕阅读器验证指南
CKEditor 4 工具栏 Toggle 按钮的无障碍实现:aria-pressed 状态与屏幕阅读器验证指南
导读
本文以 CKEditor 4 官方仓库中的屏幕阅读器手动测试文档(tests/plugins/button/manual/togglebuttonscreenreader.md)为核心,系统讲解工具栏「切换型按钮」(toggle button)在无障碍可访问性(ARIA)层面的设计与验证方法:包括 isToggle 按钮定义、aria-pressed 属性的动态维护机制,以及如何使用键盘与屏幕阅读器(VoiceOver、NVDA、JAWS)完成端到端验收。读完本文,你将掌握 CKEditor 4 中切换按钮的完整无障碍行为模型,并能复现官方手动测试与自动化测试来验证自己的自定义按钮。
背景:切换型按钮(Toggle Button)与可访问性树
工具栏中的「加粗」「斜体」这类按钮具有两种稳定状态——按下(pressed/on)与未按下(unpressed/off)。这类按钮在 WAI-ARIA 中被定义为 toggle button,需要通过 aria-pressed 属性向辅助技术(屏幕阅读器)暴露当前状态,而不是简单依赖视觉样式(如高亮背景)来传达状态。
在 CKEditor 4.19.0 之前,工具栏按钮在浏览器的可访问性树中只被暴露为普通按钮,屏幕阅读器无法告知用户该按钮是否处于激活状态。该问题由 GitHub issue #2444 追踪,并在 4.19.0 版本中得到修复——官方变更记录明确指出:
#2444:Togglable toolbar buttons are now exposed as toggle buttons in the browser's accessibility tree.(可切换的工具栏按钮现在在浏览器的可访问性树中暴露为切换按钮。)
实现位于 plugins/button/plugin.js:当按钮定义包含 isToggle: true 时,渲染模板会输出 aria-pressed="false" 初始属性,并在状态变化时动态同步该属性。
屏幕阅读器手动测试:官方验收步骤
官方为本次特性提供了专门面向屏幕阅读器的手动测试页面 togglebuttonscreenreader.html 与测试说明文档 togglebuttonscreenreader.md。该页面声明依赖插件:wysiwygarea、toolbar、link(通过 @bender-ckeditor-plugins 标签),并用 @bender-ui: collapsed 控制测试 UI 的展示形态。
测试步骤与预期结果
测试页中注册了一个名为 toggleButton 的切换按钮,绑定命令在 CKEDITOR.TRISTATE_OFF 与 CKEDITOR.TRISTATE_ON 之间往返切换。手动测试的完整步骤与预期如下:
- 将焦点移动到编辑器工具栏中的按钮上
- 预期:屏幕阅读器将该按钮播报为「可切换」(togglable),且当前为关闭/未按下(off/unpressed)。
- 通过键盘激活该按钮(回车或空格)
- 将焦点移回该按钮
- 预期:屏幕阅读器播报为「可切换」,且当前为打开/已按下(on/pressed)。
- 再次通过键盘激活该按钮
- 将焦点移回该按钮
- 预期:屏幕阅读器播报为「可切换」,且当前回到关闭/未按下(off/unpressed)。
注意测试文档开头标注:This test is intended for screen reader(本测试专为屏幕阅读器设计),即该用例的价值在于验证辅助技术端的实际播报,而非单纯检查 DOM 属性。
各主流屏幕阅读器的示例播报
原文档同时给出了三个主流屏幕阅读器的典型输出,可直接用于验收对照:
| 屏幕阅读器 | 未按下(off) | 已按下(on) |
|---|---|---|
| VoiceOver(macOS) | <button name>, toggle button |
<button name>, toggle button, selected |
| NVDA | <button name>, toggle button, not pressed |
<button name>, toggle button, pressed |
| JAWS | <button name>, toggle button |
<button name>, toggle button, pressed |
其中 <button name> 为按钮的可访问名称(即 label,通过 aria-labelledby 关联到 cke_button_label 元素,见 plugins/button/plugin.js)。可以看到三家屏幕阅读器均会播报「toggle button」语义,并用各自的措辞区分 pressed 状态——这正是 aria-pressed 正确暴露后的效果。
源码实现:aria-pressed 的渲染与同步
1. 按钮模板的初始输出
在 plugins/button/plugin.js 的 render() 方法中,按钮模板参数包含:
toggleAriaHtml: this.isToggle ? 'aria-pressed="false"' : ''
即:只有当按钮定义中显式声明 isToggle: true 时,生成的 <a class="cke_button ..."> 元素才会带 aria-pressed="false" 初始值。该属性被注入模板中的 {toggleAriaHtml} 占位符(plugins/button/plugin.js)。
同时模板中还固定输出了其他无障碍属性:
role="button"
aria-labelledby="{id}_label"
aria-describedby="{id}_description"
aria-haspopup="{hasArrow}"
aria-disabled="{ariaDisabled}"
2. 状态切换时的动态同步
按钮状态由 setState() 方法维护(plugins/button/plugin.js)。当状态变化时,除了切换 cke_button_on/off/disabled CSS 类之外,还会同步 ARIA 属性:
if ( this.isToggle && !this.hasArrow ) {
// Note: aria-pressed attribute should not be added to menuButton instances. (ticket #11331)
// For other buttons, do not remove the attribute, instead set its value (#2444).
element.setAttribute( 'aria-pressed', state === CKEDITOR.TRISTATE_ON );
}
两个关键设计点:
aria-pressed只在isToggle且无下拉箭头(!hasArrow)的按钮上维护。带菜单的按钮(menu button)不应使用aria-pressed,而是通过aria-expanded表达菜单开合状态——该限制源自 ticket #11331,并在代码注释中明确说明。- 按 #2444 的要求,状态变化时是「更新值」而非「移除属性」:始终将
aria-pressed设置为true(TRISTATE_ON)或false(其余状态),保证可访问性树中按钮始终以 toggle button 语义呈现。
命令层的状态机定义在 core/command.js:TRISTATE_DISABLED(禁用)、TRISTATE_ON(启用且激活)、TRISTATE_OFF(启用且未激活)。按钮通过监听命令的 state 事件同步自身状态(plugins/button/plugin.js)。
3. 与底层 CKEDITOR.dom.element#setState 的关系
值得区分的是:按钮 UI 的 setState 与 DOM 元素的 setState(core/dom/element.js)职责不同。DOM 层的 setState(state, base, useAria) 在 useAria 为真时会切换 aria-pressed/aria-disabled 属性,但按钮 UI 层为了满足 #2444(不删除属性、仅更新值)以及 #11331(menu button 不加 aria-pressed),在 plugins/button/plugin.js 中先调用 element.setState( state, 'cke_button' ) 完成类名切换,再自行精确控制 aria-disabled 与 aria-pressed 的赋值。
如何定义一个 Toggle 按钮
isToggle 是按钮定义的可选属性,自 4.19.0 版本起支持(见 plugins/button/plugin.js 的 API 文档)。手动测试页 togglebuttonscreenreader.html 给出了最小可复现示例:
CKEDITOR.replace( 'editor', {
toolbar: [ [ 'toggleButton' ] ],
on: {
pluginsLoaded: function( evt ) {
evt.editor.addCommand( 'toggleButton', {
exec: function() {
if ( this.state === CKEDITOR.TRISTATE_OFF ) {
this.setState( CKEDITOR.TRISTATE_ON );
} else {
this.setState( CKEDITOR.TRISTATE_OFF );
}
}
} );
evt.editor.ui.addButton( 'toggleButton', {
label: 'Sample button',
icon: 'Link',
isToggle: true,
command: 'toggleButton'
} );
}
}
} );
要点:
isToggle: true使按钮在无障碍语义上成为切换按钮;command指向一个自定义命令,其exec在TRISTATE_OFF与TRISTATE_ON之间切换命令状态;- 按钮监听命令状态变化自动刷新自身 UI 与
aria-pressed。
实际产品中,basicstyles 插件(加粗、斜体、下划线、删除线等)正是通过 isToggle: true 声明按钮的,见 plugins/basicstyles/plugin.js:
editor.ui.addButton( buttonName, {
isToggle: true,
label: buttonLabel,
command: commandName,
toolbar: 'basicstyles,' + ( order += 10 )
} );
其余使用 isToggle 的内置插件还包括 blockquote、justify、list、bidi、maximize、sourcearea、copyformatting 等(可在 plugins 目录下检索 isToggle 确认)。
自动化回归:Bender 测试对 ARIA 的守护
除了手动测试,仓库还提供了自动化测试来防止 aria-pressed 行为回归:
- tests/plugins/button/aria.js 通过
buttonTools.createAriaPressedTests()为toggle_btn生成 4 个用例:- 初始状态 →
aria-pressed="false"; - 切换到
TRISTATE_ON→aria-pressed="true"; - 切换回
TRISTATE_OFF→aria-pressed="false"; - 先
TRISTATE_ON再TRISTATE_DISABLED→ 仍为"false"(禁用态不再表达为按下)。
- 初始状态 →
- 测试辅助工具定义在 tests/plugins/button/_helpers/buttontools.js,
assertAttributes()同时校验role="button"、aria-disabled、aria-haspopup、aria-labelledby等标准属性(tests/plugins/button/_helpers/buttontools.js)。
这些用例与手动测试互补:自动化保证 DOM 层 aria-pressed 数值正确,手动测试保证屏幕阅读器实际播报符合预期。
配套手动测试与延伸阅读
围绕按钮无障碍行为,仓库还提供了多组关联手动测试,可一起运行验证:
- togglebutton.md:单按钮场景下,页面上方实时显示
aria-pressed值,点击按钮后应依次为false → true → false; - togglebuttons.md:在带完整工具栏(
basicstyles、bidi、blockquote、copyformatting、justify、list、sourcearea等)的编辑器中,用表格实时列出每个按钮的aria-pressed值,验证真实环境下状态同步; - menubuttonscreenreader.md:验证带菜单按钮(menu button)不误报为 pressed/expanded,涉及
aria-haspopup与aria-expanded(对应 4.19.1 的 #5144 修复,见 ariaexpanded.md); - label.md:验证按钮标签与菜单开合时的朗读行为(
aria-expanded、键盘alt+f10聚焦工具栏、space打开、esc关闭)。
这些测试均基于 Bender 手动测试框架运行(@bender-tags、@bender-ui 为 Bender 标签语法,测试框架配置见 bender.js 与 package.json)。
常见问题与验收要点
- 为什么我的自定义按钮屏幕阅读器不播报 toggle 状态? 检查按钮定义是否包含
isToggle: true——没有该声明,渲染模板不会输出aria-pressed,按钮在可访问性树中只是普通按钮。 - 带下拉菜单的按钮是否需要
aria-pressed? 不需要。代码注释明确限制this.isToggle && !this.hasArrow(plugins/button/plugin.js),菜单按钮应使用aria-expanded表达展开状态。 - 按钮禁用时
aria-pressed应是什么值? 按自动化测试的断言(tests/plugins/button/_helpers/buttontools.js),禁用态下aria-pressed保持"false",同时aria-disabled="true"。 - 验收标准:以本文「测试步骤与预期结果」一节为准,至少覆盖 VoiceOver、NVDA、JAWS 三家中你目标用户常用的一款,并核对上表中对应的播报文案。