開発者がReactのUIとコードを見ている様子。WAI-ARIAとAPGロゴ、スクリーンリーダーの波形、エラー表示、トグルボタンのaria-pressed属性が描かれ、アクセシブルな実装とPlaywrightテストによる検証を表現しています。

はじめに

Reactで認証フォームを作るとき、エラー表示は {error ? <p>{error}</p> : null} と書くのが定番です。
ただ、この書き方は「エラーが出たことをスクリーンリーダーに確実に伝える」という点で、意外な落とし穴があります。

実運用しているサービスのアクセシビリティ改善で、この問題に加えて、トグルボタンの作り方とa11yスナップショットの扱いも見直しました。
共通していたのは、「axe-coreで違反が出ない」ことと「実際にアクセシブルである」ことは別だ、という点です。
この記事では、その3つの修正をコード付きで整理します。

こんな人におすすめ

  • Reactのフォームで role="alert" を使っているのに、スクリーンリーダーで読まれないことがある方
  • パスワード表示切替ボタンの aria-label を、状態に応じて書き換えている方
  • axe-coreの結果をJSONで保存し、PRの差分がノイズだらけになって困っている方
  • Playwrightやtesting-libraryで、ARIA状態をテストに組み込みたい方

罠1: 条件付きレンダリングだとアナウンスされないことがある

role="alert" の要素を、エラーが出た瞬間にDOMへ追加する書き方です。

{displayError ? <p className="auth-error">{displayError}</p> : null}

ライブリージョンは、基本的に「すでにDOMにある領域の中身が変わったとき」に通知する仕組みです。
要素ごと新規に挿入すると、スクリーンリーダーによってはアナウンスしないことがあります。
axe-coreのドキュメントでも、既知の注意点として触れられています。

対策は、コンテナを常にDOMへ置いておき、中身だけを入れ替えることです。

<p
  className="auth-error"
  role="alert"
  aria-live="assertive"
  aria-atomic="true"
>
  {displayError ?? ''}
</p>

エラーがないときは空の <p> が残るだけです。
エラーが発生すると、監視中の領域に文字が流入するので、読み上げが安定します。

空コンテナを消すのに display: none は使わない

空の <p> が余白や枠線を出すのを避けたくなりますが、display: none は禁物です。
アクセシビリティツリーから外れるため、ライブリージョンとしての監視も止まってしまいます。

代わりに、box-modelだけを潰します。

/* display: none は aria-live を無効にするので使わない */
.auth-error:empty {
  padding: 0;
  border-left-width: 0;
  margin: 0;
}

:empty はReactの '' 描画でも効くため、中身があるときだけ通常の見た目に戻ります。

罠2: トグルボタンのaria-labelを切り替えない

パスワードの表示・非表示ボタンで、こう書いていないでしょうか。

aria-label={show ? 'パスワードを隠す' : 'パスワードを表示'}

押すたびに名前が変わるため、スクリーンリーダーの読み上げが冗長になります。
WAI-ARIA APGのToggle Buttonパターンでは、ラベルは固定し、状態は aria-pressed で伝えます。

<button
  type="button"
  aria-pressed={show}
  aria-label="パスワードの表示切替"
  onClick={() => setShow((v) => !v)}
>
  {/* アイコン */}
</button>

「パスワードの表示切替、トグルボタン、オフ」のように、名前と状態が分けて読まれます。

テストでも状態をロケーターに含める

testing-libraryでも、Playwrightでも、getByRole に pressed オプションを渡せます。

const TOGGLE_NAME = 'パスワードの表示切替'

expect(
  screen.getByRole('button', { name: TOGGLE_NAME, pressed: false })
).toBeInTheDocument()

fireEvent.click(screen.getByRole('button', { name: TOGGLE_NAME }))

expect(
  screen.getByRole('button', { name: TOGGLE_NAME, pressed: true })
).toBeInTheDocument()

状態のアサーションがロケーターの中に収まるので、読みやすくなります。

罠3: a11yスナップショットに実行時刻を入れない

axe-coreの結果を「既知の違反の記録」としてJSONで保存する運用があります。
このとき、次のように生成時刻を入れてしまうと、再生成のたびに全ファイルが差分になります。

  export interface BaselineFile {
    $schema?: string
    screenKey: A11yScreenKey
-   generatedAt: string
    axeVersion: string
    wcagTags: string[]
    violations: SerializedViolation[]
  }

ファイルの目的は違反の記録なので、実行時情報は不要です。
生成関数側の new Date().toISOString() によるデフォルト代入も消しました。
これで再生成が冪等になり、PRの差分には違反の増減だけが現れます。

E2Eテストでエラー状態を再現するコツ

永続コンテナにしたことで、E2Eでは「コンテナに文字が入るのを待つ」書き方ができます。

const suffix = crypto.randomUUID().slice(0, 12)
await page.getByLabel('メールアドレス').fill(`nobody-${suffix}@example.com`)
await page.getByLabel('パスワード').fill('wrong-password')
await page.getByRole('button', { name: 'ログイン' }).click()

// APIの応答待ち。空文字から何かが入るまで待つ
await expect(page.getByRole('alert')).toContainText(/.+/, { timeout: 10_000 })

メールアドレスにランダムなサフィックスを付けると、未登録アドレスでの失敗を確実に再現できます。
並列ワーカー間での衝突も避けられます。
同じアドレスで何度も失敗させないので、レート制限に当たりにくくなる点も利点です。

つまづきやすいポイント

  • 永続コンテナにすると、他の role="alert" と合わせて getByRole('alert') が複数ヒットすることがあります。クラス名やスコープで絞ってください
  • aria-live="assertive" と role="alert" は暗黙の値が重なります。明示しても害はありませんが、読み上げの優先度は用途に合わせて選んでください
  • :empty は空白文字や改行が入ると効きません。displayError ?? '' のように、本当に空の文字列を描画してください
  • スクリーンリーダーの挙動は組み合わせで異なります。実機での確認も併用してください

まとめ

エラー表示は、条件付きレンダリングではなく、永続コンテナへの内容注入にすると安定します。
空の見た目は display: none ではなく、:empty でbox-modelだけを潰します。
トグルボタンはラベルを固定し、状態を aria-pressed で伝えます。
a11yのスナップショットから実行時刻を除くと、PRの差分が読みやすくなります。
自動チェックは入口にすぎないので、読み上げの挙動まで意識して実装するのがおすすめです。