Skip to main content

无障碍先过键盘,再谈 ARIA

组件库如果自己用 div 加点击事件做一个按钮,读屏软件不知道这是按钮,键盘也 tab 不到。能用 button 就别补一堆 ARIA。验收时先把鼠标拔掉走一遍。

1. 能用原生元素就别加 ARIA​

读屏器读一个元素时按「角色 → 可访问名 → 状态」播报。用原生 <button> 时这三样浏览器已经给全了;换成 <div role="button"> 之后,角色要手写、焦点要补 tabindex、键盘事件要自己绑、默认状态(禁用、按下)要自己同步——少做一样就是坏的无障碍。所以第一条规则永远是:能用原生就用原生,ARIA 只用来补原生表达不了的部分。

可访问名有四个来源,冲突时优先级为:aria-labelledby > aria-label > 关联 <label> / 内容文本 > title。理解这一点才能解释「为什么我写了文字读屏却不读」这类问题。

2. WCAG 2.2 补上的六条硬要求​

每条对应的都是具体界面现象,不是抽象编号:

2.1 焦点不被遮挡(最小)​

吸顶导航、Cookie 条不能盖住当前键盘焦点,否则键盘用户看不到自己在哪。

2.2 拖拽替代​

凡是拖拽操作(排序、滑块)都必须有单指针替代方案(上移/下移按钮、方向键)。

2.3 目标尺寸(最小)​

可点击区域至少 24×24 CSS 像素,主操作建议 44×44,避免误触。

2.4 帮助一致​

帮助入口在各页相对位置保持一致,不能这页在左上那页在右下。

2.5 重复输入​

同一流程(如结算)不要让用户重复填已经填过的信息。

2.6 无障碍认证​

登录不能依赖记忆、拼图或手动抄写,要提供替代认证。

这些不是善事,是交付门槛。

2.7 键盘走一遍才是真验收​

Tab 顺序跟随 DOM 顺序,禁止用正 tabindex 打乱它。弹层要做焦点陷阱:打开时焦点进入弹层、Tab 在弹层内循环、Esc 关闭后焦点必须归还触发它的按钮。

function trapFocus(container, onClose) {
const items = container.querySelectorAll(
'a[href], button:not([disabled]), input, [tabindex]:not([tabindex="-1"])'
)
const first = items[0]
const last = items[items.length - 1]
container.addEventListener('keydown', (e) => {
if (e.key !== 'Tab') return
if (e.shiftKey && document.activeElement === first) {
e.preventDefault(); last.focus()
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first.focus()
}
})
}

SPA 最容易漏的是路由切换后的焦点:DOM 换了但焦点还停在已消失的链接上。正确做法是把焦点移到新页面主区域并播报标题。

<!-- 页面里放一个安静的播报区 -->
<div id="route-announcer" aria-live="polite" class="sr-only"></div>
function onRouteChange() {
const main = document.querySelector('main')
main.setAttribute('tabindex', '-1')
main.focus()
const live = document.getElementById('route-announcer')
if (live) live.textContent = document.title
}
弹层关闭打开,焦点入内Tab 在首尾之间环绕Esc 关闭触发初始焦点Esc焦点归还触发它的那个按钮「环绕」是关键:Tab 到最后一个元素后再按一次,焦点回到第一个,而不是跑到页面其他部分。只挡住 Tab 不够——Esc、点击遮罩、路由切换都要各自处理归还。
图:焦点陷阱的状态机——闭环之内 Tab 出不去,只有 Esc 才归还焦点

2.8 对比度不是越黑越好​

三类阈值:正文文本 4.5:1、大号文本(≥24px 或 ≥19px 粗体)3:1、UI 组件边界与焦点指示 3:1。状态不能只靠颜色表达——错误、成功、禁用除了颜色还要有图标或文字,否则色弱用户和强制颜色模式(forced-colors)下会丢失信息。

对比度不用靠肉眼判,算一遍就知道过不过线:

function luminance([r, g, b]) {
const a = [r, g, b].map((v) => {
v /= 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
})
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]
}

function contrast(c1, c2) {
const [hi, lo] = [luminance(c1), luminance(c2)].sort((a, b) => b - a)
return (hi + 0.05) / (lo + 0.05)
}

contrast([255, 255, 255], [118, 118, 118]) // 4.54 —— 刚过正文的 4.5:1
contrast([255, 255, 255], [119, 119, 119]) // 4.48 —— 差 0.02,正文不达标
contrast([255, 255, 255], [153, 153, 153]) // 2.85 —— 常见「次要文字灰」,正文远不达标
// 所以「浅一点更好看」往往是拿可读性换的,而这条线是算出来的,不是感觉出来的

3. 从组件库开始改造​

三层:组件库内置无障碍检查、CI 跑自动检查、人工验收兜底。

// CI 里跑 axe,仅示例:发现违规则阻断构建
import axe from 'axe-core'
const results = await axe.run(document)
if (results.violations.length) {
console.error('a11y violations:', results.violations)
process.exit(1)
}

自动化只能覆盖一部分——键盘走查、读屏验证、200% 缩放必须人工做。可执行的验收清单:

  • 纯键盘能否走完主流程(不碰鼠标);
  • 焦点是否始终可见且不被遮挡;
  • 对比度是否达标;
  • 页面缩放到 200% 是否仍可正常阅读与操作;
  • 用读屏走查关键路径,听播报是否连贯正确;
  • 开启系统高对比(forced-colors)后状态是否仍有第二重表达(图标/文字),而非只剩颜色。

4. 一个图标按钮怎么过键盘​

设计给的是一个 24 像素的图标,没有文字。用 div 加上 onClick,鼠标能点,Tab 到不了,读屏也不知道这是按钮。

换成 button。图标用 aria-hidden,避免把 SVG 里的路径读出来。按钮本身用 aria-label 写「关闭」或「删除这条评价」,不要写「按钮」或「图标」。能看见文字时就不要 aria-label,可见文字和可读名称不一致时,读屏会念错。

弹层打开后,焦点放进弹层里的第一个可操作元素,关掉时还回打开它的那个按钮。不还回去,键盘用户会从页面顶部重新开始。对比度在图标上同样要过:浅灰图标放在白底上,鼠标用户勉强能看到,弱视用户会当成装饰。

组件的键盘交互与 ARIA 用法,W3C ARIA Authoring Practices 给出了可直接照做的模式,比凭感觉写 aria-* 稳当得多。