Add accessibility improvements (P4.2 partial)
All checks were successful
Deploy / deploy (push) Successful in 1m36s

- Add skip navigation link to root layout
- Add semantic HTML landmarks (main element) to login and settings pages
- Add aria-labels to calendar day buttons with date, cycle day, and phase info
- Add id="main-content" to dashboard main element for skip link target
- Fix pre-existing type error in auth-middleware.test.ts

Tests: 781 passing (11 new accessibility tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 21:49:26 +00:00
parent 2bfd93589b
commit 649fa29df2
12 changed files with 259 additions and 34 deletions

60
src/app/layout.test.tsx Normal file
View File

@@ -0,0 +1,60 @@
// ABOUTME: Unit tests for the root layout component.
// ABOUTME: Tests accessibility features including skip navigation link.
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
// Mock next/font/google before importing the layout
vi.mock("next/font/google", () => ({
Geist: () => ({
variable: "--font-geist-sans",
className: "geist-sans",
}),
Geist_Mono: () => ({
variable: "--font-geist-mono",
className: "geist-mono",
}),
}));
import RootLayout from "./layout";
describe("RootLayout", () => {
describe("accessibility", () => {
it("renders a skip navigation link as the first focusable element", () => {
render(
<RootLayout>
<main id="main-content">Test content</main>
</RootLayout>,
);
const skipLink = screen.getByRole("link", {
name: /skip to main content/i,
});
expect(skipLink).toBeInTheDocument();
expect(skipLink).toHaveAttribute("href", "#main-content");
});
it("skip link has visually hidden styles but is focusable", () => {
render(
<RootLayout>
<main id="main-content">Test content</main>
</RootLayout>,
);
const skipLink = screen.getByRole("link", {
name: /skip to main content/i,
});
expect(skipLink).toHaveClass("sr-only");
expect(skipLink).toHaveClass("focus:not-sr-only");
});
it("renders children within the body", () => {
render(
<RootLayout>
<div data-testid="child-content">Test content</div>
</RootLayout>,
);
expect(screen.getByTestId("child-content")).toBeInTheDocument();
});
});
});

View File

@@ -30,6 +30,12 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:bg-white focus:px-4 focus:py-2 focus:rounded focus:shadow-lg focus:text-blue-600 focus:underline"
>
Skip to main content
</a>
{children}
</body>
</html>

View File

@@ -541,4 +541,24 @@ describe("LoginPage", () => {
).not.toBeInTheDocument();
});
});
describe("accessibility", () => {
it("wraps content in a main element", async () => {
render(<LoginPage />);
await waitFor(() => {
expect(screen.getByRole("main")).toBeInTheDocument();
});
});
it("has proper heading structure with h1", async () => {
render(<LoginPage />);
await waitFor(() => {
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent(/phaseflow/i);
});
});
});
});

View File

@@ -97,17 +97,23 @@ export default function LoginPage() {
// Show loading state while checking auth methods
if (isCheckingAuth) {
return (
<div className="flex min-h-screen items-center justify-center">
<main
id="main-content"
className="flex min-h-screen items-center justify-center"
>
<div className="w-full max-w-md space-y-8 p-8">
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
<div className="text-center text-gray-500">Loading...</div>
</div>
</div>
</main>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<main
id="main-content"
className="flex min-h-screen items-center justify-center"
>
<div className="w-full max-w-md space-y-8 p-8">
<h1 className="text-2xl font-bold text-center">PhaseFlow</h1>
@@ -181,6 +187,6 @@ export default function LoginPage() {
</form>
)}
</div>
</div>
</main>
);
}

View File

@@ -156,7 +156,7 @@ export default function Dashboard() {
</div>
</header>
<main className="container mx-auto p-6">
<main id="main-content" className="container mx-auto p-6">
{loading && <DashboardSkeleton />}
{error && (

View File

@@ -505,4 +505,24 @@ describe("SettingsPage", () => {
expect(screen.queryByText(/settings saved/i)).not.toBeInTheDocument();
});
});
describe("accessibility", () => {
it("wraps content in a main element", async () => {
render(<SettingsPage />);
await waitFor(() => {
expect(screen.getByRole("main")).toBeInTheDocument();
});
});
it("has proper heading structure with h1", async () => {
render(<SettingsPage />);
await waitFor(() => {
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toBeInTheDocument();
expect(heading).toHaveTextContent(/settings/i);
});
});
});
});

View File

@@ -104,15 +104,15 @@ export default function SettingsPage() {
if (loading) {
return (
<div className="container mx-auto p-8">
<main id="main-content" className="container mx-auto p-8">
<h1 className="text-2xl font-bold mb-8">Settings</h1>
<p className="text-gray-500">Loading...</p>
</div>
</main>
);
}
return (
<div className="container mx-auto p-8">
<main id="main-content" className="container mx-auto p-8">
<div className="flex items-center justify-between mb-8">
<h1 className="text-2xl font-bold">Settings</h1>
<Link
@@ -247,6 +247,6 @@ export default function SettingsPage() {
</div>
</form>
</div>
</div>
</main>
);
}

View File

@@ -187,4 +187,55 @@ describe("DayCell", () => {
expect(screen.getByText("1")).toBeInTheDocument();
});
});
describe("accessibility", () => {
it("has aria-label with date and phase information", () => {
render(
<DayCell
{...baseProps}
date={new Date("2026-01-15")}
cycleDay={5}
phase="FOLLICULAR"
/>,
);
const button = screen.getByRole("button");
expect(button).toHaveAttribute(
"aria-label",
"January 15, 2026 - Cycle day 5 - Follicular phase",
);
});
it("includes today indicator in aria-label when isToday", () => {
render(
<DayCell
{...baseProps}
date={new Date("2026-01-15")}
cycleDay={5}
phase="FOLLICULAR"
isToday={true}
/>,
);
const button = screen.getByRole("button");
expect(button).toHaveAttribute(
"aria-label",
"January 15, 2026 - Cycle day 5 - Follicular phase (today)",
);
});
it("formats phase name correctly for screen readers", () => {
render(<DayCell {...baseProps} phase="EARLY_LUTEAL" />);
const button = screen.getByRole("button");
expect(button.getAttribute("aria-label")).toContain("Early Luteal phase");
});
it("formats LATE_LUTEAL phase name correctly", () => {
render(<DayCell {...baseProps} phase="LATE_LUTEAL" />);
const button = screen.getByRole("button");
expect(button.getAttribute("aria-label")).toContain("Late Luteal phase");
});
});
});

View File

@@ -18,6 +18,30 @@ const PHASE_COLORS: Record<CyclePhase, string> = {
LATE_LUTEAL: "bg-red-100",
};
const PHASE_DISPLAY_NAMES: Record<CyclePhase, string> = {
MENSTRUAL: "Menstrual",
FOLLICULAR: "Follicular",
OVULATION: "Ovulation",
EARLY_LUTEAL: "Early Luteal",
LATE_LUTEAL: "Late Luteal",
};
function formatAriaLabel(
date: Date,
cycleDay: number,
phase: CyclePhase,
isToday: boolean,
): string {
const dateStr = date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
});
const phaseName = PHASE_DISPLAY_NAMES[phase];
const todaySuffix = isToday ? " (today)" : "";
return `${dateStr} - Cycle day ${cycleDay} - ${phaseName} phase${todaySuffix}`;
}
export function DayCell({
date,
cycleDay,
@@ -25,10 +49,13 @@ export function DayCell({
isToday,
onClick,
}: DayCellProps) {
const ariaLabel = formatAriaLabel(date, cycleDay, phase, isToday);
return (
<button
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={`p-2 rounded ${PHASE_COLORS[phase]} ${isToday ? "ring-2 ring-black" : ""}`}
>
<span className="text-sm font-medium">{date.getDate()}</span>

View File

@@ -70,16 +70,20 @@ describe("MonthView", () => {
it("highlights today's date", () => {
render(<MonthView {...baseProps} />);
// Jan 15 is "today" - find the button containing "15"
const todayCell = screen.getByRole("button", { name: /^15\s*Day 15/i });
// Jan 15 is "today" - aria-label includes date, cycle day, and phase
const todayCell = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase \(today\)/i,
});
expect(todayCell).toHaveClass("ring-2", "ring-black");
});
it("does not highlight non-today dates", () => {
render(<MonthView {...baseProps} />);
// Jan 1 is not today
const otherCell = screen.getByRole("button", { name: /^1\s*Day 1/i });
// Jan 1 is not today - aria-label includes date, cycle day, and phase
const otherCell = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 1 - Menstrual phase$/i,
});
expect(otherCell).not.toHaveClass("ring-2");
});
});
@@ -89,7 +93,9 @@ describe("MonthView", () => {
render(<MonthView {...baseProps} />);
// Days 1-3 are MENSTRUAL (bg-blue-100)
const day1 = screen.getByRole("button", { name: /^1\s*Day 1/i });
const day1 = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 1 - Menstrual phase/i,
});
expect(day1).toHaveClass("bg-blue-100");
});
@@ -97,7 +103,9 @@ describe("MonthView", () => {
render(<MonthView {...baseProps} />);
// Day 5 is FOLLICULAR (bg-green-100)
const day5 = screen.getByRole("button", { name: /^5\s*Day 5/i });
const day5 = screen.getByRole("button", {
name: /January 5, 2026 - Cycle day 5 - Follicular phase/i,
});
expect(day5).toHaveClass("bg-green-100");
});
@@ -105,7 +113,9 @@ describe("MonthView", () => {
render(<MonthView {...baseProps} />);
// Day 15 is OVULATION (bg-purple-100)
const day15 = screen.getByRole("button", { name: /^15\s*Day 15/i });
const day15 = screen.getByRole("button", {
name: /January 15, 2026 - Cycle day 15 - Ovulation phase/i,
});
expect(day15).toHaveClass("bg-purple-100");
});
@@ -113,7 +123,9 @@ describe("MonthView", () => {
render(<MonthView {...baseProps} />);
// Day 20 is EARLY_LUTEAL (bg-yellow-100)
const day20 = screen.getByRole("button", { name: /^20\s*Day 20/i });
const day20 = screen.getByRole("button", {
name: /January 20, 2026 - Cycle day 20 - Early Luteal phase/i,
});
expect(day20).toHaveClass("bg-yellow-100");
});
@@ -121,7 +133,9 @@ describe("MonthView", () => {
render(<MonthView {...baseProps} />);
// Day 25 is LATE_LUTEAL (bg-red-100)
const day25 = screen.getByRole("button", { name: /^25\s*Day 25/i });
const day25 = screen.getByRole("button", {
name: /January 25, 2026 - Cycle day 25 - Late Luteal phase/i,
});
expect(day25).toHaveClass("bg-red-100");
});
});
@@ -219,11 +233,16 @@ describe("MonthView", () => {
);
// Jan 1 should be day 28 (late luteal)
const jan1 = screen.getByRole("button", { name: /^1\s*Day 28/i });
// Button now has aria-label with full date, cycle day, and phase
const jan1 = screen.getByRole("button", {
name: /January 1, 2026 - Cycle day 28 - Late Luteal phase/i,
});
expect(jan1).toHaveClass("bg-red-100"); // LATE_LUTEAL
// Jan 2 should be day 1 (menstrual)
const jan2 = screen.getByRole("button", { name: /^2\s*Day 1/i });
const jan2 = screen.getByRole("button", {
name: /January 2, 2026 - Cycle day 1 - Menstrual phase/i,
});
expect(jan2).toHaveClass("bg-blue-100"); // MENSTRUAL
});
});

View File

@@ -36,7 +36,7 @@ import {
loadAuthFromCookies,
} from "./pocketbase";
const mockLogger = logger as {
const mockLogger = logger as unknown as {
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
info: ReturnType<typeof vi.fn>;