可访问的 Tabs 和手风琴需要哪些 role、键盘行为和 aria-expanded?
Tabs 用 tablist、tab、tabpanel 三个 role,选中项用 aria-selected,整组只占一个 Tab 位置,用左右方向键切换;手风琴的标题是放在 heading 里的 button,用 aria-expanded 表示展开,Enter 或 Space 切换,每个标题按钮都在 Tab 顺序里。
简答
商品详情页上“介绍、规格、评价”三个切换按钮是 Tabs,FAQ 页面上点一下标题展开答案的是手风琴。两者看起来都是“点标题、显示内容”,可访问性的写法却不一样。
Tabs 由三个 role 组成:外层
role="tablist",每个标签role="tab",每块内容role="tabpanel"。当前选中的 tab 设aria-selected="true",其余为false;tab 用aria-controls指向自己的面板,面板用aria-labelledby指回 tab。手风琴的每个标题是一个button,外面包一个标题元素(比如h3),按钮上用aria-expanded表示面板展开还是收起,用aria-controls指向面板。键盘行为也不同。Tabs 整组只占一个 Tab 位置,Tab 进去落在当前选中的 tab 上,在组内用左右方向键切换,到头会绕回;再按 Tab 就离开 tablist,进入面板。手风琴的每个标题按钮都在页面的 Tab 顺序里,用 Enter 或 Space 展开、收起。
要注意两点。Tabs 只有在面板能立刻显示时,才适合“焦点移到哪个 tab 就显示哪个面板”的自动激活;面板要加载数据的,改成按 Enter 或 Space 才激活。手风琴的面板加不加
role="region"要看数量,能同时展开的面板超过大约 6 个时就别加了,否则地标区域太多。
Tabs 的结构怎么写
先看一组横向的 Tabs。重点看三处:tab 之间怎样互相指向、谁有 tabindex="0"、面板怎样关联到 tab。
<div role="tablist" aria-label="商品信息">
<button role="tab" id="tab-intro" aria-selected="true" aria-controls="panel-intro">介绍</button>
<button role="tab" id="tab-spec" aria-selected="false" aria-controls="panel-spec" tabindex="-1">规格</button>
<button role="tab" id="tab-review" aria-selected="false" aria-controls="panel-review" tabindex="-1">评价</button>
</div>
<div role="tabpanel" id="panel-intro" aria-labelledby="tab-intro" tabindex="0">……</div>
<div role="tabpanel" id="panel-spec" aria-labelledby="tab-spec" tabindex="0" hidden>……</div>
<div role="tabpanel" id="panel-review" aria-labelledby="tab-review" tabindex="0" hidden>……</div>
tablist 需要一个名字。页面上有可见的标题就用 aria-labelledby 指向它,没有就用 aria-label。role="tab" 说明它是一组标签中的一个,aria-selected 说明它有没有被选中。
为什么只有选中的 tab 是 tabindex="0",其余是 -1?因为 APG 规定 Tab 键进入 tablist 时,焦点落在当前选中的 tab 上,组内的移动交给方向键。如果每个 tab 都能被 Tab 到,键盘用户穿过一组十个标签就要按十次 Tab。这种“组内只留一个 Tab 位置”的做法叫 roving tabindex,和《可访问的树控件需要怎样处理键盘和层级状态?》里的做法一样。
面板上的 tabindex="0" 是有条件的。APG 的说法是:面板里没有可聚焦元素,或者面板里第一个有内容的元素不能聚焦时,才给面板加 tabindex="0",这样按 Tab 离开 tablist 后能落到面板上。如果面板一开头就是一个链接或输入框,焦点直接落到那里就行,面板本身不用加。
Tabs 的键盘行为
APG 列出的按键如下,横向和纵向的区别只在方向键:
- 左、右方向键:移到上一个、下一个 tab,在首尾之间绕回。
- 上、下方向键:只在
aria-orientation="vertical"的纵向 tablist 里起作用;横向的 tablist 不处理它们,留给页面滚动。 - Home、End:跳到第一个、最后一个 tab。APG 标为可选。
- Enter 或 Space:如果焦点移过去时没有自动激活,按它激活当前 tab。
- Delete:只有允许关闭标签时才需要,APG 同样标为可选。
方向键绕回的逻辑容易写错,下面把它写成一个纯函数,单独验证一下:
function nextTabIndex(current, total, key) {
if (key === 'ArrowRight') return (current + 1) % total;
if (key === 'ArrowLeft') return (current - 1 + total) % total;
if (key === 'Home') return 0;
if (key === 'End') return total - 1;
return current;
}
console.log(nextTabIndex(2, 3, 'ArrowRight')); // 0
console.log(nextTabIndex(0, 3, 'ArrowLeft')); // 2
console.log(nextTabIndex(1, 3, 'End')); // 2
console.log(nextTabIndex(1, 3, 'ArrowDown')); // 1
最后一行要注意:横向 tablist 收到 ArrowDown 时什么也不做,焦点留在原地,页面照常滚动。真正接到 keydown 里时,算出新下标后要做三件事:把旧 tab 改成 tabindex="-1",新 tab 改成 tabindex="0",再调用新 tab 的 focus()。
自动激活还是手动激活
焦点移到一个 tab 上,面板要不要马上切过去?APG 给的判断标准是面板能不能“没有明显延迟”地显示。内容已经在页面里的,可以用自动激活,方向键走到哪个 tab,就显示哪个面板,同时更新 aria-selected。
如果面板要请求接口才能显示,自动激活会让用户每按一次方向键都触发一次加载,在标签之间移动会变得很慢。这时改成手动激活:方向键只移动焦点,按 Enter 或 Space 才切换面板和 aria-selected。
手风琴的结构怎么写
手风琴没有 tablist 这样的容器 role,每一项就是“标题按钮 + 面板”:
<h3>
<button type="button" id="faq-1-btn" aria-expanded="true" aria-controls="faq-1-panel">
怎样申请退款?
</button>
</h3>
<div id="faq-1-panel" role="region" aria-labelledby="faq-1-btn">……</div>
<h3>
<button type="button" id="faq-2-btn" aria-expanded="false" aria-controls="faq-2-panel">
多久能到账?
</button>
</h3>
<div id="faq-2-panel" role="region" aria-labelledby="faq-2-btn" hidden>……</div>
APG 要求标题按钮外面包一个 heading,可以直接用 h3 这样的原生标题元素,级别按页面的信息结构选。标题里只放这个按钮,标题里只放这个按钮,其他常驻的元素(比如一个菜单按钮)要放到标题外面。
aria-expanded 放在按钮上,不放在面板上。面板显示时为 true,隐藏时为 false。如果手风琴规定必须有一项展开、不允许收起,APG 建议在展开项的按钮上设 aria-disabled="true",告诉用户这个按钮现在点了也收不起来。
面板要不要加 role="region"
role="region" 加 aria-labelledby 会把面板变成一个有名字的地标区域。APG 认为,面板里有子标题或者嵌套手风琴时,这能帮读屏用户看清结构。
可是地标太多反而难用。APG 的建议是,能同时展开的面板超过大约 6 个时,不要给面板加 region。所以上面的例子只有两项,可以加;一个有 30 个问题、可以全部展开的 FAQ 就别加了。
手风琴的键盘行为
手风琴的键盘行为比 Tabs 简单。每个标题按钮都在页面的 Tab 顺序里,Tab 和 Shift + Tab 在按钮和面板里的可聚焦元素之间移动。在收起的标题上按 Enter 或 Space 会展开面板;如果手风琴一次只允许展开一项,同时收起原来展开的那项。在已展开的标题上按 Enter 或 Space,只有在实现支持收起时才会收起。
能不能直接用 details 元素
简单的展开收起可以用原生的 <details> 和 <summary>,不写脚本就能展开收起。MDN 还介绍了 name 属性:给几个 <details> 设同一个 name,它们就成了一组,打开一个会自动关掉另一个,正好是“一次只展开一项”的手风琴。
<details name="faq">
<summary>怎样申请退款?</summary>
<p>……</p>
</details>
<details name="faq">
<summary>多久能到账?</summary>
<p>……</p>
</details>
它的限制是结构固定:MDN 写明 <details> 不允许设置 role,<summary> 也不是包在标题元素里的按钮。需要 APG 那种“标题加按钮”的结构,或者要控制展开状态以外的行为时,还是自己按 APG 来写。
常见追问
Tabs 和手风琴该怎么选?
看用户是不是需要同时看多块内容。Tabs 一次只显示一个面板,适合几块并列、互相切换的内容,比如商品的介绍和规格。手风琴可以同时展开多项,适合 FAQ 这种一项一项往下读的内容。
选中的 tab 为什么用 aria-selected,不用 aria-expanded?
因为 APG 的 Tabs 模式就规定用
aria-selected。一组 tab 里同时只有一个被选中,选中的含义是“当前显示的是它的面板”。aria-expanded是给手风琴标题这类“控制一块内容展开收起”的按钮用的。两个模式在 APG 里各有规定,不要混用。