そういえば、という話
AIにコードを書かせるようになって、実装にかかる時間はそれなりに減った。
その分で機能をもう1個進める、というのが普通の使い方だと思うけれど、
そういえば、と思ったことがある。
アクセシビリティ対応って、今までずっと
「やったほうがいいのは分かっているけど手が回らない」で流してきた。
でも手が回らなかった理由のほうが先に解消されつつあるのでは、という思いつきです。
しかもこれ、お客さんに公開するものなら、
機能をもう1個増やすより効く場面もあるはず。
以下は、実際にやるとしたらどこから手をつけるか、を整理したものです。
前提:サイトの性質によって、かける労力は変わる
最初に立場をはっきりさせておくと、
すべてのプロダクトで同じだけ対応すべき、とは思っていない。
優先度が上がりやすいのは、こういうもの。
- 公共性が高いもの(自治体、医療、金融、教育)
- 不特定多数が入口として使うもの(EC、予約、問い合わせフォーム)
- 代替手段がないもの。「電話でも申し込めます」がないなら、その画面が唯一の窓口になる
- BtoBで、相手方の調達要件にアクセシビリティが入っているもの
逆に、利用者が全員把握できている社内ツールなら、
そこまで踏み込まないのも妥当な判断だと思う。
ただ、そういうケースでも「ゼロか全部か」ではなくて、
コストが安いところだけ拾うという選択肢はある。この記事で扱うのはそこです。
なぜ今まで後回しになっていたか
思い返すと、理由は2つに整理できる。
ひとつめは知識のコスト。
aria-* をどう使うのが正しいのか、role を足すべきなのか、
毎回調べ直していて、チーム全員が同じ精度で判断できる状態ではなかった。
ふたつめは検証のコスト。
キーボードだけで全画面を触り直す、コントラスト比を測る、といった作業は、
一度やっても機能追加のたびに崩れるので、継続的にやらないと意味がない。
この2つは、AIが比較的埋めやすい。
アクセシビリティ対応の多くは創造的な作業ではなく、
既知のルールを漏れなく適用する作業に近いからです。
安いところから順に
1. クリッカブルな div を button にする
たぶん一番件数が多くて、一番効くのがこれ。
修正前:
<div className="card" onClick={() => onSelect(item.id)}>
{item.name}
</div>
修正後:
<button type="button" className="card" onClick={() => onSelect(item.id)}>
{item.name}
</button>
div のままだと、Tabキーで到達できず、Enterでも反応せず、
スクリーンリーダーは「ボタン」だと読み上げない。
button にするだけでこの3つが同時に解決する。
div のまま対応しようとすると tabIndex={0}、role="button"、
EnterとSpaceそれぞれの onKeyDown を自前で書くことになるので、
要素を変えられるなら変えたほうが早いし壊れにくい。
なお、button はブラウザ既定のスタイルが載るので、
既存の見た目を維持するには appearance: none や background: none、
font: inherit あたりのリセットが必要になる。ここだけは手間が増える。
2. モーダルのキーボード操作
マウスで使っているぶんには誰も気づかないが、
モーダルを開いてTabを押し続けると背後のリンクにフォーカスが移っていく、
というのはよくある。見るべきは2点で、
- Escapeキーで閉じる
- 開いている間、フォーカスがモーダルの外に出ないようにする(フォーカストラップ)
フォーカストラップは自前で書くと地味に間違えるので、
ライブラリに寄せるか、<dialog> 要素の showModal() を使うのが確実です。
showModal() はフォーカストラップとEscapeでのクローズをブラウザ側でやってくれる。
閉じたあとに、開く前のボタンへフォーカスを戻すところまでやると、
キーボードだけで使ったときの体感がかなり変わる。
3. エラーを色だけで伝えない
バリデーションエラーを入力欄の赤い枠線だけで表現しているケース。
これだと、色の見分けがつきにくい人には何も伝わらないし、
スクリーンリーダーにも枠線の色は読み上げられない。
こう変える:
<label htmlFor="email">メールアドレス</label>
<input
id="email"
aria-invalid={!!error}
aria-describedby={error ? "email-error" : undefined}
/>
{error && <p id="email-error">{error}</p>}
テキストでエラー内容を出したうえで、aria-describedby で入力欄と紐付ける。
label と input を htmlFor / id で結ぶのも同時にやっておく
(ここが切れていると、スクリーンリーダーが「編集テキスト」としか読まない)。
4. コントラスト比
補助テキストの薄いグレーが、だいたい落ちる。
WCAG 2.1 の達成基準 1.4.3(レベルAA)は、通常サイズの文字で 4.5:1 以上。
白背景に #999999 だと約 2.85:1 で足りず、#767676 まで落とせば約 4.54:1 で満たす。
目新しい話ではないけれど、ここを挙げたのは修正コストが一番安いから。
個別のクラスを直すのではなく、デザイントークンの値を1つ差し替えれば全画面に効く。
ライトとダークでテーマを分けているなら、両方で測る必要があるのだけ注意。
5. lintとCIに載せて、戻らないようにする
一度直しても、機能追加のたびに同じパターンが戻ってくる。
なので eslint-plugin-jsx-a11y を入れて、
div にクリックハンドラを付けた場合や、label と入力欄が結ばれていない場合に
CIで落ちるようにしておく。
ただ、既存コードに対して有効にすると指摘が大量に出るので、
いきなり全部エラーにすると誰も直せない。
まずこれ以上増やさない状態を作るほうが先だと思う。
ESLint自体は「新規のコードか既存のコードか」を区別しないので、
そこは運用側で分ける必要がある。やり方はいくつかあって、
- 差分に対してだけ実行する(
lint-stagedや、CIで変更ファイルだけを対象にする) -
--max-warningsで警告の総数に上限を設け、増えたら落とす - ルールを
warnで入れておき、既存分を潰しながら順次errorに上げる
どれでもいいけれど、「既存の指摘は残っていても、新しく増えたら落ちる」
という状態にしておくと導入しやすい。
AIに直させるのは一度でいいけれど、
戻らない仕組みだけは人間側で残しておく必要がある。
AIへの投げ方
「アクセシビリティを改善して」だと、範囲が広すぎて出力が安定しない。
具体的なルールを1つずつ指定したほうがいい。たとえば
このディレクトリ配下で、onClickを持つdiv/spanを列挙して。
それぞれbuttonかaに置き換えられるか判断して、
置き換えられないものは理由を書いて。
のように、判断基準を渡して、例外は理由付きで返させる形にすると、
機械的に全部 button にされて壊れる、という事故を避けやすい。
一方で、aria-live をどこに置くべきか、
このUIに role を足すべきかどうか、といった設計寄りの判断は、
AIの出力がそれらしく見えても間違っていることがある。
ここは仕様を自分で確認したほうが早い。
ここから先は自動化できない
自動チェックで拾えるのは、機械的に判定できるルールだけです。
-
alt属性が「ある」ことは検出できるが、その説明文が適切かは判定できない - フォーカス順序が「DOM順」であることは分かるが、それが自然な順序かは分からない
- コントラスト比は測れるが、画像の上に載った文字は測れない
結局、最後はキーボードだけで一周してみるしかない。
ただ、その一周にたどり着くまでの機械的な修正をAIに潰してもらえるなら、
かかる時間はかなり違ってくるはずで、そこが今までとの差分だと思っている。
まとめ
- AIで実装が速くなった分は、機能追加だけでなく、後回しにしていた品質に回せる
- アクセシビリティ対応は「既知のルールの適用」が大半なので、AIと相性がいい
- ただし設計判断が絡む部分は、AIの出力を鵜呑みにせず自分で確認する
- 一度直すだけでは戻るので、lintとCIに載せるところまでやる
- サイトの性質によってかける労力は変わるが、「安いところだけ拾う」判断はどこでも取れる
全部やろうとすると腰が重いので、まずマウスから手を離して、
自分の担当画面をTabキーだけで一周してみるのがいいと思います。
これ自体は数分で終わるし、直すべき箇所がそこで具体的になる。
そこまで来れば、あとは今回挙げたような機械的な修正が大半です。

