diff --git a/docs/components.md b/docs/components.md index 0912a22..159713e 100644 --- a/docs/components.md +++ b/docs/components.md @@ -251,11 +251,16 @@ options as an ARIA button group. | Prop | Type | Required | Notes | | --- | --- | --- | --- | | `variant` | `"ok" \| "warn" \| "down"` | yes | Maps to Operational, Degraded, or Down text. | +| `label` | `ReactNode` | no | Overrides the default per-variant text. An omitted, `null`, or empty-string value falls back to the variant default, so a label is always present. | -The color dot is decorative; the visible label carries the status meaning. +The color dot is decorative (`aria-hidden`); the visible label carries the +status meaning. Use `label` to reuse the same dot affordance for states outside +the three defaults — for example `"Paused"` on a `warn` dot — without rendering a +separate element. ```tsx + ``` ### `Spinner` diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx index 6769a87..ab17d1d 100644 --- a/src/app/docs/page.tsx +++ b/src/app/docs/page.tsx @@ -1,5 +1,7 @@ import { PageShell } from "@/components/PageShell"; +import { CurlBlock } from "@/components/CurlBlock"; import { messages } from "@/lib/messages"; +import { resolveApiBase } from "@/lib/resolveApiBase"; export const metadata = { title: "Docs — AgentPay" }; diff --git a/src/components/StatusDot.tsx b/src/components/StatusDot.tsx index 3ed07e2..606c1bb 100644 --- a/src/components/StatusDot.tsx +++ b/src/components/StatusDot.tsx @@ -1,3 +1,5 @@ +import { type ReactNode } from "react"; + type Variant = "ok" | "warn" | "down"; const variants: Record = { @@ -12,14 +14,41 @@ const labels: Record = { down: "Down", }; -export function StatusDot({ variant }: { variant: Variant }) { +/** + * A colour-coded status indicator: a small decorative dot paired with a visible + * text label, so status is never conveyed by colour alone (WCAG 1.4.1). + * + * Variants and their default labels: + * - `ok` → emerald dot, "Operational" + * - `warn` → amber dot, "Degraded" + * - `down` → rose dot, "Down" + * + * Pass `label` to override the default text while keeping the same dot + * affordance — useful for states outside the three defaults (e.g. `"Paused"` + * on a `warn` dot). An omitted, `null`, or empty-string `label` falls back to + * the variant's default text, so the label is always present for screen + * readers. The decorative dot is always `aria-hidden`. + */ +export function StatusDot({ + variant, + label, +}: { + variant: Variant; + /** Optional text override; falls back to the variant default when empty. */ + label?: ReactNode; +}) { + const resolvedLabel = + label === undefined || label === null || label === "" + ? labels[variant] + : label; + return ( ); } diff --git a/src/components/__tests__/Header.test.tsx b/src/components/__tests__/Header.test.tsx index 54d7962..faee338 100644 --- a/src/components/__tests__/Header.test.tsx +++ b/src/components/__tests__/Header.test.tsx @@ -53,14 +53,13 @@ describe("Header", () => { mockPathname.mockReturnValue("/"); render(
); - // Exactly one link has aria-current="page" + // Exactly one link has aria-current="page". The mobile menu panel is only + // rendered while open, so at rest only the desktop "Home" link is active. const activeLinks = screen.getAllByRole("link").filter( (link) => link.getAttribute("aria-current") === "page" ); - // Home link is active twice (desktop + mobile) - expect(activeLinks.length).toBe(2); + expect(activeLinks.length).toBe(1); expect(activeLinks[0]).toHaveTextContent("Home"); - expect(activeLinks[1]).toHaveTextContent("Home"); }); it("marks zero links as active for an unknown route", () => { @@ -80,14 +79,15 @@ describe("Header", () => { // Open the secondary menu to expose secondary links fireEvent.click(screen.getByRole("button", { name: /more/i })); - // The active links should strictly be the desktop "Services" and the mobile "Services" + // With the mobile panel closed, the only current link is the desktop + // "Services" primary link; the opened "More" menu holds secondary links, + // none of which match /services. const activeLinks = screen.getAllByRole("link", { hidden: true }).filter( (link) => link.getAttribute("aria-current") === "page" ); - - expect(activeLinks.length).toBe(2); + + expect(activeLinks.length).toBe(1); expect(activeLinks[0]).toHaveTextContent("Services"); - expect(activeLinks[1]).toHaveTextContent("Services"); }); it("shows More button that opens secondary menu", () => { diff --git a/src/components/__tests__/StatusDot.test.tsx b/src/components/__tests__/StatusDot.test.tsx index 1f5a02a..bc94e51 100644 --- a/src/components/__tests__/StatusDot.test.tsx +++ b/src/components/__tests__/StatusDot.test.tsx @@ -2,48 +2,96 @@ import { render, screen } from "@testing-library/react"; import { StatusDot } from "../StatusDot"; describe("StatusDot", () => { - it("renders 'Operational' label for ok variant", () => { - render(); - expect(screen.getByText("Operational")).toBeInTheDocument(); - }); + describe("default labels per variant", () => { + it("renders 'Operational' for the ok variant", () => { + render(); + expect(screen.getByText("Operational")).toBeInTheDocument(); + }); - it("renders 'Degraded' label for warn variant", () => { - render(); - expect(screen.getByText("Degraded")).toBeInTheDocument(); - }); + it("renders 'Degraded' for the warn variant", () => { + render(); + expect(screen.getByText("Degraded")).toBeInTheDocument(); + }); - it("renders 'Down' label for down variant", () => { - render(); - expect(screen.getByText("Down")).toBeInTheDocument(); + it("renders 'Down' for the down variant", () => { + render(); + expect(screen.getByText("Down")).toBeInTheDocument(); + }); }); - it("colour dot is aria-hidden so the label carries the meaning", () => { - const { container } = render(); - const dot = container.querySelector("[aria-hidden='true']"); - expect(dot).toBeInTheDocument(); - }); + describe("variant colour dot", () => { + it("ok dot has the emerald colour class", () => { + const { container } = render(); + const dot = container.querySelector('[aria-hidden="true"]'); + expect(dot?.className).toMatch(/bg-emerald-500/); + }); + + it("warn dot has the amber colour class", () => { + const { container } = render(); + const dot = container.querySelector('[aria-hidden="true"]'); + expect(dot?.className).toMatch(/bg-amber-500/); + }); + + it("down dot has the rose colour class", () => { + const { container } = render(); + const dot = container.querySelector('[aria-hidden="true"]'); + expect(dot?.className).toMatch(/bg-rose-500/); + }); - it("ok dot has emerald colour class", () => { - const { container } = render(); - const dot = container.querySelector("[aria-hidden='true']"); - expect(dot?.className).toMatch(/emerald/); + it("marks the colour dot as aria-hidden so colour is not the only cue", () => { + const { container } = render(); + const dot = container.querySelector(".rounded-full"); + expect(dot).toHaveAttribute("aria-hidden", "true"); + }); + + it("does not mark the visible label as aria-hidden", () => { + render(); + const label = screen.getByText("Operational"); + expect(label).not.toHaveAttribute("aria-hidden"); + }); }); - it("warn dot has amber colour class", () => { - const { container } = render(); - const dot = container.querySelector("[aria-hidden='true']"); - expect(dot?.className).toMatch(/amber/); + describe("custom label override", () => { + it("renders a custom string label instead of the default", () => { + render(); + expect(screen.getByText("Paused")).toBeInTheDocument(); + expect(screen.queryByText("Degraded")).not.toBeInTheDocument(); + }); + + it("accepts a ReactNode label", () => { + render(Live now} />); + const node = screen.getByText("Live now"); + expect(node.tagName).toBe("STRONG"); + expect(screen.queryByText("Operational")).not.toBeInTheDocument(); + }); + + it("keeps the variant dot colour when the label is overridden", () => { + const { container } = render( + , + ); + const dot = container.querySelector('[aria-hidden="true"]'); + expect(dot?.className).toMatch(/bg-rose-500/); + }); }); - it("down dot has rose colour class", () => { - const { container } = render(); - const dot = container.querySelector("[aria-hidden='true']"); - expect(dot?.className).toMatch(/rose/); + describe("empty-string label fallback", () => { + it("falls back to the default label for an empty string", () => { + render(); + expect(screen.getByText("Operational")).toBeInTheDocument(); + }); + + it("falls back to the default label for an explicit undefined", () => { + render(); + expect(screen.getByText("Down")).toBeInTheDocument(); + }); }); - it("visible label is not aria-hidden", () => { - render(); - const label = screen.getByText("Operational"); - expect(label).not.toHaveAttribute("aria-hidden"); + it("always renders a visible text label for screen readers", () => { + const { container } = render(); + // The dot is aria-hidden; the label span must hold non-empty, non-hidden text. + const labelSpan = Array.from(container.querySelectorAll("span")).find( + (s) => !s.hasAttribute("aria-hidden") && s.textContent, + ); + expect(labelSpan?.textContent).toBe("Operational"); }); });