form-accessibility について
Form Accessibilityは、フォームをWCAG 2.2 AAに準拠させるための実装パターンをまとめたスキルです。キーボードやスクリーンリーダー、認知・運動能力の異なる利用者に対応するフォーム開発者を対象とします。
主な機能
- 01可視ラベルと入力目的のヒント
- 02ARIAで関連付けるエラーと説明
- 03キーボード操作とフォーカス管理
- 04アクセシブルな検証と状態メッセージ
- 05自動・手動テストのガイダンス
活用シーン
- 01フォームのマークアップをWCAG 2.2 AAに照らして確認
- 02アクセシブルなエラー案内を追加
- 03複数ステップフォームのキーボード操作に対応
- 04axe-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 対応