Rails アプリの a11y 対応を本気で組み込む前に決めたこと
class セレクタ依存をやめて a11y ベース(role + accessible name)に寄せる Capybara はもともと a11y ベース設計。click_button "保存" を徹底すれば 8 割対応できる ただし Capybara.enable_aria_label = true / enable_aria_role = true は最初に必ず設定する 学習リソースは「一般…
まとめ
- class セレクタ依存をやめて a11y ベース(role + accessible name)に寄せる
- Capybara はもともと a11y ベース設計。
click_button "保存"を徹底すれば 8 割対応できる - ただし
Capybara.enable_aria_label = true/enable_aria_role = trueは最初に必ず設定する - 学習リソースは「一般 a11y → ARIA 仕様 → Rails 実装事例 → axe で担保」の 4 段で組む
- 手始めにモーダルダイアログ 1 個を題材にこの流れを 1 周する
フロントに強い同僚から「class でエレメント取るのは脆い、a11y ベースのセレクタにしよう」と言われた。これから自分のプロジェクトに入れていく前提で、何をどう進めるかを書き留めておく。
何がそんなに違うのか
.btn-primary-large のような class は、デザインリニューアルや Tailwind 化、CSS Modules 化で簡単に消える。BEM や utility 系なら組み合わせも頻繁に変わる。テストが先に壊れる UI ライブラリ更新、よくある。
a11y ベースは「人間がその要素をどう認識するか」を軸にする。
<!-- どれも accessible name は "保存" -->
<button>保存</button>
<button aria-label="保存"><svg>...</svg></button>
<button><span class="sr-only">保存</span><svg>...</svg></button>ブラウザは DOM とは別に accessibility tree を持っていて、スクリーンリーダーはそれを読む。テストもそれを参照すれば、見た目の都合で壊れなくなる。実装の意図そのものをアサーションできるのが大きい。
Testing Library の優先順位を Rails に翻訳する
React の Testing Library で有名な優先順位がある。
1. getByRole
2. getByLabelText
3. getByPlaceholderText
4. getByText
5. getByAltText / getByTitle
6. getByTestId ← 最終手段Rails (Capybara) でやろうとすると、自然な API はすでに同じ思想で組まれている。
click_button "保存"
click_link "ホーム"
fill_in "メールアドレス", with: "a@b.com"
check "利用規約に同意する"
select "東京", from: "都道府県"
find :button, "保存"
find_field "メールアドレス"
expect(page).to have_button("保存", disabled: false)
expect(page).to have_field("メールアドレス", with: "a@b.com")
expect(page).to have_link("ホーム", href: root_path)これらは内部で label / button text / 関連付けを見ているので、Testing Library の getByRole / getByLabelText 相当。find('.save-btn') を捨てて上記に寄せれば大半の system spec は a11y ベースになる。
最初に設定しておくべきこと
ここが落とし穴で、Capybara はデフォルトでは aria-label と role を accessible name に使わない。これを知らずに aria-label="閉じる" のアイコンボタンを click_button "閉じる" で取ろうとして無限にハマる経路がある。
# rails_helper.rb
Capybara.enable_aria_label = true # aria-label を accessible name として認識
Capybara.enable_aria_role = true # role="button" な div も :button としてヒット
Capybara.default_normalize_ws = true # 連続空白を 1 つに、両端 trimenable_aria_role には副作用がある。role="button" な <div> も find :button でヒットするようになる。レガシーコードを許容するなら ON、ストイックにいくなら OFF、というトレードオフ。プロジェクトのコード状況を見て決める。
fill_in の placeholder フォールバックに気をつける
<input placeholder="メールアドレス"> のように label が無い input でも、fill_in "メールアドレス" が通ってしまう。placeholder はスクリーンリーダーには accessible name として確実には伝わらないし、入力中に消える。テストが通る = a11y OK ではない、という話。
実装側で <label> を必ず書く運用にしておかないと、自動テスト緑なのに a11y 違反だらけ、が起こる。
同名ボタンの曖昧マッチは UI 設計の問題
Capybara はデフォルトで複数ヒットを例外にする(良い設計)。ヘッダとフッタに「保存」が並ぶようなフォームで例外が出る。match: :first で逃げると意図が消える。
# 雑な逃げ方
click_button "保存", match: :first
# within で文脈を絞る
within "form#user_form" do
click_button "保存"
end
# accessible name そのものを区別する(本筋)
click_button "プロフィールを保存"
click_button "パスワードを保存"「同じ accessible name が複数ある」のはスクリーンリーダー利用者も区別できない、つまり a11y 違反。テストで曖昧マッチに困った時は実装の問題と疑う、を原則にする。
学習リソースは 1 冊では足りない
「Rails × a11y を縦串で深く解説した文献」は今のところ無い。公式ガイドにも a11y セクションは無い。代わりに 4 段で組み立てる。
- 一般 a11y の地図を作る(日本語なら「Webアプリケーションアクセシビリティ」技術評論社 2023 が現代の Web 前提で書かれている)
- WAI-ARIA Authoring Practices で UI パターン別の正解を引く(モーダル、タブ、コンボボックス等)
- Primer ViewComponents や GOV.UK Design System で「Rails でどう書くか」を実装事例から拾う
axe-core-rspecで自動監査を CI に組み込む
Rails 特化文献を待つよりこの 4 段を回す方が早い。
モーダルを題材に 1 周してみる
抽象論で終わると何も変わらない。最初の題材を 1 個決めた。モーダルダイアログ。a11y 課題がてんこ盛りで応用が効く。
Step 1: 何が課題か洗い出す
- 背後のコンテンツが操作不能になっているか(Tab で背景にいかない)
- focus がモーダル内に閉じ込められるか(フォーカストラップ)
- 開いた瞬間に focus が中の妥当な要素に移るか
- Esc で閉じるか
- 閉じた時に元のトリガーに focus が戻るか
- スクリーンリーダーに「ダイアログが開いた」と伝わるか(
role="dialog"+aria-modal="true") - 名前が読み上げられるか(
aria-labelledbyでタイトル参照)
ここで「focus トラップなんて考えたことなかった」となれば収穫。
Step 2: 正解パターンを WAI-ARIA APG で引く
WAI-ARIA Authoring Practices の dialog (modal) pattern に最小 HTML 構造とキーボード操作の表がある。これをチェックリスト化して保存する。
Step 3: Rails のテンプレートに落とす
ネイティブの <dialog> 要素を使う前提で書く(Safari も含めてサポート済み)。
<%# app/views/shared/_dialog.html.erb %>
<dialog id="<%= dom_id %>"
aria-labelledby="<%= dom_id %>-title"
aria-modal="true">
<h2 id="<%= dom_id %>-title"><%= title %></h2>
<%= yield %>
<button type="button" data-action="dialog#close" aria-label="閉じる">×</button>
</dialog>トリガー側は aria-haspopup="dialog" と aria-controls を button_tag の aria: オプションで書く(HTML 直書きはしない)。
<%= button_tag "編集",
type: "button",
data: { action: "dialog#open" },
aria: { haspopup: "dialog", controls: "user-edit" } %>Step 4: system spec で担保する
Step 1 のチェックリストをそのまま spec に落とす。
require "rails_helper"
require "axe-rspec"
RSpec.describe "ユーザー編集ダイアログ", type: :system do
before do
Capybara.enable_aria_label = true
Capybara.enable_aria_role = true
sign_in users(:alice)
visit user_path(users(:alice))
end
it "ダイアログの基本動作" do
expect(page).not_to have_css("[role=dialog]")
click_button "編集"
dialog = find("[role=dialog][aria-modal=true]", wait: 2)
expect(dialog).to have_css("h2", text: "プロフィール編集")
within dialog do
focused = page.evaluate_script(
'document.activeElement?.getAttribute("aria-label") || document.activeElement?.textContent'
)
expect(focused).to be_present
find("body").send_keys(:escape)
end
expect(page).not_to have_css("[role=dialog]")
trigger = page.evaluate_script("document.activeElement.textContent.trim()")
expect(trigger).to eq "編集"
end
it "axe で a11y 違反がない" do
click_button "編集"
expect(page).to be_axe_clean.within("[role=dialog]")
end
end「テストが書けない要素は a11y 違反」のシグナルとして使えば、テスト自体が a11y チェックを兼ねる。
次にやること
すぐ手を付けるリスト。
Capybara.enable_aria_label/enable_aria_roleをrails_helper.rbに追加axe-core-rspecを Gemfile に追加して system spec の末尾にbe_axe_cleanを仕込む- 再利用可能なダイアログ部品を 1 個作り、既存のモーダル実装をこれに置き換える
- WAI-ARIA APG のモーダルパターン表をチェックリスト化して手元に保存する
最初の 1 周をモーダルでやり切ったら、次の題材で同じ 4 段を回す。