Add calendar keyboard navigation (P4.2 complete)
All checks were successful
Deploy / deploy (push) Successful in 2m26s

Implement keyboard navigation for MonthView calendar:
- ArrowLeft/Right: navigate to previous/next day
- ArrowUp/Down: navigate to previous/next week (7 days)
- Home/End: navigate to first/last day of month
- Boundary navigation triggers month change

Features:
- Added role="grid" for proper ARIA semantics
- Added data-day attribute to DayCell for focus management
- Wrapped navigation handlers in useCallback for stability

Tests: 9 new tests for keyboard navigation (790 total)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 21:55:18 +00:00
parent 649fa29df2
commit 4015f1ba3a
4 changed files with 242 additions and 18 deletions

View File

@@ -8,6 +8,7 @@ interface DayCellProps {
phase: CyclePhase;
isToday: boolean;
onClick?: () => void;
dataDay?: number;
}
const PHASE_COLORS: Record<CyclePhase, string> = {
@@ -48,6 +49,7 @@ export function DayCell({
phase,
isToday,
onClick,
dataDay,
}: DayCellProps) {
const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday);
@@ -56,6 +58,7 @@ export function DayCell({
type="button"
onClick={onClick}
aria-label={ariaLabel}
data-day={dataDay}
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
>
<span className="text-sm font-medium">{date.getDate()}</span>

View File

@@ -265,4 +265,154 @@ describe("MonthView", () => {
expect(screen.getByText("29")).toBeInTheDocument();
});
});
describe("keyboard navigation", () => {
it("moves focus to next day when pressing ArrowRight", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today)
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
});
jan15.focus();
// Press ArrowRight to move to Jan 16
fireEvent.keyDown(jan15, { key: "ArrowRight" });
const jan16 = screen.getByRole("button", {
name: /January 16, 2026 - Cycle day 16 - Ovulation phase$/i,
});
expect(document.activeElement).toBe(jan16);
});
it("moves focus to previous day when pressing ArrowLeft", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today)
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
});
jan15.focus();
// Press ArrowLeft to move to Jan 14
fireEvent.keyDown(jan15, { key: "ArrowLeft" });
const jan14 = screen.getByRole("button", {
name: /January 14, 2026 - Cycle day 14 - Follicular phase$/i,
});
expect(document.activeElement).toBe(jan14);
});
it("moves focus to same day next week when pressing ArrowDown", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today)
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
});
jan15.focus();
// Press ArrowDown to move to Jan 22 (7 days later)
fireEvent.keyDown(jan15, { key: "ArrowDown" });
const jan22 = screen.getByRole("button", {
name: /January 22, 2026 - Cycle day 22 - Early Luteal phase$/i,
});
expect(document.activeElement).toBe(jan22);
});
it("moves focus to same day previous week when pressing ArrowUp", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15 (today)
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
});
jan15.focus();
// Press ArrowUp to move to Jan 8 (7 days earlier)
fireEvent.keyDown(jan15, { key: "ArrowUp" });
const jan8 = screen.getByRole("button", {
name: /January 8, 2026 - Cycle day 8 - Follicular phase$/i,
});
expect(document.activeElement).toBe(jan8);
});
it("calls onMonthChange when navigating past end of month with ArrowRight", () => {
const onMonthChange = vi.fn();
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
// Focus on Jan 31 (last day of month)
// With lastPeriod Jan 1, cycleLength 28: Jan 31 = cycle day 3 (MENSTRUAL)
const jan31 = screen.getByRole("button", {
name: /January 31, 2026 - Cycle day 3 - Menstrual phase$/i,
});
jan31.focus();
// Press ArrowRight - should trigger month change to February
fireEvent.keyDown(jan31, { key: "ArrowRight" });
expect(onMonthChange).toHaveBeenCalledWith(2026, 1);
});
it("calls onMonthChange when navigating before start of month with ArrowLeft", () => {
const onMonthChange = vi.fn();
render(<MonthView {...baseProps} onMonthChange={onMonthChange} />);
// Focus on Jan 1 (first day of month)
const jan1 = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 1 - Menstrual phase$/i,
});
jan1.focus();
// Press ArrowLeft - should trigger month change to December 2025
fireEvent.keyDown(jan1, { key: "ArrowLeft" });
expect(onMonthChange).toHaveBeenCalledWith(2025, 11);
});
it("wraps focus at row boundaries for Home and End keys", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
});
jan15.focus();
// Press Home to move to first day of month
fireEvent.keyDown(jan15, { key: "Home" });
const jan1 = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 1 - Menstrual phase$/i,
});
expect(document.activeElement).toBe(jan1);
});
it("moves focus to last day when pressing End key", () => {
render(<MonthView {...baseProps} />);
// Focus on Jan 15
const jan15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
});
jan15.focus();
// Press End to move to last day of month
fireEvent.keyDown(jan15, { key: "End" });
// With lastPeriod Jan 1, cycleLength 28: Jan 31 = cycle day 3 (MENSTRUAL)
const jan31 = screen.getByRole("button", {
name: /January 31, 2026 - Cycle day 3 - Menstrual phase$/i,
});
expect(document.activeElement).toBe(jan31);
});
it("calendar grid has proper role for accessibility", () => {
render(<MonthView {...baseProps} />);
expect(screen.getByRole("grid")).toBeInTheDocument();
});
});
});

View File

@@ -2,6 +2,8 @@
// ABOUTME: Displays calendar grid with phase colors and day details.
"use client";
import { useCallback, useRef } from "react";
import { getCycleDay, getPhase } from "@/lib/cycle";
import { DayCell } from "./day-cell";
@@ -41,22 +43,23 @@ export function MonthView({
const today = new Date();
const daysInMonth = getDaysInMonth(year, month);
const firstDayOfWeek = getFirstDayOfMonth(year, month);
const gridRef = useRef<HTMLDivElement>(null);
const handlePreviousMonth = () => {
const handlePreviousMonth = useCallback(() => {
if (month === 0) {
onMonthChange?.(year - 1, 11);
} else {
onMonthChange?.(year, month - 1);
}
};
}, [month, year, onMonthChange]);
const handleNextMonth = () => {
const handleNextMonth = useCallback(() => {
if (month === 11) {
onMonthChange?.(year + 1, 0);
} else {
onMonthChange?.(year, month + 1);
}
};
}, [month, year, onMonthChange]);
const handleTodayClick = () => {
onMonthChange?.(today.getFullYear(), today.getMonth());
@@ -74,6 +77,66 @@ export function MonthView({
days.push(new Date(year, month, day));
}
const handleKeyDown = useCallback(
(event: React.KeyboardEvent) => {
const grid = gridRef.current;
if (!grid) return;
const buttons = Array.from(
grid.querySelectorAll("button[data-day]"),
) as HTMLButtonElement[];
const currentIndex = buttons.indexOf(
document.activeElement as HTMLButtonElement,
);
if (currentIndex === -1) return;
let newIndex: number | null = null;
switch (event.key) {
case "ArrowRight":
event.preventDefault();
if (currentIndex === buttons.length - 1) {
// At end of month, navigate to next month
handleNextMonth();
} else {
newIndex = currentIndex + 1;
}
break;
case "ArrowLeft":
event.preventDefault();
if (currentIndex === 0) {
// At start of month, navigate to previous month
handlePreviousMonth();
} else {
newIndex = currentIndex - 1;
}
break;
case "ArrowDown":
event.preventDefault();
newIndex = Math.min(currentIndex + 7, buttons.length - 1);
break;
case "ArrowUp":
event.preventDefault();
newIndex = Math.max(currentIndex - 7, 0);
break;
case "Home":
event.preventDefault();
newIndex = 0;
break;
case "End":
event.preventDefault();
newIndex = buttons.length - 1;
break;
}
if (newIndex !== null && buttons[newIndex]) {
buttons[newIndex].focus();
}
},
[handleNextMonth, handlePreviousMonth],
);
return (
<div className="rounded-lg border p-4">
{/* Header with navigation */}
@@ -124,11 +187,20 @@ export function MonthView({
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7 gap-1">
{/* biome-ignore lint/a11y/useSemanticElements: role="grid" is correct WAI-ARIA pattern for calendars */}
<div
ref={gridRef}
role="grid"
aria-label="Calendar"
onKeyDown={handleKeyDown}
className="grid grid-cols-7 gap-1"
>
{days.map((date, index) => {
if (!date) {
// biome-ignore lint/suspicious/noArrayIndexKey: Empty cells are always at fixed positions at the start of the month
return <div key={`empty-${index}`} className="p-2" />;
return (
// biome-ignore lint/suspicious/noArrayIndexKey: Empty cells are always at fixed positions at the start of the month
<div key={`empty-${index}`} className="p-2" aria-hidden="true" />
);
}
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, date);
@@ -145,6 +217,7 @@ export function MonthView({
cycleDay={cycleDay}
phase={phase}
isToday={isToday}
dataDay={date.getDate()}
/>
);
})}