From e0399aec647fea94dec66c48dd766f6ce32056dd Mon Sep 17 00:00:00 2001 From: SundayEmmanualEkwe Date: Sat, 27 Jun 2026 00:33:56 +0100 Subject: [PATCH 1/2] 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/2] 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