自定义下拉框用不了键盘:ARIA、焦点管理和屏幕阅读器该怎么补

QA 这周提的一个 bug 单,标题写得很直白:"筛选下拉框,键盘用不了"。描述里贴了一句:打开搜索框旁边那个自定义的商品分类下拉,鼠标点没问题,但只用键盘的话,Tab 能聚焦到它,回车能展开,之后上下方向键完全没反应,选项高亮不会跟着走,Esc 也关不掉,只能靠鼠标点别处才能收起来。

这个下拉框是组件原作者半年前写的,样式和交互都照着设计稿做得挺精致,问题是它整个组件全靠 divmousedown/mouseover 拼出来,键盘事件一行没写。我们先复现了一遍,确认测试同事说的没错:这东西对键盘用户来说基本等于一个只能看不能操作的图层。同一个迭代里还有一处,购物车页面的收藏图标按钮,读屏念出来只有一句"按钮",听不出这是"收藏"还是"取消收藏"。这两个问题看着不一样,根子是同一个——组件把原生控件的默认能力扔掉之后,没有把等价的语义和交互补回去。

先把下拉框的键盘行为按原生 select 的标准补齐

这版下拉框的 DOM 大致是这样,一个触发器加一个绝对定位的选项列表:

1<div class="select" id="category-select">
2  <div class="select-trigger" tabindex="0">全部分类</div>
3  <ul class="select-options" hidden>
4    <li>手机数码</li>
5    <li>家用电器</li>
6    <li>服饰鞋包</li>
7  </ul>
8</div>

tabindex="0" 让触发器能被 Tab 聚焦到,这是唯一做对的地方。但它既没有 role,也没监听任何 keydown,读屏工具划到这个元素上只会念出文本"全部分类",不会说这是个下拉选择器,键盘用户更是完全没法操作后面的列表。

要让它的行为追平原生 <select>,得按 WAI-ARIA 的 Combobox / Listbox 模式把角色和状态标全。基本结构是触发器标 role="combobox",挂 aria-haspopup="listbox"aria-expanded,选项列表标 role="listbox",每个选项标 role="option" 并用 aria-selected 标出当前选中项:

1<div class="select" id="category-select">
2  <div
3    class="select-trigger"
4    role="combobox"
5    tabindex="0"
6    aria-haspopup="listbox"
7    aria-expanded="false"
8    aria-controls="category-listbox"
9  >全部分类</div>
10  <ul
11    id="category-listbox"
12    role="listbox"
13    class="select-options"
14    hidden
15  >
16    <li role="option" aria-selected="true" id="opt-all">全部分类</li>
17    <li role="option" aria-selected="false" id="opt-phone">手机数码</li>
18    <li role="option" aria-selected="false" id="opt-appliance">家用电器</li>
19  </ul>
20</div>

键盘交互按下拉框的通用约定实现:回车或空格展开,方向键在选项间移动并更新 aria-selected,Esc 收起并把焦点留在触发器上,Home/End 跳到首尾选项。用原生事件写出来大概是这样:

1const trigger = document.querySelector('.select-trigger')
2const listbox = document.querySelector('.select-options')
3const options = [...listbox.querySelectorAll('[role="option"]')]
4let activeIndex = options.findIndex(o => o.getAttribute('aria-selected') === 'true')
5
6function openList() {
7  listbox.hidden = false
8  trigger.setAttribute('aria-expanded', 'true')
9}
10function closeList() {
11  listbox.hidden = true
12  trigger.setAttribute('aria-expanded', 'false')
13  trigger.focus()
14}
15function highlight(index) {
16  options.forEach((o, i) => o.setAttribute('aria-selected', String(i === index)))
17  trigger.setAttribute('aria-activedescendant', options[index].id)
18  options[index].scrollIntoView({ block: 'nearest' })
19}
20
21trigger.addEventListener('keydown', e => {
22  const expanded = trigger.getAttribute('aria-expanded') === 'true'
23  switch (e.key) {
24    case 'Enter':
25    case ' ':
26      e.preventDefault()
27      expanded ? selectCurrent() : openList()
28      break
29    case 'ArrowDown':
30      e.preventDefault()
31      if (!expanded) { openList(); break }
32      activeIndex = Math.min(activeIndex + 1, options.length - 1)
33      highlight(activeIndex)
34      break
35    case 'ArrowUp':
36      e.preventDefault()
37      activeIndex = Math.max(activeIndex - 1, 0)
38      highlight(activeIndex)
39      break
40    case 'Escape':
41      if (expanded) closeList()
42      break
43    case 'Home':
44      activeIndex = 0
45      highlight(activeIndex)
46      break
47    case 'End':
48      activeIndex = options.length - 1
49      highlight(activeIndex)
50      break
51  }
52})
53
54function selectCurrent() {
55  trigger.textContent = options[activeIndex].textContent
56  closeList()
57}

这里有个容易漏掉的细节:aria-activedescendant。焦点其实一直停在 trigger 上,没有真的移到某个 <li> 上——这是 Combobox 模式的标准做法,焦点不动,靠 aria-activedescendant 告诉读屏"当前逻辑焦点在哪个选项"。如果反过来把焦点真的移到每个 <li> 上,Tab 顺序会被打乱,读屏的播报逻辑也会变得不可控。这个模式一开始很反直觉,组件原作者最初的想法就是"选中哪项焦点就移到哪项",试了一下发现 VoiceOver 播报节奏很怪,才改成现在这种"焦点不动、状态用 ARIA 属性描述"的写法。

补完这一圈,我们拿 VoiceOver 重新过一遍:Tab 聚焦到触发器,念出"全部分类,组合框,已折叠";按下箭头,念出当前高亮的选项文本和"已选中";Esc 收起后焦点回到触发器,不会像之前那样掉到页面顶部重新数起。键盘走查也顺了,Tab、方向键、Enter、Esc 整条链路都能走通。

原生 select 和自定义组件之间,到底该怎么选

补完这个下拉框之后,写这个组件的同事提了个更根本的问题:既然要补这么多 ARIA 属性和键盘逻辑才能追平原生 <select>,为什么一开始不直接用原生的?

这个问题值得认真想一层。原生 <select> 自带的能力其实相当多:键盘上下键切换选项、输入首字母快速定位到对应选项、聚焦时读屏自动播报当前选中值、移动端会唤起系统原生的选择器界面(iOS 是那个从底部弹出的滚轮,Android 是系统对话框),这几条自定义实现里能补齐一半就算用心了。它的短板也很明确:样式几乎没法跨浏览器统一,选项列表的视觉呈现完全由操作系统接管,做不出带图标、带分组、带搜索的复杂下拉。

我们后来定的取舍原则是:表单里没有强样式诉求的地方(后台管理系统里的普通筛选项、基础表单),优先用原生 <select>,样式上能做的定制有限,但换来的是键盘和读屏支持全部免费;真正需要搜索、多选、分组这类复杂交互的下拉,才值得投入去做自定义组件,而且要按 WAI-ARIA Authoring Practices 里 Combobox 模式的标准去实现,不能只做视觉、漏掉交互。这次商品分类下拉之所以出问题,就是拿了"需要自定义样式"的理由,跳过了"其实不需要这么复杂交互"的判断,两头都没占到便宜——样式做了,键盘和读屏支持却全丢了。

Tab 切换组件同样容易漏掉一套键盘约定

下拉框修完没几天,我们在另一个页面的详情 Tabs 组件上发现了类似问题。这个 Tabs 是自己撸的,鼠标点击切换没问题,但键盘只能靠 Tab 键一个个切换标签页,每次都要经过所有标签才能到达内容区,效率很低。

WAI-ARIA 对 Tabs 的标准模式规定了一套和"逐个 Tab"不一样的键盘约定:整组标签只占一个 Tab 停靠点,进入标签组之后用左右方向键在各个标签之间切换(切换时同步触发内容区更新),再按 Tab 才会跳出标签组、进入当前激活的面板内容。结构上,标签容器标 role="tablist",每个标签标 role="tab" 并用 aria-selected 标出当前项,内容面板标 role="tabpanel" 并用 aria-labelledby 关联对应标签:

1<div role="tablist" aria-label="商品详情">
2  <button role="tab" aria-selected="true" aria-controls="panel-desc" id="tab-desc">商品描述</button>
3  <button role="tab" aria-selected="false" aria-controls="panel-spec" id="tab-spec" tabindex="-1">规格参数</button>
4  <button role="tab" aria-selected="false" aria-controls="panel-review" id="tab-review" tabindex="-1">用户评价</button>
5</div>
6<div role="tabpanel" id="panel-desc" aria-labelledby="tab-desc"></div>
7<div role="tabpanel" id="panel-spec" aria-labelledby="tab-spec" hidden></div>
8<div role="tabpanel" id="panel-review" aria-labelledby="tab-review" hidden></div>

这里未选中的标签都要设 tabindex="-1",只有当前激活的标签保留 tabindex="0",Tab 键才会一次性跳过整组、只停在激活项上;左右方向键切换时,代码要同步把新激活标签的 tabindex 改成 0、旧的改回 -1,否则下次 Tab 键行为会跟视觉状态对不上。这个"只有一个成员参与 Tab 顺序,组内导航靠方向键"的模式,在 Radio Group、Menu、Toolbar 这几类组件里都是同一套约定,一旦搞懂 Tabs 这一个,其余几种基本可以照搬。

图标按钮没有可访问的名称,读屏用户完全不知道它是干什么的

购物车页面那个收藏按钮,代码是这样写的:

1<button class="icon-btn" @click="toggleFavorite">
2  <svg class="icon-heart"><use href="#icon-heart"></use></svg>
3</button>

视觉上一目了然,一颗心形图标,谁都知道是收藏。可读屏用户听到的只是"按钮"两个字——按钮内部没有任何文本节点,svg 默认也不会被朗读,等于给了一个空按钮。这类"图标按钮没有可访问名称"的问题在电商中后台里出现频率相当高,尤其是表格里那一排操作图标:编辑、删除、复制、收藏,鼠标用户靠图形和 tooltip 区分,读屏用户面对的是一排听起来一模一样的"按钮"。

WCAG 2.1 里有一条判定标准专门针对这个问题,叫 4.1.2 Name, Role, Value:所有 UI 组件都必须有程序可识别的名称、角色和状态。图标按钮补名称最简单的办法是 aria-label

1<button
2  class="icon-btn"
3  :aria-label="isFavorited ? '取消收藏' : '收藏'"
4  :aria-pressed="isFavorited"
5  @click="toggleFavorite"
6>
7  <svg class="icon-heart" aria-hidden="true"><use href="#icon-heart"></use></svg>
8</button>

两个点值得展开说。第一,aria-label 要跟着状态变化,不能写死成"收藏"——如果已经收藏了还念"收藏",用户点了以后会困惑到底是变成收藏还是取消。第二,svg 上补一个 aria-hidden="true",明确告诉读屏这块装饰性图形不需要单独播报,避免有的读屏实现把 <use> 引用的内容也念出来,造成"按钮,收藏,图标"这种冗余播报。

如果按钮本身表达的是一个开关状态(比如"收藏/取消收藏"、"关注/取消关注"这类二元切换),更贴切的做法是用 aria-pressed 把它标成一个 toggle button,读屏会播报"已按下"或"未按下",比单纯换文案更精确地表达了状态语义。团队里表格操作列那批图标按钮,我们后来统一按这个模式整改:编辑、删除这类跳转或触发动作的按钮用 aria-label 补名称,收藏、置顶这类可切换状态的按钮加 aria-pressed

表格操作列的图标按钮补完 aria-label 之后,我们顺手把整个后台系统里同类图标搜了一遍,发现同一个"编辑"图标在不同页面上的 aria-label 文案五花八门:有的写"编辑",有的写"修改",有的干脆没写完整、只写了"操作"。这类不一致对读屏用户的影响比想象中大——同一个图标反复出现在不同页面,读屏用户靠记忆建立起"这个图标是编辑"的认知之后,遇到文案不一致的地方,等于每次都要重新确认。我们后来在组件库里把常用的操作型 aria-label 文案统一成一份词表(编辑、删除、复制、收藏、置顶、下载),新增图标按钮时直接引用,不再各写各的。

提示信息也需要被正确感知

这次改造顺带牵出另一个问题:分类切换成功之后,页面右上角会弹一个"已切换到 XX 分类"的 toast,但这段文字是切换那一刻才用 JS 临时 createElement 插进 DOM 里的,读屏工具完全没有反应,因为它默认不会主动播报页面里刚刚出现的内容,尤其是临时创建插入的节点,很多实现干脆捕捉不到这次 DOM 变化。

解决办法是用 ARIA 的 live region,而且容器要在页面初始渲染时就存在、保持空着,需要时只更新里面的文本,而不是整个节点动态插入:

1<!-- polite:等用户当前操作念完再播报,不打断当前朗读 -->
2<div role="status" aria-live="polite" class="toast"></div>
3
4<!-- assertive:立刻打断播报,留给真正紧急的错误 -->
5<div role="alert" aria-live="assertive" class="error-banner"></div>
1document.querySelector('.toast').textContent = '已切换到手机数码分类'

表单报错走的是同一套逻辑,但落点不太一样。报错信息应该跟对应输入框绑定,而不是丢一个全局 toast了事。用 aria-describedby 把错误文案和输入框关联起来,再用 aria-invalid 标出具体是哪个字段出了问题,读屏焦点落到输入框的瞬间就会把控件名称和错误原因一起念出来:

1<label for="email">邮箱</label>
2<input id="email" aria-invalid="true" aria-describedby="email-err" />
3<span id="email-err" class="error">邮箱格式不正确</span>

这里有个容易踩的反例,值得单独提一下:aria-live 不是加了就一定生效。我们最初把 live region 直接写成 <div class="toast" aria-live="polite"></div>,然后整块容器连同文本一起用 innerHTML 替换,结果发现 VoiceOver 有时候压根没反应。后来查到的原因是,如果容器本身在替换的同时被重新插入或者 display 发生了从 none 到非 none 的切换,部分读屏实现会把这次更新当成"新元素出现"而不是"内容变化",播报逻辑就不稳定。稳妥的做法是容器的显示状态和存在性都保持不变,只动文本节点本身,前面那段"容器要在页面初始渲染时就存在、保持空着"说的正是这个原因,不是随口的经验之谈。

另外 aria-live="assertive" 不要滥用。它的语义是"不管用户当前在做什么,立刻打断去播报这条内容",适合真正紧急的场景,比如支付失败、连接中断这类必须立刻让用户知道的错误。如果把它用在"保存成功"这种非紧急提示上,读屏用户操作到一半会被频繁打断,体验反而更差。我们这次顺带把系统里几处滥用 assertive 的提示改成了 polite,只留支付相关的错误提示还用 assertive

焦点管理:弹窗、抽屉这类临时界面最容易漏掉的三件事

下拉框和 toast 修完,我们顺手把团队里几个弹窗组件也过了一遍键盘走查,发现同一类问题反复出现:弹窗打开后焦点没有移进去,用户还停在背后的触发按钮上,得 Tab 穿过整个页面才能进弹窗;关闭之后焦点又不还给触发元素,直接掉到 body,下一次 Tab 从页头重新数起;更麻烦的是 Tab 键能一路穿透弹窗边界,跑到背景页面里那些理论上应该被遮住的元素上。

一个合格的弹窗至少要处理这三件事:打开时把焦点移进去、关闭时把焦点还给触发元素、Tab 不能逃出弹窗(焦点陷阱)。焦点陷阱的实现思路是,监听 Tab 键,如果当前焦点已经落在弹窗内最后一个可聚焦元素上,再按 Tab 就把焦点手动拉回第一个;反向同理:

1function trapFocus(modal) {
2  const focusables = modal.querySelectorAll(
3    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
4  )
5  const first = focusables[0]
6  const last = focusables[focusables.length - 1]
7
8  modal.addEventListener('keydown', e => {
9    if (e.key !== 'Tab') return
10    if (e.shiftKey && document.activeElement === first) {
11      e.preventDefault()
12      last.focus()
13    } else if (!e.shiftKey && document.activeElement === last) {
14      e.preventDefault()
15      first.focus()
16    }
17  })
18}
19
20function openModal(modal, trigger) {
21  modal.hidden = false
22  modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]')[0]?.focus()
23  trapFocus(modal)
24
25  modal.addEventListener('keydown', e => {
26    if (e.key === 'Escape') {
27      modal.hidden = true
28      trigger.focus()
29    }
30  })
31}

这段逻辑我们组件库里现在是抽成公共 hook 用的,业务组件不用每次手写。一位同事提过一个更省事的思路:与其自己维护这套焦点陷阱和 ARIA 状态机,不如直接上 Radix、Reach UI 这类专门打磨过无障碍细节的组件库,弹窗、Popover、Tabs 这些交互模式它们都封装好了。我们评估之后的结论是:内部组件库的基础交互模式(弹窗、下拉、Tooltip)值得自己按 WAI-ARIA Authoring Practices 里的标准模式实现一遍,吃透之后收益是长期的;但业务代码里如果只是临时需要一个弹窗,直接用现成方案效率更高,不必每次重新造轮子。

页面级的导航效率:跳过重复区块和地标角色

弹窗的焦点陷阱解决之后,QA 又提了一个更宏观的问题:后台系统每个页面顶部都是同一套导航加筛选栏,键盘用户每次进入一个新页面,都得从头 Tab 穿过整套导航才能到达主内容区,页面越多,重复劳动越明显。这个问题原生按钮和 ARIA 属性都解决不了,得从页面结构的地标角色(landmark)入手。

最基础的做法是加一个"跳到主内容"的跳转链接,默认视觉上隐藏,只有键盘聚焦到它时才显示出来,点击或回车之后焦点直接跳到主内容区,绕过整套导航:

1<a href="#main-content" class="skip-link">跳到主内容</a>
2<nav>…导航和筛选栏,很长…</nav>
3<main id="main-content" tabindex="-1"></main>
1.skip-link {
2  position: absolute;
3  top: -40px;
4  left: 0;
5  background: #fff;
6  padding: 8px 16px;
7  z-index: 100;
8}
9.skip-link:focus {
10  top: 0;
11}

<main> 上加 tabindex="-1" 是必须的,否则它默认不在可聚焦元素之列,href="#main-content" 跳过去之后浏览器只会滚动到那个位置,键盘焦点并没有真正落在主内容区上,下一次按 Tab 还是从页面顶部开始数。

跳转链接解决的是"一步到位",但读屏用户还有另一种更常用的导航方式——按地标角色在页面区块之间跳转,NVDA 和 VoiceOver 都支持类似"下一个地标"这样的快捷键,前提是页面把 <nav><main><aside><footer> 这些原生地标标签用对了地方,或者用对应的 role 补上(role="navigation"role="main" 等,原生标签已经自带这些角色,不需要重复标注)。我们查了一遍后台系统的页面结构,发现有两个页面的主内容区偷懒直接用了 <div class="main">,没用 <main> 标签,这种页面读屏用户按地标跳转时会漏掉主内容这一环,只能一路 Tab 摸过去。

键盘可用性: 焦点样式不能被一刀切掉

这次走查还带出一个老问题——公共样式里有一行全局重置:

1/* 这是在毁掉键盘用户的导航能力 */
2*:focus { outline: none; }

这是组件原作者两年前为了压掉 Chrome 按钮点击后那圈蓝色轮廓加的,当时没考虑到副作用是键盘用户彻底失去了"焦点在哪"的视觉反馈,Tab 半天不知道走到了第几个元素。这次一并改成 :focus-visible——它只在键盘聚焦时显示轮廓,鼠标点击不会触发,视觉上的抱怨和键盘可用性两头都照顾到了:

1button:focus-visible,
2a:focus-visible,
3[tabindex]:focus-visible {
4  outline: 2px solid #2563eb;
5  outline-offset: 2px;
6}

:focus-visible 这几年浏览器支持已经比较完整,不需要再靠 :focus:hover 反选这类 hack 去模拟。

WCAG 2.1 的判定层级:不是一句“做得好不好”

这次修复过程里我们对着 WCAG 2.1 的文档核对了好几条判定标准,越核对越觉得有必要弄清楚它的结构,而不是凭直觉判断"这个够无障碍了吗"。WCAG 2.1 把所有要求归到四个原则下:可感知(Perceivable,内容要能被用户感知到,不管是视觉、听觉还是触觉)、可操作(Operable,交互要能通过键盘等多种方式完成)、可理解(Understandable,内容和操作方式要清晰一致)、健壮(Robust,能被各类辅助技术正确解析)。每条原则下面挂着具体的成功判定标准,还分 A、AA、AAA 三个等级,国内外大多数无障碍规范要求达到的是 AA 级,AAA 级的要求偏严格,很多场景做不到也不强求。

这次踩到的几个问题,对号入座能分别挂到不同条款上:自定义下拉框键盘用不了对应的是 2.1.1(键盘可访问,所有功能都要能通过键盘完成);图标按钮没有可访问名称对应的是 4.1.2(名称、角色、值);焦点样式被删对应的是 2.4.7(焦点可见);toast 读屏播报不出来对应的是 4.1.3(状态消息,这一条是 WCAG 2.1 相比 2.0 新增的,专门针对不需要转移焦点但需要被感知到的状态变化)。把问题对应到具体条款上的好处是,下次评审时不用靠"感觉这里不太友好"这种模糊判断,可以直接说"这里不满足 2.1.1",讨论会精确很多。

检查手段:把无障碍验证工具化

这几处问题能被发现,靠的不是设计评审或者代码走查,而是测试同事这次测试用例里专门加了"仅键盘操作"和"读屏朗读"两项。往前推一步想,这类问题最好在开发阶段就能拦下来,而不是等测试阶段被动发现。我们现在实际在用的几样工具,按投入产出排一下:

键盘走查几乎零成本——开发完一个交互组件,先把鼠标放一边,只用 Tab、Shift+Tab、方向键、回车、空格、Esc 把整个流程走一遍。这条能抓出八成问题,是性价比最高的一步。

axe DevTools 和 Chrome 自带的 Lighthouse Accessibility 评分能覆盖对比度不足、缺 altlabel 没关联这类机器可判定的静态问题,但它们查不出"这个下拉框键盘用不了"这种需要实际交互的问题,分数满分不等于真的可用。

eslint-plugin-jsx-a11y 我们这次给 React 项目补上了,写代码的时候就能提示"imgalt""onClick 加在了非交互元素上",把一部分问题拦在提交之前。

真机读屏是最费时间但信息量最大的一步。Mac 上用 Cmd+F5 开关 VoiceOver,配合 Control+Option+方向键 移动光标;Windows 上免费的 NVDA 效果类似。第一次用会很不适应,但亲耳听一遍自己写的页面被念出来是什么样子,比看多少篇规范文档都直接——那个只念"按钮"的收藏图标,自己戴上耳机听一遍就知道问题有多严重。

VoiceOver 和 NVDA 用起来手感不太一样,值得都试一遍,不能只验证一种。VoiceOver 是 macOS 自带的,不用额外装,Control+Option 这个组合键(读屏圈里管它叫 VO 键)加方向键是最基础的移动方式,加 U 键能唤出一个"转子"(rotor)菜单,快速切换成按标题、按链接、按表单控件浏览页面,这个转子菜单在验证标题层级和表单结构是否清晰时特别有用。NVDA 是 Windows 上免费开源的方案,快捷键体系和 VoiceOver 不同,用 H 键在标题间跳转,Tab 键在表单控件间跳转,B 键跳到下一个按钮,这些单字母快捷键只有在"浏览模式"下才生效,进了表单区域会自动切到"焦点模式",行为逻辑和 VoiceOver 有明显差异。两者对同一段代码的解读也不总是一致,我们这次测下拉框时就发现,同样的 aria-activedescendant,VoiceOver 播报很及时,NVDA 这边有一次出现了播报延迟一拍的情况,这类差异只有真机测过才会发现,光看规范文档看不出来。

商用软件里 JAWS 也值得提一句,虽然我们没有正版授权,只在官网下过免费的 40 分钟限时版本简单验证过。JAWS 在国外企业级场景里用得很多,对复杂的 ARIA 属性支持比较严谨,有时候会比 VoiceOver、NVDA 更早暴露出属性用错的问题——比如这次如果 aria-expanded 的值忘了从字符串 "false" 更新成 "true",只用肉眼看 DOM 容易漏掉,但换成 JAWS 读一遍,状态播报错误会很明显地暴露出来。三选一的话,日常开发用 VoiceOver 或 NVDA 验证已经够用,遇到拿不准的复杂交互,才值得再拉一次 JAWS 做交叉验证。

表单里两个容易被忽视的关联问题

顺着表单报错这条线,我们又查了一遍系统里其他表单页,发现两个更隐蔽的问题,都和"标签关联"有关,比 aria-describedby 漏加更容易被忽略。

第一个是用 placeholder 替代 label 的做法。有几处筛选表单为了视觉紧凑,把 label 直接省掉,只用 placeholder 提示用户该填什么:

1<!-- 看起来够用,实际上问题不小 -->
2<input type="text" placeholder="请输入订单号" />

这么写有两个问题。一是 placeholder 的文本一旦用户开始输入就会消失,如果中途需要回忆"这个框是干什么的",视觉上已经没有提示了;二是读屏工具对 placeholder 的支持不算稳定,有些读屏会念出来,有些完全忽略,靠它做唯一的标签来源是在赌运气。正确的做法是 labelplaceholder各司其职,label 负责稳定的字段说明,placeholder 只用来提示输入格式或者举例:

1<label for="order-no">订单号</label>
2<input id="order-no" type="text" placeholder="例如 202111030001" />

如果视觉上确实不想让 label 常驻显示,可以用视觉隐藏的写法,把 label 从版面上挪走但保留给读屏访问,而不是直接删掉:

1.visually-hidden {
2  position: absolute;
3  width: 1px;
4  height: 1px;
5  padding: 0;
6  margin: -1px;
7  overflow: hidden;
8  clip: rect(0, 0, 0, 0);
9  white-space: nowrap;
10  border: 0;
11}

这里要避免一个常见的错误替代方案——用 display: nonevisibility: hidden 去"隐藏" label,这两种写法会让读屏工具也一并跳过它,等于白写;上面这种裁剪加定位的写法才能做到"视觉上不可见,但读屏依然能访问"。

第二个问题出现在一组单选按钮上。筛选栏里有个"发货状态"是三个并列的 radio,每个都单独有 label,但整组按钮缺一个统一的分组说明,读屏用户逐个划过这三个选项时,只能听到"待发货""已发货""已签收",却不知道这三者是同属"发货状态"这一组的。原生的解决办法是用 fieldsetlegend 把一组相关控件包起来:

1<fieldset>
2  <legend>发货状态</legend>
3  <label><input type="radio" name="status" value="pending" /> 待发货</label>
4  <label><input type="radio" name="status" value="shipped" /> 已发货</label>
5  <label><input type="radio" name="status" value="received" /> 已签收</label>
6</fieldset>

legend 是原生语义,读屏划到组内任意一个选项时都会自动带出"发货状态"这个分组名,不需要额外写 aria-labelaria-labelledby 去模拟。这类分组场景我们之前基本没意识到要处理,因为视觉上"发货状态"这四个字就写在筛选栏最前面,肉眼看很清楚是哪一组,但读屏用户是逐项线性听下来的,没有"一眼看到分组标题"这个前提,原生标签能免费补上的语义,不用它就是浪费。

ARIA 是用来补语义的,不是用来覆盖语义的

这轮改造里我们也踩了一次相反方向的坑:有人在整改图标按钮时,顺手给一个本来就是 <button> 的元素又加了 role="button",纯属多余;还有一处把 aria-hidden="true" 加在了一个包含可聚焦输入框的容器上,结果读屏跳过了整块内容不播报,键盘却还能 Tab 进去操作里面的输入框——读屏用户完全感知不到自己正在往一个"隐藏"的表单里打字。这类问题比"完全没做无障碍"更麻烦,因为它主动向读屏传递了错误信息,圈内常说的"No ARIA is better than bad ARIA"说的就是这个。原则很简单:能用原生语义解决的,不上 ARIA;确实需要用 ARIA 补语义的地方,状态要跟交互实时同步,不能加了就不管。

延伸: 对比度和触控目标尺寸也是无障碍的一部分

这次整改顺带查了两处经常被忽略的点。一处是文字对比度,WCAG 2.1 的 AA 级标准要求正文文字和背景的对比度至少达到 4.5:1,大号文字(18pt 以上或 14pt 加粗)放宽到 3:1;我们后台管理系统里有一批浅灰色的辅助说明文字,拿 axe 一测,对比度只有 2.8:1,不达标,虽然肉眼在正常显示器上看着还行,但对低视力用户和强光环境下用手机看后台的人来说基本看不清。另一处是移动端的触控目标尺寸,WCAG 2.1 建议可点击区域至少 44×44 像素,我们表格里那批图标按钮的实际可点区域只有 20×20,在触屏设备上经常点不准,这个问题手动测一遍比看代码更容易发现——拿手机实际点一遍那批操作图标,漏点、误点的概率明显偏高。这两项后续列进了组件库的走查清单,和键盘可用性、读屏播报放在同一优先级。

图片和数据可视化里的替代文本,不是随便填一句就够

这次整改还顺带牵出一个和图标按钮相似但更容易被忽略的问题:后台首页那几张数据看板上的趋势图,全是 <img> 标签直接引用后端生成的静态图表,alt 属性要么是空的,要么写着图片文件名。装饰性图片 alt="" 是对的写法(明确告诉读屏这张图不需要播报,比完全不写 alt 更规范,完全不写的话有些读屏会念出图片路径,反而更糟),但这几张趋势图承载的是"最近七天订单量走势"这类实际数据,属于信息性图片,alt 留空等于把这部分信息完全对读屏用户屏蔽掉了。

信息性图表的替代文本不该只是一句"订单趋势图",那种描述对读屏用户没有实际信息增量。更合适的做法是把图表想表达的关键结论写进 alt,或者在图片邻近位置提供一份文字或表格形式的等价数据:

1<img
2  src="/charts/order-trend.png"
3  alt="最近七天订单量呈上升趋势,从周一的320单增长到周日的510单"
4/>

如果图表本身交互复杂(比如可以切换时间维度、悬浮查看具体数值的那种),静态 alt 文本很难覆盖全部信息,这种情况下更稳妥的做法是图表本身之外,另外提供一份数据表格,用视觉隐藏或者可展开的方式让读屏用户能访问到完整数据,而不是试图把所有交互都塞进一句 alt。我们这次没时间把全部看板改造完,先把几个核心指标的图表按"结论文字化"的方式补了 alt,复杂的交互图表列进了下个迭代的待办。

QA 那张 bug 单最后关闭时,附言写的是"键盘和读屏都验证过了,没问题"。这条验证路径我们打算固定下来,以后新增的交互组件,上线前都过一遍键盘走查加 axe 扫描,不再只靠鼠标点一遍就算测完。