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

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