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
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]>
260 lines
8.4 KiB
TypeScript
260 lines
8.4 KiB
TypeScript
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());
|
||
});
|
||
});
|