【アドベントカレンダー2025】キーボード操作でも快適に!ウエディングサイトリニューアルで実践したカレンダーのアクセシビリティ対応

はじめに

こんにちは。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} を設定します。

Tabキーを押したら翌月の最初にフォーカス可能な日付に移動

矢印キーでの日付移動

日付にフォーカスが当たっている状態で、矢印キー(↑→↓←)で直感的に移動できるようにしました。

また、日付移動時に移動先の日付が選択不可な要素の場合は、そこで止まらずにさらに次の日付へ自動的にスキップするよう再帰処理を入れています。

実装した機能

  • ←/→キー:前日/翌日に移動

右キーを押したら次にフォーカス可能な日付に移動

  • ↑/↓キー: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を活用することである程度の担保はできつつあります。

しかしアクセシビリティを考慮した形に専用に実装する必要のある対応方法もまだまだ多くあります。

これらをキャッチアップしたり共有し合うことで、将来的に開発コストを下げながらアクセシビリティを高めていきたいです。


フロントエンドメインのエンジニアです。
趣味でケーキやプリンなど洋菓子をよく作っています。