Add calendar keyboard navigation (P4.2 complete)
All checks were successful
Deploy / deploy (push) Successful in 2m26s
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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
return (
|
||||
// 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);
|
||||
@@ -145,6 +217,7 @@ export function MonthView({
|
||||
cycleDay={cycleDay}
|
||||
phase={phase}
|
||||
isToday={isToday}
|
||||
dataDay={date.getDate()}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user