家計簿アプリを作る #76:アクセシビリティ改善

家計簿アプリ作成シリーズの第76回です。ここまで機能を積み上げてきましたが、キーボード操作やスクリーンリーダーでの使いやすさ(アクセシビリティ)はあまり意識してきませんでした。基本的な改善を行います。

aria-labelが必要な場面、不要な場面

aria-label は、テキストの無いアイコンだけのボタンに「これは何のボタンか」を教えるための属性です。既に「削除」「編集」のような視覚的なテキストを持つボタンには、スクリーンリーダーがそのテキストをそのまま読み上げてくれるので不要です。逆に、アイコンだけで文字が無いチェックボックスやソートボタンには必要です。

ソート可能な列見出しにはaria-sortが正しい

テーブルのソート機能を実装する際、最初はソートアイコン(CaretUpSolid/CaretDownSolid)自体に aria-label="昇順"/"降順" を付けていましたが、これには2つの問題がありました。

  1. 4つの列すべてで同じラベルになり、どの列のソートか区別できない
  2. アイコン自体はクリックできず、実際の操作対象(TableHeadCell 全体)とズレている

HTML/ARIAの標準機能である aria-sort を、実際にクリックする <th>(TableHeadCell)に付けるのが正しい方法でした。

<TableHeadCell
  onclick={() => handleSort("date")}
  aria-sort={data.sortKey === "date" ? (data.sortOrder === "asc" ? "ascending" : "descending") : "none"}
>
  <div class="flex items-center gap-1 min-w-0">
    日付
    <div class="flex flex-col leading-none -space-y-1" aria-hidden="true">
      <CaretUpSolid class="h-3 w-3 {sortIconColor('date', 'asc')}" />
      <CaretDownSolid class="h-3 w-3 {sortIconColor('date', 'desc')}" />
    </div>
  </div>
</TableHeadCell>

アイコン部分は装飾なので aria-hidden="true" にして読み上げ対象から外しています。

role="button"を付けなかった理由

キーボード操作対応で <th>tabindex/onkeydown を追加する際、「クリック可能なことを明示するために role="button" を付けるべきか」を検討しましたが、あえて付けませんでした。aria-sort はARIA仕様上 role="columnheader"(<th> のデフォルトロール)に対してのみ有効なため、role="button" で上書きしてしまうと aria-sort が意味を持たなくなってしまいます。tabindex={0} + onkeydown + aria-sort の組み合わせのままにするのが正解でした。

<TableHeadCell
  onclick={() => handleSort("date")}
  onkeydown={(e) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      handleSort("date");
    }
  }}
  tabindex={0}
  aria-sort={...}
>

tabindex="0"(文字列)ではなく tabindex={0}(数値)にする必要がある点にも注意が必要でした。Svelteの型定義は tabindexnumber 型として期待しています。

tabindex={0}の意図

tabindex={0} は、「本来キーボードでフォーカスできない要素(<th><div> など)を、通常のTabキーでのフォーカス移動の対象に加える」という意味です。値によって挙動が変わります。

  • 指定しない(デフォルト): <button>/<a>/<input> のようなネイティブの操作可能要素は自動的にフォーカス対象になるが、<th><div> はTabキーで飛ばされる
  • tabindex={0}: DOM上の出現順に沿って、自然な順序でTabキーのフォーカス対象に加える
  • tabindex={-1}: Tabキーでは移動できないが、element.focus() などプログラムからはフォーカス可能にする
  • 正の数(tabindex={1}など): フォーカス順序を強制的に変更するが、他の要素の自然な順序と衝突しやすく非推奨

今回の TableHeadCell はデフォルトではフォーカスできない要素なので、tabindex={0} を付けることで、マウスでのクリックと同じようにTabキーでも列見出しにフォーカスを移動でき、Enter/Spaceonkeydown が発火してソートが実行される、という流れが成立しています。

スペースキーの既定動作を止める

キーボードでの動作確認中、スペースキーでソートは実行されるものの、ページ自体もスクロールしてしまう現象がありました。<button> のようなネイティブの操作可能要素はブラウザがスペースキーの既定動作(スクロール)を自動的に抑制してくれますが、<th> はそうではないため、e.preventDefault() を明示的に呼ぶ必要がありました。

Lighthouseでのカラーコントラスト監査

Chrome DevToolsの「Lighthouse」タブでアクセシビリティ監査を実行し、コントラスト比が不足している箇所を1つずつ潰していきました。指摘のたびに、実際のDOM要素とクラスを見て、原因の色と背景色を特定する作業を繰り返しました。

いくつか印象的だった原因を挙げます。

  • カスタムテーマカラーの明るさ不足: app.css で定義したオレンジ系の primary-700/primary-600 が、白背景に対して4.5:1を下回っていた。テーマカラーは見た目だけでなく数値的な検証が必要
  • flowbite-svelteコンポーネントのclassclassesの違い: Progressbar の内側ラベルは class ではなく classes={{ label: "..." }} で指定する必要があった。class は外側のトラック部分にしか効かない
  • ライブラリのデフォルト配色自体が低コントラスト: Alertcolor="red"bg-red-100 text-red-500 がデフォルトで、単体で約3.08:1しかない。class="text-red-800" で上書きして解消
  • bodydark:text-*が無かった: ダークモード対応時に dark:bg-gray-900 は追加していたが、文字色のダークモード用指定を忘れていた。色指定の無いテキストが軒並み低コントラストになっていた根本原因

最後の「bodyの文字色」は、個別の要素を直すより先に直すべき根本原因でした。

/* app.css */
body {
  @apply dark:bg-gray-900 dark:text-gray-100;
}

これを追加してから、明示的な色指定の無い要素の指摘がまとめて解消されました。個別の要素を1つずつ直す前に、まず土台となる基本スタイルを見直すべきだった、という反省点です。

まとめ

ポイント 内容
aria-labelの要不要 テキストがあるボタンには不要。アイコンのみの操作要素にだけ必要
ソート可能な列見出し aria-sortを使い、role="button"で上書きしない
tabindexの型 Svelteではtabindex={0}(数値)。文字列だと型エラーになる
スペースキーの既定動作 ネイティブでない要素はe.preventDefault()で明示的に止める
コントラスト調査の順番 個別要素より先に、bodyなど土台のスタイルを見直す
flowbite-svelteのclassesプロパティ コンポーネント内部の特定パーツに色を当てるにはclassではなくclassesが必要な場合がある
← トップページに戻る