mcpField
F
form-accessibility

form-accessibility

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

关于 form-accessibility

Form Accessibility 是提供表单实现模式的技能,帮助表单符合 WCAG 2.2 AA,面向需要支持键盘、屏幕阅读器及不同认知或运动能力用户的开发者。

主要功能

  1. 01可见标签与输入用途提示
  2. 02通过 ARIA 关联错误和说明
  3. 03键盘导航与焦点管理
  4. 04无障碍验证和状态消息
  5. 05自动化与手动测试指南

适用场景

  1. 01按 WCAG 2.2 AA 检查表单标记
  2. 02添加无障碍错误反馈
  3. 03支持多步骤表单的键盘操作
  4. 04使用 axe-core 和手动检查测试表单

安装

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

$npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill form-accessibility

安装后在 Claude Code 中输入/form-accessibility确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

_meta.json
scripts/
aria-form-wrapper.tsx
focus-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);
}
手动测试清单
  1. 仅使用键盘:能否仅使用 Tab、Enter、Space 和方向键完成表单?
  2. 屏幕阅读器:VoiceOver/NVDA 是否会播报标签、错误和必填状态?
  3. 200% 缩放:浏览器缩放到 200% 时,表单是否仍可用?
  4. 高对比度:Windows 高对比度模式下,所有内容是否都清晰可见?
  5. 焦点可见:能否始终看到当前获得焦点的元素?
测试脚本
// 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