CKEditor 4 工具栏按钮 `aria-expanded` 无障碍状态剖析:从菜单开关同步到手动测试验证
CKEditor 4 工具栏按钮 aria-expanded 无障碍状态剖析:从菜单开关同步到手动测试验证
CKEditor 4 的工具栏按钮(包括普通按钮、菜单按钮、面板按钮与下拉组合框)通过 aria-expanded 属性向屏幕阅读器准确传达"弹出菜单是否已打开"这一状态。本文以仓库中 tests/plugins/button/manual/ariaexpanded.md 手动测试为线索,结合 button、menubutton、panelbutton、richcombo 等插件的源码实现与自动化测试用例,完整讲解该属性在渲染、状态同步、测试验证三个层面的工作原理,帮助你理解并复现这一无障碍修复,也为自定义按钮插件的 ARIA 实现提供可直接对照的范本。
背景:为什么按钮需要 aria-expanded
在 WAI-ARIA 语义中,一个可以展开弹出内容(菜单、面板、下拉列表)的控件,应当通过 aria-expanded="true" / aria-expanded="false" 告知辅助技术当前是否处于展开状态。CKEditor 4 的工具栏中存在大量这类控件,例如:
- 菜单按钮(
menubutton):点击后弹出菜单,如语言(Language)按钮; - 面板按钮(
panelbutton):点击后弹出面板,如文本颜色、背景颜色按钮; - 下拉组合框(
richcombo):点击后展开列表,如字体、字号、格式下拉框。
在 4.19.1 版本之前,这些按钮在浏览器无障碍树(accessibility tree)中会错误地报告弹出菜单的打开状态,这正是变更日志 CHANGES.md 中记录的 bug:
[#5144]: Fixed: Menu buttons and panel buttons incorrectly indicate the open status of their associated pop-up menus in the browser's accessibility tree.
该修复与更早的 #421(可展开按钮被点击后 label 尾部错误追加 "(Selected)" 文本)同属一条无障碍优化主线。而 tests/plugins/button/manual/ariaexpanded.md 正是为验证 #5144 修复而编写的手动测试:它要求检查编辑器下方展示的各按钮 [aria-expanded] 属性值,期望"菜单打开时值为 true,菜单关闭时值为 false"。
初始渲染:带下拉箭头的按钮默认 aria-expanded="false"
所有工具栏按钮的 HTML 骨架都由 plugins/button/plugin.js 中的模板生成。模板中与 ARIA 展开状态相关的部分是:
var template = '<a id="{id}"' +
' class="cke_button cke_button__{name} cke_button_{state} {cls}"' +
...
' aria-haspopup="{hasArrow}"' +
' aria-disabled="{ariaDisabled}"' +
'{hasArrowAriaHtml}' +
'{toggleAriaHtml}';
其中 hasArrowAriaHtml 的取值规则位于 plugins/button/plugin.js:
hasArrowAriaHtml: this.hasArrow ? ' aria-expanded="false"' : '',
也就是说:只有带下拉箭头(hasArrow 为真)的按钮才会渲染 aria-expanded 属性,初始值恒为 false;不带箭头的普通按钮(如加粗、斜体)不会出现该属性。这与自动化测试 tests/plugins/button/aria.js 的断言完全一致:
// (#5144)
'test button label with arrow has [aria-expanded] attribute added with the default value of false':
function() {
var button = buttonTools.getUiItem( this.editor, 'arrow2_btn' ),
buttonElement = buttonTools.getButtonDomElement( button ),
ariaExpanded = buttonElement.getAttribute( 'aria-expanded' );
assert.areEqual( 'false', ariaExpanded, '[aria-expanded] value is incorrect' );
}
hasArrow 参数:Boolean 与 String 两种形态
hasArrow 是按钮定义(definition)上的关键参数,官方注释在 plugins/button/plugin.js 中说明:
If Boolean, it indicates whether the button should have a dropdown. If a string, it acts as a value of the button's
aria-haspopupattribute. Since 4.11.0 it supports the string as a value.
结合渲染逻辑 plugins/button/plugin.js:
hasArrow: typeof this.hasArrow === 'string' && this.hasArrow || ( this.hasArrow ? 'true' : 'false' ),
可以归纳出三种典型用法:
hasArrow 取值 |
aria-haspopup 渲染值 |
aria-expanded |
典型按钮 |
|---|---|---|---|
未设置 / false |
false |
不渲染 | 普通命令按钮(Bold 等) |
true |
true |
初始 false,随菜单状态切换 |
通用下拉按钮 |
字符串(如 'menu'、'listbox') |
字符串本身 | 初始 false,随菜单状态切换 |
菜单按钮 / 面板按钮 |
字符串形式的 aria-haspopup 符合 WAI-ARIA 1.1 新增的取值枚举(menu、listbox、dialog 等),对应自动化测试 tests/plugins/button/aria.js:
// WAI-ARIA 1.1 has added new values for aria-haspopup property (#2072).
'test aria-haspopup': function() {
var btn = buttonTools.getUiItem( this.editor, 'haspopup_btn' ),
btnEl = CKEDITOR.document.getById( btn._.id );
assert.areEqual( btnEl.getAttribute( 'aria-haspopup' ), 'menu' );
}
运行时状态同步:三类弹出控件的实现
aria-expanded 初始为 false 只是起点,关键是如何在菜单打开/关闭时把属性值同步为 true / false。不同插件各自在弹出控件的生命周期回调中维护该属性。
菜单按钮(menubutton):点击与 hide 两个时机
plugins/menubutton/plugin.js 中,点击处理函数 clickFn 在打开菜单时立即标记:
this.setState( CKEDITOR.TRISTATE_ON );
_.on = 1;
// Indicates that menu button is open (#421, #5144).
buttonElement.setAttribute( 'aria-expanded', 'true' );
同时,菜单隐藏回调 menu.onHide 中恢复关闭状态(plugins/menubutton/plugin.js):
menu.onHide = CKEDITOR.tools.bind( function() {
...
_.on = 0;
// Indicates that menu button is closed (#421, #5144).
buttonElement.setAttribute( 'aria-expanded', 'false' );
}, this );
注意 menubutton 构造函数会把 this.hasArrow 固定为字符串 'menu'(plugins/menubutton/plugin.js),因此渲染出的 aria-haspopup="menu" 与 aria-expanded 配合,构成完整的可展开菜单语义。真实用例可参考 plugins/language/plugin.js:语言按钮即通过 editor.ui.add( 'Language', CKEDITOR.UI_MENUBUTTON, {...} ) 注册。
面板按钮(panelbutton):onShow / onHide 回调
plugins/panelbutton/plugin.js 将状态同步挂到浮动面板的生命周期上:
- 面板显示(
panel.onShow):
me.setState( CKEDITOR.TRISTATE_ON );
// Indicates that panel button is open (#421, #5144).
buttonElement.setAttribute( 'aria-expanded', 'true' );
- 面板隐藏(
panel.onHide):
_.on = 0;
// Indicates that panel button is closed (#421, #5144).
buttonElement.setAttribute( 'aria-expanded', 'false' );
面板按钮构造时同样会把 hasArrow 固定为字符串 'listbox'(plugins/panelbutton/plugin.js),与 aria-haspopup="listbox" 语义一致。真实用例:文本颜色按钮通过 CKEDITOR.UI_PANELBUTTON 注册,见 plugins/colorbutton/plugin.js。
下拉组合框(richcombo):随 TRISTATE 状态联动
plugins/richcombo/plugin.js 的做法更直接——在 setState 中把 aria-expanded 与按钮三态状态绑定:
if ( linkEl ) {
linkEl.setAttribute( 'aria-expanded', state == CKEDITOR.TRISTATE_ON );
}
即 CKEDITOR.TRISTATE_ON(下拉展开)时值为 true,TRISTATE_OFF(收起)时值为 false。字体、字号、格式等 richcombo 下拉框因此天然获得正确的展开语义。
内联编辑场景:autocomplete 的补充实现
除工具栏按钮外,plugins/autocomplete/plugin.js 从 4.16.1 起也为内联可编辑区维护了一套 ARIA 组合:aria-controls、aria-activedescendant、aria-autocomplete 以及随候选列表激活状态变化的 aria-expanded:
editable.setAttribute( 'aria-expanded', isActive ? 'true' : 'false' );
这说明 aria-expanded 在 CKEditor 4 中是贯穿工具栏按钮与编辑区两套交互体系的一致语义约定。
手动测试:如何验证 aria-expanded 状态同步
原文档 tests/plugins/button/manual/ariaexpanded.md 定义了一套 Bender 手动测试(bender-tags 标注 4.19.1, 5144, bug, button),配套页面为 tests/plugins/button/manual/ariaexpanded.html。
测试页面实现原理
测试页面初始化了一个仅加载 toolbar、wysiwygarea、colorbutton、emoji、language 五个插件的编辑器(ariaexpanded.html),并在 instanceReady 后每 250ms 定时轮询一次按钮状态(ariaexpanded.html):
// Due to inability to actually detect when menus are opened,
// the periodic update is used instead.
setInterval( renderButtonStates, 250 );
renderButtonStates 通过 CKEDITOR.document.find( '.cke_toolbar .cke_button' ) 收集工具栏中所有按钮元素,读取每个按钮的 aria-expanded 属性值,连同按钮 label 一起渲染成一张对照表(ariaexpanded.html),列结构为:
| Button | [aria-expanded] value |
|---|
选择 colorbutton(面板按钮)、emoji(面板按钮)与 language(菜单按钮)三类控件,恰好覆盖了上文提到的两种状态同步实现路径。
操作步骤与期望(原文档继承)
-
等待编辑器完全加载(Wait for editor to fully load)。
-
检查编辑器下方关于
[aria-expanded]属性值的信息展示。期望:值应如实反映按钮菜单的状态——
- 菜单打开时对应值为
true; - 菜单关闭时对应值为
false。
- 菜单打开时对应值为
-
在工具栏中随意操作各个按钮(Have fun with buttons in the toolbar)。
期望:值始终如实反映按钮菜单的开关状态。
换言之,无论何时查看对照表,打开的菜单按钮都必须是 true,关闭的都必须是 false,不允许出现 null 或陈旧值。这一期望与同目录下的 label.md 手动测试(针对 #421)互为印证——后者同样要求:菜单打开时 aria-expanded 为 true,关闭后不得残留 null。
面向屏幕阅读器的补充验证
若手头有屏幕阅读器,可结合 menubuttonscreenreader.md 做听觉层面的验证。该测试记录了修复后三大主流屏幕阅读器的期望输出,例如:
- VoiceOver (macOS):
<button name>, menu pop-up collapsed, button; - NVDA:
<button name>, button, collapsed, submenu; - JAWS:
<button name>, button menu。
若修复缺失,屏幕阅读器会把已关闭的菜单按钮错误播报为 "expanded",这正是 #5144 的回归场景。
自动化测试:把状态契约固化下来
手动测试之外,仓库还提供了自动化回归保障。核心用例位于 tests/plugins/button/aria.js,它构建了一个包含 custom_btn、disabled_btn、haspopup_btn、arrow_btn、toggle_btn、arrow2_btn 六种按钮的编辑器(aria.js),覆盖:
- 默认按钮属性断言:
role="button"、aria-haspopup、aria-labelledby、aria-disabled="false"(aria.js); - 禁用按钮:
aria-disabled="true"(aria.js); - 带箭头按钮的
aria-expanded默认值:false(aria.js); aria-pressed全状态机:通过createAriaPressedTests生成"初始态 / 开启 / 关闭 / 开启后禁用"四组断言(tests/plugins/button/_helpers/buttontools.js)。
其中标准属性集合定义在 buttontools.js:
buttonStandardAriaAttributes: {
'aria-disabled': 'false',
'role': 'button',
'aria-haspopup': 'false',
'aria-labelledby': /^cke_\d+_label$/
}
这些测试与手动测试共同把"按钮 → 弹出控件 → 展开状态"的 ARIA 契约固化到持续集成中,防止后续改动再次破坏无障碍语义。
给插件开发者的落地建议
如果你正在为自己的 CKEditor 4 插件添加带下拉的工具栏按钮,可参照以下清单保证 aria-expanded 语义正确:
- 声明下拉形态:在按钮定义中设置
hasArrow: true(通用下拉)或hasArrow: 'menu'/hasArrow: 'listbox'(明确弹出类型),即可获得初始aria-expanded="false"与对应的aria-haspopup(plugins/button/plugin.js)。 - 同步打开状态:在弹出控件的显示回调(
panel.onShow/ 菜单打开逻辑)中执行element.setAttribute( 'aria-expanded', 'true' ),在隐藏回调中恢复'false',参考 menubutton/plugin.js 与 panelbutton/plugin.js。 - 与三态状态联动:若你的控件直接基于
setState驱动,可仿照 richcombo/plugin.js,将aria-expanded绑定到TRISTATE_ON。 - 回归验证:为按钮补充自动化断言(参考 aria.js),并配套手动测试页面(参考 ariaexpanded.html 的轮询渲染方案),确保
true/false随菜单状态实时刷新。
小结
aria-expanded 在 CKEditor 4 中并非静态装饰属性,而是一条贯穿"模板初始渲染 → 菜单/面板生命周期 → 三态状态联动 → 手动与自动化双重测试"的完整无障碍契约。从 #5144 修复在 CHANGES.md 中的记录,到 button/plugin.js、menubutton/plugin.js、panelbutton/plugin.js 三处源码的同步逻辑,再到 ariaexpanded.md 手动测试与 aria.js 自动化断言,整条链路展示了开源项目如何系统性地保障无障碍体验——这对于任何构建可访问富文本编辑器的开发者都具有直接的参考价值。