mcpField
A
accessibility-audit

accessibility-audit

github.com/mgd34msuテスト・品質660 回インストール10 週間前に更新
リポジトリ

accessibility-audit について

accessibility-auditは、アプリのアクセシビリティ監査を行う開発者やレビュアー向けのスキルです。段階的な手順でセマンティックHTML、キーボード操作、スクリーンリーダー、コントラスト、フォームなどを確認し、検証スクリプトとパターン資料も備えています。

主な機能

  1. 01WCAG 2.1 AA監査の段階的な手順
  2. 02セマンティックHTMLとARIAの確認
  3. 03キーボード操作とフォーカスの確認
  4. 04スクリーンリーダーと色コントラストの確認
  5. 05フォーム、エラー、メディアの監査
  6. 06検証スクリプトとパターン資料

活用シーン

  1. 01リリース前のアクセシビリティ確認
  2. 02UI改修後の操作性とフォーカスの再確認
  3. 03アクセシビリティに関する苦情や適合性の調査
  4. 04チーム内の監査手順の整備

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/mgd34msu/goodvibes-plugin --skill accessibility-audit

インストール後、Claude Code で/accessibility-auditを入力して読み込まれたか確認します。

同梱ファイルreferences/accessibility-patterns.mdscripts/validate-accessibility-audit.sh

詳細

リソース

scripts/
  validate-accessibility-audit.sh
references/
  accessibility-patterns.md

アクセシビリティ監査

このスキルでは、WCAG 2.1 AA 準拠を確保するための包括的なアクセシビリティ監査の実施方法を説明します。アプリケーションのインクルーシブデザインを検証するとき、アクセシビリティレビューに備えるとき、またはアクセシビリティの問題を修正するときに使用してください。

このスキルを使用する場面

  • リリース前のアクセシビリティ準拠レビューを実施するとき
  • アクセシビリティに関する苦情や法的要件に対応するとき
  • 既存のアプリケーションの定期的なアクセシビリティ監査を実施するとき
  • 大規模な UI リファクタリング後にアクセシビリティを検証するとき
  • WCAG 2.1 AA 認証に備えるとき
  • アクセシビリティのベストプラクティスをチームに導入するとき

監査の方法

体系的なアクセシビリティ監査は、次のフェーズに沿って進めます。

フェーズ 1:事前調査

目的: コンポーネントを把握し、アクセシビリティ上重要な領域を特定します。

discover を使用して UI の範囲を把握します:

discover:
  queries:
    - id: interactive_components
      type: grep
      pattern: "(button|input|select|textarea|a href)"
      glob: "**/*.{tsx,jsx}"
    - id: form_components
      type: grep
      pattern: "<form|onSubmit|FormProvider"
      glob: "**/*.{tsx,jsx}"
    - id: aria_usage
      type: grep
      pattern: "aria-(label|labelledby|describedby|live|role)"
      glob: "**/*.{tsx,jsx}"
    - id: image_components
      type: grep
      pattern: "(<img|<Image|next/image)"
      glob: "**/*.{tsx,jsx}"
  verbosity: files_only

重要な領域を特定します:

  • フォームと入力フィールド
  • ナビゲーションメニューとルーティング
  • モーダルダイアログとオーバーレイ
  • データテーブルとリスト
  • メディアプレーヤー(音声/動画)
  • カスタムのインタラクティブウィジェット
フェーズ 2:セマンティック HTML 監査

目的: HTML5 のセマンティック要素が適切に使用されていることを確認します。

見出しの階層を確認する

見出しの使用箇所を検索します:

precision_grep:
  queries:
    - id: heading_elements
      pattern: "<h[1-6]|heading.*level"
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

よくある違反:

  • 見出しレベルを飛ばしている(h1 から h2 を使わずに h3 へ進む)
  • 1 ページ内に複数の h1 要素がある
  • 構造ではなく見た目のスタイル設定に見出しを使用している
  • 複雑なコンポーネントで見出しの階層が欠けている

適切な見出し構造:

import { ReactNode } from 'react';

interface PageLayoutProps {
  title: string;
  children: ReactNode;
}

export function PageLayout({ title, children }: PageLayoutProps) {
  return (
    <div className="page-layout">
      {/* Single h1 per page */}
      <h1 className="text-3xl font-bold">{title}</h1>
      
      <main>
        {children}
      </main>
    </div>
  );
}

interface SectionProps {
  title: string;
  children: ReactNode;
}

export function Section({ title, children }: SectionProps) {
  return (
    <section>
      {/* h2 for main sections */}
      <h2 className="text-2xl font-semibold">{title}</h2>
      {children}
    </section>
  );
}

interface SubsectionProps {
  title: string;
  children: ReactNode;
}

export function Subsection({ title, children }: SubsectionProps) {
  return (
    <div>
      {/* h3 for subsections */}
      <h3 className="text-xl font-medium">{title}</h3>
      {children}
    </div>
  );
}
ランドマーク領域を確認する

ランドマークの使用箇所を検索します:

precision_grep:
  queries:
    - id: landmarks
      pattern: "(<header|<main|<nav|<aside|<footer|role=\"(banner|navigation|main|complementary|contentinfo)\")"
      glob: "**/*.{tsx,jsx}"
  output:
    format: files_only

必須のランドマーク:

  • サイトヘッダーには <header> または role="banner"
  • ナビゲーションには <nav> または role="navigation"
  • 主コンテンツには <main> または role="main"(ページごとに必ず 1 つ)
  • サイドバーには <aside> または role="complementary"
  • サイトフッターには <footer> または role="contentinfo"

適切なランドマーク構造:

import { ReactNode } from 'react';

interface AppLayoutProps {
  navigation: ReactNode;
  sidebar?: ReactNode;
  children: ReactNode;
}

export function AppLayout({ navigation, sidebar, children }: AppLayoutProps) {
  return (
    <div className="app-layout">
      <header className="site-header">
        <div className="logo">MyApp</div>
        {navigation}
      </header>
      
      <div className="content-container">
        {sidebar && (
          <aside className="sidebar" aria-label="Filters">
            {sidebar}
          </aside>
        )}
        
        {/* Exactly one main per page */}
        <main className="main-content">
          {children}
        </main>
      </div>
      
      <footer className="site-footer">
        <p>&copy; 2026 MyApp. All rights reserved.</p>
      </footer>
    </div>
  );
}
リストのセマンティクスを確認する

リストのパターンを検索します:

precision_grep:
  queries:
    - id: list_elements
      pattern: "<(ul|ol|li|dl|dt|dd)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: files_only

よくある違反:

  • <ul> や <ol> の代わりに、リストに <div> を使用している
  • <ul> や <ol> の外側に <li> を配置している
  • セマンティックなグループ化ではなく、レイアウトにリストを使用している

セマンティックなリストの使用:

interface User {
  id: string;
  name: string;
  email: string;
}

interface UserListProps {
  users: User[];
}

export function UserList({ users }: UserListProps) {
  return (
    <ul className="user-list" aria-label="Team members">
      {users.map((user) => (
        <li key={user.id}>
          <span className="user-name">{user.name}</span>
          <span className="user-email">{user.email}</span>
        </li>
      ))}
    </ul>
  );
}
フェーズ 3:ARIA パターン監査

目的: ARIA の使い方とパターンが適切であることを検証します。

ARIA ロールを確認する

ARIA ロールの使用箇所を検索します:

precision_grep:
  queries:
    - id: aria_roles
      pattern: 'role="(button|link|dialog|alertdialog|menu|menuitem|tab|tabpanel|listbox|option)"'
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

よくある違反:

  • セマンティック要素に冗長なロールを指定している(例:<button role="button">)
  • キーボードハンドラーを設定せずに role="button" を使用している
  • 複雑なロールに必要な ARIA 属性が欠けている
  • 抽象ロールを使用している(例:role="widget")

適切な ARIA ボタンパターン:

import { MouseEvent, KeyboardEvent } from 'react';

interface CustomButtonProps {
  onClick: () => void;
  disabled?: boolean;
  children: React.ReactNode;
}

export function CustomButton({ onClick, disabled, children }: CustomButtonProps) {
  const handleClick = (e: MouseEvent<HTMLDivElement>) => {
    if (!disabled) {
      onClick();
    }
  };

  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (!disabled && (e.key === 'Enter' || e.key === ' ')) {
      e.preventDefault();
      onClick();
    }
  };

  return (
    <div
      role="button"
      tabIndex={disabled ? -1 : 0}
      aria-disabled={disabled}
      onClick={handleClick}
      onKeyDown={handleKeyDown}
      className="custom-button"
    >
      {children}
    </div>
  );
}

注: 可能な場合は、ネイティブの <button> 要素を優先してください。role="button" は、どうしても必要な場合に限り、ボタン以外の要素に使用してください。

ARIA ラベルと説明を確認する

ラベル付けのパターンを検索します:

precision_grep:
  queries:
    - id: aria_labels
      pattern: "aria-(label|labelledby|describedby)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: context

よくある違反:

  • アクセシブルな名前が付いていないインタラクティブ要素
  • 存在しない ID を参照する aria-labelledby
  • 冗長なラベル(aria-label と aria-labelledby の両方がある)
  • インタラクティブでない要素に aria-label を使用している

適切なラベル付けパターン:

import { useId } from 'react';

interface SearchFormProps {
  onSearch: (query: string) => void;
}

export function SearchForm({ onSearch }: SearchFormProps) {
  const searchId = useId();
  const hintId = useId();

  return (
    <form role="search" onSubmit={(e) => {
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      onSearch(formData.get('query') as string);
    }}>
      <label htmlFor={searchId} className="sr-only">
        Search articles
      </label>
      <input
        id={searchId}
        type="search"
        name="query"
        placeholder="Search..."
        aria-describedby={hintId}
      />
      <p id={hintId} className="text-sm text-gray-600">
        Search by title, author, or keyword
      </p>
      <button type="submit" aria-label="Submit search">
        <SearchIcon aria-hidden="true" />
      </button>
    </form>
  );
}
ライブリージョンを確認する

ライブリージョンの使用箇所を検索します:

precision_grep:
  queries:
    - id: live_regions
      pattern: 'aria-live="(polite|assertive|off)"|role="(status|alert)"'
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

ライブリージョンのベストプラクティス:

  • 緊急性の低い更新には aria-live="polite" を使用する
  • 緊急のメッセージには aria-live="assertive" または role="alert" を使用する
  • ステータスの更新には role="status" を使用する(暗黙的に aria-live="polite" が設定されます)
  • コンテンツが更新される前に、ライブリージョンが DOM に存在することを確認する

アクセシブルな通知パターン:

import { ReactNode, useEffect, useState } from 'react';

interface NotificationProps {
  message: string;
  type: 'success' | 'error' | 'info';
  onDismiss: () => void;
}

export function Notification({ message, type, onDismiss }: NotificationProps) {
  useEffect(() => {
    const timer = setTimeout(onDismiss, 5000);
    return () => clearTimeout(timer);
  }, [onDismiss]);

  return (
    <div
      role={type === 'error' ? 'alert' : 'status'}
      aria-live={type === 'error' ? 'assertive' : 'polite'}
      className={`notification notification-${type}`}
    >
      <p>{message}</p>
      <button onClick={onDismiss} aria-label="Dismiss notification">
        <CloseIcon aria-hidden="true" />
      </button>
    </div>
  );
}
フェーズ 4:キーボードナビゲーション監査

目的: キーボードだけで完全に操作できることを確認します。

フォーカス管理を確認する

フォーカス関連のコードを検索します:

precision_grep:
  queries:
    - id: focus_management
      pattern: "(focus\\(\\)|autoFocus|tabIndex|useRef.*focus)"
      glob: "**/*.{tsx,jsx,ts}"
  output:
    format: standard

よくある違反:

  • フォーカスインジケーター(:focus スタイル)がない
  • モーダルに、解除方法のないフォーカストラップがある
  • 到達可能であるべきインタラクティブ要素に tabIndex="-1" が設定されている
  • ページ読み込み時に、不必要に要素へ自動フォーカスしている

フォーカストラップを備えたアクセシブルなモーダル:

import { useEffect, useRef, ReactNode } from 'react';
import { createPortal } from 'react-dom';

interface ModalProps {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null);
  const previousFocusRef = useRef<HTMLElement | null>(null);

  useEffect(() => {
    if (!isOpen) return;

    // Save previously focused element
    previousFocusRef.current = document.activeElement as HTMLElement;

    // Focus first focusable element in modal
    const focusableElements = modalRef.current?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    
    if (focusableElements && focusableElements.length > 0) {
      (focusableElements[0] as HTMLElement).focus();
    }

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === 'Escape') {
        onClose();
      }

      // Trap focus within modal
      if (e.key === 'Tab' && focusableElements) {
        const firstElement = focusableElements[0] as HTMLElement;
        const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;

        if (e.shiftKey && document.activeElement === firstElement) {
          e.preventDefault();
          lastElement.focus();
        } else if (!e.shiftKey && document.activeElement === lastElement) {
          e.preventDefault();
          firstElement.focus();
        }
      }
    };

    document.addEventListener('keydown', handleKeyDown);

    return () => {
      document.removeEventListener('keydown', handleKeyDown);
      // Restore focus when modal closes
      previousFocusRef.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div className="modal-overlay" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        className="modal-content"
        onClick={(e) => e.stopPropagation()}
      >
        <h2 id="modal-title">{title}</h2>
        <div className="modal-body">{children}</div>
        <button onClick={onClose} className="modal-close">
          Close
        </button>
      </div>
    </div>,
    document.body
  );
}
スキップリンクを確認する

スキップリンクの実装を検索します:

precision_grep:
  queries:
    - id: skip_links
      pattern: "(skip.*main|skip.*content|skip.*navigation)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: files_only

スキップリンクの実装:

export function SkipLink() {
  return (
    <a
      href="#main-content"
      className="skip-link"
    >
      Skip to main content
    </a>
  );
}

// CSS (in global styles)
// .skip-link {
//   position: absolute;
//   top: -40px;
//   left: 0;
//   background: #000;
//   color: #fff;
//   padding: 8px;
//   text-decoration: none;
//   z-index: 100;
// }
//
// .skip-link:focus {
//   top: 0;
// }
キーボードイベントハンドラーを確認する

キーボード操作に対応していない onClick を検索します:

precision_grep:
  queries:
    - id: onclick_handlers
      pattern: "onClick=\\{"
      glob: "**/*.{tsx,jsx}"
  output:
    format: locations

手動レビュー: ボタンやリンク以外の要素に設定された各 onClick ハンドラーについて、次を確認します:

  • 要素に role="button" または適切なロールが設定されている
  • キーボードフォーカス用に tabIndex={0} が設定されている
  • onKeyDown ハンドラーが Enter キーと Space キーに応答する

アクセシブルなクリックハンドラーのパターン:

import { MouseEvent, KeyboardEvent } from 'react';

interface ClickableCardProps {
  title: string;
  onClick: () => void;
}

export function ClickableCard({ title, onClick }: ClickableCardProps) {
  const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      onClick();
    }
  };

  return (
    <div
      role="button"
      tabIndex={0}
      onClick={onClick}
      onKeyDown={handleKeyDown}
      className="clickable-card"
    >
      <h3>{title}</h3>
    </div>
  );
}
フェーズ 5:スクリーンリーダーサポートの監査

目的: スクリーンリーダーとの互換性を確認します。

視覚的に隠されたテキストを確認する

sr-only パターンを検索します:

precision_grep:
  queries:
    - id: sr_only
      pattern: "(sr-only|visually-hidden|screen-reader)"
      glob: "**/*.{tsx,jsx,css,scss}"
  output:
    format: files_only

アイコン用の視覚的に隠されたテキスト:

interface IconButtonProps {
  onClick: () => void;
  ariaLabel: string;
  icon: React.ReactNode;
}

export function IconButton({ onClick, ariaLabel, icon }: IconButtonProps) {
  return (
    <button onClick={onClick} aria-label={ariaLabel}>
      {icon}
      <span className="sr-only">{ariaLabel}</span>
    </button>
  );
}

// CSS for sr-only class:
// .sr-only {
//   position: absolute;
//   width: 1px;
//   height: 1px;
//   padding: 0;
//   margin: -1px;
//   overflow: hidden;
//   clip: rect(0, 0, 0, 0);
//   white-space: nowrap;
//   border-width: 0;
// }
aria-hidden の使い方を確認する

aria-hidden を検索します:

precision_grep:
  queries:
    - id: aria_hidden
      pattern: 'aria-hidden="true"'
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

よくある違反:

  • インタラクティブ要素(ボタン、リンク)に aria-hidden="true" が設定されている
  • アクセシブルであるべきコンテンツに aria-hidden="true" が設定されている
  • 装飾用アイコンに aria-hidden="true" が設定されていない

正しい aria-hidden の使い方:

interface ButtonWithIconProps {
  onClick: () => void;
  label: string;
}

export function ButtonWithIcon({ onClick, label }: ButtonWithIconProps) {
  return (
    <button onClick={onClick}>
      {/* Icon is decorative, hide from screen readers */}
      <CheckIcon aria-hidden="true" />
      {/* Label provides accessible text */}
      <span>{label}</span>
    </button>
  );
}
フェーズ 6:色とコントラストの監査

目的: 十分な色のコントラストを確保し、色だけで情報を伝えないようにします。

コントラスト比を確認する

ブラウザーの DevTools または外部ツールを使用します:

  • Chrome DevTools:要素を検証 -> Accessibility ペイン -> コントラスト比
  • Firefox DevTools:Inspector -> Accessibility パネル
  • axe DevTools ブラウザー拡張機能
  • Lighthouse のアクセシビリティ監査

WCAG 2.1 AA の要件:

  • 通常のテキスト(< 18pt):コントラスト比 4.5:1 以上
  • 大きなテキスト(>= 18pt または >= 14pt の太字):コントラスト比 3:1 以上
  • UI コンポーネントとグラフィック:コントラスト比 3:1 以上

色の定義を検索します:

precision_grep:
  queries:
    - id: color_definitions
      pattern: "(bg-|text-|color:|background:)"
      glob: "**/*.{tsx,jsx,css,scss}"
  output:
    format: count_only

アクセシブルなカラーパレット(Tailwind の例):

// tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  theme: {
    extend: {
      colors: {
        // Accessible color palette with documented contrast ratios
        primary: {
          50: '#f0f9ff',
          100: '#e0f2fe',
          600: '#0284c7', // 4.54:1 on white (WCAG AA)
          700: '#0369a1', // 7.09:1 on white (WCAG AAA)
          900: '#0c4a6e', // 13.14:1 on white
        },
        // Error color with good contrast
        error: {
          600: '#dc2626', // 4.51:1 on white
          700: '#b91c1c', // 6.70:1 on white
        },
        // Success color
        success: {
          600: '#16a34a', // 4.51:1 on white
          700: '#15803d', // 6.68:1 on white
        },
      },
    },
  },
};

export default config;
色だけで伝える情報を確認する

ステータスインジケーターを検索します:

precision_grep:
  queries:
    - id: status_colors
      pattern: "(bg-red|bg-green|bg-yellow|text-red|text-green|text-yellow)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

手動レビュー: ステータスが複数の方法(色 + アイコン + テキスト)で伝わることを確認します。

アクセシブルなステータスインジケーター:

type Status = 'success' | 'warning' | 'error' | 'info';

interface StatusBadgeProps {
  status: Status;
  message: string;
}

const statusConfig: Record<Status, { icon: React.ReactNode; className: string }> = {
  success: { icon: <CheckCircleIcon />, className: 'bg-green-100 text-green-800' },
  warning: { icon: <AlertTriangleIcon />, className: 'bg-yellow-100 text-yellow-800' },
  error: { icon: <XCircleIcon />, className: 'bg-red-100 text-red-800' },
  info: { icon: <InfoIcon />, className: 'bg-blue-100 text-blue-800' },
};

export function StatusBadge({ status, message }: StatusBadgeProps) {
  const { icon, className } = statusConfig[status];

  return (
    <div className={`flex items-center gap-2 px-3 py-2 rounded ${className}`}>
      {/* Icon provides visual indicator beyond color */}
      <span aria-hidden="true">{icon}</span>
      {/* Text provides clear status information */}
      <span>{message}</span>
    </div>
  );
}
強制カラーモードを確認する

forced-colors メディアクエリーを検索します:

precision_grep:
  queries:
    - id: forced_colors
      pattern: "@media.*forced-colors|prefers-contrast"
      glob: "**/*.{css,scss,tsx,jsx}"
  output:
    format: files_only

Windows のハイコントラストモードをサポートします:

/* Ensure borders are visible in forced-colors mode */
.card {
  border: 1px solid #e5e7eb;
}

@media (forced-colors: active) {
  .card {
    border: 1px solid CanvasText;
  }
}

/* Ensure custom controls are visible */
.custom-checkbox {
  border: 2px solid #3b82f6;
}

@media (forced-colors: active) {
  .custom-checkbox {
    border: 2px solid ButtonText;
  }
  
  .custom-checkbox:checked {
    background-color: Highlight;
  }
}
フェーズ 7:フォームと入力検証の監査

目的: フォームがアクセシブルで、エラー処理が明確であることを確認します。

フォームラベルを確認する

入力要素を検索します:

precision_grep:
  queries:
    - id: input_elements
      pattern: "<input|<textarea|<select"
      glob: "**/*.{tsx,jsx}"
  output:
    format: locations

手動レビュー: 各入力に次のいずれかを介して関連付けられたラベルがあることを確認します:

  • htmlFor 属性が一致する <label>
  • aria-label 属性
  • ラベル要素を参照する aria-labelledby

よくある違反:

  • ラベルがなく、プレースホルダーだけの入力
  • htmlFor 属性がないラベル
  • 複数の入力で同じラベルを共有している

アクセシブルなフォームのパターン:

import { useId, FormEvent } from 'react';

interface FormData {
  email: string;
  password: string;
}

interface LoginFormProps {
  onSubmit: (data: FormData) => Promise<void>;
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const emailId = useId();
  const passwordId = useId();
  const emailErrorId = useId();
  const passwordErrorId = useId();

  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    await onSubmit({
      email: formData.get('email') as string,
      password: formData.get('password') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div className="form-field">
        {/* Explicit label with htmlFor */}
        <label htmlFor={emailId}>
          Email address
        </label>
        <input
          id={emailId}
          type="email"
          name="email"
          required
          aria-required="true"
          aria-describedby={emailErrorId}
          autoComplete="email"
        />
        {/* Error message linked via aria-describedby */}
        <p id={emailErrorId} className="error-message" role="alert">
          {/* Error text populated on validation */}
        </p>
      </div>

      <div className="form-field">
        <label htmlFor={passwordId}>
          Password
        </label>
        <input
          id={passwordId}
          type="password"
          name="password"
          required
          aria-required="true"
          aria-describedby={passwordErrorId}
          autoComplete="current-password"
        />
        <p id={passwordErrorId} className="error-message" role="alert">
          {/* Error text populated on validation */}
        </p>
      </div>

      <button type="submit">
        Sign in
      </button>
    </form>
  );
}
エラー処理を確認する

エラーパターンを検索します:

precision_grep:
  queries:
    - id: error_messages
      pattern: "(error|invalid|required).*message"
      glob: "**/*.{tsx,jsx,ts}"
  output:
    format: standard

アクセシブルなエラー処理:

import { useState, useId } from 'react';

interface FieldError {
  field: string;
  message: string;
}

interface FormWithValidationProps {
  onSubmit: (data: Record<string, string>) => void;
}

export function FormWithValidation({ onSubmit }: FormWithValidationProps) {
  const [errors, setErrors] = useState<FieldError[]>([]);
  const nameId = useId();
  const nameErrorId = useId();
  const errorSummaryId = useId();

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    const name = formData.get('name') as string;

    // Validation
    const newErrors: FieldError[] = [];
    if (!name || name.length < 2) {
      newErrors.push({ field: 'name', message: 'Name must be at least 2 characters' });
    }

    if (newErrors.length > 0) {
      setErrors(newErrors);
      // Focus error summary for screen readers
      document.getElementById(errorSummaryId)?.focus();
      return;
    }

    setErrors([]);
    onSubmit({ name });
  };

  const nameError = errors.find((e) => e.field === 'name');

  return (
    <form onSubmit={handleSubmit} noValidate>
      {/* Error summary at top of form */}
      {errors.length > 0 && (
        <div
          id={errorSummaryId}
          role="alert"
          aria-labelledby="error-summary-title"
          className="error-summary"
          tabIndex={-1}
        >
          <h2 id="error-summary-title">There are {errors.length} errors</h2>
          <ul>
            {errors.map((error) => (
              <li key={error.field}>
                <a href={`#${error.field}`}>{error.message}</a>
              </li>
            ))}
          </ul>
        </div>
      )}

      <div className="form-field">
        <label htmlFor={nameId}>Name</label>
        <input
          id={nameId}
          name="name"
          aria-required="true"
          aria-invalid={nameError ? 'true' : 'false'}
          aria-describedby={nameError ? nameErrorId : undefined}
        />
        {nameError && (
          <p id={nameErrorId} className="error-message" role="alert">
            {nameError.message}
          </p>
        )}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}
必須フィールドを確認する

必須フィールドの表示を検索します:

precision_grep:
  queries:
    - id: required_fields
      pattern: "(required|aria-required)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: files_only

アクセシブルな必須フィールドの表示:

interface RequiredFieldLabelProps {
  htmlFor: string;
  children: React.ReactNode;
}

export function RequiredFieldLabel({ htmlFor, children }: RequiredFieldLabelProps) {
  return (
    <label htmlFor={htmlFor}>
      {children}
      {/* Visual indicator */}
      <span className="text-red-600" aria-hidden="true"> *</span>
      {/* Screen reader text */}
      <span className="sr-only"> (required)</span>
    </label>
  );
}
フェーズ 8:メディアアクセシビリティの監査

目的: 画像、動画、音声コンテンツがアクセシブルであることを確認します。

画像の代替テキストを確認する

画像を検索します:

precision_grep:
  queries:
    - id: img_elements
      pattern: "(<img|<Image|next/image)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

手動レビュー: 各画像について、次を確認します:

  • コンテンツ画像には意味のある alt テキストがある
  • 装飾画像には空の alt="" が設定されている
  • リンク画像の代替テキストが、その機能を説明している

よくある違反:

  • alt 属性がない
  • 汎用的な代替テキスト(「画像」、「写真」、「アイコン」)
  • 冗長な代替テキスト(「〜の画像」、「〜の写真」)
  • ファイル名を説明する代替テキスト(「IMG_1234.jpg」)

アクセシブルな画像のパターン:

import Image from 'next/image';

interface ProductImageProps {
  src: string;
  productName: string;
}

// Content image with descriptive alt
export function ProductImage({ src, productName }: ProductImageProps) {
  return (
    <Image
      src={src}
      alt={`${productName} product photo`}
      width={400}
      height={400}
    />
  );
}

// Decorative image with empty alt
export function DecorativePattern() {
  return (
    <div className="background-pattern">
      <Image
        src="/patterns/dots.svg"
        alt=""
        fill
        aria-hidden="true"
      />
    </div>
  );
}

// Functional image (icon button)
interface DeleteButtonProps {
  onDelete: () => void;
}

export function DeleteButton({ onDelete }: DeleteButtonProps) {
  return (
    <button onClick={onDelete} aria-label="Delete item">
      <Image
        src="/icons/trash.svg"
        alt=""
        width={20}
        height={20}
        aria-hidden="true"
      />
    </button>
  );
}
動画のキャプションを確認する

動画要素を検索します:

precision_grep:
  queries:
    - id: video_elements
      pattern: "(<video|<track)"
      glob: "**/*.{tsx,jsx}"
  output:
    format: standard

キャプション付きのアクセシブルな動画:

interface AccessibleVideoProps {
  src: string;
  captionsSrc: string;
  title: string;
}

export function AccessibleVideo({ src, captionsSrc, title }: AccessibleVideoProps) {
  return (
    <video controls aria-label={title}>
      <source src={src} type="video/mp4" />
      {/* Captions for deaf/hard of hearing users */}
      <track
        kind="captions"
        src={captionsSrc}
        srcLang="en"
        label="English captions"
        default
      />
      {/* Fallback text */}
      <p>
        Your browser does not support the video element.
        <a href={src}>Download the video</a>
      </p>
    </video>
  );
}
動きの軽減設定を確認する

アニメーションを検索します:

precision_grep:
  queries:
    - id: animations
      pattern: "(animate|transition|prefers-reduced-motion)"
      glob: "**/*.{tsx,jsx,css,scss}"
  output:
    format: files_only

prefers-reduced-motion に対応します:

import { motion, useReducedMotion } from 'framer-motion';

interface AnimatedCardProps {
  children: React.ReactNode;
}

export function AnimatedCard({ children }: AnimatedCardProps) {
  const shouldReduceMotion = useReducedMotion();

  return (
    <motion.div
      initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
      animate={{ opacity: 1, y: 0 }}
      transition={{
        duration: shouldReduceMotion ? 0 : 0.3,
      }}
    >
      {children}
    </motion.div>
  );
}

CSS による方法:

/* Default: animated */
.card {
  transition: transform 0.3s ease;
}

.card:hover {
  transform: scale(1.05);
}

/* Reduced motion: disable animations */
@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
  }
  
  .card:hover {
    transform: none;
  }
}

自動テスト

Precision ツールのワークフロー

包括的なアクセシビリティチェックを実行します:

discover:
  queries:
    # Missing alt attributes
    - id: missing_alt
      type: grep
      pattern: "<img(?![^>]*alt=)"
      glob: "**/*.{tsx,jsx}"
    
    # onClick without keyboard handler
    - id: onclick_no_keyboard
      type: grep
      pattern: 'onClick=\\{(?!.*onKeyDown)'
      glob: "**/*.{tsx,jsx}"
    
    # Non-button with role=button without keyboard
    - id: role_button_no_keyboard
      type: grep
      pattern: 'role="button"(?!.*onKeyDown)'
      glob: "**/*.{tsx,jsx}"
    
    # Missing form labels
    - id: unlabeled_inputs
      type: grep
      pattern: "<input(?![^>]*aria-label)(?![^>]*id=)"
      glob: "**/*.{tsx,jsx}"
    
    # Heading level skips
    - id: heading_usage
      type: grep
      pattern: "<h[1-6]"
      glob: "**/*.{tsx,jsx}"
  verbosity: locations
ブラウザーベースのテスト

包括的な自動テストを行うには、次のツールを実行します。

  1. Lighthouse アクセシビリティ監査

    npx lighthouse https://localhost:3000 --only-categories=accessibility --view
    
  2. axe DevTools

  3. Pa11y CI

    npm install --save-dev pa11y-ci
    npx pa11y-ci --sitemap https://yoursite.com/sitemap.xml
    

監査レポート

WCAG の達成基準に基づいて問題を整理する

レポートのテンプレート:

# Accessibility Audit Report

**Date:** 2026-02-16
**Auditor:** [Name]
**WCAG Version:** 2.1 Level AA
**Pages Audited:** 12

## Executive Summary

- **Critical Issues:** 3
- **Serious Issues:** 8
- **Moderate Issues:** 15
- **Minor Issues:** 22

## Findings by WCAG Principle

### 1. Perceivable

#### 1.1.1 Non-text Content (Level A) - FAIL

**Issue:** 12 images missing alt text

**Location:**
- `src/components/ProductCard.tsx:45`
- `src/components/Gallery.tsx:78`

**Impact:** Screen reader users cannot understand image content

**Recommendation:**
```typescript
// Before
<img src="/product.jpg" />

// After
<img src="/product.jpg" alt="Blue cotton t-shirt" />
1.4.3 コントラスト(最低限)(レベル AA)- 不合格

問題: プライマリーボタンのテキストのコントラスト比が 3.2:1 です(4.5:1 が必要です)。

場所: src/styles/globals.css:45

影響: 弱視のユーザーはボタンのテキストを読めない可能性があります。

推奨事項:

/* Before: #60a5fa on #3b82f6 = 3.2:1 */
.btn-primary {
  background: #3b82f6;
  color: #60a5fa;
}

/* After: white on #2563eb = 4.5:1 */
.btn-primary {
  background: #2563eb;
  color: #ffffff;
}
2. 操作可能
2.1.1 キーボード(レベル A)- 不合格

問題: カスタムドロップダウンがキーボードで操作できません。

場所: src/components/Dropdown.tsx

影響: キーボードユーザーはドロップダウンを操作できません。

推奨事項: 矢印キーでの移動と Enter/Space キーでの有効化を実装します。

3. 理解可能
3.3.2 ラベルまたは説明(レベル A)- 不合格

問題: フォーム入力に視認できるラベルがありません。

場所: src/components/ContactForm.tsx:23-45

推奨事項: htmlFor 属性を指定した明示的な <label> 要素を追加します。

4. 堅牢
4.1.2 名前、役割、値(レベル A)- 不合格

問題: カスタムチェックボックスに ARIA の状態が設定されていません。

場所: src/components/CustomCheckbox.tsx

推奨事項: aria-checked 状態と role="checkbox" を追加します。

優先対応事項

  1. 緊急: すべての画像に代替テキストを追加する(スクリーンリーダーユーザーの 100% に影響します)
  2. 緊急: ドロップダウンのキーボード操作を修正する(キーボードユーザーが操作できなくなっています)
  3. 高: プライマリーボタンの色のコントラストを修正する(ユーザーの約 4.5% に影響します)
  4. 高: フォームラベルを追加する(スクリーンリーダーを利用するユーザーと認知障害のあるユーザーに影響します)

## Related Skills

- **code-review** - Apply accessibility checks during code review
- **component-architecture** - Design accessible component APIs
- **testing-strategy** - Include accessibility in test suites

## References

- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
- [ARIA Authoring Practices Guide (APG)](https://www.w3.org/WAI/ARIA/apg/)
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- [A11y Project Checklist](https://www.a11yproject.com/checklist/)

コメント