From e0399aec647fea94dec66c48dd766f6ce32056dd Mon Sep 17 00:00:00 2001 From: SundayEmmanualEkwe Date: Sat, 27 Jun 2026 00:33:56 +0100 Subject: [PATCH 1/3] feat(navigation): add accessible responsive menu to the header --- README.md | 5 + TODO.md | 28 ++-- src/components/Header.tsx | 170 +++++++++++++++++++++-- src/components/__tests__/Header.test.tsx | 57 ++++++++ 4 files changed, 241 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 6c73d99..793d826 100644 --- a/README.md +++ b/README.md @@ -157,8 +157,13 @@ Key design decisions: message renders, `reset` is called on click, no stack trace appears, and the component renders standalone without Header/Footer. +## Responsive header navigation + +On small screens (below Tailwind `md`), the Header collapses into an accessible disclosure menu with a keyboard-operable toggle (Escape closes; focus returns to the toggle). The inline primary navigation remains for `md` and larger screens. + ## Accessibility + ### Route loading skeleton The App Router fallback in [`src/app/loading.tsx`](src/app/loading.tsx) renders an diff --git a/TODO.md b/TODO.md index 4ced298..ecc9ffa 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,21 @@ -- [ ] Update src/app/admin/page.tsx with a real latest-wins stale-status guard using useRef. -- [ ] Ensure load useCallback is stable (no statusSeq deps) and remove eslint-disable hacks for deps. -- [ ] Add/extend unit tests in src/app/admin/page.test.tsx to verify out-of-order status responses are ignored, latest response wins, and toggle refresh works. -- [ ] Add a JSDoc note documenting latest-wins semantics. -- [ ] Run npm run lint, npm run typecheck, npm test, npm run build. -- [ ] Verify tests cover edge cases (slow then fast status, toggle during in-flight status, unmount during fetch, load error). -- [ ] Commit changes with message: refactor(admin): replace dead statusSeq with working latest-wins guard -- [ ] Push branch to GitHub. +# TODO - Responsive collapsible header menu + +- [ ] Implement responsive disclosure menu in `src/components/Header.tsx` + - [ ] Add hamburger toggle button visible only below Tailwind `md` + - [ ] Wire `aria-expanded`, `aria-controls`, and real ` + + {menuOpen && ( +
+ +
+ )} + + ); +} + export function Header() { const pathname = usePathname(); - const [menuOpen, setMenuOpen] = useState(false); + const [moreOpen, setMoreOpen] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + + // Close desktop dropdown on route change. + useEffect(() => { + setMoreOpen(false); + setMobileOpen(false); + }, [pathname]); return (
@@ -48,8 +184,8 @@ export function Header() { AgentPay - {/* Primary links — always visible */} -
); } + diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index b0d27ff..c55fdb3 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -8,7 +8,12 @@ jest.mock("next/navigation", () => ({ import { usePathname } from "next/navigation"; const mockPathname = usePathname as jest.Mock; +function getMobileToggle() { + return screen.getByRole("button", { name: /menu/i }); +} + describe("Header", () => { + it("renders a named navigation landmark", () => { render(
); expect( @@ -93,6 +98,57 @@ describe("Header", () => { expect(screen.queryByRole("menu")).not.toBeInTheDocument(); }); + it("mobile menu toggle has aria-expanded and aria-controls", () => { + mockPathname.mockReturnValue("/"); + render(
); + + const toggle = getMobileToggle(); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(toggle).toHaveAttribute("aria-controls"); + }); + + it("mobile menu opens and closes on toggle", () => { + mockPathname.mockReturnValue("/"); + render(
); + + const toggle = getMobileToggle(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("region", { name: /mobile navigation/i })).toBeInTheDocument(); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("region", { name: /mobile navigation/i })).not.toBeInTheDocument(); + }); + + it("mobile menu closes on Escape and returns focus to toggle", () => { + mockPathname.mockReturnValue("/"); + render(
); + + const toggle = getMobileToggle(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + fireEvent.keyDown(window, { key: "Escape" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(document.activeElement).toBe(toggle); + }); + + it("mobile menu auto-closes on route change", () => { + mockPathname.mockReturnValue("/"); + const { rerender } = render(
); + + const toggle = getMobileToggle(); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + + mockPathname.mockReturnValue("/services"); + rerender(
); + + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("region", { name: /mobile navigation/i })).not.toBeInTheDocument(); + }); + it("preserves focus-visible ring classes on links", () => { mockPathname.mockReturnValue("/"); render(
); @@ -100,3 +156,4 @@ describe("Header", () => { expect(homeLink.className).toContain("focus-visible:outline"); }); }); + From b5db851125fe3b567bd08df314de11513772f998 Mon Sep 17 00:00:00 2001 From: SundayEmmanualEkwe Date: Sat, 27 Jun 2026 11:53:58 +0100 Subject: [PATCH 2/3] test(components): cover EmptyState, KeyValueGrid, and PageHeading --- TODO.md | 29 ++++----- src/components/EmptyState.tsx | 5 ++ src/components/KeyValueGrid.tsx | 3 + src/components/PageHeading.tsx | 4 ++ src/components/__tests__/EmptyState.test.tsx | 59 +++++++++++++++++++ .../__tests__/KeyValueGrid.test.tsx | 49 +++++++++++++++ src/components/__tests__/PageHeading.test.tsx | 58 ++++++++++++++++++ .../__snapshots__/__placeholder__.txt | 1 + 8 files changed, 191 insertions(+), 17 deletions(-) create mode 100644 src/components/__tests__/EmptyState.test.tsx create mode 100644 src/components/__tests__/KeyValueGrid.test.tsx create mode 100644 src/components/__tests__/PageHeading.test.tsx create mode 100644 src/components/__tests__/__snapshots__/__placeholder__.txt diff --git a/TODO.md b/TODO.md index ecc9ffa..097ea4a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,21 +1,16 @@ -# TODO - Responsive collapsible header menu +- [ ] Create branch `test/testing-layout-primitives` +- [x] Add JSDoc headers to `src/components/EmptyState.tsx`, `src/components/KeyValueGrid.tsx`, `src/components/PageHeading.tsx` where missing -- [ ] Implement responsive disclosure menu in `src/components/Header.tsx` - - [ ] Add hamburger toggle button visible only below Tailwind `md` - - [ ] Wire `aria-expanded`, `aria-controls`, and real `} + /> + ); + + expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument(); + }); +}); + diff --git a/src/components/__tests__/KeyValueGrid.test.tsx b/src/components/__tests__/KeyValueGrid.test.tsx new file mode 100644 index 0000000..df20f86 --- /dev/null +++ b/src/components/__tests__/KeyValueGrid.test.tsx @@ -0,0 +1,49 @@ +import { render, screen } from "@testing-library/react"; +import { KeyValueGrid } from "../KeyValueGrid"; + +describe("KeyValueGrid", () => { + it("renders a dt/dd pair for each row", () => { + render( + + ); + + // semantic structure + expect(screen.getByRole("term", { name: /status/i })).toBeInTheDocument(); + expect( + screen.getByRole("definition", { name: /active/i }) + ).toBeInTheDocument(); + + expect(screen.getByRole("term", { name: /plan/i })).toBeInTheDocument(); + expect(screen.getByRole("definition", { name: /pro/i })).toBeInTheDocument(); + }); + + it("renders correct label/value text for each row", () => { + render( + + ); + + const terms = screen.getAllByRole("term"); + const definitions = screen.getAllByRole("definition"); + + expect(terms.map((n) => n.textContent)).toEqual(["Name", "ID"]); + expect(definitions.map((n) => n.textContent)).toEqual(["AgentPay", "ap_123"]); + }); + + it("renders nothing meaningful for an empty rows array", () => { + render(); + + expect(screen.queryByRole("term")).not.toBeInTheDocument(); + expect(screen.queryByRole("definition")).not.toBeInTheDocument(); + }); +}); + diff --git a/src/components/__tests__/PageHeading.test.tsx b/src/components/__tests__/PageHeading.test.tsx new file mode 100644 index 0000000..2d8e084 --- /dev/null +++ b/src/components/__tests__/PageHeading.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import { PageHeading } from "../PageHeading"; + +describe("PageHeading", () => { + it("renders the title as an h1", () => { + render(); + + expect(screen.getByRole("heading", { level: 1, name: "Services" })).toBeInTheDocument(); + }); + + it("does not render description when not provided", () => { + render(); + + expect(screen.queryByText(/choose a service/i)).not.toBeInTheDocument(); + }); + + it("renders description when provided", () => { + render( + + ); + + expect(screen.getByText("Choose a service to manage")).toBeInTheDocument(); + }); + + it("does not render action when not provided", () => { + render(); + + expect(screen.queryByRole("link", { name: /new service/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /refresh/i })).not.toBeInTheDocument(); + }); + + it("renders action slot when provided as a link", () => { + render( + New service} + /> + ); + + const link = screen.getByRole("link", { name: /new service/i }); + expect(link).toHaveAttribute("href", "/services/new"); + }); + + it("renders action slot when provided as a button", () => { + render( + Refresh} + /> + ); + + expect(screen.getByRole("button", { name: /refresh/i })).toBeInTheDocument(); + }); +}); + diff --git a/src/components/__tests__/__snapshots__/__placeholder__.txt b/src/components/__tests__/__snapshots__/__placeholder__.txt new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/src/components/__tests__/__snapshots__/__placeholder__.txt @@ -0,0 +1 @@ +placeholder From 33959c42d69a27c59ff8adb5811c4b28f8063992 Mon Sep 17 00:00:00 2001 From: SundayEmmanualEkwe Date: Sat, 27 Jun 2026 12:52:45 +0100 Subject: [PATCH 3/3] refactor(forms): extract shared numeric-field validation helper --- TODO.md | 25 ++--- src/app/services/[serviceId]/edit/page.tsx | 35 ++++--- src/app/services/new/page.tsx | 37 +++++--- src/app/usage/page.tsx | 32 +++---- src/lib/__tests__/validateNumber.test.ts | 105 +++++++++++++++++++++ src/lib/tsconfig.jest.json | 7 ++ src/lib/validateNumber.ts | 47 +++++++++ 7 files changed, 224 insertions(+), 64 deletions(-) create mode 100644 src/lib/__tests__/validateNumber.test.ts create mode 100644 src/lib/tsconfig.jest.json create mode 100644 src/lib/validateNumber.ts diff --git a/TODO.md b/TODO.md index 097ea4a..289737e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,16 +1,11 @@ -- [ ] Create branch `test/testing-layout-primitives` -- [x] Add JSDoc headers to `src/components/EmptyState.tsx`, `src/components/KeyValueGrid.tsx`, `src/components/PageHeading.tsx` where missing - - -- [ ] Ensure tests exist for: - - [ ] `src/components/__tests__/EmptyState.test.tsx` - - [ ] `src/components/__tests__/KeyValueGrid.test.tsx` - - [ ] `src/components/__tests__/PageHeading.test.tsx` -- [x] Run and capture results: - - [ ] `npm run lint` - - [ ] `npm run typecheck` - - [ ] `npm test -- --coverage` -- [ ] Verify coverage thresholds for the three components meet requirements -- [ ] Commit with message `test(components): cover EmptyState, KeyValueGrid, and PageHeading` - +# TODO + +- [x] Inspect existing numeric validation helper (`src/lib/validateNumber.ts`). +- [x] Verify usage/edit/new pages already import and use the helper. +- [ ] Update helper tests (`src/lib/__tests__/validateNumber.test.ts`) to cover required edge cases for both ranges. +- [ ] Adjust `src/app/usage/page.test.tsx` to assert validation message is surfaced through `TextField` error UI for non-integer requests. +- [ ] Update `README.md` with validation rule summary (price: >=0 int; requests: >=1 int). +- [ ] Run `npm run lint`, `npm run typecheck`, `npm test`, `npm run test:coverage`. +- [ ] Ensure coverage threshold (>=95%) for helper + changed pages. +- [ ] Commit with message: `refactor(forms): extract shared numeric-field validation helper`. diff --git a/src/app/services/[serviceId]/edit/page.tsx b/src/app/services/[serviceId]/edit/page.tsx index f836c70..c56d868 100644 --- a/src/app/services/[serviceId]/edit/page.tsx +++ b/src/app/services/[serviceId]/edit/page.tsx @@ -3,6 +3,8 @@ import { useEffect, useState, use } from "react"; import { useRouter } from "next/navigation"; import { apiGet, apiPatch } from "@/lib/apiClient"; +import { TextField } from "@/components/TextField"; +import { parseNonNegativeInt } from "@/lib/validateNumber"; type Service = { serviceId: string; priceStroops: number }; @@ -26,16 +28,17 @@ export default function EditServicePage({ const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - const n = Number(price); - if (!Number.isInteger(n) || n < 0) { - setError("Price must be a non-negative integer."); + const parsed = parseNonNegativeInt(price); + if (!parsed.ok) { + setError(parsed.message); return; } + setLoading(true); try { await apiPatch( `/api/v1/services/${encodeURIComponent(serviceId)}/price`, - { priceStroops: n } + { priceStroops: parsed.value } ); router.push(`/services/${encodeURIComponent(serviceId)}`); } catch (err) { @@ -54,16 +57,14 @@ export default function EditServicePage({

Edit price

{serviceId}

- + setPrice(e.target.value)} + error={error} + /> - {error && ( -

- {error} -

- )} + ); diff --git a/src/app/services/new/page.tsx b/src/app/services/new/page.tsx index 2c46adb..a3ee0a9 100644 --- a/src/app/services/new/page.tsx +++ b/src/app/services/new/page.tsx @@ -4,6 +4,8 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { apiPost } from "@/lib/apiClient"; import { PageShell } from "@/components/PageShell"; +import { TextField } from "@/components/TextField"; +import { parseNonNegativeInt } from "@/lib/validateNumber"; export default function NewServicePage() { const router = useRouter(); @@ -15,14 +17,19 @@ export default function NewServicePage() { const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(null); - const n = Number(priceStroops); - if (!Number.isInteger(n) || n < 0) { - setError("Price must be a non-negative integer."); + + const parsed = parseNonNegativeInt(priceStroops); + if (!parsed.ok) { + setError(parsed.message); return; } + setLoading(true); try { - await apiPost("/api/v1/services", { serviceId, priceStroops: n }); + await apiPost("/api/v1/services", { + serviceId, + priceStroops: parsed.value, + }); router.push("/services"); } catch (err) { setError((err as Error).message); @@ -45,16 +52,16 @@ export default function NewServicePage() { className="rounded-md border border-zinc-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700 dark:bg-zinc-900" /> - + + setPriceStroops(e.target.value)} + error={error} + /> + + {error && (

{error} @@ -71,3 +79,4 @@ export default function NewServicePage() { ); } + diff --git a/src/app/usage/page.tsx b/src/app/usage/page.tsx index 4823249..87b27dd 100644 --- a/src/app/usage/page.tsx +++ b/src/app/usage/page.tsx @@ -1,10 +1,12 @@ "use client"; import { Spinner } from "@/components/Spinner"; +import { TextField } from "@/components/TextField"; import type { ApiError } from "@/lib/apiClient"; import { apiGet, apiPost } from "@/lib/apiClient"; import type { FormEvent } from "react"; import { useState } from "react"; +import { parsePositiveInt } from "@/lib/validateNumber"; type QueryResult = { agent: string; @@ -58,9 +60,10 @@ export default function UsagePage() { const onRecord = async (event: FormEvent) => { event.preventDefault(); if (isRecording) return; - const requestsNum = Number(requests); - if (!Number.isInteger(requestsNum) || requestsNum <= 0) { - setStatus({ kind: "error", message: "requests must be a positive integer" }); + const parsed = parsePositiveInt(requests); + if (!parsed.ok) { + // Surface the validation message through the field error. + setStatus({ kind: "error", message: parsed.message }); return; } @@ -69,7 +72,7 @@ export default function UsagePage() { const body = await apiPost<{ total: number }>("/api/v1/usage", { agent, serviceId, - requests: requestsNum, + requests: parsed.value, }); setStatus({ kind: "ok", total: body?.total }); } catch (error) { @@ -134,18 +137,15 @@ export default function UsagePage() { className="rounded-md border border-zinc-300 px-3 py-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:border-zinc-700 dark:bg-zinc-900" /> - + setRequests(e.target.value)} + error={status.kind === "error" ? status.message : undefined} + /> +