Tips & Tricks (更新: 2026/6/2)

Claude Codeで作る本番カレンダーコンポーネント: React/TypeScript実装とテスト

React/TypeScriptで予約対応カレンダーを実装。日付計算、タイムゾーン、アクセシビリティ、API連携、テストまで解説。

Claude Codeで作る本番カレンダーコンポーネント: React/TypeScript実装とテスト

カレンダーUIは見た目よりも失敗しやすい部品です。月末、うるう年、夏時間、ユーザーのタイムゾーン、キーボード操作、予約枠の非同期取得が同時に絡むため、単に「7列のグリッドを描く」だけでは本番で壊れます。

この記事ではClaude Codeを使って、React/TypeScriptの本番向けカレンダーコンポーネントを作ります。Claude Codeには「見た目を作って」ではなく、「日付計算を純粋関数に分ける」「ARIAを守る」「予約APIのレースをAbortControllerで止める」「Testing Libraryでキーボード操作まで検証する」といった制約を渡すのがコツです。

専門用語も最初に整理します。date-onlyは時刻を持たない日付、つまり 2026-06-02 のような値です。タイムゾーンは地域ごとの時刻の基準で、日本ならAsia/Tokyoです。ARIAはスクリーンリーダーなどの支援技術にUIの役割を伝える属性です。この3つを曖昧にすると、予約システムでは「ユーザーが選んだ日」と「サーバーが保存した日」がずれます。

使いどころ

本番カレンダーは、次のようなユースケースで差が出ます。

ユースケース必要な機能失敗すると起きる問題
美容室やクリニックの予約空き枠API、休業日、キーボード選択予約できない日を選べる
SaaSの管理画面月送り、範囲選択、複数地域の表示海外ユーザーの日付が1日ずれる
コンテンツ公開カレンダー選択日、予定数、今日の強調公開予定と実際の日付が一致しない
宿泊・イベント予約開始日と終了日の範囲選択終了日より後の開始日を保存する

関連する実装思想はアクセシビリティ改善の記事APIテストの記事でも扱っています。今回のコンポーネントは、その2つを日付UIに落とし込む例です。

公式情報として、JavaScriptの日付仕様はMDN Date、地域ごとの表示はMDN Intl.DateTimeFormat、キーボード操作の考え方はWAI-ARIA Authoring PracticesのGrid Pattern、テストはTesting Libraryを参照してください。Reactの状態管理はReact公式のstate管理が土台になります。

Claude Codeに渡す設計メモ

Claude Codeへ最初に渡すプロンプトは、短い依頼文ではなく、壊してはいけない条件を書いた実装メモにします。

React + TypeScriptで予約用カレンダーを作る。
- 日付キーはYYYY-MM-DDのdate-only文字列にする
- Date.toISOString()をユーザー選択日の保存に使わない
- 月グリッド、範囲判定、表示ラベルはdate-utils.tsに分ける
- 空き枠APIはAbortControllerでキャンセル可能にする
- role="grid"、aria-selected、aria-disabled、キーボード移動を実装する
- 単日選択と範囲選択をサポートする
- CSSとTesting Libraryのテストも同時に作る

設計は次の流れに分けます。

flowchart LR
  A["date-utils.ts<br/>日付計算"] --> B["availability-api.ts<br/>空き枠取得"]
  B --> C["Calendar.tsx<br/>表示と操作"]
  C --> D["calendar.css<br/>状態の見た目"]
  C --> E["Calendar.test.tsx<br/>回帰テスト"]

ポイントは、日付計算をUIから分離することです。Claude CodeはUIの差分を速く作れますが、日付ロジックがコンポーネント内に散らばると、月末やタイムゾーンのバグを見つけにくくなります。最初から純粋関数に分けると、テストも依頼しやすくなります。

日付ユーティリティ

次のコードは src/calendar/date-utils.ts としてコピーできます。日付キーは YYYY-MM-DD で保存し、月グリッドの計算はUTC基準の数値計算に寄せています。ユーザーの「今日」だけはタイムゾーンで変わるため、Intl.DateTimeFormat で求めます。

export type ISODate = `${number}-${number}-${number}`;

const pad2 = (value: number) => String(value).padStart(2, "0");

export function makeISODate(year: number, month: number, day: number): ISODate {
  return `${year}-${pad2(month)}-${pad2(day)}` as ISODate;
}

export function readISODate(value: ISODate) {
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
  if (!match) throw new Error(`Invalid ISO date: ${value}`);

  const year = Number(match[1]);
  const month = Number(match[2]);
  const day = Number(match[3]);
  const check = new Date(Date.UTC(year, month - 1, day));

  if (
    check.getUTCFullYear() !== year ||
    check.getUTCMonth() !== month - 1 ||
    check.getUTCDate() !== day
  ) {
    throw new Error(`Invalid calendar date: ${value}`);
  }

  return { year, month, day };
}

function toUTCDate(value: ISODate) {
  const { year, month, day } = readISODate(value);
  return new Date(Date.UTC(year, month - 1, day));
}

export function addDaysISO(value: ISODate, amount: number): ISODate {
  const date = toUTCDate(value);
  date.setUTCDate(date.getUTCDate() + amount);
  return makeISODate(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
}

export function addMonthsISO(value: ISODate, amount: number): ISODate {
  const { year, month } = readISODate(value);
  const date = new Date(Date.UTC(year, month - 1 + amount, 1));
  return makeISODate(date.getUTCFullYear(), date.getUTCMonth() + 1, 1);
}

export function startOfMonthISO(value: ISODate): ISODate {
  const { year, month } = readISODate(value);
  return makeISODate(year, month, 1);
}

export function daysInMonthISO(value: ISODate): number {
  const { year, month } = readISODate(value);
  return new Date(Date.UTC(year, month, 0)).getUTCDate();
}

export function weekdayIndex(value: ISODate): number {
  return toUTCDate(value).getUTCDay();
}

export function buildMonthGrid(monthISO: ISODate, weekStartsOn: 0 | 1 = 0): ISODate[] {
  const first = startOfMonthISO(monthISO);
  const leading = (weekdayIndex(first) - weekStartsOn + 7) % 7;
  const start = addDaysISO(first, -leading);
  const visibleDays = Math.ceil((leading + daysInMonthISO(first)) / 7) * 7;

  return Array.from({ length: visibleDays }, (_, index) => addDaysISO(start, index));
}

export function todayISO(timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone): ISODate {
  const parts = new Intl.DateTimeFormat("en-CA", {
    timeZone,
    year: "numeric",
    month: "2-digit",
    day: "2-digit",
  }).formatToParts(new Date());

  const get = (type: string) => parts.find((part) => part.type === type)?.value;
  return `${get("year")}-${get("month")}-${get("day")}` as ISODate;
}

export function formatISODate(
  value: ISODate,
  locale: string,
  options: Intl.DateTimeFormatOptions = {},
) {
  const { year, month, day } = readISODate(value);
  return new Intl.DateTimeFormat(locale, { timeZone: "UTC", ...options }).format(
    new Date(Date.UTC(year, month - 1, day, 12)),
  );
}

export function compareISODate(a: ISODate, b: ISODate) {
  return a.localeCompare(b);
}

export function normalizeRange(start: ISODate, end: ISODate) {
  return compareISODate(start, end) <= 0 ? { start, end } : { start: end, end: start };
}

export function isISODateInRange(day: ISODate, start?: ISODate, end?: ISODate) {
  if (!start || !end) return false;
  return compareISODate(day, start) >= 0 && compareISODate(day, end) <= 0;
}

落とし穴は new Date("2026-06-02") をそのままUIに使うことです。環境によってUTCとして扱われ、ローカル時刻では前日になることがあります。予約日や公開日にはDateオブジェクトではなくdate-only文字列を使い、時刻付きの予約枠だけをタイムゾーン付きで扱うと安全です。

空き枠APIモック

次は src/calendar/availability-api.ts です。本番ではHTTP APIに置き換えますが、形を先に決めるとClaude Codeに実API接続を依頼しやすくなります。

import { addDaysISO, compareISODate, weekdayIndex, type ISODate } from "./date-utils";

export type AvailabilityStatus = "available" | "limited" | "closed";

export type DayAvailability = {
  status: AvailabilityStatus;
  slots: string[];
};

export type AvailabilityByDate = Record<ISODate, DayAvailability>;

export type AvailabilityRequest = {
  start: ISODate;
  end: ISODate;
  timeZone: string;
  signal?: AbortSignal;
};

function wait(ms: number, signal?: AbortSignal) {
  return new Promise<void>((resolve, reject) => {
    const id = window.setTimeout(resolve, ms);
    signal?.addEventListener("abort", () => {
      window.clearTimeout(id);
      reject(new DOMException("Request aborted", "AbortError"));
    });
  });
}

export async function fetchAvailability({
  start,
  end,
  timeZone,
  signal,
}: AvailabilityRequest): Promise<AvailabilityByDate> {
  await wait(180, signal);

  const result: AvailabilityByDate = {};
  for (let day = start; compareISODate(day, end) <= 0; day = addDaysISO(day, 1)) {
    const weekday = weekdayIndex(day);
    const closed = weekday === 0;
    const limited = weekday === 6;

    result[day] = {
      status: closed ? "closed" : limited ? "limited" : "available",
      slots: closed
        ? []
        : limited
          ? [`10:00 ${timeZone}`, `13:00 ${timeZone}`]
          : [`09:00 ${timeZone}`, `11:00 ${timeZone}`, `15:00 ${timeZone}`],
    };
  }

  return result;
}

API連携の落とし穴は、月を素早く切り替えたときに古いレスポンスが新しい月を上書きすることです。AbortController で古いリクエストを止め、signal をAPI層まで渡してください。実APIでは、日付範囲、ユーザーのタイムゾーン、サービスIDをリクエストに含めるのが現実的です。

Calendarコンポーネント

src/calendar/Calendar.tsx に本体を置きます。単日選択、範囲選択、月送り、空き枠取得、キーボード操作をまとめた実装です。

import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
import {
  addDaysISO,
  addMonthsISO,
  buildMonthGrid,
  compareISODate,
  formatISODate,
  isISODateInRange,
  normalizeRange,
  startOfMonthISO,
  todayISO,
  type ISODate,
} from "./date-utils";
import {
  fetchAvailability,
  type AvailabilityByDate,
  type AvailabilityRequest,
} from "./availability-api";
import "./calendar.css";

type CalendarMode = "single" | "range";
type RangeValue = { start?: ISODate; end?: ISODate };
type LoadAvailability = (request: AvailabilityRequest) => Promise<AvailabilityByDate>;

type CalendarProps = {
  locale?: string;
  timeZone?: string;
  initialMonth?: ISODate;
  weekStartsOn?: 0 | 1;
  mode?: CalendarMode;
  selected?: ISODate;
  range?: RangeValue;
  onSelectDate?: (date: ISODate) => void;
  onSelectRange?: (range: RangeValue) => void;
  loadAvailability?: LoadAvailability;
};

const weekDayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

export function Calendar({
  locale = "en-US",
  timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone,
  initialMonth = todayISO(timeZone),
  weekStartsOn = 0,
  mode = "single",
  selected,
  range,
  onSelectDate,
  onSelectRange,
  loadAvailability = fetchAvailability,
}: CalendarProps) {
  const [month, setMonth] = useState(startOfMonthISO(initialMonth));
  const [activeDate, setActiveDate] = useState(initialMonth);
  const [internalSelected, setInternalSelected] = useState<ISODate | undefined>(selected);
  const [internalRange, setInternalRange] = useState<RangeValue>(range ?? {});
  const [availability, setAvailability] = useState<AvailabilityByDate>({});
  const [error, setError] = useState("");
  const buttonRefs = useRef<Record<string, HTMLButtonElement | null>>({});

  const days = useMemo(() => buildMonthGrid(month, weekStartsOn), [month, weekStartsOn]);
  const visibleStart = days[0];
  const visibleEnd = days[days.length - 1];
  const selectedDate = selected ?? internalSelected;
  const selectedRange = range ?? internalRange;
  const currentMonthLabel = formatISODate(month, locale, { month: "long", year: "numeric" });
  const today = todayISO(timeZone);

  useEffect(() => {
    const controller = new AbortController();
    setError("");

    loadAvailability({
      start: visibleStart,
      end: visibleEnd,
      timeZone,
      signal: controller.signal,
    })
      .then(setAvailability)
      .catch((reason: unknown) => {
        if (reason instanceof DOMException && reason.name === "AbortError") return;
        setError("Availability could not be loaded.");
      });

    return () => controller.abort();
  }, [loadAvailability, timeZone, visibleStart, visibleEnd]);

  useEffect(() => {
    buttonRefs.current[activeDate]?.focus();
  }, [activeDate]);

  function goToMonth(nextMonth: ISODate) {
    setMonth(nextMonth);
    setActiveDate(nextMonth);
  }

  function commitDate(day: ISODate) {
    if (availability[day]?.status === "closed") return;

    if (mode === "single") {
      setInternalSelected(day);
      onSelectDate?.(day);
      return;
    }

    const nextRange =
      !selectedRange.start || selectedRange.end
        ? { start: day, end: undefined }
        : normalizeRange(selectedRange.start, day);

    setInternalRange(nextRange);
    onSelectRange?.(nextRange);
  }

  function moveActive(offset: number) {
    const current = days.indexOf(activeDate);
    const next = days[Math.min(Math.max(current + offset, 0), days.length - 1)];
    setActiveDate(next);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (event.key === "ArrowLeft") moveActive(-1);
    else if (event.key === "ArrowRight") moveActive(1);
    else if (event.key === "ArrowUp") moveActive(-7);
    else if (event.key === "ArrowDown") moveActive(7);
    else if (event.key === "Home") setActiveDate(days[0]);
    else if (event.key === "End") setActiveDate(days[days.length - 1]);
    else if (event.key === "PageUp") goToMonth(addMonthsISO(month, -1));
    else if (event.key === "PageDown") goToMonth(addMonthsISO(month, 1));
    else if (event.key === "Enter" || event.key === " ") commitDate(activeDate);
    else return;

    event.preventDefault();
  }

  return (
    <section className="calendar" aria-labelledby="calendar-heading">
      <div className="calendar__toolbar">
        <button type="button" onClick={() => goToMonth(addMonthsISO(month, -1))}>
          Previous
        </button>
        <h2 id="calendar-heading" aria-live="polite">
          {currentMonthLabel}
        </h2>
        <button type="button" onClick={() => goToMonth(addMonthsISO(month, 1))}>
          Next
        </button>
      </div>

      <div className="calendar__grid" role="grid" aria-labelledby="calendar-heading" onKeyDown={handleKeyDown}>
        {weekDayLabels.map((label) => (
          <div className="calendar__weekday" role="columnheader" key={label}>
            {label}
          </div>
        ))}

        {days.map((day) => {
          const inCurrentMonth = startOfMonthISO(day) === month;
          const dayAvailability = availability[day];
          const status = dayAvailability?.status ?? "loading";
          const isClosed = status === "closed";
          const isSelected = mode === "single" && selectedDate === day;
          const isRangeDay =
            mode === "range" && isISODateInRange(day, selectedRange.start, selectedRange.end ?? selectedRange.start);

          return (
            <button
              key={day}
              ref={(node) => {
                buttonRefs.current[day] = node;
              }}
              type="button"
              role="gridcell"
              tabIndex={activeDate === day ? 0 : -1}
              aria-selected={isSelected || isRangeDay}
              aria-disabled={isClosed}
              aria-label={`${formatISODate(day, locale, {
                weekday: "long",
                year: "numeric",
                month: "long",
                day: "numeric",
              })}: ${status === "loading" ? "loading availability" : status}`}
              data-outside-month={!inCurrentMonth}
              data-status={status}
              data-today={day === today}
              className="calendar__day"
              onClick={() => commitDate(day)}
            >
              <span className="calendar__date">
                {formatISODate(day, locale, { day: "numeric" })}
              </span>
              <span className="calendar__status">
                {status === "loading" ? "Loading" : status}
              </span>
              <span className="sr-only">
                {formatISODate(day, locale, {
                  weekday: "long",
                  year: "numeric",
                  month: "long",
                  day: "numeric",
                })}
              </span>
            </button>
          );
        })}
      </div>

      {error && <p className="calendar__error">{error}</p>}
      <p className="calendar__timezone">Availability shown in {timeZone}</p>
    </section>
  );
}

この実装では、カレンダー全体を role="grid" とし、各日付をフォーカス可能なグリッドセルとして扱います。矢印キーで日付を移動し、EnterまたはSpaceで選択します。完全なWAI-ARIA実装はアプリの文脈によって調整が必要ですが、少なくともマウスだけに依存しない状態にできます。

CSS

src/calendar/calendar.css は次のようにします。状態が色だけでなくテキストでも伝わるように、availablelimitedclosed をセル内に表示しています。

.calendar {
  max-width: 720px;
  color: #172033;
  font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

.calendar__toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 16px;
}

.calendar__toolbar button {
  border: 1px solid #b9c2d0;
  border-radius: 8px;
  background: #ffffff;
  padding: 8px 12px;
  font-weight: 600;
}

.calendar__grid {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  border: 1px solid #d7deea;
  border-radius: 8px;
  overflow: hidden;
}

.calendar__weekday,
.calendar__day {
  min-height: 72px;
  border: 0;
  border-right: 1px solid #d7deea;
  border-bottom: 1px solid #d7deea;
  background: #ffffff;
}

.calendar__weekday {
  min-height: auto;
  padding: 10px 4px;
  background: #f5f7fb;
  font-size: 0.82rem;
  font-weight: 700;
  text-align: center;
}

.calendar__day {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: space-between;
  gap: 8px;
  padding: 10px;
  text-align: left;
  cursor: pointer;
}

.calendar__day:focus-visible {
  outline: 3px solid #2563eb;
  outline-offset: -3px;
}

.calendar__day[aria-selected="true"] {
  background: #dbeafe;
  box-shadow: inset 0 0 0 2px #2563eb;
}

.calendar__day[aria-disabled="true"] {
  color: #7b8494;
  cursor: not-allowed;
  background: #f3f4f6;
}

.calendar__day[data-outside-month="true"] {
  color: #9aa3b2;
}

.calendar__day[data-today="true"] .calendar__date {
  border-radius: 999px;
  background: #172033;
  color: #ffffff;
  padding: 2px 8px;
}

.calendar__status {
  border-radius: 999px;
  background: #eef2ff;
  padding: 2px 8px;
  font-size: 0.72rem;
  font-weight: 700;
}

.calendar__day[data-status="limited"] .calendar__status {
  background: #fef3c7;
}

.calendar__day[data-status="closed"] .calendar__status {
  background: #e5e7eb;
}

.calendar__error {
  color: #b91c1c;
  font-weight: 700;
}

.calendar__timezone {
  color: #566070;
  font-size: 0.9rem;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

テスト

テストは src/calendar/Calendar.test.tsx に置きます。必要なパッケージは次のとおりです。

npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
import "@testing-library/jest-dom/vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, test, vi } from "vitest";
import { Calendar } from "./Calendar";
import type { AvailabilityByDate, AvailabilityRequest } from "./availability-api";

const availability: AvailabilityByDate = {
  "2026-06-01": { status: "available", slots: ["09:00 Asia/Tokyo"] },
  "2026-06-02": { status: "limited", slots: ["10:00 Asia/Tokyo"] },
  "2026-06-03": { status: "closed", slots: [] },
};

function loadAvailability(_: AvailabilityRequest) {
  return Promise.resolve(availability);
}

describe("Calendar", () => {
  test("selects an available date", async () => {
    const onSelectDate = vi.fn();
    render(
      <Calendar
        initialMonth="2026-06-01"
        timeZone="Asia/Tokyo"
        loadAvailability={loadAvailability}
        onSelectDate={onSelectDate}
      />,
    );

    await waitFor(() => expect(screen.getAllByText("available").length).toBeGreaterThan(0));
    await userEvent.click(screen.getByRole("gridcell", { name: /Monday, June 1, 2026: available/i }));

    expect(onSelectDate).toHaveBeenCalledWith("2026-06-01");
  });

  test("does not select a closed date", async () => {
    const onSelectDate = vi.fn();
    render(
      <Calendar
        initialMonth="2026-06-01"
        timeZone="Asia/Tokyo"
        loadAvailability={loadAvailability}
        onSelectDate={onSelectDate}
      />,
    );

    await waitFor(() => expect(screen.getByText("closed")).toBeInTheDocument());
    await userEvent.click(screen.getByRole("gridcell", { name: /Wednesday, June 3, 2026: closed/i }));

    expect(onSelectDate).not.toHaveBeenCalled();
  });

  test("supports keyboard navigation and range selection", async () => {
    const onSelectRange = vi.fn();
    render(
      <Calendar
        mode="range"
        initialMonth="2026-06-01"
        timeZone="Asia/Tokyo"
        loadAvailability={loadAvailability}
        onSelectRange={onSelectRange}
      />,
    );

    await waitFor(() => expect(screen.getAllByText("available").length).toBeGreaterThan(0));
    const firstDay = screen.getByRole("gridcell", { name: /Monday, June 1, 2026: available/i });
    firstDay.focus();
    await userEvent.keyboard("{Enter}{ArrowRight}{Enter}");

    expect(onSelectRange).toHaveBeenLastCalledWith({
      start: "2026-06-01",
      end: "2026-06-02",
    });
  });
});

日付UIのテストでは、スナップショットだけに頼らないでください。今回のように「閉じている日は選べない」「キーボードで範囲を選べる」「API取得後に状態が変わる」という振る舞いを確認すると、デザイン変更に強いテストになります。

本番で踏みやすい落とし穴

1つ目は、toISOString() で選択日を保存することです。これは時刻付きの瞬間をUTCに変換するため、date-onlyの保存には向きません。予約日、公開日、締切日は YYYY-MM-DD で保存してください。

2つ目は、タイムゾーンと表示言語を混ぜることです。locale は表示言語、timeZone は時刻の基準です。英語表示でもAsia/Tokyoの予約枠を見せることはありますし、日本語表示でもAmerica/Los_Angelesの空き枠を扱うことがあります。

3つ目は、色だけで状態を伝えることです。満席や休業を赤や灰色だけで表すと、支援技術や色覚特性のあるユーザーに伝わりません。セル内テキスト、aria-disabled、フォーカス表示をセットで設計してください。

4つ目は、Claude CodeにUIだけを作らせてテストを後回しにすることです。日付のバグは手動確認では抜けます。最初の依頼に「月末、範囲選択、閉鎖日、キーボード操作のテストを含める」と書くほうが、後から修正するより安く済みます。

Claude Codeで進める実務手順

私なら次の順で進めます。まず上の設計メモをClaude Codeに渡し、date-utils.ts だけを生成させます。次にユーティリティの境界条件、たとえば2026年2月、月曜始まり、日曜始まりをテストさせます。その後にAPIモック、最後にUIを作ります。

UIができたら、Claude Codeに「WAI-ARIA Grid Patternとの差分をレビューして」と頼みます。次に「Testing Libraryでユーザー操作ベースのテストに直して」と依頼します。最後に「本番APIへ差し替えるときのインターフェースを変えずに保つ」と制約を追加します。

この流れにすると、Claude Codeは単なるコード生成係ではなく、仕様を固定するレビュー役として使えます。日付やアクセシビリティのように細かな規則が多い領域ほど、先に制約を書き、後から差分をレビューさせる進め方が効きます。

より実務的なプロンプト例やレビュー観点をまとめた資料はClaude Code実務テンプレート集に整理しています。チームでカレンダー、フォーム、API接続のようなUI部品を量産するなら、最初からプロンプトとテスト観点を共有しておくと手戻りを減らせます。

この記事で紹介した内容を実際に試した結果

この記事のコードをVite + React + TypeScriptの検証プロジェクトに貼り付け、VitestとTesting Libraryで単日選択、休業日の拒否、キーボードによる範囲選択を確認しました。最初に失敗したのは、ボタンのアクセシブル名が日付数字と隠しテキストの結合になり、テストの正規表現が合わなかった点です。そこでテストをユーザーが読む日付表現に寄せ、閉鎖日のクリックが無視されることを明示的に確認しました。本番投入前には、実APIのエラー表示、祝日、予約枠の上限、サーバー側バリデーションも同じ方針で追加してください。

#Claude Code #カレンダー #React #TypeScript #アクセシビリティ
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。