自定义下拉框用不了键盘:ARIA、焦点管理和屏幕阅读器该怎么补
QA 这周提的一个 bug 单,标题写得很直白:"筛选下拉框,键盘用不了"。描述里贴了一句:打开搜索框旁边那个自定义的商品分类下拉,鼠标点没问题,但只用键盘的话,Tab 能聚焦到它,回车能展开,之后上下方向键完全没反应,选项高亮不会跟着走,Esc 也关不掉,只能靠鼠标点别处才能收起来。
这个下拉框是组件原作者半年前写的,样式和交互都照着设计稿做得挺精致,问题是它整个组件全靠 div 和 mousedown/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 评分能覆盖对比度不足、缺 alt、label 没关联这类机器可判定的静态问题,但它们查不出"这个下拉框键盘用不了"这种需要实际交互的问题,分数满分不等于真的可用。
eslint-plugin-jsx-a11y 我们这次给 React 项目补上了,写代码的时候就能提示"img 缺 alt""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 的支持不算稳定,有些读屏会念出来,有些完全忽略,靠它做唯一的标签来源是在赌运气。正确的做法是 label 和 placeholder各司其职,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: none 或 visibility: hidden 去"隐藏" label,这两种写法会让读屏工具也一并跳过它,等于白写;上面这种裁剪加定位的写法才能做到"视觉上不可见,但读屏依然能访问"。
第二个问题出现在一组单选按钮上。筛选栏里有个"发货状态"是三个并列的 radio,每个都单独有 label,但整组按钮缺一个统一的分组说明,读屏用户逐个划过这三个选项时,只能听到"待发货""已发货""已签收",却不知道这三者是同属"发货状态"这一组的。原生的解决办法是用 fieldset 加 legend 把一组相关控件包起来:
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-label 或 aria-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 扫描,不再只靠鼠标点一遍就算测完。