React・実務の道具立て

Reactでのアクセシビリティ

画面を書きかえるReactでは、HTMLだけのときに無かった問題が出ます。ページを移動してもフォーカスが動かない、更新が読み上げられない、divで作ったボタンが押せない——実務で指摘される点を、直し方つきで扱います。

サイトにはアクセシビリティのコースがありますが、Reactにはそこで扱っていない問題があります。原因は1つです。

ページを読み直さずに中身だけ書きかえるから、ブラウザが本来やってくれることが起きません。

問題①:移動してもフォーカスが動かない

<Link>で詳細ページへ移ると、見た目は変わるのにフォーカスは押したリンクの位置に残ります

  • キーボードだけで操作する人は、新しいページの先頭ではなく前のページの途中から続きを操作することになる
  • 読み上げソフトはページが変わったことに気づかない

対処は、移動したら見出しにフォーカスを移すことです。

const headingRef = useRef<HTMLHeadingElement>(null)

useEffect(() => {
  headingRef.current?.focus()
}, [id])          // URLのidが変わるたびに実行

<h1 ref={headingRef} tabIndex={-1} className="focus:outline-2 focus:outline-sky-500">
  {bird.name}
</h1>

tabIndex={-1}が必要です。見出しは本来フォーカスできない要素なので、「プログラムからならフォーカスできる」という印を付けます(-1なのでTabキーの順番には入りません)。

問題②:静かに変わったところが伝わらない

検索の件数のように、押した場所とは別のところが変わる表示があります。目で見ていれば分かりますが、読み上げでは何も起きません。

<p aria-live="polite">{shownBirds.length}羽みつかりました</p>

aria-live="polite"を付けると、中身が変わったときに読み上げソフトが知らせてくれますpoliteは「いま読んでいることが終わったら」という意味で、ふつうはこちらを使います。

動き使う場面
polite読んでいる途中は割りこまない件数・保存しました・検索結果
assertiveすぐ割りこむ重大なエラーのみ

問題③:divで作ったボタン

Reactでは何にでもonClickを付けられるので、つい書いてしまいます。

<div onClick={handleClick}>おす</div>   // ✕
<button type="button" onClick={handleClick}>おす</button>   // ○

<button>にするとまとめて付いてくるものがあります。

  • Tabキーで到達できる
  • Enter・Spaceで押せる
  • 読み上げで「ボタン」と分かる
  • disabledが効く

divで作ると、これらを全部自分で足すことになりますtabIndexrole="button"onKeyDown…)。素直に<button>を使うのが、いちばん短くて確実です。

問題④:文字のないボタン

stateの回で付けたaria-labelが、これにあたります。

<button aria-label={`${name}をお気に入りにする`}>{isFavorite ? '★' : '☆'}</button>

★だけのボタンは、読み上げでは「星印」か、何も読まれないかのどちらかです。何のボタンか分かりません。

アイコンだけのボタンには必ずラベル——これは実務のレビューでも定番の指摘です。

問題⑤:モーダルの中に閉じこめる

自分でモーダル(ダイアログ)を作ると、こうなりがちです。

  • 開いてもフォーカスが中に移らない
  • Tabで後ろの画面に抜けてしまう
  • Escで閉じられない
  • 閉じたあと、どこにフォーカスが戻るか決まっていない

これを全部自分で書くのは大変なので、<dialog>要素か、アクセシビリティ対応のライブラリ(Radix UI・React Ariaなど)を使うのが現実的です。

const dialogRef = useRef(null)

<button onClick={() => dialogRef.current?.showModal()}>ひらく</button>
<dialog ref={dialogRef}>

  <button onClick={() => dialogRef.current?.close()}>とじる</button>
</dialog>

<dialog>showModal()なら、フォーカスの閉じこめ・Escで閉じる・背景の操作を止めるがブラウザ側で効きます。

確かめ方

キーボードだけで触るのがいちばん確実です。

  1. マウスから手を離す
  2. Tabだけで全部の操作に到達できるか
  3. いまどこにいるか、見て分かるか(フォーカスの枠が消えていないか)
  4. Enter・Spaceで押せるか、Escで閉じられるか

機械的なチェックはLighthouse(Chromeの開発者ツール)やaxe DevTools拡張で拾えます。ただしフォーカスの流れは機械には判定できないので、手で触る工程は残ります。

アクセシビリティキーボードだけで使えるページフォーカスの見た目(:focus-visible)はアクセシビリティコースのこの回に。Reactでも書き方は同じです

公式ドキュメントも見てみる

ja.react.dev外部サイト<div> などの一般的なコンポーネント – React公式の共通propsのリファレンス。aria-*属性がそのまま書けること、キャメルケースにならない例外であることが書かれています

このレッスンのまとめ

  1. Reactはページを読み直さないので、ブラウザ任せにできていたことが起きなくなる
  2. 移動したら見出しにフォーカスを移すuseReftabIndex={-1}
  3. 静かに変わる表示にはaria-live="polite"assertiveは緊急時だけ
  4. divonClickにしない<button>は到達・キー操作・読み上げがまとめて付く
  5. アイコンだけのボタンにはaria-label——テストのしやすさにも直結する
  6. モーダルは<dialog>かライブラリに任せる(フォーカスの閉じこめは自作しない)
  7. 点検はキーボードだけで触る。機械的なチェックはLighthouseやaxeで

次は、動き(アニメーション)です。この回で扱った「動きを減らす設定」とも地続きです。

やってみよう:キーボードだけで触ってみる

まず点検し、それから直します。

  1. マウスから手を離し、Tabキーだけでことりずかんを操作する
  2. 検索・チェックボックス・星ボタン・カードのリンクに全部到達できるか確認する
  3. いまどこにフォーカスがあるか、見て分かるかを確認する
  4. 詳細ページへ移動したとき、フォーカスがどこにあるかを確認する(たいてい置き去りです)
  5. 詳細ページの<h1>reftabIndex={-1}を足し、useEffectでフォーカスを移す
  6. 一覧の件数にaria-live="polite"を足す
const headingRef = useRef(null)

useEffect(() => {
  headingRef.current?.focus()
}, [id])

<h1 ref={headingRef} tabIndex={-1} className="focus:outline-2 focus:outline-sky-500">
  {bird.name}
</h1>

4番の「置き去り」を自分の目で見ておくと、この回の意味がはっきりします。マウスだけで作っていると、一生気づかない種類の問題です。

よくある質問

Reactだとアクセシビリティで何が変わりますか?
ページを読み直さずに中身を書きかえるため、ブラウザが本来やってくれる処理が起きません。移動してもフォーカスが先頭に戻らない、内容が変わっても読み上げられない、といった問題が出ます。
divにonClickを付けてはいけませんか?
避けてください。buttonなら、キーボードで押せる、Tabで到達できる、読み上げでボタンだと分かる、が全部ついてきます。divで作ると全部を自分で足すことになります。
aria-liveとは何ですか?
その要素の中身が変わったとき、読み上げソフトに知らせるための指定です。検索結果の件数のように、画面のどこかが静かに変わる場所に付けます。
自動でチェックできますか?
できる範囲があります。Lighthouseやaxeの拡張機能で機械的な問題は拾えますが、フォーカスの流れや操作の分かりやすさは、キーボードだけで触ってみるのが確実です。