关于 accessibility-implementation
accessibility-implementation 是为开发者提供的无障碍实现技能,涵盖 WCAG、ARIA、键盘操作和屏幕阅读器支持。它也提供自动化及手动测试方法,帮助检查组件和页面的可访问性。
主要功能
- 01实现 WCAG 2.1/2.2 标准
- 02应用 ARIA 角色、状态和属性
- 03配置键盘导航与焦点管理
- 04支持屏幕阅读器内容播报
- 05使用自动化工具进行无障碍测试
适用场景
- 01为自定义组件添加键盘操作
- 02实现对话框焦点限制与恢复
- 03检查页面的无障碍违规
- 04改进表单标签和错误提示
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ laurigates/ claude-plugins --skill accessibility-implementation安装后在 Claude Code 中输入/accessibility-implementation确认已加载。
详细介绍
无障碍功能实现
WCAG 指南、ARIA 模式和辅助技术支持的技术实现。
何时使用此技能
| 在以下情况下使用此技能…… | 在以下情况下改用 design-tokens…… |
|---|---|
| 在代码中实现 WCAG 2.1/2.2 成功标准 | 设置 CSS 自定义属性或主题系统 |
| 添加 ARIA 角色、状态或实时区域 | 定义主题使用的语义颜色令牌 |
| 实现键盘导航、焦点陷阱或跳过链接 | 组织基础/语义/组件令牌层级 |
| 使用 axe-core、jest-axe 或 Playwright 审核组件 | 实现浅色/深色模式令牌覆盖 |
核心专业知识
- WCAG 合规性:在代码中实现 WCAG 2.1/2.2 成功标准
- ARIA 模式:正确使用角色、状态和属性
- 键盘导航:焦点管理、按键处理器、合理的 Tab 顺序
- 屏幕阅读器:内容结构、播报、实时区域
- 测试:自动化和手动无障碍测试
WCAG 快速参考
A 级(必须具备)
| 标准 | 实现方式 |
|---|---|
| 1.1.1 非文本内容 | 为图像提供 alt,为输入控件提供标签 |
| 1.3.1 信息和关系 | 语义化 HTML、ARIA 关系 |
| 2.1.1 键盘 | 所有交互元素均可通过键盘访问 |
| 2.4.1 绕过区块 | 跳过链接、地标 |
| 4.1.2 名称、角色、值 | 自定义小组件的 ARIA 标签和角色 |
AA 级(应当具备)
| 标准 | 实现方式 |
|---|---|
| 1.4.3 对比度(最低) | 文本 4.5:1,大号文本 3:1 |
| 1.4.11 非文本对比度 | UI 组件达到 3:1 |
| 2.4.6 标题和标签 | 描述清晰、层级分明的标题 |
| 2.4.7 焦点可见 | 可见的焦点指示器(2px 以上的轮廓) |
ARIA 模式
按钮和链接
<div role="button" tabindex="0"
aria-pressed="false"
onkeydown="handleKeyDown(event)">
Toggle Feature
</div>
<button aria-label="Close dialog">
<svg aria-hidden="true">...</svg>
</button>
<a href="/page">Go to page</a>
<button type="button">Submit form</button>
表单控件
<label for="email">Email address</label>
<input id="email" type="email"
aria-describedby="email-hint email-error"
aria-invalid="true"
required>
<div id="email-hint">We'll never share your email</div>
<div id="email-error" role="alert">Please enter a valid email</div>
<fieldset>
<legend>Notification preferences</legend>
<label><input type="checkbox" name="notif" value="email"> Email</label>
<label><input type="checkbox" name="notif" value="sms"> SMS</label>
</fieldset>
<label for="country">Country</label>
<input id="country"
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-controls="country-listbox">
<ul id="country-listbox" role="listbox" hidden>
<li role="option" id="opt-us">United States</li>
<li role="option" id="opt-uk">United Kingdom</li>
</ul>
模态对话框
<div role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc">
<h2 id="dialog-title">Confirm Action</h2>
<p id="dialog-desc">Are you sure you want to proceed?</p>
<button>Cancel</button>
<button>Confirm</button>
</div>
// Focus trap implementation
function trapFocus(dialog: HTMLElement) {
const focusable = dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0] as HTMLElement;
const last = focusable[focusable.length - 1] as HTMLElement;
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Tab') {
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
if (e.key === 'Escape') {
closeDialog();
}
});
// Move focus to first element
first.focus();
}
标签页
<div role="tablist" aria-label="Settings tabs">
<button role="tab"
id="tab-1"
aria-selected="true"
aria-controls="panel-1">
General
</button>
<button role="tab"
id="tab-2"
aria-selected="false"
aria-controls="panel-2"
tabindex="-1">
Privacy
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
General settings content
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
Privacy settings content
</div>
// Tab keyboard navigation
tablist.addEventListener('keydown', (e) => {
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
const current = tabs.indexOf(document.activeElement as Element);
let next: number;
switch (e.key) {
case 'ArrowRight':
next = (current + 1) % tabs.length;
break;
case 'ArrowLeft':
next = (current - 1 + tabs.length) % tabs.length;
break;
case 'Home':
next = 0;
break;
case 'End':
next = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
(tabs[next] as HTMLElement).focus();
activateTab(tabs[next]);
});
实时区域
<div role="status" aria-live="polite">
Form saved successfully
</div>
<div role="alert" aria-live="assertive">
Error: Connection lost
</div>
<div aria-live="polite" aria-atomic="true">
Loading: 45% complete
</div>
键盘导航
标准按键绑定
| 按键 | 行为 |
|---|---|
| Tab | 移至下一个可聚焦元素 |
| Shift+Tab | 移至上一个可聚焦元素 |
| Enter/Space | 激活按钮、选择选项 |
| Escape | 关闭模态框、取消操作 |
| 方向键 | 在组件内导航(标签页、菜单、列表框) |
| Home/End | 移至列表中的第一项/最后一项 |
焦点管理
// Return focus after modal close
const triggerElement = document.activeElement;
openModal();
// On close:
closeModal();
triggerElement?.focus();
// Move focus to error
function showValidationErrors() {
const firstError = document.querySelector('[aria-invalid="true"]');
(firstError as HTMLElement)?.focus();
}
// Skip link
<a href="#main-content" class="skip-link">Skip to main content</a>
<main id="main-content" tabindex="-1">...</main>
循环 Tabindex
// For composite widgets (toolbar, menu, tabs)
function setRovingTabindex(container: HTMLElement, selector: string) {
const items = container.querySelectorAll(selector);
items.forEach((item, index) => {
item.setAttribute('tabindex', index === 0 ? '0' : '-1');
});
container.addEventListener('keydown', (e) => {
const current = Array.from(items).indexOf(document.activeElement as Element);
let next = current;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (current + 1) % items.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (current - 1 + items.length) % items.length;
}
if (next !== current) {
items[current].setAttribute('tabindex', '-1');
items[next].setAttribute('tabindex', '0');
(items[next] as HTMLElement).focus();
e.preventDefault();
}
});
}
测试
自动化测试
# axe-core CLI
npx @axe-core/cli https://localhost:3000
# Lighthouse accessibility audit
npx lighthouse http://localhost:3000 --only-categories=accessibility --output=json
# pa11y
npx pa11y http://localhost:3000
# jest-axe for unit tests
npm install --save-dev jest-axe
// jest-axe example
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('component is accessible', async () => {
const { container } = render(<MyComponent />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
// Playwright accessibility testing
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('page should not have accessibility violations', async ({ page }) => {
await page.goto('/');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa'])
.analyze();
expect(results.violations).toEqual([]);
});
手动测试清单
键盘导航
- 所有交互元素均可通过 Tab 访问
- 焦点顺序与视觉顺序一致
- 焦点指示器始终可见
- 没有键盘陷阱
- 按 Escape 可关闭模态框/菜单
屏幕阅读器测试
- VoiceOver(macOS):Cmd+F5
- NVDA(Windows):免费下载
- 测试:链接会播报目标位置
- 测试:表单会播报标签和错误
- 测试:动态内容会被播报
视觉测试
- 放大至 200% 时无需水平滚动
- 颜色对比度符合比例要求
- 信息不单靠颜色传达
- 所有主题中的焦点指示器均清晰可见
常见修复方法
缺少可访问名称
<button><svg>...</svg></button>
<button aria-label="Close">
<svg aria-hidden="true">...</svg>
</button>
缺少表单标签
<input placeholder="Email">
<label for="email">Email</label>
<input id="email" type="email">
<label for="search" class="visually-hidden">Search</label>
<input id="search" type="search" placeholder="Search...">
缺少标题结构
<h1>Page Title</h1>
<h3>Section</h3>
<h1>Page Title</h1>
<h2>Section</h2>
<h3>Subsection</h3>
焦点不可见
/* Bad: Removing focus outline */
button:focus { outline: none; }
/* Good: Custom focus indicator */
button:focus-visible {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
颜色对比度
/* Bad: Low contrast */
.text { color: #999; background: #fff; } /* 2.85:1 ratio */
/* Good: Sufficient contrast */
.text { color: #595959; background: #fff; } /* 4.56:1 ratio */
CSS 工具类
/* Visually hidden but accessible */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Skip link */
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px;
background: #000;
color: #fff;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
最佳实践
优先使用语义化 HTML
优先使用原生 HTML 元素,而不是 ARIA。<button> 优于 <div role="button">。
不要覆盖默认行为
原生元素内置了无障碍功能。不要用 JavaScript 破坏这些功能。
与真实用户一起测试
自动化工具只能发现约 30% 的问题。使用辅助技术进行手动测试至关重要。
提供多种方式
为所有交互提供键盘、鼠标和触控替代方式。
References
- WCAG 2.1 Guidelines: https://www.w3.org/WAI/WCAG21/quickref/
- ARIA Authoring Practices: https://www.w3.org/WAI/ARIA/apg/
- axe-core Rules: https://dequeuniversity.com/rules/axe/
- A11y Project Checklist: https://www.a11yproject.com/checklist/
评论
支持 Markdown