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

用Claude Code构建生产级日历组件:React/TypeScript、时区与测试

从日期计算、时区、无障碍、预约可用性API到范围选择和测试,构建可上线的React日历组件。

用Claude Code构建生产级日历组件:React/TypeScript、时区与测试

日历组件看起来只是一个七列网格,但真正上线后会遇到很多细节:月底、闰年、夏令时、用户所在时区、键盘操作、预约名额接口,以及接口返回顺序不稳定的问题。只让Claude Code“做一个漂亮日历”通常不够,必须把生产约束一起交给它。

本文用Claude Code设计一个React/TypeScript日历组件。date-only指没有时间的日期,例如 2026-06-02;time zone是决定“今天”和预约时间含义的地区规则;ARIA是让读屏软件理解控件角色和状态的属性。理解这三点,才能避免用户选择6月2日、服务器却保存成6月1日的事故。

适用场景

这个组件适合下列真实产品场景。

场景需要的能力常见风险
诊所、美容院、教练预约可用性API、休息日、时区内的时间段用户选到没有名额的日期
SaaS后台排班月份切换、范围选择、可审计状态跨地区团队看到不同日期
内容发布计划已选日期、今日高亮、计划数量发布时间和日历显示不一致
酒店或活动预订开始日和结束日范围保存出无效范围或价格错误

无障碍的思路可以参考Claude Code无障碍文章,接口契约可以参考Claude Code API测试文章。官方资料建议同时阅读MDN DateMDN Intl.DateTimeFormatWAI-ARIA Grid PatternTesting LibraryReact状态管理文档

给Claude Code的约束

我会先把需求写成实现备忘录,而不是一句“做个日历”。

Build a booking calendar in React and TypeScript.
- Store selected days as YYYY-MM-DD date-only strings
- Do not use Date.toISOString() for user-selected dates
- Put month grids, ranges, and labels in date-utils.ts
- Make the availability API abortable with AbortController
- Implement role="grid", aria-selected, aria-disabled, and keyboard movement
- Support single-date and range selection
- Include CSS and Testing Library tests

结构保持小而清晰。

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/>回归测试"]

把日期计算从JSX中拆出来很重要。Claude Code很擅长快速改UI,但如果月历计算、范围判断和可用状态都散在组件里,月底和时区问题会很难排查。拆成纯函数后,你可以要求Claude Code单独补测试,再让它审查边界条件。

日期工具

将下面内容保存为 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;
}

不要把用户选择的日期直接交给 toISOString()。它表达的是UTC瞬间,不是业务日期。预约日、发布日、截止日适合保存成date-only字符串,真正带时间的预约slot再携带时区信息。

可用性API mock

将下面内容保存为 src/calendar/availability-api.ts。真实项目中可以替换成HTTP请求,但请求形状先固定下来:起止日期、时区和可取消的signal。

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;
}

这里防住的坑是旧响应覆盖新月份。用户快速切换月份时,较慢的旧请求可能最后返回;把 AbortSignal 传到API层,可以在组件卸载或月份变化时取消不再需要的请求。

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>
  );
}

这个组件使用roving tabIndex,也就是只有当前日期进入Tab顺序,左右上下方向键负责在网格内移动。这样键盘用户不必逐个Tab穿过35或42个日期格。

CSS和测试

CSS将可用、有限、关闭三个状态同时用颜色和文字呈现。保存为 src/calendar/calendar.css

.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;
}

安装测试依赖。

npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom

src/calendar/Calendar.test.tsx 测试用户行为,而不是只做快照。

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",
    });
  });
});

生产环境的坑与结果

第一,不要用 toISOString() 保存用户选择的日期;它适合时间点,不适合业务日期。第二,不要把locale和time zone混在一起;显示语言和预约时区是两个输入。第三,不要只靠颜色表达关闭或满员状态;文字、焦点、aria-disabled 都要存在。第四,不要让Claude Code先写UI再补测试,日历边界应该一开始就进入测试要求。

如果团队经常做表单、预约、日历和API连接,可以把这些约束整理成复用模板。Claude Code产品模板中可以放组件生成、审查、测试和API集成清单,减少每次重新发现同类错误的成本。实际项目还应把节假日、容量上限、后端校验和错误文案列入同一份清单。

我把本文代码粘到Vite + React + TypeScript项目中,用Vitest和Testing Library验证了单日选择、关闭日期不可选、键盘范围选择。最初失败的是测试查询的无障碍名称,因为真实名称包含日期和状态;把测试改成按用户实际听到的名称查询后,行为更明确。上线前还应增加真实节假日、后端校验、错误文案和容量限制。

#Claude Code #日历 #React #TypeScript #无障碍
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。