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
7 changes: 6 additions & 1 deletion docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<StatusDot variant="warn" />
<StatusDot variant="warn" label="Paused" />
```

### `Spinner`
Expand Down
2 changes: 2 additions & 0 deletions src/app/docs/page.tsx
Original file line number Diff line number Diff line change
@@ -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" };

Expand Down
33 changes: 31 additions & 2 deletions src/components/StatusDot.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { type ReactNode } from "react";

type Variant = "ok" | "warn" | "down";

const variants: Record<Variant, string> = {
Expand All @@ -12,14 +14,41 @@ const labels: Record<Variant, string> = {
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 (
<span className="inline-flex items-center gap-2 text-xs">
<span
aria-hidden="true"
className={`h-2.5 w-2.5 rounded-full ${variants[variant]}`}
/>
<span>{labels[variant]}</span>
<span>{resolvedLabel}</span>
</span>
);
}
16 changes: 8 additions & 8 deletions src/components/__tests__/Header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,13 @@ describe("Header", () => {
mockPathname.mockReturnValue("/");
render(<Header />);

// 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", () => {
Expand All @@ -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", () => {
Expand Down
112 changes: 80 additions & 32 deletions src/components/__tests__/StatusDot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<StatusDot variant="ok" />);
expect(screen.getByText("Operational")).toBeInTheDocument();
});
describe("default labels per variant", () => {
it("renders 'Operational' for the ok variant", () => {
render(<StatusDot variant="ok" />);
expect(screen.getByText("Operational")).toBeInTheDocument();
});

it("renders 'Degraded' label for warn variant", () => {
render(<StatusDot variant="warn" />);
expect(screen.getByText("Degraded")).toBeInTheDocument();
});
it("renders 'Degraded' for the warn variant", () => {
render(<StatusDot variant="warn" />);
expect(screen.getByText("Degraded")).toBeInTheDocument();
});

it("renders 'Down' label for down variant", () => {
render(<StatusDot variant="down" />);
expect(screen.getByText("Down")).toBeInTheDocument();
it("renders 'Down' for the down variant", () => {
render(<StatusDot variant="down" />);
expect(screen.getByText("Down")).toBeInTheDocument();
});
});

it("colour dot is aria-hidden so the label carries the meaning", () => {
const { container } = render(<StatusDot variant="ok" />);
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(<StatusDot variant="ok" />);
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(<StatusDot variant="warn" />);
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(<StatusDot variant="down" />);
const dot = container.querySelector('[aria-hidden="true"]');
expect(dot?.className).toMatch(/bg-rose-500/);
});

it("ok dot has emerald colour class", () => {
const { container } = render(<StatusDot variant="ok" />);
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(<StatusDot variant="ok" />);
const dot = container.querySelector(".rounded-full");
expect(dot).toHaveAttribute("aria-hidden", "true");
});

it("does not mark the visible label as aria-hidden", () => {
render(<StatusDot variant="ok" />);
const label = screen.getByText("Operational");
expect(label).not.toHaveAttribute("aria-hidden");
});
});

it("warn dot has amber colour class", () => {
const { container } = render(<StatusDot variant="warn" />);
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(<StatusDot variant="warn" label="Paused" />);
expect(screen.getByText("Paused")).toBeInTheDocument();
expect(screen.queryByText("Degraded")).not.toBeInTheDocument();
});

it("accepts a ReactNode label", () => {
render(<StatusDot variant="ok" label={<strong>Live now</strong>} />);
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(
<StatusDot variant="down" label="Maintenance" />,
);
const dot = container.querySelector('[aria-hidden="true"]');
expect(dot?.className).toMatch(/bg-rose-500/);
});
});

it("down dot has rose colour class", () => {
const { container } = render(<StatusDot variant="down" />);
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(<StatusDot variant="ok" label="" />);
expect(screen.getByText("Operational")).toBeInTheDocument();
});

it("falls back to the default label for an explicit undefined", () => {
render(<StatusDot variant="down" label={undefined} />);
expect(screen.getByText("Down")).toBeInTheDocument();
});
});

it("visible label is not aria-hidden", () => {
render(<StatusDot variant="ok" />);
const label = screen.getByText("Operational");
expect(label).not.toHaveAttribute("aria-hidden");
it("always renders a visible text label for screen readers", () => {
const { container } = render(<StatusDot variant="ok" label="" />);
// 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");
});
});
Loading