diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 144a1ea..73c9bb6 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -4,7 +4,7 @@ This file is maintained by Ralph. Run `./ralph-sandbox.sh plan 3` to generate ta ## Current State Summary -### Overall Status: 781 tests passing across 43 test files +### Overall Status: 790 tests passing across 43 test files ### Library Implementation | 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 | | `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 | -| `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 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/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/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/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) | @@ -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 - **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] Semantic HTML landmarks (main elements) - [x] Screen reader labels for calendar buttons -- [ ] Keyboard navigation for calendar +- [x] Keyboard navigation for calendar - **Spec Reference:** specs/dashboard.md accessibility requirements - **Implementation Details:** - Skip navigation link added to layout with sr-only styling - Semantic HTML landmarks (main element) added to login and settings pages - 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:** - `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 @@ -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.test.tsx` - Added 2 accessibility tests (29 total) - `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/month-view.test.tsx` - Updated tests to match new aria-labels -- **Remaining Work:** - - Keyboard navigation for calendar (arrow keys to navigate between dates) + - `src/components/calendar/month-view.tsx` - Added role="grid", keyboard navigation handler + - `src/components/calendar/month-view.test.tsx` - Added 9 keyboard navigation tests (30 total) - **Why:** Required for accessibility compliance ### P4.3: Dark Mode Configuration @@ -837,7 +836,6 @@ P4.* UX Polish ────────> After core functionality complete | 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 | ### Dependency Summary diff --git a/src/components/calendar/day-cell.tsx b/src/components/calendar/day-cell.tsx index cf33965..2b54201 100644 --- a/src/components/calendar/day-cell.tsx +++ b/src/components/calendar/day-cell.tsx @@ -8,6 +8,7 @@ interface DayCellProps { phase: CyclePhase; isToday: boolean; onClick?: () => void; + dataDay?: number; } const PHASE_COLORS: Record = { @@ -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" : ""}`} > {date.getDate()} diff --git a/src/components/calendar/month-view.test.tsx b/src/components/calendar/month-view.test.tsx index 6f1fa6a..ab1d217 100644 --- a/src/components/calendar/month-view.test.tsx +++ b/src/components/calendar/month-view.test.tsx @@ -265,4 +265,154 @@ describe("MonthView", () => { expect(screen.getByText("29")).toBeInTheDocument(); }); }); + + describe("keyboard navigation", () => { + it("moves focus to next day when pressing ArrowRight", () => { + render(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + // 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(); + + expect(screen.getByRole("grid")).toBeInTheDocument(); + }); + }); }); diff --git a/src/components/calendar/month-view.tsx b/src/components/calendar/month-view.tsx index a60465c..e8f8038 100644 --- a/src/components/calendar/month-view.tsx +++ b/src/components/calendar/month-view.tsx @@ -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(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 (
{/* Header with navigation */} @@ -124,11 +187,20 @@ export function MonthView({
{/* Calendar grid */} -
+ {/* biome-ignore lint/a11y/useSemanticElements: role="grid" is correct WAI-ARIA pattern for calendars */} +
{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
; + return ( + // biome-ignore lint/suspicious/noArrayIndexKey: Empty cells are always at fixed positions at the start of the month +