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

@@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
## Current State Summary ## Current State Summary
### Overall Status: 781 tests passing across 43 test files ### Overall Status: 790 tests passing across 43 test files
### Library Implementation ### Library Implementation
| File | Status | Gap Analysis | | File | Status | Gap Analysis |
@@ -73,7 +73,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `DayCell` | **COMPLETE** | Phase-colored day with click handler | | `DayCell` | **COMPLETE** | Phase-colored day with click handler |
| `MiniCalendar` | **COMPLETE** | Compact calendar widget with phase colors, navigation, legend (23 tests) | | `MiniCalendar` | **COMPLETE** | Compact calendar widget with phase colors, navigation, legend (23 tests) |
| `OnboardingBanner` | **COMPLETE** | Setup prompts for new users with icons and action buttons, 16 tests | | `OnboardingBanner` | **COMPLETE** | Setup prompts for new users with icons and action buttons, 16 tests |
| `MonthView` | **COMPLETE** | Calendar grid with DayCell integration, navigation controls, phase legend | | `MonthView` | **COMPLETE** | Calendar grid with DayCell integration, navigation controls, phase legend, keyboard navigation |
### Test Coverage ### Test Coverage
| Test File | Status | | Test File | Status |
@@ -107,7 +107,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta
| `src/app/api/health/route.test.ts` | **EXISTS** - 14 tests (healthy/unhealthy states, PocketBase connectivity, error handling) | | `src/app/api/health/route.test.ts` | **EXISTS** - 14 tests (healthy/unhealthy states, PocketBase connectivity, error handling) |
| `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) | | `src/app/history/page.test.tsx` | **EXISTS** - 26 tests (rendering, data loading, pagination, date filtering, styling) |
| `src/app/api/metrics/route.test.ts` | **EXISTS** - 15 tests (Prometheus format validation, metric types, route handling) | | `src/app/api/metrics/route.test.ts` | **EXISTS** - 15 tests (Prometheus format validation, metric types, route handling) |
| `src/components/calendar/month-view.test.tsx` | **EXISTS** - 21 tests (calendar grid, phase colors, navigation, legend) | | `src/components/calendar/month-view.test.tsx` | **EXISTS** - 30 tests (calendar grid, phase colors, navigation, legend, keyboard navigation) |
| `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) | | `src/app/calendar/page.test.tsx` | **EXISTS** - 23 tests (rendering, navigation, ICS subscription, token regeneration) |
| `src/app/settings/page.test.tsx` | **EXISTS** - 29 tests (form rendering, validation, submission, accessibility) | | `src/app/settings/page.test.tsx` | **EXISTS** - 29 tests (form rendering, validation, submission, accessibility) |
| `src/app/settings/garmin/page.test.tsx` | **EXISTS** - 27 tests (connection status, token management) | | `src/app/settings/garmin/page.test.tsx` | **EXISTS** - 27 tests (connection status, token management) |
@@ -727,17 +727,17 @@ Enhancements from spec requirements that improve user experience.
- `src/components/dashboard/onboarding-banner.test.tsx` - 16 tests covering rendering, interactions, dismissal - `src/components/dashboard/onboarding-banner.test.tsx` - 16 tests covering rendering, interactions, dismissal
- **Why:** Helps new users complete setup for full functionality - **Why:** Helps new users complete setup for full functionality
### P4.2: Accessibility Improvements (PARTIAL COMPLETE) ### P4.2: Accessibility Improvements COMPLETE
- [x] Skip navigation link - [x] Skip navigation link
- [x] Semantic HTML landmarks (main elements) - [x] Semantic HTML landmarks (main elements)
- [x] Screen reader labels for calendar buttons - [x] Screen reader labels for calendar buttons
- [ ] Keyboard navigation for calendar - [x] Keyboard navigation for calendar
- **Spec Reference:** specs/dashboard.md accessibility requirements - **Spec Reference:** specs/dashboard.md accessibility requirements
- **Implementation Details:** - **Implementation Details:**
- Skip navigation link added to layout with sr-only styling - Skip navigation link added to layout with sr-only styling
- Semantic HTML landmarks (main element) added to login and settings pages - Semantic HTML landmarks (main element) added to login and settings pages
- Aria-labels added to DayCell calendar buttons with date and phase information - Aria-labels added to DayCell calendar buttons with date and phase information
- Tests added: layout.test.tsx (3 tests), accessibility tests in login/settings page tests - Keyboard navigation: ArrowLeft/Right for prev/next day, ArrowUp/Down for prev/next week, Home/End for first/last day, boundary navigation triggers month change
- **Files:** - **Files:**
- `src/app/layout.tsx` - Added skip navigation link with sr-only styling - `src/app/layout.tsx` - Added skip navigation link with sr-only styling
- `src/app/layout.test.tsx` - 3 tests for skip link rendering and accessibility - `src/app/layout.test.tsx` - 3 tests for skip link rendering and accessibility
@@ -746,11 +746,10 @@ Enhancements from spec requirements that improve user experience.
- `src/app/settings/page.tsx` - Wrapped content in main element - `src/app/settings/page.tsx` - Wrapped content in main element
- `src/app/settings/page.test.tsx` - Added 2 accessibility tests (29 total) - `src/app/settings/page.test.tsx` - Added 2 accessibility tests (29 total)
- `src/app/page.tsx` - Added id="main-content" to existing main element - `src/app/page.tsx` - Added id="main-content" to existing main element
- `src/components/calendar/day-cell.tsx` - Added aria-label with date/phase info - `src/components/calendar/day-cell.tsx` - Added aria-label with date/phase info, dataDay prop
- `src/components/calendar/day-cell.test.tsx` - Added 4 accessibility tests (27 total) - `src/components/calendar/day-cell.test.tsx` - Added 4 accessibility tests (27 total)
- `src/components/calendar/month-view.test.tsx` - Updated tests to match new aria-labels - `src/components/calendar/month-view.tsx` - Added role="grid", keyboard navigation handler
- **Remaining Work:** - `src/components/calendar/month-view.test.tsx` - Added 9 keyboard navigation tests (30 total)
- Keyboard navigation for calendar (arrow keys to navigate between dates)
- **Why:** Required for accessibility compliance - **Why:** Required for accessibility compliance
### P4.3: Dark Mode Configuration ### P4.3: Dark Mode Configuration
@@ -837,7 +836,6 @@ P4.* UX Polish ────────> After core functionality complete
| Priority | Task | Effort | Notes | | Priority | Task | Effort | Notes |
|----------|------|--------|-------| |----------|------|--------|-------|
| Low | P4.2 Keyboard Navigation | Small | Calendar arrow key navigation pending |
| Low | P4.3-P4.6 UX Polish | Various | After core complete | | Low | P4.3-P4.6 UX Polish | Various | After core complete |
### Dependency Summary ### Dependency Summary

View File

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

View File

@@ -265,4 +265,154 @@ describe("MonthView", () => {
expect(screen.getByText("29")).toBeInTheDocument(); 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. // ABOUTME: Displays calendar grid with phase colors and day details.
"use client"; "use client";
import { useCallback, useRef } from "react";
import { getCycleDay, getPhase } from "@/lib/cycle"; import { getCycleDay, getPhase } from "@/lib/cycle";
import { DayCell } from "./day-cell"; import { DayCell } from "./day-cell";
@@ -41,22 +43,23 @@ export function MonthView({
const today = new Date(); const today = new Date();
const daysInMonth = getDaysInMonth(year, month); const daysInMonth = getDaysInMonth(year, month);
const firstDayOfWeek = getFirstDayOfMonth(year, month); const firstDayOfWeek = getFirstDayOfMonth(year, month);
const gridRef = useRef<HTMLDivElement>(null);
const handlePreviousMonth = () => { const handlePreviousMonth = useCallback(() => {
if (month === 0) { if (month === 0) {
onMonthChange?.(year - 1, 11); onMonthChange?.(year - 1, 11);
} else { } else {
onMonthChange?.(year, month - 1); onMonthChange?.(year, month - 1);
} }
}; }, [month, year, onMonthChange]);
const handleNextMonth = () => { const handleNextMonth = useCallback(() => {
if (month === 11) { if (month === 11) {
onMonthChange?.(year + 1, 0); onMonthChange?.(year + 1, 0);
} else { } else {
onMonthChange?.(year, month + 1); onMonthChange?.(year, month + 1);
} }
}; }, [month, year, onMonthChange]);
const handleTodayClick = () => { const handleTodayClick = () => {
onMonthChange?.(today.getFullYear(), today.getMonth()); onMonthChange?.(today.getFullYear(), today.getMonth());
@@ -74,6 +77,66 @@ export function MonthView({
days.push(new Date(year, month, day)); 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 ( return (
<div className="rounded-lg border p-4"> <div className="rounded-lg border p-4">
{/* Header with navigation */} {/* Header with navigation */}
@@ -124,11 +187,20 @@ export function MonthView({
</div> </div>
{/* Calendar grid */} {/* 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) => { {days.map((date, index) => {
if (!date) { if (!date) {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: Empty cells are always at fixed positions at the start of the month // 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" />; <div key={`empty-${index}`} className="p-2" aria-hidden="true" />
);
} }
const cycleDay = getCycleDay(lastPeriodDate, cycleLength, date); const cycleDay = getCycleDay(lastPeriodDate, cycleLength, date);
@@ -145,6 +217,7 @@ export function MonthView({
cycleDay={cycleDay} cycleDay={cycleDay}
phase={phase} phase={phase}
isToday={isToday} isToday={isToday}
dataDay={date.getDate()}
/> />
); );
})} })}