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..097ea4a 100644 --- a/TODO.md +++ b/TODO.md @@ -1,9 +1,16 @@ -- [ ] 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. +- [ ] 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` + diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx index bdbac16..3b47356 100644 --- a/src/components/EmptyState.tsx +++ b/src/components/EmptyState.tsx @@ -1,5 +1,10 @@ import { type ReactNode } from "react"; +/** + * EmptyState is a small presentational helper for empty list/detail screens. + * + * It renders a title and optional description and action content. + */ type Props = { title: ReactNode; description?: ReactNode; diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 1249201..6160619 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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" }, @@ -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(null); + const panelRef = useRef(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( + "a[role='menuitem'], a, [role='menuitem']" + ); + first?.focus?.(); + return; + } + toggleRef.current?.focus?.(); + }, [menuOpen]); + + return ( +
+ + + {menuOpen && ( +
+
    + {primary.map((l) => { + const active = isActive(pathname, l.href); + return ( +
  • + 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} + +
  • + ); + })} + +
  • + More +
  • + + {secondary.map((l) => { + const active = isActive(pathname, l.href); + return ( +
  • + 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} + +
  • + ); + })} +
+
+ )} +
+ ); +} + 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 */} -
    + {/* Desktop links — always visible on md+ */} +
      {primaryLinks.map((l) => { const active = isActive(pathname, l.href); return ( @@ -65,13 +201,13 @@ export function Header() { ); })} - {/* More menu — secondary links */} + {/* More menu — secondary links (desktop) */}
    • - {menuOpen && ( + {moreOpen && (
        { if (!e.currentTarget.contains(e.relatedTarget)) { - setMenuOpen(false); + setMoreOpen(false); } }} > @@ -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} @@ -115,7 +253,17 @@ export function Header() { )}
      + + {/* Mobile disclosure menu — toggle below md */} +
); } + diff --git a/src/components/KeyValueGrid.tsx b/src/components/KeyValueGrid.tsx index 7439be7..c02076d 100644 --- a/src/components/KeyValueGrid.tsx +++ b/src/components/KeyValueGrid.tsx @@ -1,5 +1,8 @@ import { type ReactNode } from "react"; +/** + * KeyValueGrid renders semantic label/value pairs using a
. + */ type Row = { label: ReactNode; value: ReactNode }; export function KeyValueGrid({ rows }: { rows: Row[] }) { diff --git a/src/components/PageHeading.tsx b/src/components/PageHeading.tsx index 62ef1b5..223f2d9 100644 --- a/src/components/PageHeading.tsx +++ b/src/components/PageHeading.tsx @@ -1,5 +1,9 @@ import { type ReactNode } from "react"; +/** + * PageHeading renders a consistent

header with optional description and + * an action slot (e.g., button/link) on the right. + */ type Props = { title: ReactNode; description?: ReactNode; diff --git a/src/components/__tests__/EmptyState.test.tsx b/src/components/__tests__/EmptyState.test.tsx new file mode 100644 index 0000000..75ebb5b --- /dev/null +++ b/src/components/__tests__/EmptyState.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { EmptyState } from "../EmptyState"; + +describe("EmptyState", () => { + it("renders the title", () => { + render(); + + expect(screen.getByText("No results")).toBeInTheDocument(); + }); + + it("does not render description when not provided", () => { + render(); + + expect(screen.queryByText("Nothing to show")).not.toBeInTheDocument(); + }); + + it("renders description when provided", () => { + render( + + ); + + expect(screen.getByText("Nothing to show")).toBeInTheDocument(); + }); + + it("does not render action when not provided", () => { + render(); + + expect(screen.queryByRole("link", { name: /learn more/i })).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /create/i }) + ).not.toBeInTheDocument(); + }); + + it("renders action when provided as a link", () => { + render( + Learn more} + /> + ); + + expect(screen.getByRole("link", { name: /learn more/i })).toHaveAttribute( + "href", + "/docs" + ); + }); + + it("renders action when provided as a button", () => { + render( + Create} + /> + ); + + expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument(); + }); +}); + 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"); }); }); + 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