用Claude Code构建生产级日历组件:React/TypeScript、时区与测试
从日期计算、时区、无障碍、预约可用性API到范围选择和测试,构建可上线的React日历组件。
日历组件看起来只是一个七列网格,但真正上线后会遇到很多细节:月底、闰年、夏令时、用户所在时区、键盘操作、预约名额接口,以及接口返回顺序不稳定的问题。只让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 Date、MDN Intl.DateTimeFormat、WAI-ARIA Grid Pattern、Testing Library和React状态管理文档。
给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验证了单日选择、关闭日期不可选、键盘范围选择。最初失败的是测试查询的无障碍名称,因为真实名称包含日期和状态;把测试改成按用户实际听到的名称查询后,行为更明确。上线前还应增加真实节假日、后端校验、错误文案和容量限制。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。