mcpField
B
building-accessible-interfaces

building-accessible-interfaces

github.com/osmontero设计与 UI16354 次安装今天更新
代码仓库

关于 building-accessible-interfaces

这项实践型技能指导按 WCAG 2.2 AA 级设计和验证界面。它强调原生语义 HTML、键盘操作、无障碍名称、对比度、表单和动态更新,并要求结合自动化工具、键盘导航与屏幕阅读器进行检查。

主要功能

  1. 01原生语义 HTML 模式
  2. 02键盘操作与焦点管理
  3. 03无障碍名称与对比度检查
  4. 04无障碍表单和动态播报
  5. 05自动化、键盘及屏幕阅读器验证

适用场景

  1. 01构建无障碍网页控件
  2. 02检查键盘访问和焦点行为
  3. 03改进表单标签与错误处理
  4. 04依据 WCAG 2.2 AA 验证界面

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/osmontero/opencode-skills --skill building-accessible-interfaces

安装后在 Claude Code 中输入/building-accessible-interfaces确认已加载。

详细介绍

构建无障碍界面

概述

无障碍不是最后才进行的一项检查。几乎所有无障碍缺陷都源于结构设计决策——本该用 button 却用了 div、只通过颜色传达状态、模态框从未移动焦点——而结构设计决策很难逆转。

核心原则: 原生语义 HTML 默认就具备无障碍性。在它之上添加的每一层,都会成为你需要负责其无障碍性的部分。

目标:WCAG 2.2 AA 级。

铁律

NO CUSTOM CONTROL WHERE A NATIVE ELEMENT EXISTS

<div onclick> 需要角色、tabindex、用于处理 Enter 和 Space 的按键处理程序、焦点样式和禁用状态。而 <button> 无需这些——它本身就具备所有这些特性,在每个平台和辅助技术中都能正确工作,而且是免费的。

写了 <div onclick>?把它换成 <button>。不要添加 ARIA 来让 div 表现得像按钮——ARIA 只会改变辅助技术播报的内容,不会改变浏览器的行为。

检查清单

  1. 优先考虑语义——正确的元素、地标、标题顺序
  2. 键盘操作——所有内容均可操作、焦点可见、顺序合理、没有焦点陷阱
  3. 名称——每个控件和图像都有无障碍名称
  4. 对比度——文本为 4.5:1,大号文本和 UI 边界为 3:1
  5. 表单——标签、错误关联、错误播报
  6. 动态内容——任何无需导航就会变化的内容都使用实时区域
  7. 动效和目标——尊重减少动态效果的设置,目标尺寸 ≥ 24px(建议 44px)
  8. 验证——自动扫描,然后进行键盘操作检查,最后使用屏幕阅读器

1. 语义

使用正确的元素
需求元素切勿使用
跳转到 URL<a href><button onclick=location…>
执行操作<button><div onclick>、<a href="#">
一组选项,只能选择一个<input type="radio">样式化的 div
显示/隐藏内容<details><summary>div + JS 切换
模态对话框<dialog>使用 position: fixed 的 div
从列表中选择<select>自定义列表框,除非确有需要
表格数据带 <th scope> 的 <table>由 div 组成的网格
进度<progress>动画 div

<a> 和 <button> 的区别并非外观上的差异:链接支持在新标签页中打开、复制链接,且仅在按 Enter 时激活;按钮在按 Enter 和 Space 时都会激活。屏幕阅读器用户会按元素类型进行导航,因此在链接列表中出现一个“按钮”确实会造成误导。

地标

只能有一个 <main>。使用 <nav> 包裹导航。使用 <header>、<footer>、<aside>。屏幕阅读器用户经常在地标之间跳转;只有嵌套 div 的页面没有任何可供跳转的内容。

<a href="#main" class="skip-link">Skip to content</a>
<header>…</header>
<nav aria-label="Primary">…</nav>
<main id="main" tabindex="-1">…</main>
<footer>…</footer>

多个 <nav> 元素都需要使用能区分它们的 aria-label(“Primary”、“Breadcrumb”、“Pagination”)。

.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: var(--space-s); top: var(--space-s); z-index: 9999; }
标题

每个页面使用一个 <h1>。绝不跳过级别(h2 → h4 是缺陷)。标题级别用于传达文档结构——根据结构选择级别,并用 CSS 设置大小。

2. 键盘操作

每个交互元素都必须仅使用键盘即可访问和操作。 这是本文档中最有效的检查方式,只需 90 秒:拔掉鼠标,然后按 Tab 键遍历页面。

按键预期行为
Tab / Shift+Tab按 DOM 顺序在可聚焦元素之间移动
Enter激活链接或按钮
Space激活按钮、切换复选框、滚动页面
Esc关闭模态框、弹出层、菜单;取消当前交互
Arrows在组合控件中移动——单选按钮组、标签页、菜单、列表框
Home / End跳到组合控件中的第一项 / 最后一项

Roving tabindex——组合控件整体只占用 一个 Tab 停靠点,使用方向键在其中移动:

// Only the active item is tabbable; arrows move the active item.
items.forEach((el, i) => el.tabIndex = i === active ? 0 : -1);

标签页、菜单、工具栏和列表框都采用这种方式。如果标签列表中的每个标签都是单独的 Tab 停靠点,那就是错误的。

焦点可见性
:focus-visible {
  outline: 2px solid var(--focus, currentColor);
  outline-offset: 2px;
  border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none; }  /* no ring on mouse click */

绝不要在没有替代样式的情况下使用 outline: none。 焦点指示器与相邻颜色之间的对比度必须达到 3:1,厚度至少为 2px(WCAG 2.2 SC 2.4.11/2.4.13)。

焦点管理

上下文变化时,焦点必须随之移动;上下文关闭时,焦点必须返回。

// Opening
lastFocused = document.activeElement;
dialog.showModal();                    // <dialog> traps focus natively
dialog.querySelector('h2').focus();    // or the first meaningful control

// Closing
dialog.close();
lastFocused?.focus();                  // MUST return focus

切勿让焦点被困在模态框之外。 按 Esc 无法退出的焦点陷阱违反 WCAG 2.1.2,属于 A 级失败——这可能导致键盘用户永久卡在页面上。

SPA 中的路由变化: 将焦点移至新页面的 <h1> 或 <main tabindex="-1">,并播报新标题。否则,屏幕阅读器用户将不知道导航已发生。

切勿在未经提示的情况下移动焦点。 在用户输入过程中自动聚焦某个字段,或在后台更新时跳转焦点,都会让所有人感到困惑。

3. 可访问名称

每个控件都需要一个名称。在开发者工具的「辅助功能」面板中检查计算出的名称,而不是你原本打算写在标记中的名称。


<label for="q">Search</label><input id="q">


<button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button>


<section aria-labelledby="h-recent"><h2 id="h-recent">Recent</h2>…</section>

规则:

  • 装饰性图片:alt=""。完全省略 alt 会让屏幕阅读器读出文件名。
  • 信息性图片:alt 应描述信息,而非图片本身。图表的替代文本应说明其结论。
  • 已命名控件中的图标:使用 aria-hidden="true",避免名称重复。
  • 链接文本脱离上下文也必须有意义。页面上出现 8 个「阅读更多」毫无用处;屏幕阅读器用户会浏览链接列表。
  • aria-label 会覆盖可见文本。如果按钮显示「保存」,但设置了 aria-label="Submit",语音控制用户说「点击保存」就会失败。两者应保持一致。

4. 对比度

内容比率
正文4.5:1
≥ 24px 的文本,或 ≥ 18.66px 的粗体文本3:1
传达含义的图标和 UI 边界3:1
焦点指示器与相邻颜色3:1

请实际测量,不要估算。计算公式见 designing-frontend-interfaces/references/color-and-theme.md,可运行的检查工具见 applying-themes/scripts/check_contrast.py。

颜色绝不能是唯一的信号。 凡是用颜色传达状态的地方,都应添加图标、标签、图案或形状——参见同一参考资料中的表格。

5. 表单

<div class="field">
  <label for="email">Email address</label>
  <p id="email-hint" class="hint">We'll only use this to send your receipt.</p>
  <input id="email" type="email" inputmode="email" autocomplete="email"
         aria-describedby="email-hint email-err" aria-invalid="true" required>
  <p id="email-err" class="error">Enter an email address with an @.</p>
</div>
规则原因
每个输入框都有 <label for>占位符不是标签——聚焦时会消失,且对比度不足
通过 aria-describedby 关联错误信息否则屏幕阅读器无法识别错误信息
无效字段设置 aria-invalid="true"播报字段状态,而不只是错误信息
提示和错误信息都放在 aria-describedby 中ID 列表以空格分隔;两者都会被播报
切勿用提交按钮上的 disabled 表示验证状态Tab 会跳过禁用元素,而且不会说明原因
使用 <fieldset><legend> 对相关输入框分组单选按钮组和复选框组需要播报组名
使用 required 属性,而不只是红色星号浏览器会原生播报

提交失败时:将焦点移至第一个无效字段,或移至一个包含各项错误链接的摘要。

6. 实时区域

任何未通过页面导航发生的变化都必须播报。

<div aria-live="polite" aria-atomic="true" class="sr-only" id="status"></div>
<div role="alert" id="errors"></div>   
区域用途会打断播报吗?
aria-live="polite"状态、结果数量、保存确认否——会等到播报间隙
role="alert" / assertive错误、会话过期、破坏性警告会

关键: 实时区域元素必须在内容变化之前就存在于 DOM 中。注入一个已经包含文本的元素不会触发播报。

// ✅ Region already present; only its text changes
status.textContent = `${n} results`;

// ❌ Injecting a populated live region — silent
container.innerHTML = '<div aria-live="polite">12 results</div>';

请谨慎使用。三个实时区域同时播报会造成难以使用的嘈杂体验。

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0;
  margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}

注意,如果 .sr-only 内容包含控件,它仍然可以获得焦点——使用 .sr-only:focus-within 样式显示跳过链接。

7. 动效和目标尺寸

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .01ms !important; animation-iteration-count: 1 !important;
    transition-duration: .01ms !important; scroll-behavior: auto !important;
  }
}
  • 每秒闪烁不得超过 3 次(有癫痫风险,WCAG 2.3.1)。
  • 自动播放超过 5 秒的动效需要提供暂停控件(WCAG 2.2.2)。
  • 轮播需要提供暂停功能,并且在获得焦点时不得自动切换。

目标尺寸(WCAG 2.2 SC 2.5.8,AA): 最小为 24×24 CSS px。实际建议为 44×44。 不改变视觉尺寸的同时扩大可点击区域:

.icon-btn { position: relative; }
.icon-btn::after { content:""; position:absolute; inset:-10px; }

8. ARIA 的五条规则

  1. 如果存在语义符合需求的原生元素,就改用原生元素。
  2. 不要更改原生语义——<button role="heading"> 是错误的;应改用包装元素。
  3. 所有交互式 ARIA 都必须支持键盘操作——带有 role="button" 的 div 需要 tabindex="0" 以及 Enter/Space 处理程序。
  4. 绝不要在可聚焦元素上使用 role="presentation" 或 aria-hidden="true"——这样元素仍可聚焦,却不会被读出,这是最糟糕的状态。
  5. 每个交互元素都需要可访问名称。

**没有 ARIA 好过错误的 ARIA。**错误的 ARIA 会主动传递错误信息;缺少 ARIA 至少还会回退到原生行为。

组件模式

对话框
<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">Delete 47 records?</h2>
  <p>Records in Q3 Archive will be permanently removed.</p>
  <button data-close>Keep records</button>
  <button data-confirm class="danger">Delete 47 records</button>
</dialog>

dialog.showModal() 原生提供焦点陷阱、按 Esc 关闭、背景惰性处理以及 ::backdrop 伪元素。你仍需负责:打开时将焦点设到有意义的位置,关闭时恢复焦点。

折叠面板
<button aria-expanded="false" aria-controls="panel-1">Shipping details</button>
<div id="panel-1" hidden>…</div>

同时切换 aria-expanded 和 hidden。或者使用 <details><summary>,完全不写 JavaScript。

标签页
<div role="tablist" aria-label="Account settings">
  <button role="tab" aria-selected="true"  aria-controls="p1" id="t1" tabindex="0">Profile</button>
  <button role="tab" aria-selected="false" aria-controls="p2" id="t2" tabindex="-1">Billing</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>

使用循环 tabindex;按方向键可在标签页之间移动;按 Home/End 可跳到第一个/最后一个标签页。

Toast
<div aria-live="polite" id="toasts"></div>   

错误使用 role="alert"。绝不要自动关闭需要用户处理的内容——WCAG 2.2.1 要求时间限制可调整。

组合框

这是最难实现的常见模式。请使用维护良好的库(React Aria、Radix、Headless UI),不要自行实现。若自行实现:使用带有 aria-expanded、aria-controls 和 aria-activedescendant 的 role="combobox",让它们指向高亮选项的 id——焦点留在输入框中,按方向键时移动的是高亮项。

验证

自动化工具大约能发现 30-40% 的问题。以下三项检查都必须进行。

1. 自动扫描

npx @axe-core/cli http://localhost:5173 --exit
npx lighthouse http://localhost:5173 --only-categories=accessibility --view

也可以在 Playwright 测试中进行——请参阅 testing-webapps 技能:

page.add_script_tag(url="https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js")
violations = page.evaluate("async () => (await axe.run()).violations")
assert not violations, [v["id"] for v in violations]

2. 键盘遍历——不使用鼠标:

  • 按 Tab 可以到达每个控件,顺序与视觉布局一致
  • 焦点始终可见——不会丢失,也不会在任何背景上变得不可见
  • Enter 和 Space 可以触发相应操作
  • Esc 可以关闭所有覆盖层
  • 在标签页、菜单和单选按钮组中,方向键可以正常工作
  • 打开模态框时焦点移入其中;关闭时焦点返回触发元素
  • 没有只能通过悬停才能访问的内容
  • 模态框之外不存在焦点陷阱

3. 屏幕阅读器——使用实际设备花 5 分钟体验:

平台阅读器启动 / 停止
macOSVoiceOverCmd+F5
WindowsNVDA(免费)Ctrl+Alt+N
LinuxOrcaSuper+Alt+S

按标题(H)、地标(D)和表单控件(F)导航。每个控件都应读出其名称、角色和状态。

常见错误

**用 <div onclick> 代替 <button>。**这是最常见的问题。它不支持键盘操作,没有角色、没有焦点,也没有禁用状态。

**使用 outline: none,却不提供替代样式。**这会移除键盘用户唯一能用来判断当前位置的提示。

**用占位符代替标签。**聚焦后占位符会消失、对比度不足、会干扰自动填充,也不利于屏幕阅读器识别。

**从模态框关闭后焦点没有返回。**焦点会落到 <body> 上,迫使用户重新按 Tab 遍历整个页面。

**随内容一起插入实时区域。**不会读出任何内容。该元素必须预先存在。

**aria-label 与可见文本相矛盾。**这会破坏语音控制——用户说出屏幕上看到的词,却不会触发任何操作。

**纯图标按钮没有名称。**它会被读作“按钮”。每个图标按钮都需要 aria-label。

**根据字号选择标题级别。**因为看起来合适就选了 <h4>。级别应根据结构确定,字号则用 CSS 设置。

**只在浅色模式下检查对比度。**深色模式需要单独检查。

**在可聚焦元素上使用 aria-hidden="true"。**按 Tab 可以访问到该元素,但它不会被读出。对于隐藏容器,应改用 inert。

评论

支持 Markdown