Files
Ekklesia/packages/ui/tests/elements.test.tsx
T
ekklesiaandClaude Opus 5.5 5a7d44781b
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m6s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m44s
feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives
Stage 1's later screens and modules need the interactions DESIGN.md
fixes once for the product (ADR 0059). @ekklesia/ui gains them,
additively:

- ConfirmationDialog: simple, destructive, or typed-phrase
  confirmation, with severity chosen by the caller. Destructive is
  marked by symbol and frame, not colour alone, and starts on Cancel.
  A required phrase must match exactly (forgiving only surrounding
  whitespace and NFC). While busy, Cancel and Escape are disabled.
- ToastProvider / useToasts: a top-center toast system per React tree,
  never a package singleton (ADR 0040). Live regions are mounted
  before any toast arrives; errors are assertive and persist until
  dismissed; routine toasts last at least three seconds and longer for
  longer text. Timers pause on hover and focus; duplicates refresh;
  errors are never displaced; every timer is cleared on unmount.
- ErrorState: recovery actions, plus collapsed technical details and a
  reference, rendered as bounded inert text.
- Skeleton / SkeletonGroup: an aria-busy region labelled once, with
  every animation stopped under prefers-reduced-motion.
- PageHeader `back` (link or button) above the page's one h1.
- Dialog: the focus trap ADR 0039 deferred, plus focus restoration and
  an accessible description. Escape is now handled on the dialog
  itself, so a drawer it was opened from stays open.

The shell validates Back targets (parseReturnTarget, dashboardBack):
only a canonical Dashboard page on this origin is accepted, otherwise
Back goes to the page's parent. It never uses history.back().

Biome's scope-aware suspicious/noAlert is now an error in the root
biome.json. tests/native-dialogs.test.ts proves it catches real
dialogs and not lookalikes, and scans for the forms the rule misses.
It supersedes the shell-only regex test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:24:07 -05:00

260 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
Badge,
Button,
Checkbox,
Dialog,
EkklesiaUiStyles,
EmptyState,
ErrorState,
Field,
IconButton,
List,
ListItem,
LoadingState,
Panel,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
TextInput,
Toast,
ToastStack,
} from "../src/index.js";
declare global {
// eslint-disable-next-line no-var
var IS_REACT_ACT_ENVIRONMENT: boolean | undefined;
}
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
function render(node: React.ReactNode): void {
act(() => root.render(node));
}
describe("Ekklesia elements", () => {
it("renders a button that does not submit a form by accident", () => {
render(<Button variant="primary">Save</Button>);
const button = container.querySelector("button");
expect(button?.getAttribute("type")).toBe("button");
expect(button?.className).toContain("ek-button--primary");
expect(button?.className).toContain("ek-focusable");
});
it("requires an accessible name on an icon-only button", () => {
render(<IconButton label="Remove slide">×</IconButton>);
expect(container.querySelector("button")?.getAttribute("aria-label")).toBe("Remove slide");
});
it("associates a field's label, hint, and error with its control", () => {
render(
<Field error="Enter a name." hint="Shown on the display." label="Name">
{(control) => <TextInput {...control} />}
</Field>,
);
const label = container.querySelector("label");
const input = container.querySelector("input");
const error = container.querySelector(".ek-field__error");
expect(input?.id).toBeTruthy();
expect(label?.getAttribute("for")).toBe(input?.id);
expect(input?.getAttribute("aria-invalid")).toBe("true");
expect(input?.getAttribute("aria-describedby")?.split(" ")).toContain(error?.id);
expect(error?.getAttribute("role")).toBe("alert");
});
it("renders a select and a labelled checkbox as real form controls", () => {
render(
<>
<Select aria-label="Mode">
<option value="a">A</option>
</Select>
<Checkbox label="Enabled" />
</>,
);
expect(container.querySelector("select")?.className).toContain("ek-control");
expect(container.querySelector('input[type="checkbox"]')).not.toBeNull();
expect(container.querySelector(".ek-checkbox")?.textContent).toContain("Enabled");
});
it("renders a panel with an accessible heading and its actions", () => {
render(
<Panel actions={<Button>New</Button>} description="What this panel is." title="Slides">
<p>Body</p>
</Panel>,
);
expect(container.querySelector("h2")?.textContent).toBe("Slides");
expect(container.querySelector(".ek-panel__description")?.textContent).toBe(
"What this panel is.",
);
expect(container.querySelector(".ek-panel__header button")?.textContent).toBe("New");
});
it("renders a dialog in place, labels it, and dismisses it on Escape", () => {
const onDismiss = vi.fn();
render(
<Dialog onDismiss={onDismiss} open title="Delete slide">
Are you sure?
</Dialog>,
);
const dialog = container.querySelector('[role="dialog"]');
expect(dialog?.getAttribute("aria-modal")).toBe("true");
expect(
container.querySelector(`#${dialog?.getAttribute("aria-labelledby")}`)?.textContent,
).toBe("Delete slide");
// Rendered inside the caller's own subtree, never portalled to document.body: an artifact's
// dialog must not escape its isolated root.
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
// Focus is inside the dialog, which handles Escape itself (ADR 0059).
act(() => {
dialog?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
it("renders nothing for a closed dialog", () => {
render(
<Dialog onDismiss={() => undefined} open={false} title="Hidden">
Body
</Dialog>,
);
expect(container.querySelector('[role="dialog"]')).toBeNull();
});
it("exposes tabs with ARIA roles and arrow-key movement", () => {
const onSelect = vi.fn();
const tabs = [
{ content: <p>One</p>, id: "one", label: "One" },
{ content: <p>Two</p>, id: "two", label: "Two" },
];
render(<Tabs label="Sections" onSelect={onSelect} selectedId="one" tabs={tabs} />);
const tabButtons = [...container.querySelectorAll('[role="tab"]')];
expect(tabButtons.map((tab) => tab.getAttribute("aria-selected"))).toEqual(["true", "false"]);
expect(tabButtons.map((tab) => tab.getAttribute("tabindex"))).toEqual(["0", "-1"]);
expect(container.querySelector('[role="tabpanel"]')?.textContent).toBe("One");
act(() => {
tabButtons[0]?.dispatchEvent(
new KeyboardEvent("keydown", { bubbles: true, key: "ArrowRight" }),
);
});
expect(onSelect).toHaveBeenCalledWith("two");
});
it("renders table and list primitives with header semantics", () => {
render(
<>
<Table label="Slides">
<TableHead>
<TableRow>
<TableCell header>Name</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow selected>
<TableCell>Welcome</TableCell>
</TableRow>
</TableBody>
</Table>
<List label="Recent">
<ListItem>First</ListItem>
</List>
</>,
);
expect(container.querySelector("table")?.getAttribute("aria-label")).toBe("Slides");
expect(container.querySelector("th")?.getAttribute("scope")).toBe("col");
expect(container.querySelector("tbody tr")?.getAttribute("aria-selected")).toBe("true");
expect(container.querySelector(".ek-list__item")?.textContent).toBe("First");
});
it("never signals status with colour alone", () => {
render(
<>
<Badge tone="success">Running</Badge>
<ToastStack>
<Toast title="Saved" tone="success">
Everything went through.
</Toast>
<Toast title="Failed" tone="danger" />
</ToastStack>
</>,
);
expect(container.querySelector(".ek-badge__symbol")?.textContent).toBe("✓");
expect(container.querySelector(".ek-badge__symbol")?.getAttribute("aria-hidden")).toBe("true");
const toasts = [...container.querySelectorAll(".ek-toast")];
expect(toasts[0]?.textContent).toContain("✓");
expect(toasts[0]?.getAttribute("aria-live")).toBe("polite");
expect(toasts[1]?.textContent).toContain("✕");
expect(toasts[1]?.getAttribute("aria-live")).toBe("assertive");
});
it("renders empty, error, and loading states with the right announcements", () => {
render(
<>
<EmptyState description="Add one to begin." title="No slides yet" />
<ErrorState description="Try again." title="This could not load" />
<LoadingState label="Loading slides…" />
</>,
);
expect(container.querySelector(".ek-message")?.textContent).toContain("No slides yet");
expect(container.querySelector('[role="alert"]')?.textContent).toContain("This could not load");
const loading = container.querySelector('[role="status"]');
expect(loading?.getAttribute("aria-live")).toBe("polite");
expect(loading?.textContent).toContain("Loading slides…");
expect(container.querySelector(".ek-spinner")?.getAttribute("aria-hidden")).toBe("true");
});
it("puts its stylesheet in whichever root it is rendered into", () => {
const host = document.createElement("div");
container.appendChild(host);
const shadow = host.attachShadow({ mode: "open" });
const shadowRoot = createRoot(shadow);
act(() =>
shadowRoot.render(
<>
<EkklesiaUiStyles />
<Button>Inside</Button>
</>,
),
);
expect(shadow.querySelector("style[data-ekklesia-ui]")?.textContent).toContain(".ek-button");
// The host document never receives the artifact's copy.
expect(document.head.querySelector("style[data-ekklesia-ui]")).toBeNull();
act(() => shadowRoot.unmount());
});
});