为什么 Web 无障碍如此重要?
Web 无障碍(Accessibility,简称 A11y)确保所有用户,包括残障人士,都能平等地访问和使用网站。据统计,全球约 15% 的人口有某种形式的残疾。忽视无障碍不仅会失去大量用户,还可能违反法律(如 ADA、WCAG)。然而,许多开发者认为无障碍只是“添加 alt 文本”,实际上它涉及语义化结构、ARIA、焦点管理等多个层面。
本文将带你从零实现一个无障碍的交互组件,涵盖以下最佳实践:
- 语义化 HTML 与 ARIA 的正确使用
- 键盘导航与焦点管理
- 屏幕阅读器支持
- 常见陷阱与调试技巧
准备项目
我们创建一个简单的自定义下拉菜单(Select),因为原生 在样式上受限,但自定义版本常常忽略无障碍。开始前,确保你的环境支持 ES6+ 和 CSS3。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>A11y 下拉菜单示例</title>
<style>
/* 样式稍后添加 */
</style>
</head>
<body>
<div id="app"></div>
<script src="script.js"></script>
</body>
</html>
第一步:语义化结构与 ARIA 角色
自定义下拉菜单需要模拟原生 select 的行为。首先定义按钮和列表的语义:
- 按钮使用
元素(原生可聚焦) - 列表使用
,并添加role="listbox" - 每个选项使用
,添加role="option" - 使用
aria-expanded表示展开状态 - 使用
aria-activedescendant或aria-selected指示当前选项
<div class="custom-select">
<button
id="select-button"
aria-haspopup="listbox"
aria-expanded="false"
aria-labelledby="select-label"
>
选择颜色
</button>
<ul
id="select-listbox"
role="listbox"
aria-labelledby="select-label"
tabindex="-1"
>
<li role="option" aria-selected="false" data-value="red">红色</li>
<li role="option" aria-selected="false" data-value="green">绿色</li>
<li role="option" aria-selected="false" data-value="blue">蓝色</li>
</ul>
</div>
注意:aria-labelledby 指向一个标签元素,这里我们假设有一个 选择颜色。确保每个可交互元素都有可访问的名称。
第二步:键盘交互与焦点管理
原生 select 支持方向键、Enter、Esc 等。我们需要模拟这些行为。
- 按钮获得焦点时,按 Enter/Space 展开列表
- 展开后,焦点自动移到列表(或使用
aria-activedescendant管理虚拟焦点) - 方向键上下移动选项
- Enter/Space 确认选择,Esc 关闭列表并返回按钮
- Tab 键关闭列表并移动到下一个元素
我们使用 aria-activedescendant 来指示当前活动选项,而无需移动实际焦点。这样屏幕阅读器能正确朗读选项。
const button = document.getElementById('select-button');
const listbox = document.getElementById('select-listbox');
const options = listbox.querySelectorAll('[role="option"]');
let currentIndex = -1;
button.addEventListener('click', () => {
const expanded = button.getAttribute('aria-expanded') === 'true';
if (expanded) {
closeListbox();
} else {
openListbox();
}
});
button.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openListbox();
}
});
listbox.addEventListener('keydown', (e) => {
switch (e.key) {
case 'Escape':
closeListbox();
button.focus();
break;
case 'ArrowDown':
e.preventDefault();
moveFocus(1);
break;
case 'ArrowUp':
e.preventDefault();
moveFocus(-1);
break;
case 'Enter':
case ' ':
e.preventDefault();
selectOption(currentIndex);
closeListbox();
button.focus();
break;
case 'Tab':
closeListbox();
break;
}
});
function openListbox() {
button.setAttribute('aria-expanded', 'true');
listbox.hidden = false;
listbox.focus();
// 默认聚焦第一个选项
if (currentIndex === -1) {
currentIndex = 0;
}
updateActiveDescendant();
}
function closeListbox() {
button.setAttribute('aria-expanded', 'false');
listbox.hidden = true;
listbox.removeAttribute('aria-activedescendant');
}
function moveFocus(direction) {
const newIndex = currentIndex + direction;
if (newIndex >= 0 && newIndex < options.length) {
currentIndex = newIndex;
updateActiveDescendant();
}
}
function updateActiveDescendant() {
const id = `option-${currentIndex}`;
options.forEach((opt, idx) => {
opt.id = `option-${idx}`;
opt.setAttribute('aria-selected', idx === currentIndex ? 'true' : 'false');
});
listbox.setAttribute('aria-activedescendant', id);
// 滚动到可见区域(可选)
options[currentIndex].scrollIntoView({ block: 'nearest' });
}
function selectOption(index) {
if (index === -1) return;
const selected = options[index];
button.textContent = selected.textContent;
// 更新隐藏的 input 值(如果有表单)
}
注意:当列表关闭时,确保 aria-expanded 为 false,并且列表隐藏。使用 hidden 属性或 display: none,但不要仅用 CSS 隐藏,因为屏幕阅读器可能仍会读取。
第三步:样式与视觉提示
除了功能,视觉上也要考虑低视力用户:
- 焦点指示器(outline)不能移除,除非自定义更明显的样式
- 颜色对比度符合 WCAG AA 标准(至少 4.5:1)
- 不要仅依赖颜色传达信息(如错误状态加图标)
.custom-select {
position: relative;
width: 200px;
}
#select-button {
width: 100%;
padding: 8px 12px;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
text-align: left;
}
#select-button:focus {
outline: 2px solid #005fcc;
outline-offset: 2px;
}
#select-listbox {
position: absolute;
top: 100%;
left: 0;
width: 100%;
margin: 4px 0;
padding: 0;
list-style: none;
background: #fff;
border: 1px solid #ccc;
border-radius: 4px;
max-height: 200px;
overflow-y: auto;
z-index: 10;
}
#select-listbox[hidden] {
display: none;
}
[role="option"] {
padding: 8px 12px;
cursor: pointer;
}
[role="option"][aria-selected="true"] {
background: #e0e0e0;
}
[role="option"]:hover {
background: #f0f0f0;
}
注意:不要使用 :focus 伪类在 [role="option"] 上,因为焦点实际在 listbox 上。使用 aria-selected 高亮当前选项。
第四步:屏幕阅读器测试
使用 NVDA 或 VoiceOver 测试:
- 聚焦按钮,屏幕阅读器应朗读“选择颜色 按钮 折叠”
- 按 Enter 展开,朗读“列表 选项 红色 当前 未选中 共3项”
- 方向键移动,朗读选项名称和选中状态
- 按 Enter 选择,朗读“红色 按钮 折叠”
如果朗读不正确,检查 ARIA 属性和标签。
常见陷阱与调试技巧
- 焦点陷阱:确保用户能通过 Tab 键离开组件。
- 隐藏内容:使用
hidden属性而非display: none,但注意hidden对 CSS 动画不友好。 - 动态内容:使用
aria-live区域通知变化,但不要滥用。 - 颜色对比度:使用工具(如 WebAIM Contrast Checker)检查。
- 鼠标事件:不要依赖
click作为唯一交互,键盘用户需要keydown。
调试技巧:
- 使用浏览器 DevTools 的 Accessibility 面板检查 ARIA 属性
- 使用 aXe 或 Lighthouse 自动检测问题
完整代码与演示
将以上 HTML、CSS、JS 合并,即可得到一个完整可用的无障碍下拉菜单。
总结
Web 无障碍不是可选项,而是现代开发的必备技能。通过语义化 HTML、正确使用 ARIA、实现键盘导航和焦点管理,你可以构建出包容性强的组件。记住:无障碍受益的不仅是残障用户,所有用户都能获得更好的体验(如键盘导航提高效率)。
下一步:学习 WCAG 2.1 标准,尝试为复杂组件(如模态框、树形视图)实现无障碍。
参考资源
- WAI-ARIA Authoring Practices
- MDN Web Docs: Accessibility
- WebAIM: Keyboard Accessibility