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