
はじめに
こんにちは。Restaurant Service Devグループの高岡です。現在ぐるなびウエディングのフロントエンド開発・運用を行っています。
ぐるなびウエディングのリニューアルプロジェクトでは、UIを一新するにあたり「すべての利用者にとって使いやすいサイト」を目指してアクセシビリティ対応に力を入れています。
特にキーボード操作への対応は、マウスを使わない利用者やスクリーンリーダーを使用する視覚障害者の方々にとって重要です。しかし、特に意識せず実装してしまうと使い勝手が悪くなるケースも多く、細かな配慮が必要でした。
この記事では、ウエディング二次会のリニューアル時にデザイナーと協力して実装したカレンダーのキーボード操作について、課題と解決策を詳しく紹介します。
同じような実装をする際の参考になれば幸いです。
アクセシビリティに配慮したカレンダーのキーボード操作
課題:通常の実装では使いにくいカレンダー
選択可能な日付を並べたカレンダーのUIにおいて、アクセシビリティを考慮せずに実装すると各日付がTabキーでのフォーカス対象となってしまいます。
月初から月末までのすべての日付をTabキーで順番に移動しなければならないため、キーボードユーザーにとって非常に操作体験が悪い問題があります。
悪い例:8/1 → 8/2 → 8/3 → ... → 8/31 → 次の要素 (最大31回もTabキーを押さなければならない)
解決策:Roving TabIndex(動くtabIndex)の採用
著名なサービスで見られるカレンダーのUIやW3Cの記載内容を参考に、Roving TabIndex という手法を用いて以下の操作系を実装しました。
Tabキーの挙動
その月の「現在選択中の日付(または今日)」のみにフォーカスを当て、他の日付はスキップします。
良い例:「月切り替えボタン」→「8/1」→「9/1」→「カレンダー外の要素」 (月ごとに1回のTabでOK!)
これを実現するために、フォーカス対象の最初にクリック可能な日付(と日付の複数選択が可能な場合は選択中の日付)のみ tabIndex={0} を設定し、それ以外の日付には tabIndex={-1} を設定します。

矢印キーでの日付移動
日付にフォーカスが当たっている状態で、矢印キー(↑→↓←)で直感的に移動できるようにしました。
また、日付移動時に移動先の日付が選択不可な要素の場合は、そこで止まらずにさらに次の日付へ自動的にスキップするよう再帰処理を入れています。
実装した機能:
- ←/→キー:前日/翌日に移動

- ↑/↓キー:1週間前/後に移動

- 月をまたぐ移動:表示中の最初/最後の日付で矢印キーを押した場合:カレンダーを切り替えて最初/最後にフォーカス可能な日に移動

矢印キーでの日付移動を実現するカスタムフックの実装
上記の矢印キーの移動機能をReactのカスタムフックで実装する場合の例を以下で示します。
// 日付操作には軽量なライブラリである date-fns を使用 import { parse, addDays, format } from 'date-fns' import { useRef } from 'react' interface UseCalendarFocusReturn { handleKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void containerReference: React.RefObject<HTMLDivElement | null> } export const useCalendarFocus = (): UseCalendarFocusReturn => { const containerReference = useRef<HTMLDivElement>(null) const handleKeyDown = (event: React.KeyboardEvent<HTMLButtonElement>) => { const TARGET_KEYS = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'] as const if (!TARGET_KEYS.includes(event.key as (typeof TARGET_KEYS)[number])) { return } if (!(event.target instanceof HTMLButtonElement)) { return } event.preventDefault() // 矢印キーによる画面スクロール等を防止 const FORMAT = 'yyyy-M-d' // data-date属性から現在の日付を取得 const focusedDate = parse(event.target.dataset['date'] as string, FORMAT, new Date()) // フォーカス移動関数(再帰的に次の有効な日付を探す) const moveFocus = (diffDay: number, baseDiffDay: number) => { const movedDate = addDays(focusedDate, diffDay) // DOMから次の日付要素を探す const movedElement = containerReference.current?.querySelector<HTMLButtonElement>( `[data-date="${format(movedDate, FORMAT)}"]`, ) if (movedElement != null) { if (movedElement.disabled) { // 移動先がdisabledなら、同じ方向へさらに再帰的に探索 moveFocus(diffDay + baseDiffDay, baseDiffDay) } else { // 有効な日付ならフォーカスを当てる movedElement.focus() } } } switch (event.key) { case 'ArrowLeft': { moveFocus(-1, -1) break } case 'ArrowRight': { moveFocus(1, 1) break } case 'ArrowUp': { moveFocus(-7, -7) break } case 'ArrowDown': { moveFocus(7, 7) break } default: { break } } } return { handleKeyDown, containerReference } }
Roving TabIndex(動くtabIndex)の実装
カレンダーの各日付でRoving TabIndex(動くtabIndex)を実現するときのReactでの実装例です。
表示されている月で最初にクリック可能な要素の日付の取得ロジックは省略しています。
interface CalendarDateProps { dayOfMonth: number year: number month: number firstClickableDate: string // YYYY-M-d } export const CalendarDate: FC<CalendarDateProps> = ({ dayOfMonth, year, month, firstClickableDate, }) => { const isFocusable = firstClickableDate === `${year}-${month}-${dayOfMonth}` return ( <td role="gridcell"> <button data-date={`${year}-${month}-${dayOfMonth}`} tabIndex={isFocusable ? 0 : -1} > {dayOfMonth} </button> </td> ) }
この実装により、キーボードユーザーでもストレスなく日付を選択できるようになりました。
まとめ
アクセシビリティ対応はWeb標準技術やLintを活用することである程度の担保はできつつあります。
しかしアクセシビリティを考慮した形に専用に実装する必要のある対応方法もまだまだ多くあります。
これらをキャッチアップしたり共有し合うことで、将来的に開発コストを下げながらアクセシビリティを高めていきたいです。
