关于 web-accessibility
面向构建和重构 Web 界面的人员及 Agent,提供 WCAG 2.1 AA 无障碍实施规范。内容涵盖语义 HTML、键盘操作、ARIA、表单与测试,并提供检查清单和可确定性检查的规则。
主要功能
- 01WCAG 2.1 AA 实施指南
- 02语义 HTML 与 ARIA 模式
- 03键盘导航和焦点管理
- 04表单、对比度及媒体规范
- 05自动化与手动测试清单
适用场景
- 01编写新的 Web 界面
- 02检查并修复无障碍问题
- 03设计可键盘操作的模态框
- 04验证表单和动态内容的辅助技术支持
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ alexanderstephenthompson/ claude-hub --skill web-accessibility安装后在 Claude Code 中输入/web-accessibility确认已加载。
详细介绍
Web 无障碍技能
版本: 1.0 标准: WCAG 2.1 AA 级
无障碍性不是可选项。这些模式可确保所有用户都能使用你的应用程序。
问题
AI Agent 构建的界面看起来正确,却并不具备无障碍性。没有明确的 WCAG 标准,每次会话都会漏掉替代文本、用 div 代替按钮、错误嵌套标题,以及忽略键盘导航——并非有意为之,而是因为这是阻力最小的做法。每次会话都会引入略有不同的无障碍缺陷,使得整改目标不断变化。这些标准可确保从第一个元素开始就构建无障碍体验。
使用方式
- 构建者: 在编写任何 HTML 或组件标记之前,阅读
## Builder Checklist。无障碍性必须在设计时融入,而不是事后通过 ARIA 修补。 - 重构者: 使用
## Enforced Rules查找无障碍违规。阅读说明性章节,了解整改模式。 - 两者: 说明性章节是权威标准。清单和规则表是同一内容的精简呈现。
范围与边界
本技能涵盖:
- WCAG 2.1 AA 级合规
- POUR 原则(可感知、可操作、可理解、健壮)
- 用于无障碍体验的语义 HTML(地标、标题、按钮与链接)
- 键盘导航和焦点管理
- 模态框的焦点陷阱
- ARIA 用法模式和角色
- 表单无障碍(标签、错误、必填字段)
- 颜色对比度要求
- 图像和媒体的替代文本
- 动态内容和实时区域
- 减少动态效果支持
- 屏幕阅读器测试
交由其他技能处理:
design:设计令牌系统、整体设计原则、组件状态web-css:CSS 实现细节、文件组织、响应式断点
在以下情况下使用本技能: 你需要符合 WCAG、支持屏幕阅读器、管理焦点或使用 ARIA 模式。 在以下情况下使用 design: 你需要设计系统原则、令牌规范或布局理念。 在以下情况下使用 web-css: 你需要 CSS 架构或响应式实现。
核心原则(POUR)
- 可感知——用户能够感知所有内容(看见、听见或感受到)。
- 可操作——用户能够操作所有控件(键盘、鼠标、语音等)。
- 可理解——用户能够理解内容和界面行为。
- 健壮——内容可与当前及未来的辅助技术配合使用。
优先使用语义化 HTML
使用恰当的元素
<header>Site header</header>
<nav>Navigation</nav>
<main>
<article>
<h1>Article Title</h1>
<p>Content...</p>
</article>
<aside>Related content</aside>
</main>
<footer>Site footer</footer>
<div class="header">Site header</div>
<div class="nav">Navigation</div>
<div class="main">
<div class="article">
<div class="title">Article Title</div>
<div class="content">Content...</div>
</div>
</div>
按钮与链接
// ✅ Button - Performs an action
<button onClick={handleSubmit}>Submit Form</button>
<button onClick={openModal}>Open Settings</button>
// ✅ Link - Navigates somewhere
<a href="/products">View Products</a>
<Link to="/checkout">Proceed to Checkout</Link>
// ❌ Bad - Wrong semantics
<div onClick={handleSubmit}>Submit Form</div>
<a onClick={openModal}>Open Settings</a> {/* No href! */}
<button onClick={() => navigate('/products')}>View Products</button>
标题层级
// ✅ Good - Proper hierarchy
<h1>Page Title</h1>
<section>
<h2>Section Title</h2>
<h3>Subsection</h3>
</section>
<section>
<h2>Another Section</h2>
</section>
// ❌ Bad - Skipped levels
<h1>Page Title</h1>
<h3>Subsection</h3> {/* Skipped h2! */}
<h5>Deep section</h5> {/* Skipped h4! */}
键盘导航
焦点管理
/* Visible focus indicator */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Remove outline only for mouse users */
:focus:not(:focus-visible) {
outline: none;
}
Tab 顺序
// ✅ Good - Logical tab order (follows DOM order)
<form>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
<label htmlFor="password">Password</label>
<input id="password" type="password" />
<button type="submit">Login</button>
</form>
// ❌ Bad - Jumpy tab order
<form>
<button type="submit" tabIndex={1}>Login</button>
<input tabIndex={3} />
<input tabIndex={2} />
</form>
跳过链接
// At the very top of your app
function SkipLink() {
return (
<a href="#main-content" className="skip-link">
Skip to main content
</a>
);
}
// CSS
.skip-link {
position: absolute;
top: -100%;
left: var(--space-4);
padding: var(--space-2) var(--space-4);
background: var(--color-surface);
z-index: var(--z-tooltip);
}
.skip-link:focus {
top: var(--space-4);
}
键盘快捷键
// Listen for keyboard events
function SearchModal({ isOpen, onClose }) {
useEffect(() => {
function handleKeyDown(e) {
if (e.key === 'Escape') {
onClose();
}
}
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, onClose]);
// ...
}
焦点陷阱(模态框)
模态框模式
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousFocus = useRef(null);
useEffect(() => {
if (isOpen) {
// Store current focus
previousFocus.current = document.activeElement;
// Focus the modal
modalRef.current?.focus();
// Trap focus inside modal
const modal = modalRef.current;
const focusableElements = modal?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements?.[0];
const lastElement = focusableElements?.[focusableElements.length - 1];
function handleTab(e) {
if (e.key !== 'Tab') return;
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
modal?.addEventListener('keydown', handleTab);
return () => modal?.removeEventListener('keydown', handleTab);
} else {
// Restore focus when closing
previousFocus.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
className="modal"
onClick={(e) => e.stopPropagation()}
tabIndex={-1}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose} aria-label="Close modal">
×
</button>
</div>
</div>,
document.body
);
}
ARIA 用法
何时使用 ARIA
ARIA 的首要原则: 如果可以使用原生 HTML,就不要使用 ARIA。
// ❌ Unnecessary ARIA
<div role="button" tabIndex={0} onClick={handleClick}>
Click me
</div>
// ✅ Just use a button
<button onClick={handleClick}>Click me</button>
必要的 ARIA 模式
// Labeling
<button aria-label="Close menu">×</button>
<input aria-labelledby="name-label helper-text" />
// Descriptions
<button aria-describedby="delete-warning">Delete Account</button>
<p id="delete-warning">This action cannot be undone.</p>
// States
<button aria-pressed={isActive}>Toggle</button>
<button aria-expanded={isOpen} aria-controls="menu">Menu</button>
<div id="menu" aria-hidden={!isOpen}>Menu content</div>
// Live regions (for dynamic content)
<div aria-live="polite" aria-atomic="true">
{statusMessage}
</div>
常见 ARIA 角色
| 角色 | 用例 |
|---|---|
alert | 重要消息(错误、警告) |
alertdialog | 需要用户响应的模态框 |
dialog | 模态对话框 |
navigation | 导航区域 |
search | 搜索表单 |
tablist、tab、tabpanel | 选项卡界面 |
menu、menuitem | 下拉菜单 |
status | 状态更新(加载、保存) |
表单
标签
// ✅ Explicit label association
<label htmlFor="email">Email Address</label>
<input id="email" type="email" />
// ✅ Implicit association (wrapped)
<label>
Email Address
<input type="email" />
</label>
// ❌ Bad - No association
<span>Email Address</span>
<input type="email" />
错误消息
function FormField({ id, label, error, ...props }) {
const errorId = `${id}-error`;
return (
<div className="form-field">
<label htmlFor={id}>{label}</label>
<input
id={id}
aria-invalid={!!error}
aria-describedby={error ? errorId : undefined}
{...props}
/>
{error && (
<p id={errorId} className="error-message" role="alert">
{error}
</p>
)}
</div>
);
}
必填字段
<label htmlFor="name">
Name <span aria-hidden="true">*</span>
<span className="visually-hidden">(required)</span>
</label>
<input id="name" required aria-required="true" />
颜色与对比度
最低对比度
| 文本类型 | 比例 | 示例 |
|---|---|---|
| 普通文本(< 18px) | 4.5:1 | 正文 |
| 大号文本(≥ 18px 或 14px 粗体) | 3:1 | 标题 |
| UI 组件 | 3:1 | 按钮、输入框 |
不要只依赖颜色
// ❌ Bad - Color is only indicator
<span style={{ color: error ? 'red' : 'green' }}>
{error ? 'Invalid' : 'Valid'}
</span>
// ✅ Good - Color + icon + text
<span className={error ? 'error' : 'success'}>
{error ? (
<>
<ErrorIcon aria-hidden="true" /> Invalid: {error}
</>
) : (
<>
<CheckIcon aria-hidden="true" /> Valid
</>
)}
</span>
图像与媒体
替代文本
// Informative image
<img src="chart.png" alt="Sales increased 40% from January to March" />
// Decorative image
<img src="divider.png" alt="" role="presentation" />
// Complex image
<figure>
<img src="diagram.png" alt="Architecture diagram" aria-describedby="diagram-desc" />
<figcaption id="diagram-desc">
The system consists of three layers: presentation, business logic, and data.
{/* Full description */}
</figcaption>
</figure>
SVG 图标
// Decorative icon (with visible text)
<button>
<SearchIcon aria-hidden="true" />
Search
</button>
// Standalone icon (needs label)
<button aria-label="Search">
<SearchIcon aria-hidden="true" />
</button>
// Icon with title
<svg role="img" aria-labelledby="icon-title">
<title id="icon-title">Search</title>
<path d="..." />
</svg>
动态内容
加载状态
function ProductList() {
const { data, loading, error } = useQuery(GET_PRODUCTS);
if (loading) {
return (
<div role="status" aria-live="polite">
<Spinner aria-hidden="true" />
<span className="visually-hidden">Loading products...</span>
</div>
);
}
if (error) {
return (
<div role="alert">
Error loading products. Please try again.
</div>
);
}
return (
<ul aria-label="Products">
{data.products.map(product => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
实时区域
// Polite - Waits for user to finish current task
<div aria-live="polite" aria-atomic="true">
{saveStatus} {/* "Saving...", "Saved!", etc. */}
</div>
// Assertive - Interrupts immediately (use sparingly)
<div aria-live="assertive" role="alert">
{errorMessage}
</div>
减少动态效果
/* Respect user preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// In React
function AnimatedComponent() {
const prefersReducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches;
return (
<motion.div
animate={{ opacity: 1 }}
transition={{
duration: prefersReducedMotion ? 0 : 0.3,
}}
/>
);
}
视觉隐藏文本
.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;
}
// Use for screen reader only content
<button>
<TrashIcon aria-hidden="true" />
<span className="visually-hidden">Delete item</span>
</button>
<a href="/products">
View all products
<span className="visually-hidden"> in the catalog</span>
</a>
测试无障碍性
自动化测试
// jest + jest-axe
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
it('has no accessibility violations', async () => {
const { container } = render(<ProductCard product={mockProduct} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
手动测试清单
- 仅使用键盘浏览整个页面
- 使用屏幕阅读器(VoiceOver、NVDA)
- 缩放到 200% 后仍可正常使用?
- 使用浏览器无障碍检查器检查
- 使用高对比度模式测试
- 启用减少动态效果后测试
反模式
| 反模式 | 问题 | 修复方法 |
|---|---|---|
| 所有内容都用 Div | 对辅助技术没有语义信息 | 使用语义化 HTML |
| tabIndex > 0 | 破坏自然的 Tab 顺序 | 移除正数 tabIndex |
| Outline: none | 没有焦点指示器 | 使用 :focus-visible |
| 过度使用 ARIA | 复杂且容易出错 | 优先使用原生 HTML |
| 仅用颜色表达含义 | 色盲用户无法识别 | 添加图标和文字 |
| 媒体自动播放 | 令人迷失方向、烦恼 | 仅由用户主动触发 |
| 仅支持鼠标交互 | 排斥键盘用户 | 添加键盘事件处理程序 |
| 缺少 alt 文本 | 屏幕阅读器无法识别图像 | 添加描述或标记为装饰性内容 |
| 链接描述不明确 | “点击此处”毫无用处 | 使用描述性链接文本 |
构建者清单
在编写受此技能约束的 UI 代码之前,请根据以下约束检查你的计划。构建者在编写代码前阅读本节;重构者则使用“强制规则”表和完整说明。
语义化 HTML
- 标题层级正确
- 操作用按钮,导航用链接
- 使用语义化地标(header、nav、main、footer)
- 列表使用 ul/ol/li
键盘
- 所有交互元素均可获得焦点
- 焦点指示器清晰可见
- Tab 顺序符合逻辑
- 提供跳过链接
- 模态框限制焦点范围
屏幕阅读器
- 所有图像均有 alt 文本
- 表单输入项均有标签
- 错误消息与输入项关联
- 动态内容使用实时区域
- 图标具有无障碍名称
视觉
- 颜色对比度达到 4.5:1
- 颜色不是唯一的指示方式
- 在 200% 缩放下正常使用
- 尊重减少动态效果设置
表单
- 所有输入项均有标签
- 标明必填字段
- 清楚标识错误
- 错误消息提供有用信息
强制规则
这些规则由 check.js(clean-team)进行确定性检查。更新这些标准时,请同步更新对应的 check.js 规则,反之亦然。
| 规则 ID | 严重性 | 检查内容 |
|---|---|---|
img-alt-required | error | <img> 缺少 alt 属性 |
title-required | error | 缺少 <title> 元素 |
tabindex-no-positive | error | tabindex 值为正数(会破坏 Tab 顺序) |
heading-order | warn | 标题级别跳跃(h1 → h3) |
single-h1 | warn | 每页有多个 <h1> 元素 |
no-div-as-button | warn | <div>/<span> 使用 onclick 处理程序 |
资源
评论
支持 Markdown