投稿

Rails アプリの a11y 対応を本気で組み込む前に決めたこと

class セレクタ依存をやめて a11y ベース(role + accessible name)に寄せる Capybara はもともと a11y ベース設計。click_button "保存" を徹底すれば 8 割対応できる ただし Capybara.enable_aria_label = true / enable_aria_role = true は最初に必ず設定する 学習リソースは「一般…

Rails アプリの a11y 対応を本気で組み込む前に決めたこと

まとめ

  • 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 つに、両端 trim

enable_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 段で組み立てる。

  1. 一般 a11y の地図を作る(日本語なら「Webアプリケーションアクセシビリティ」技術評論社 2023 が現代の Web 前提で書かれている)
  2. WAI-ARIA Authoring Practices で UI パターン別の正解を引く(モーダル、タブ、コンボボックス等)
  3. Primer ViewComponents や GOV.UK Design System で「Rails でどう書くか」を実装事例から拾う
  4. 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-controlsbutton_tagaria: オプションで書く(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_rolerails_helper.rb に追加
  • axe-core-rspec を Gemfile に追加して system spec の末尾に be_axe_clean を仕込む
  • 再利用可能なダイアログ部品を 1 個作り、既存のモーダル実装をこれに置き換える
  • WAI-ARIA APG のモーダルパターン表をチェックリスト化して手元に保存する

最初の 1 周をモーダルでやり切ったら、次の題材で同じ 4 段を回す。

トレンドのタグ