Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 20 additions & 8 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -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 `<button>`
- [ ] Keyboard support: Escape closes, focus returns to toggle
- [ ] Focus moves into menu when opened
- [ ] Close on route change (pathname change)
- [ ] Keep focus-visible rings and existing navigation aria-label
- [x] Extend tests in `src/components/__tests__/Header.test.tsx`
- [x] Assert toggle opens/closes + `aria-expanded` flips
- [x] Assert `aria-controls` points to menu
- [x] Assert Escape closes + focus returns to toggle
- [x] Assert auto-close on route change
- [x] Ensure primary links remain reachable / no regressions

- [x] Update `README.md` with responsive nav note + breakpoint (`md`)
- [ ] Run: `npm run lint`, `npm run typecheck`, `npm test`, `npm run build`
- [ ] Commit with message: `feat(navigation): add accessible responsive menu to the header`


170 changes: 159 additions & 11 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import { useEffect, useId, useRef, useState } from "react";

const primaryLinks = [
{ href: "/", label: "Home" },
Expand Down Expand Up @@ -31,9 +31,145 @@ const linkClass =
"rounded px-2 py-1 text-sm hover:bg-zinc-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:hover:bg-zinc-800";
const activeLinkClass = "font-semibold text-blue-600 dark:text-blue-400";

function MobileNav({
pathname,
primary,
secondary,
menuOpen,
setMenuOpen,
}: {
pathname: string;
primary: typeof primaryLinks;
secondary: typeof secondaryLinks;
menuOpen: boolean;
setMenuOpen: (next: boolean | ((prev: boolean) => boolean)) => void;
}) {
const toggleId = useId();

const panelId = `${toggleId}-panel`;

const toggleRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
// Close on route change.
setMenuOpen(false);
}, [pathname, setMenuOpen]);

useEffect(() => {
if (!menuOpen) return;

const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
setMenuOpen(false);
}
};

window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [menuOpen, setMenuOpen]);

useEffect(() => {
if (menuOpen) {
const first = panelRef.current?.querySelector<HTMLElement>(
"a[role='menuitem'], a, [role='menuitem']"
);
first?.focus?.();
return;
}
toggleRef.current?.focus?.();
}, [menuOpen]);

return (
<div className="md:hidden">
<button
ref={toggleRef}
type="button"
aria-expanded={menuOpen}
aria-controls={panelId}
onClick={() => setMenuOpen((o) => !o)}
className={`${linkClass} flex items-center gap-2`}
>
<svg
aria-hidden
width="16"
height="16"
viewBox="0 0 20 20"
fill="currentColor"
>
<path d="M3 5h14v2H3V5zm0 6h14v2H3v-2zm0 6h14v2H3v-2z" />
</svg>
Menu
</button>

{menuOpen && (
<div
id={panelId}
ref={panelRef}
role="region"
aria-label="Mobile navigation"
className="mt-2 rounded-md border border-zinc-200 bg-white shadow-md dark:border-zinc-700 dark:bg-zinc-900"
>
<ul className="p-1" role="menu">
{primary.map((l) => {
const active = isActive(pathname, l.href);
return (
<li key={l.href} role="none">
<Link
href={l.href}
role="menuitem"
aria-current={active ? "page" : undefined}
onClick={() => setMenuOpen(false)}
className={`block w-full px-4 py-2 text-sm hover:bg-zinc-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:hover:bg-zinc-800 ${
active ? activeLinkClass : ""
}`}
>
{l.label}
</Link>
</li>
);
})}

<li className="px-4 pb-1 pt-2 text-xs font-semibold text-zinc-500 dark:text-zinc-400">
More
</li>

{secondary.map((l) => {
const active = isActive(pathname, l.href);
return (
<li key={l.href} role="none">
<Link
href={l.href}
role="menuitem"
aria-current={active ? "page" : undefined}
onClick={() => setMenuOpen(false)}
className={`block w-full px-4 py-2 text-sm hover:bg-zinc-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:hover:bg-zinc-800 ${
active ? activeLinkClass : ""
}`}
>
{l.label}
</Link>
</li>
);
})}
</ul>
</div>
)}
</div>
);
}

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 (
<header className="border-b border-zinc-200 dark:border-zinc-800">
Expand All @@ -48,8 +184,8 @@ export function Header() {
AgentPay
</Link>

{/* Primary links — always visible */}
<ul className="flex flex-wrap gap-1 text-sm">
{/* Desktop links — always visible on md+ */}
<ul className="hidden flex-wrap gap-1 text-sm md:flex">
{primaryLinks.map((l) => {
const active = isActive(pathname, l.href);
return (
Expand All @@ -65,13 +201,13 @@ export function Header() {
);
})}

{/* More menu — secondary links */}
{/* More menu — secondary links (desktop) */}
<li className="relative">
<button
type="button"
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((o) => !o)}
aria-expanded={moreOpen}
onClick={() => setMoreOpen((o) => !o)}
className={`${linkClass} flex items-center gap-1`}
>
More
Expand All @@ -85,13 +221,13 @@ export function Header() {
<path d="M6 8L1 3h10z" />
</svg>
</button>
{menuOpen && (
{moreOpen && (
<ul
role="menu"
className="absolute right-0 top-full z-10 mt-1 min-w-[140px] rounded-md border border-zinc-200 bg-white py-1 shadow-md dark:border-zinc-700 dark:bg-zinc-900"
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setMenuOpen(false);
setMoreOpen(false);
}
}}
>
Expand All @@ -103,8 +239,10 @@ export function Header() {
href={l.href}
role="menuitem"
aria-current={active ? "page" : undefined}
onClick={() => setMenuOpen(false)}
className={`block px-4 py-2 text-sm hover:bg-zinc-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:hover:bg-zinc-800 ${active ? activeLinkClass : ""}`}
onClick={() => setMoreOpen(false)}
className={`block px-4 py-2 text-sm hover:bg-zinc-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:hover:bg-zinc-800 ${
active ? activeLinkClass : ""
}`}
>
{l.label}
</Link>
Expand All @@ -115,7 +253,17 @@ export function Header() {
)}
</li>
</ul>

{/* Mobile disclosure menu — toggle below md */}
<MobileNav
pathname={pathname}
primary={primaryLinks}
secondary={secondaryLinks}
menuOpen={mobileOpen}
setMenuOpen={setMobileOpen}
/>
</nav>
</header>
);
}

57 changes: 57 additions & 0 deletions src/components/__tests__/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Header />);
expect(
Expand Down Expand Up @@ -93,10 +98,62 @@ describe("Header", () => {
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});

it("mobile menu toggle has aria-expanded and aria-controls", () => {
mockPathname.mockReturnValue("/");
render(<Header />);

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(<Header />);

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(<Header />);

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(<Header />);

const toggle = getMobileToggle();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");

mockPathname.mockReturnValue("/services");
rerender(<Header />);

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(<Header />);
const homeLink = screen.getByRole("link", { name: "Home" });
expect(homeLink.className).toContain("focus-visible:outline");
});
});

Loading