1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ARIAを付けるだけでは不十分:React・TypeScriptで作るアクセシブルなカレンダーUI

1
Posted at

カレンダーには、奇妙な性質がある。マウスで見れば簡単なのに、キーボードだけで操作すると急に難しくなることだ。

例えば、日付を選ぶために42個のボタンを並べた場合、毎月42回もTabを押さないと次の入力欄へ移動できない設計になり得る。一方、すべてのボタンにtabIndex="-1"を指定すると、今度はキーボードからカレンダーに入れない。

さらに、月末で右矢印キーを押した瞬間、カレンダーを再描画したためにフォーカスがbodyへ移ってしまう実装も珍しくない。

今回は、React + TypeScriptで、キーボード・スクリーンリーダー・タッチ操作を同時に考慮した日付選択UIを構築する。前回扱ったPDFや印刷レイアウトの設計とは異なり、テーマは人が操作できるカレンダーである。

この記事のコードは、dialogを使わないインライン型の日付選択カレンダーの実装例です。WAI-ARIA Authoring Practices Guide(APG)を参考にしていますが、すべての支援技術での動作保証やWCAG適合宣言を意味しません。製品導入時には実機・支援技術での検証が必要です。

1. 「日付が見える」と「日付を選べる」は別の要件

まず、次の3つのUIを区別したい。

UI 主な目的 代表的なHTML
閲覧用の月間カレンダー 日付を把握する table、見出し、テキスト
日付選択カレンダー 日を選択する button、必要に応じてgrid
予定管理カレンダー 予定を閲覧・編集する 予定のリスト、ボタン、ダイアログ等

閲覧用なら、操作できる42個のボタンを無理に作る必要はない。意味のある表やリストとして日付を提示すればよい。

一方、日付選択UIには、選択状態、フォーカス、キーボード操作、現在表示中の月、月をまたぐ移動などの状態が必要になる。

BetaCalendarsの月間カレンダーは「月全体を見渡す」という利用目的を考える際の実例になる。これに対して、この記事で実装するのは、同じ7列の暦を操作可能なウィジェットへ変換する際の設計である。

典型的な失敗例

// 見た目はカレンダーでも、42回のTab操作が必要になり得る
<div className="calendar">
  {days.map((day) => (
    <button key={day.iso}>{day.number}</button>
  ))}
</div>

各ボタンは操作できるが、矢印キー移動、日付の読み上げ、月末での遷移が定義されていない。role="grid"を後から足しても、キーボード操作が自動的に実装されるわけではない。

2. WAI-ARIAのGrid Patternと「roving tabindex」

W3CのAPG Date Picker Dialog Exampleでは、日付の集合をgridとして扱い、矢印キーによる移動を実装している。

重要な考え方は、カレンダー全体を原則としてTabキー1回で出入りできる複合ウィジェットとして扱うことだ。

  • フォーカスされる日付のtabIndexを0にする
  • それ以外の日付を-1にする
  • 矢印キーで移動すると、0の位置も更新する
  • EnterまたはSpaceで日付を選ぶ
  • 月移動や年移動でもフォーカスを失わない

これはroving tabindexと呼ばれる設計手法だ。

フォーカスと選択状態は分ける。 例えば、5月17日が選択済みでも、キーボードで5月20日まで移動した時点では選択結果を変更しない。Enterを押した段階で選択を確定させる。

selectedDate = 2027-05-17  // 確定済みの値
focusedDate  = 2027-05-20  // 今操作している位置
visibleMonth = 2027-05     // 画面に表示している月

ここを1つの状態変数にまとめると、矢印キーを押すだけで入力値が書き換わるUIになってしまう。

3. キーボード操作の仕様を先に決める

実装前に振る舞いを表にまとめる。今回の仕様では週は月曜日から始まる。

キー 操作
← / → 前日 / 翌日に移動
↑ / ↓ 7日前 / 7日後に移動
Home / End 同じ週の月曜 / 日曜へ移動
PageUp / PageDown 前月 / 翌月の同じ日付へ移動
Shift + PageUp / Shift + PageDown 前年 / 翌年の同じ日付へ移動
Enter / Space フォーカス中の日付を選択
Tab カレンダー内を42回巡回せず、次の操作要素へ移動

月の変更時、移動先に同じ日がなければ末日に丸める。例えば2027年1月31日からPageDownすると2027年2月28日へ移動する。

なお、矢印キーの挙動は一般的なテーブルの標準動作ではない。インタラクティブなGridとして採用する場合に、アプリケーション側が実装する操作契約である。

4. 日付演算をUIから独立させる

まず、キーボード操作とReactから独立した日付関数を作る。今回は説明を簡潔にするため、グレゴリオ暦の日付をYYYY-MM-DDで保持し、UTCメソッドで計算する。

calendar-date.ts
export function fromISO(iso: string): Date {
  const [year, month, day] = iso.split("-").map(Number);
  return new Date(Date.UTC(year, month - 1, day));
}

export function toISO(date: Date): string {
  return date.toISOString().slice(0, 10);
}

export function addDays(iso: string, amount: number): string {
  const date = fromISO(iso);
  date.setUTCDate(date.getUTCDate() + amount);
  return toISO(date);
}

export function addMonthsClamped(
  iso: string,
  amount: number
): string {
  const oldDate = fromISO(iso);
  const desiredDay = oldDate.getUTCDate();

  const first = new Date(Date.UTC(
    oldDate.getUTCFullYear(),
    oldDate.getUTCMonth() + amount,
    1
  ));

  const lastDay = new Date(Date.UTC(
    first.getUTCFullYear(),
    first.getUTCMonth() + 1,
    0
  )).getUTCDate();

  first.setUTCDate(Math.min(desiredDay, lastDay));
  return toISO(first);
}

export function moveByKey(
  iso: string,
  key: string,
  shiftKey = false
): string | null {
  const weekday = (fromISO(iso).getUTCDay() + 6) % 7;

  switch (key) {
    case "ArrowLeft": return addDays(iso, -1);
    case "ArrowRight": return addDays(iso, 1);
    case "ArrowUp": return addDays(iso, -7);
    case "ArrowDown": return addDays(iso, 7);
    case "Home": return addDays(iso, -weekday);
    case "End": return addDays(iso, 6 - weekday);
    case "PageUp": return addMonthsClamped(iso, shiftKey ? -12 : -1);
    case "PageDown": return addMonthsClamped(iso, shiftKey ? 12 : 1);
    default: return null;
  }
}

この例のfromISO()は内部で生成された正しい日付文字列を前提とした小さなサンプルです。利用者入力を直接渡す製品では、形式・存在する日付・サポート年範囲を先に検証してください。特にDate.UTC()の0〜99年の扱いにも注意が必要です。

5. Reactでアクセス可能な日付選択Gridを作る

次に、Reactコンポーネントを実装する。デモの初期日付は2027年5月17日とする。

  • selectedISOは確定した日付
  • focusedISOはキーボード操作位置
  • 表示月はfocusedISOから算出する
  • 42個のセルは前後月も含めて生成する
  • レンダリング後に必要な場合だけフォーカスを復元する

この例はViteのReact + TypeScriptプロジェクトで利用できる。

npm create vite@latest accessible-calendar -- --template react-ts
cd accessible-calendar
npm install

次のファイルを追加する。

AccessibleCalendar.tsx
import {
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
} from "react";
import {
  addDays,
  addMonthsClamped,
  fromISO,
  moveByKey,
  toISO,
} from "./calendar-date";
import "./AccessibleCalendar.css";

const weekdays = [
  ["月", "月曜日"], ["火", "火曜日"], ["水", "水曜日"],
  ["木", "木曜日"], ["金", "金曜日"], ["土", "土曜日"],
  ["日", "日曜日"],
];

const fullDate = new Intl.DateTimeFormat("ja-JP", {
  year: "numeric",
  month: "long",
  day: "numeric",
  weekday: "long",
  timeZone: "UTC",
});

const monthName = new Intl.DateTimeFormat("ja-JP", {
  year: "numeric",
  month: "long",
  timeZone: "UTC",
});

function getGridDates(focusedISO: string): string[] {
  const focused = fromISO(focusedISO);
  const first = new Date(Date.UTC(
    focused.getUTCFullYear(),
    focused.getUTCMonth(),
    1
  ));

  // 月曜始まり: Mon=0 ... Sun=6
  const offset = (first.getUTCDay() + 6) % 7;
  const start = addDays(toISO(first), -offset);

  return Array.from({ length: 42 }, (_, i) => addDays(start, i));
}

export function AccessibleCalendar() {
  const [focusedISO, setFocusedISO] = useState("2027-05-17");
  const [selectedISO, setSelectedISO] = useState<string | null>(null);
  const dayRefs = useRef(new Map<string, HTMLButtonElement>());
  const restoreFocus = useRef(false);

  const visible = fromISO(focusedISO);
  const visibleYear = visible.getUTCFullYear();
  const visibleMonth = visible.getUTCMonth();
  const gridDates = getGridDates(focusedISO);

  useLayoutEffect(() => {
    if (!restoreFocus.current) return;
    dayRefs.current.get(focusedISO)?.focus();
    restoreFocus.current = false;
  }, [focusedISO]);

  function onDayKeyDown(
    event: KeyboardEvent<HTMLButtonElement>,
    iso: string
  ) {
    const next = moveByKey(iso, event.key, event.shiftKey);
    if (!next) return;

    event.preventDefault();
    restoreFocus.current = true;
    setFocusedISO(next);
  }

  function selectDate(iso: string) {
    if (iso !== focusedISO) {
      restoreFocus.current = true;
      setFocusedISO(iso);
    }
    setSelectedISO(iso);
  }

  function changeMonth(amount: number) {
    // 月移動ボタンをクリックしたときは、そのボタンにフォーカスを残す
    setFocusedISO((current) => addMonthsClamped(current, amount));
  }

  return (
    <section className="calendar-widget" aria-label="日付を選択">
      <div className="calendar-toolbar">
        <button type="button" onClick={() => changeMonth(-1)}>
          前月
        </button>
        <h2 id="calendar-month" aria-live="polite" aria-atomic="true">
          {monthName.format(visible)}
        </h2>
        <button type="button" onClick={() => changeMonth(1)}>
          翌月
        </button>
      </div>

      <p id="calendar-help" className="calendar-help">
        矢印キーで日付を移動し、Enterキーで選択します。
        PageUpとPageDownで月を変更できます。
      </p>

      <table
        className="calendar-grid"
        role="grid"
        aria-labelledby="calendar-month"
        aria-describedby="calendar-help"
      >
        <thead>
          <tr>
            {weekdays.map(([short, long]) => (
              <th scope="col" abbr={long} key={long}>{short}</th>
            ))}
          </tr>
        </thead>
        <tbody>
          {Array.from({ length: 6 }, (_, row) => (
            <tr key={row}>
              {gridDates.slice(row * 7, row * 7 + 7).map((iso) => {
                const date = fromISO(iso);
                const selected = selectedISO === iso;
                const outside = date.getUTCFullYear() !== visibleYear ||
                  date.getUTCMonth() !== visibleMonth;

                return (
                  <td role="gridcell" aria-selected={selected} key={iso}>
                    <button
                      ref={(node) => {
                        if (node) dayRefs.current.set(iso, node);
                        else dayRefs.current.delete(iso);
                      }}
                      type="button"
                      data-outside={outside}
                      className={selected ? "selected" : ""}
                      tabIndex={focusedISO === iso ? 0 : -1}
                      aria-label={`${fullDate.format(date)}${selected ? "、選択中" : ""}`}
                      onKeyDown={(event) => onDayKeyDown(event, iso)}
                      onClick={() => selectDate(iso)}
                    >
                      {date.getUTCDate()}
                    </button>
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>

      <p className="calendar-result" role="status">
        {selectedISO
          ? `選択した日付:${fullDate.format(fromISO(selectedISO))}`
          : "日付はまだ選択されていません"}
      </p>
    </section>
  );
}

src/App.tsxなどから<AccessibleCalendar />を描画すればよい。

ここで最も重要なのは、useLayoutEffectによるフォーカス復元だ。例えば5月31日から右矢印キーで6月1日へ移動すると、表示する月が変わり、ボタンのDOMも再構成される。そのあと新しい日付のボタンにフォーカスを戻す。

単にsetFocusedISO()するだけでは、DOMの再描画後もフォーカスが維持されるとは限らない。

なお、月移動ボタンをマウスで押した場合は、意図的にボタン側にフォーカスを残している。キーボードで日付を移動した場合と、ツールバーで月を切り替えた場合とでは、適切なフォーカス動作が異なるためだ。

6. フォーカスの見た目は「装飾」ではなくUIの一部

ARIA属性が正しくても、フォーカスが見えなければキーボード利用者は現在位置を把握できない。

AccessibleCalendar.css
.calendar-widget {
  max-width: 540px;
  padding: 16px;
  color: #17263c;
  background: white;
  font-family: system-ui, sans-serif;
}

.calendar-toolbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
}

.calendar-toolbar h2 {
  margin: 0;
  font-size: 1.2rem;
}

.calendar-toolbar button {
  min-width: 64px;
  min-height: 40px;
  border: 1px solid #475569;
  border-radius: 6px;
  background: #fff;
  color: #17263c;
  cursor: pointer;
}

.calendar-help {
  font-size: 0.9rem;
  line-height: 1.6;
}

.calendar-grid {
  width: 100%;
  border-collapse: separate;
  border-spacing: 2px;
  table-layout: fixed;
}

.calendar-grid th {
  padding: 8px 0;
  font-weight: 700;
}

.calendar-grid td { padding: 0; }

.calendar-grid td button {
  display: block;
  width: 100%;
  min-height: 44px;
  border: 1px solid transparent;
  border-radius: 5px;
  background: white;
  color: #17263c;
  font: inherit;
  cursor: pointer;
}

.calendar-grid button[data-outside="true"] {
  background: #f8fafc;
  color: #475569;
}

.calendar-grid button.selected {
  background: #173a68;
  color: white;
  font-weight: 700;
}

.calendar-widget button:focus-visible {
  outline: 3px solid #d97706;
  outline-offset: 2px;
}

.calendar-result { min-height: 1.5em; }

@media (forced-colors: active) {
  .calendar-grid button.selected {
    border: 2px solid Highlight;
  }
}

ここでは日付の選択状態を色だけでなく、aria-selected・太字・選択中の読み上げでも表現している。

WCAG 2.2では、通常サイズのテキストに原則4.5:1以上のコントラスト、重要なUI部品・状態の非テキスト指標に3:1以上のコントラストが求められる。また、AAのターゲットサイズ要件は原則24×24 CSS px以上(例外あり)だ。今回の日付ボタンは操作しやすさを考慮して最低44pxの高さを設けた。

ただし、すべての画面サイズ・配色状態で基準を満たすかどうかは別途計測が必要である。

7. 2027年5月31日から6月1日への移動をテストする

アクセシビリティ対応では「動くと思う」だけでは足りない。月境界でフォーカスが失われないことを自動テストにする。

npm install -D @playwright/test @axe-core/playwright
npx playwright install chromium

Playwrightの実行時にVite開発サーバーを起動する設定を書く。

playwright.config.ts
import { defineConfig } from "@playwright/test";

export default defineConfig({
  use: { baseURL: "http://127.0.0.1:5173" },
  webServer: {
    command: "npm run dev -- --host 127.0.0.1",
    url: "http://127.0.0.1:5173",
    reuseExistingServer: !process.env.CI,
  },
});
tests/calendar.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test.beforeEach(async ({ page }) => {
  await page.goto("/");
});

test("矢印キーで翌日へ移動し、Enterで選択できる", async ({ page }) => {
  await page.getByRole("button", { name: /2027年5月17日/ }).focus();
  await page.keyboard.press("ArrowRight");

  const nextDay = page.getByRole("button", { name: /2027年5月18日/ });
  await expect(nextDay).toBeFocused();

  await page.keyboard.press("Enter");
  await expect(page.getByRole("gridcell", { selected: true }))
    .toContainText("18");
});

test("月末でもフォーカスを失わない", async ({ page }) => {
  await page.getByRole("button", { name: /2027年5月31日/ }).focus();
  await page.keyboard.press("ArrowRight");

  await expect(page.getByRole("heading", { name: "2027年6月" }))
    .toBeVisible();
  await expect(page.getByRole("button", { name: /2027年6月1日/ }))
    .toBeFocused();
});

test("自動検出できるアクセシビリティ違反を検査する", async ({ page }) => {
  const result = await new AxeBuilder({ page }).analyze();
  expect(result.violations).toEqual([]);
});

実行コマンド:

npx playwright test

このテストで特に重要なのは、日付が変わったことだけでなく、新しい日付ボタンにフォーカスが移ったことも確認している点だ。

axe-coreによる静的な検査は、ラベル不足やARIA属性の問題などを検出する助けになる。しかし、読み上げの分かりやすさや、実際のスクリーンリーダー操作の品質を完全には検証できない。Playwright公式ドキュメントも、自動検査と手動検査の併用を推奨している。

8. スクリーンリーダーでは何を確認するべきか

テスト対象を、少なくとも次のように分ける。

環境 確認ポイント
Windows + NVDA + Firefox セルの日付、選択状態、矢印キー操作
Windows + JAWS + Chrome Gridの行列関係、フォーカス移動
macOS + VoiceOver + Safari 月変更、ボタン名、選択状態
iOS + VoiceOver スワイプとタッチ操作、読み上げ順序
Android + TalkBack 日付ボタンへの移動、選択結果

この組み合わせは検証例であり、どの組み合わせでも同じ読み上げになるという意味ではない。

特に次の点を実際に確認する。

  1. 日付に移動したとき、「17」だけでなく年月日と曜日が伝わるか。
  2. フォーカスがある日と、選択済みの日を区別できるか。
  3. 月が切り替わったとき、現在の月が伝わるか。
  4. 日付選択の結果がrole="status"で通知されるか。
  5. Tabでカレンダーから次の操作へ移動できるか。
  6. 400%ズームなどで操作不能になる箇所がないか。

なお、aria-live="polite"は通知を要求する仕組みであり、すべてのブラウザ・支援技術で毎回同じように読み上げられる保証はない。連続操作中に通知が多すぎないかも検証する。

9. デジタルのアクセシビリティと紙の可読性を混同しない

アクセシブルなUIの設計を考えていると、印刷用テンプレートの設計にも共通する課題が見えてくる。

例えば、BetaCalendarsのWeekly Calendarのように、1週間の予定をまとめて見るテンプレートでは、曜日の位置、文字サイズ、記入スペースの明確さが重要になる。

また、Blank Calendarのような空白テンプレートでは、文字情報が少ない分、グリッドの意味や記入領域の区切りを分かりやすくする必要がある。

しかし、印刷物の読みやすさとWeb上のスクリーンリーダー対応は別物だ。

画像だけのカレンダーに代替テキストを1行追加しても、すべての日付や予定を操作可能にできるわけではない。逆に、Web上で操作できる日付選択UIが、そのまま手書き用テンプレートとして適切とも限らない。

同じ「カレンダー」であっても、利用目的に合わせて適切な情報構造と操作方法を選びたい。

10. 本番運用に進む前に追加したい要件

今回のコードは、キーボードで日付を選択する基本構造を示したものだ。製品レベルでは、さらに次の機能を検討する。

  • min/max日付:選択できない日付を意味的にも視覚的にも示す
  • 無効な日付:aria-disabled等の使い方を操作仕様とセットで設計する
  • 年月の直接移動:大量の月移動を繰り返さなくて済む手段を設ける
  • 日付の直接入力:カレンダー操作以外にも入力経路を用意する
  • 言語・週開始曜日:Intlと地域設定を使って切り替える
  • 日時とタイムゾーン:予約時刻の選択が必要なら、暦日とは別モデルとして扱う
  • 再描画コスト:イベントを大量表示する場合はパフォーマンスを計測する
  • 境界値テスト:閏日、月末、年境界、選択不可期間をテストする

特に、日付選択が必要なだけならネイティブの<input type="date">も候補になる。カスタムGridを自作する前に、対象ユーザー・環境でネイティブコントロールが要件を満たせるか確認したい。

11. まとめ:アクセシビリティは見た目の後処理ではない

アクセス可能なカレンダーUIを作るには、aria-labelを付けるだけでは不十分だ。

必要なのは、以下の状態と契約を明確にすること。

  1. 表示:現在どの月を見ているのか
  2. フォーカス:キーボード操作の対象はどの日付か
  3. 選択:ユーザーが確定した日付はどれか
  4. 操作:どのキーで何が起こるのか
  5. 通知:画面を見なくても結果を理解できるか
  6. 検証:月境界や支援技術で動作を再現できるか

Accessible calendar design is not an ARIA attribute checklist. It is a focus-management and interaction contract.

カレンダーを作る際には、日付の正しさだけでなく、人がその情報へどう到達するのかも設計の中心に置きたい。

私が取り組んでいるBetaCalendarsでは、月間・週間・空白などのカレンダーフォーマットを提供している。カレンダーの用途を考える際、閲覧、印刷、入力・選択という異なる体験を区別することは、今後のUI設計にも役立つと思う。


参考資料

著者:Mateo Pedersen
Web開発、TypeScript、ユーザーインターフェース設計、カレンダーシステムに関心を持つ開発者。
Project: BetaCalendars

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?