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
23 changes: 15 additions & 8 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -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`


5 changes: 5 additions & 0 deletions src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
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>
);
}

3 changes: 3 additions & 0 deletions src/components/KeyValueGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { type ReactNode } from "react";

/**
* KeyValueGrid renders semantic label/value pairs using a <dl>.
*/
type Row = { label: ReactNode; value: ReactNode };

export function KeyValueGrid({ rows }: { rows: Row[] }) {
Expand Down
4 changes: 4 additions & 0 deletions src/components/PageHeading.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { type ReactNode } from "react";

/**
* PageHeading renders a consistent <h1> header with optional description and
* an action slot (e.g., button/link) on the right.
*/
type Props = {
title: ReactNode;
description?: ReactNode;
Expand Down
59 changes: 59 additions & 0 deletions src/components/__tests__/EmptyState.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import { EmptyState } from "../EmptyState";

describe("EmptyState", () => {
it("renders the title", () => {
render(<EmptyState title="No results" />);

expect(screen.getByText("No results")).toBeInTheDocument();
});

it("does not render description when not provided", () => {
render(<EmptyState title="No results" />);

expect(screen.queryByText("Nothing to show")).not.toBeInTheDocument();
});

it("renders description when provided", () => {
render(
<EmptyState title="No results" description="Nothing to show" />
);

expect(screen.getByText("Nothing to show")).toBeInTheDocument();
});

it("does not render action when not provided", () => {
render(<EmptyState title="No results" />);

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(
<EmptyState
title="No results"
action={<a href="/docs">Learn more</a>}
/>
);

expect(screen.getByRole("link", { name: /learn more/i })).toHaveAttribute(
"href",
"/docs"
);
});

it("renders action when provided as a button", () => {
render(
<EmptyState
title="No results"
action={<button type="button">Create</button>}
/>
);

expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument();
});
});

Loading
Loading