关于 form-accessibility
Form Accessibility 是提供表单实现模式的技能,帮助表单符合 WCAG 2.2 AA,面向需要支持键盘、屏幕阅读器及不同认知或运动能力用户的开发者。
主要功能
- 01可见标签与输入用途提示
- 02通过 ARIA 关联错误和说明
- 03键盘导航与焦点管理
- 04无障碍验证和状态消息
- 05自动化与手动测试指南
适用场景
- 01按 WCAG 2.2 AA 检查表单标记
- 02添加无障碍错误反馈
- 03支持多步骤表单的键盘操作
- 04使用 axe-core 和手动检查测试表单
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ bbeierle12/ skill-mcp-claude --skill form-accessibility安装后在 Claude Code 中输入/form-accessibility确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
_meta.jsonscripts/aria-form-wrapper.tsxfocus-manager.ts详细介绍
表单无障碍
符合 WCAG 2.2 AA 标准的表单设计模式。确保键盘用户、屏幕阅读器用户以及有认知或运动障碍的用户都能使用表单。
快速入门
// Accessible form field pattern
<div className="form-field">
{/* 1. Visible label (never placeholder-only) */}
<label htmlFor="email">
Email
<span className="required" aria-hidden="true">*</span>
</label>
{/* 2. Hint text (separate from label) */}
<span id="email-hint" className="hint">
We'll send your confirmation here
</span>
{/* 3. Input with full ARIA binding */}
<input
id="email"
type="email"
autoComplete="email"
aria-required="true"
aria-invalid={hasError}
aria-describedby={hasError ? "email-error email-hint" : "email-hint"}
/>
{/* 4. Error message (announced by screen readers) */}
{hasError && (
<span id="email-error" className="error" role="alert">
Please enter a valid email address
</span>
)}
</div>
WCAG 2.2 表单要求
关键标准
| 标准 | 级别 | 要求 | 实现方式 |
|---|---|---|---|
| 1.3.1 信息和关系 | A | 通过程序传达结构 | <label>、<fieldset>、aria-describedby |
| 1.3.5 识别输入用途 | AA | 可识别输入用途 | autocomplete 属性 |
| 2.1.1 键盘 | A | 所有功能均可通过键盘使用 | Tab 顺序、焦点管理 |
| 2.4.6 标题和标签 | AA | 标签说明用途 | 清晰且可见的标签 |
| 2.4.11 焦点未被遮挡 | AA | 焦点不被其他内容隐藏 | 滚动行为、固定元素 |
| 2.5.8 目标尺寸 | AA | 最小触控目标为 24×24px | 按钮/输入框尺寸 |
| 3.3.1 错误识别 | A | 识别并说明错误 | aria-invalid、错误消息 |
| 3.3.2 标签或说明 | A | 提供标签 | 可见标签,而非仅使用占位符 |
| 3.3.3 错误建议 | AA | 提供修正错误的建议 | 可执行的错误消息 |
| 3.3.7 避免重复输入 | A | 不要重复询问已提供的信息 | 表单状态管理 |
| 3.3.8 无障碍身份验证 | AA | 不使用认知功能测试 | 不使用要求识别文本的 CAPTCHA |
WCAG 2.2 新增内容(2023 年 10 月)
2.4.11 焦点未被遮挡(AA)
/* Ensure focus is never hidden by sticky headers */
.sticky-header {
position: sticky;
top: 0;
}
input:focus {
/* Browser should scroll input into view above sticky elements */
scroll-margin-top: 80px; /* Height of sticky header */
}
2.5.8 目标尺寸(AA)
/* Minimum 24×24px touch targets */
button,
input[type="submit"],
input[type="checkbox"],
input[type="radio"] {
min-width: 24px;
min-height: 24px;
}
/* Better: 44×44px for comfortable touch */
.touch-friendly {
min-width: 44px;
min-height: 44px;
}
3.3.7 避免重复输入(A)
// ❌ BAD: Asking for email twice
<input name="email" />
<input name="confirmEmail" />
// ✅ GOOD: Ask once, show confirmation
<input name="email" />
<p>Confirmation will be sent to: {email}</p>
3.3.8 无障碍身份验证(AA)
// ❌ BAD: CAPTCHA requiring text recognition
<img src="captcha.png" alt="Enter the text shown" />
// ✅ GOOD: Alternative verification methods
<button type="button" onClick={sendVerificationEmail}>
Send verification code to email
</button>
ARIA 模式
错误消息绑定
// Pattern: aria-describedby links input to error
<input
id="email"
aria-invalid={hasError ? "true" : "false"}
aria-describedby={hasError ? "email-error" : undefined}
/>
{hasError && (
<span id="email-error" role="alert">
{errorMessage}
</span>
)}
多条描述
// Pattern: Combine hint + error in aria-describedby
<input
id="password"
aria-describedby={[
"password-hint",
hasError && "password-error"
].filter(Boolean).join(" ")}
/>
<span id="password-hint">Must be at least 8 characters</span>
{hasError && <span id="password-error" role="alert">{error}</span>}
必填字段
// Pattern: Announce required status
<label htmlFor="name">
Name
<span className="required" aria-hidden="true">*</span>
{/* Visual indicator hidden from SR, aria-required announces it */}
</label>
<input
id="name"
aria-required="true"
/>
// Alternative: Required in label (simpler)
<label htmlFor="name">Name (required)</label>
<input id="name" required />
字段分组
// Pattern: fieldset + legend for related fields
<fieldset>
<legend>Shipping Address</legend>
<label htmlFor="street">Street</label>
<input id="street" autoComplete="street-address" />
<label htmlFor="city">City</label>
<input id="city" autoComplete="address-level2" />
</fieldset>
单选按钮/复选框组
// Pattern: fieldset groups options, legend is the question
<fieldset>
<legend>Preferred contact method</legend>
<label>
<input type="radio" name="contact" value="email" />
Email
</label>
<label>
<input type="radio" name="contact" value="phone" />
Phone
</label>
</fieldset>
焦点管理
将焦点移至第一个错误
// On form submit with errors, focus first invalid field
function handleSubmit(e: FormEvent) {
e.preventDefault();
const firstError = formRef.current?.querySelector('[aria-invalid="true"]');
if (firstError) {
(firstError as HTMLElement).focus();
return;
}
// Submit if valid
submitForm();
}
步骤变更时移动焦点(多步骤)
// Move focus to step heading when changing steps
function goToStep(stepNumber: number) {
setCurrentStep(stepNumber);
// Wait for render, then focus
requestAnimationFrame(() => {
const heading = document.getElementById(`step-${stepNumber}-heading`);
heading?.focus();
});
}
// Heading must be focusable
<h2 id="step-2-heading" tabIndex={-1}>Shipping Address</h2>
跳过链接
// Allow skipping to form
<a href="#main-form" className="skip-link">
Skip to form
</a>
<form id="main-form">
{/* Form content */}
</form>
// CSS for skip link
.skip-link {
position: absolute;
top: -40px;
left: 0;
z-index: 100;
}
.skip-link:focus {
top: 0;
}
焦点陷阱(模态框)
// Keep focus within modal form
function FocusTrap({ children }) {
const trapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const trap = trapRef.current;
if (!trap) return;
const focusableElements = trap.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0] as HTMLElement;
const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;
function handleKeyDown(e: KeyboardEvent) {
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();
}
}
trap.addEventListener('keydown', handleKeyDown);
firstElement?.focus();
return () => trap.removeEventListener('keydown', handleKeyDown);
}, []);
return <div ref={trapRef}>{children}</div>;
}
颜色和对比度
错误状态(色盲友好)
/* ❌ BAD: Color only */
.error {
border-color: red;
}
/* ✅ GOOD: Color + icon + text */
.field-error {
border-color: #dc2626;
border-width: 2px;
}
.field-error::after {
content: "";
background-image: url("data:image/svg+xml,..."); /* Error icon */
}
.error-message {
color: #dc2626;
font-weight: 500;
}
.error-message::before {
content: "⚠ "; /* Text indicator */
}
焦点指示器
/* Focus must have 3:1 contrast ratio */
input:focus {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* For dark backgrounds */
input:focus {
outline: 2px solid #60a5fa;
outline-offset: 2px;
}
/* Never remove outline without replacement */
/* ❌ BAD */
input:focus {
outline: none;
}
/* ✅ GOOD: Custom focus style */
input:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.5);
}
验证状态(色盲友好)
// Use icons + text, not just color
function ValidationIndicator({ state }: { state: 'valid' | 'invalid' | 'idle' }) {
if (state === 'idle') return null;
return (
<span className={`indicator ${state}`} aria-hidden="true">
{state === 'valid' && '✓'}
{state === 'invalid' && '✗'}
</span>
);
}
键盘导航
Tab 顺序
// Natural tab order (no positive tabindex needed)
// ❌ BAD: Manual tab order
<input tabIndex={2} />
<input tabIndex={1} />
<input tabIndex={3} />
// ✅ GOOD: Natural DOM order
<input /> {/* tabIndex implicitly 0 */}
<input />
<input />
Escape 键处理
// Allow Escape to close dropdowns, cancel modals
function Modal({ onClose, children }) {
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === 'Escape') {
onClose();
}
}
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [onClose]);
return <div role="dialog" aria-modal="true">{children}</div>;
}
按 Enter 提交
// Forms submit on Enter by default
// For buttons that shouldn't submit:
<button type="button" onClick={handleAction}>
Add Item
</button>
// For preventing Enter submit on specific fields:
<input
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
// Do something else
}
}}
/>
实时区域
错误播报
// Announce errors when they appear
<div aria-live="polite" aria-atomic="true" className="sr-only">
{errorCount > 0 && `${errorCount} errors in form`}
</div>
// Or use role="alert" for immediate announcement
{hasError && (
<span role="alert">{errorMessage}</span>
)}
加载状态
// Announce loading state
<button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<span aria-hidden="true">Loading...</span>
<span className="sr-only">Submitting form, please wait</span>
</>
) : (
'Submit'
)}
</button>
// Or use aria-busy
<form aria-busy={isLoading}>
{/* ... */}
</form>
成功消息
// Announce successful submission
{isSuccess && (
<div role="status" aria-live="polite">
Form submitted successfully!
</div>
)}
仅供屏幕阅读器读取的内容
/* Visually hidden but announced by screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Allow focus for skip links */
.sr-only-focusable:focus {
position: static;
width: auto;
height: auto;
overflow: visible;
clip: auto;
white-space: normal;
}
无障碍测试
自动化工具
# axe-core (recommended)
npm install @axe-core/react
# In development
import React from 'react';
import ReactDOM from 'react-dom';
import axe from '@axe-core/react';
if (process.env.NODE_ENV !== 'production') {
axe(React, ReactDOM, 1000);
}
手动测试清单
- 仅使用键盘:能否仅使用 Tab、Enter、Space 和方向键完成表单?
- 屏幕阅读器:VoiceOver/NVDA 是否会播报标签、错误和必填状态?
- 200% 缩放:浏览器缩放到 200% 时,表单是否仍可用?
- 高对比度:Windows 高对比度模式下,所有内容是否都清晰可见?
- 焦点可见:能否始终看到当前获得焦点的元素?
测试脚本
// Automated accessibility test
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('form is accessible', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('error state is accessible', async () => {
const { container } = render(<LoginForm />);
// Trigger error
fireEvent.blur(screen.getByLabelText(/email/i));
const results = await axe(container);
expect(results).toHaveNoViolations();
});
文件结构
form-accessibility/
├── SKILL.md
├── references/
│ ├── wcag-2.2-forms.md # Full WCAG criteria breakdown
│ └── aria-patterns.md # Complete ARIA reference
└── scripts/
├── aria-form-wrapper.tsx # Automatic ARIA binding
├── focus-manager.ts # Focus trap, error focus
├── error-announcer.ts # Live region management
└── accessibility-validator.ts # Runtime a11y checks
参考资料
references/wcag-2.2-forms.md— 完整的 WCAG 2.2 表单标准references/aria-patterns.md— 详细的 ARIA 实现模式
评论
支持 Markdown