Files
Ekklesia/packages/ui/tests/interaction.test.tsx
ekklesiaandClaude Opus 5.5 d42b64a093
CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m20s
CI / image-build (pull_request) Successful in 1m5s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m38s
feat(theme): Light/Dark/System, reduced motion, and one radius scale
The theme foundation DESIGN.md requires (ADR 0060):

- A design token may carry a `dark` value and a `reduced` value. The
  token stylesheet selects them in CSS alone: dark under
  prefers-color-scheme unless the host chose light, or when it chose
  dark; motion durations zero under prefers-reduced-motion unless it
  allowed motion, or when it chose reduce. `color-scheme` travels with
  the tokens. System is live, an explicit choice wins, and every module
  root follows through custom-property inheritance, with no module
  contract change.
- @ekklesia/design-tokens adds a small appearance API: system/light/dark
  and system/reduce/allow, parsers for untrusted stored values,
  resolvers, and a controller that applies the attributes immediately.
  The shell applies it before React renders. That is the hook the
  account preferences work will feed; nothing is persisted. The
  Dashboard page declares `color-scheme: light dark` so a dark system
  never flashes white. Displays stay light.
- A designed dark theme: teal-tinted charcoal, never pure black or
  white, the same teal accent.
- WCAG AA contrast is tested for 41 element pairings in both schemes.
  Two light pairings failed and are fixed visibly: text.muted darkens,
  and form controls get a new border.control (3:1).
  background.placeholder keeps skeletons visible in dark.
- Motion tokens (duration-short, duration-ambient) replace literal
  durations and every per-element reduced-motion rule. radius.scale
  multiplies sm/md/lg, unchanged at its default of 1.
- The sign-in card and module loading/error text move from literal
  colours to tokens. Guards keep raw colours and literal radii out of
  Dashboard product source and @ekklesia/ui.
- Fixes ConfirmationDialog's phrase input, which used a class that
  does not exist (`ek-input`) and so was unstyled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:00:39 -05:00

799 lines
28 KiB
TypeScript

import { act, type ReactElement, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { matchesRequiredPhrase } from "../src/confirmation.js";
import { ERROR_DETAILS_MAX_LENGTH, errorDetailsText } from "../src/feedback.js";
import {
ConfirmationDialog,
type ConfirmationDialogProps,
Dialog,
ErrorState,
PageHeader,
Skeleton,
SkeletonGroup,
type ToastApi,
ToastProvider,
useToasts,
} from "../src/index.js";
import { EKKLESIA_UI_STYLESHEET } from "../src/styles.js";
import { TOAST_MINIMUM_DURATION_MS, toastDuration } from "../src/toasts.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();
vi.useRealTimers();
});
function render(node: ReactElement): void {
act(() => root.render(node));
}
function byTestId(id: string): HTMLElement {
const element = container.querySelector<HTMLElement>(`[data-testid="${id}"]`);
if (!element) {
throw new Error(`missing ${id}`);
}
return element;
}
function dialog(): HTMLElement {
const element = container.querySelector<HTMLElement>('[role="dialog"]');
if (!element) {
throw new Error("no dialog");
}
return element;
}
function buttonNamed(name: string, scope: ParentNode = container): HTMLButtonElement {
const button = [...scope.querySelectorAll("button")].find(
(candidate) => (candidate.getAttribute("aria-label") ?? candidate.textContent) === name,
);
if (!button) {
throw new Error(`no button ${name}`);
}
return button;
}
function press(element: Element, key: string, init: KeyboardEventInit = {}): KeyboardEvent {
const event = new KeyboardEvent("keydown", { bubbles: true, cancelable: true, key, ...init });
act(() => {
element.dispatchEvent(event);
});
return event;
}
function type(input: HTMLInputElement, value: string): void {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
act(() => {
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
});
}
/** A page with a control that opens a confirmation, so opening, focus return, and the background
* can all be observed. */
function ConfirmHarness(props: {
readonly onConfirm?: () => void;
readonly onCancel?: () => void;
readonly dialog?: Partial<ConfirmationDialogProps>;
}): ReactElement {
const [open, setOpen] = useState(false);
return (
<div>
<button data-testid="background" type="button">
Something behind
</button>
<button data-testid="opener" onClick={() => setOpen(true)} type="button">
Delete display
</button>
<ConfirmationDialog
confirmLabel="Delete display"
description="Foyer-1 stops showing content and must be paired again to come back."
onCancel={() => {
props.onCancel?.();
setOpen(false);
}}
onConfirm={() => {
props.onConfirm?.();
setOpen(false);
}}
open={open}
title="Delete Foyer-1?"
{...props.dialog}
/>
</div>
);
}
function openHarness(options: Parameters<typeof ConfirmHarness>[0] = {}): void {
render(<ConfirmHarness {...options} />);
const opener = byTestId("opener");
act(() => opener.focus());
act(() => opener.click());
}
describe("ConfirmationDialog", () => {
it("is closed until asked, and opens as a named, described modal dialog", () => {
render(<ConfirmHarness />);
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => byTestId("opener").click());
const element = dialog();
expect(element.getAttribute("aria-modal")).toBe("true");
expect(
container.querySelector(`#${element.getAttribute("aria-labelledby")}`)?.textContent,
).toBe("Delete Foyer-1?");
expect(
container.querySelector(`#${element.getAttribute("aria-describedby")}`)?.textContent,
).toBe("Foyer-1 stops showing content and must be paired again to come back.");
});
it("confirms with its named action, and cancels with Cancel, without the other firing", () => {
const onConfirm = vi.fn();
const onCancel = vi.fn();
openHarness({ onCancel, onConfirm });
act(() => buttonNamed("Cancel", dialog()).click());
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
expect(container.querySelector('[role="dialog"]')).toBeNull();
act(() => byTestId("opener").click());
act(() => buttonNamed("Delete display", dialog()).click());
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("starts a simple confirmation on its action, ready for Enter", () => {
openHarness();
expect(document.activeElement?.textContent).toBe("Delete display");
expect(document.activeElement?.className).toContain("ek-button--primary");
});
it("marks a destructive action by symbol and frame as well as colour, and starts on Cancel", () => {
openHarness({ dialog: { tone: "destructive" } });
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
button.textContent?.includes("Delete display"),
);
expect(confirm?.className).toContain("ek-button--danger");
expect(confirm?.querySelector(".ek-confirmation__symbol")?.getAttribute("aria-hidden")).toBe(
"true",
);
expect(dialog().className).toContain("ek-confirmation--destructive");
expect(dialog().querySelector("h2 .ek-confirmation__symbol")).not.toBeNull();
// The safe choice has focus, so a stray Enter cancels rather than destroys.
expect(document.activeElement?.textContent).toBe("Cancel");
expect(EKKLESIA_UI_STYLESHEET).toContain(".ek-confirmation__symbol::before");
});
it("requires the exact phrase before a typed confirmation can be confirmed", () => {
const onConfirm = vi.fn();
openHarness({ dialog: { requiredPhrase: "Foyer-1", tone: "destructive" }, onConfirm });
const input = dialog().querySelector("input") as HTMLInputElement;
const confirm = [...dialog().querySelectorAll("button")].find((button) =>
button.textContent?.includes("Delete display"),
) as HTMLButtonElement;
// Focus starts at the field, which is labelled with the phrase it wants.
expect(document.activeElement).toBe(input);
expect(input.labels?.[0]?.textContent).toBe("Type Foyer-1 to confirm");
expect(input.getAttribute("autocapitalize")).toBe("off");
expect(input.getAttribute("spellcheck")).toBe("false");
expect(confirm.disabled).toBe(true);
type(input, "foyer-1");
expect(confirm.disabled).toBe(true);
expect(input.getAttribute("aria-invalid")).toBe("true");
act(() => {
(input.form as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
});
expect(onConfirm).not.toHaveBeenCalled();
type(input, " Foyer-1 ");
expect(confirm.disabled).toBe(false);
expect(input.getAttribute("aria-invalid")).toBeNull();
// Enter in the field submits once it matches.
act(() => {
(input.form as HTMLFormElement).dispatchEvent(
new Event("submit", { bubbles: true, cancelable: true }),
);
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("forgets a typed phrase when it closes", () => {
openHarness({ dialog: { requiredPhrase: "DELETE" } });
type(dialog().querySelector("input") as HTMLInputElement, "DELETE");
act(() => buttonNamed("Cancel", dialog()).click());
act(() => byTestId("opener").click());
expect((dialog().querySelector("input") as HTMLInputElement).value).toBe("");
});
it("matches a phrase exactly, forgiving only surrounding whitespace and Unicode composition", () => {
expect(matchesRequiredPhrase("DELETE", "DELETE")).toBe(true);
expect(matchesRequiredPhrase(" DELETE\n", "DELETE")).toBe(true);
expect(matchesRequiredPhrase("delete", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("DELET", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("DELETE DELETE", "DELETE")).toBe(false);
expect(matchesRequiredPhrase("Café", "Café")).toBe(true);
});
it("closes on Escape and returns focus to the control that opened it", () => {
const onCancel = vi.fn();
openHarness({ onCancel });
const event = press(dialog(), "Escape");
expect(event.defaultPrevented).toBe(true);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(container.querySelector('[role="dialog"]')).toBeNull();
expect(document.activeElement).toBe(byTestId("opener"));
});
it("does not close on Escape or Cancel while the confirmed action is in progress", () => {
const onCancel = vi.fn();
openHarness({ dialog: { busy: true }, onCancel });
press(dialog(), "Escape");
expect(onCancel).not.toHaveBeenCalled();
expect(buttonNamed("Cancel", dialog()).disabled).toBe(true);
expect(dialog().querySelector("form")?.getAttribute("aria-busy")).toBe("true");
});
it("keeps Tab and Shift+Tab inside the dialog", () => {
openHarness({ dialog: { requiredPhrase: "DELETE" } });
const input = dialog().querySelector("input") as HTMLInputElement;
type(input, "DELETE");
const cancel = buttonNamed("Cancel", dialog());
const confirm = buttonNamed("Delete display", dialog());
act(() => confirm.focus());
expect(press(confirm, "Tab").defaultPrevented).toBe(true);
expect(document.activeElement).toBe(input);
expect(press(input, "Tab", { shiftKey: true }).defaultPrevented).toBe(true);
expect(document.activeElement).toBe(confirm);
// Between inner controls, the browser moves focus itself.
act(() => input.focus());
expect(press(input, "Tab").defaultPrevented).toBe(false);
expect(cancel).toBeTruthy();
});
it("blocks the page behind: the scrim takes the pointer and nothing behind is in the tab loop", () => {
openHarness();
const scrim = dialog().parentElement as HTMLElement;
expect(scrim.className).toBe("ek-dialog__scrim");
const pointer = new MouseEvent("pointerdown", { bubbles: true, cancelable: true });
act(() => {
scrim.dispatchEvent(pointer);
});
expect(pointer.defaultPrevented).toBe(true);
expect(container.querySelector('[role="dialog"]')).not.toBeNull();
expect(dialog().contains(byTestId("background"))).toBe(false);
// Tab from the last control wraps inside rather than reaching "Something behind".
const last = buttonNamed("Delete display", dialog());
act(() => last.focus());
press(last, "Tab");
expect(document.activeElement).not.toBe(byTestId("background"));
expect(dialog().contains(document.activeElement)).toBe(true);
});
it("stops Escape at the dialog, so a drawer or menu around it stays open", () => {
const outer = vi.fn();
render(
// biome-ignore lint/a11y/noStaticElementInteractions: a test stand-in for a drawer that closes on Escape.
<div
onKeyDown={(event) => {
if (event.key === "Escape") {
outer();
}
}}
>
<ConfirmHarness />
</div>,
);
act(() => byTestId("opener").click());
press(dialog(), "Escape");
expect(outer).not.toHaveBeenCalled();
});
it("is operable with the keyboard alone", () => {
const onConfirm = vi.fn();
openHarness({ dialog: { requiredPhrase: "DELETE" }, onConfirm });
const input = document.activeElement as HTMLInputElement;
type(input, "DELETE");
act(() => {
(input.form as HTMLFormElement).requestSubmit();
});
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
describe("Dialog", () => {
it("traps focus, describes itself, and returns focus when it closes", () => {
function Harness(): ReactElement {
const [open, setOpen] = useState(false);
return (
<>
<button data-testid="open" onClick={() => setOpen(true)} type="button">
Open
</button>
<Dialog
actions={
<button onClick={() => setOpen(false)} type="button">
Done
</button>
}
description="Details about the dialog."
onDismiss={() => setOpen(false)}
open={open}
title="Information"
/>
</>
);
}
render(<Harness />);
act(() => byTestId("open").focus());
act(() => byTestId("open").click());
expect(document.activeElement).toBe(dialog());
expect(
container.querySelector(`#${dialog().getAttribute("aria-describedby")}`)?.textContent,
).toBe("Details about the dialog.");
const done = buttonNamed("Done");
act(() => done.focus());
press(done, "Tab");
expect(document.activeElement).toBe(done);
act(() => done.click());
expect(document.activeElement).toBe(byTestId("open"));
});
it("ignores Escape when the caller says dismissing is not safe", () => {
const onDismiss = vi.fn();
render(<Dialog dismissible={false} onDismiss={onDismiss} open title="Saving" />);
press(dialog(), "Escape");
expect(onDismiss).not.toHaveBeenCalled();
});
});
function ToastHarness(props: { readonly onApi: (api: ToastApi) => void }): null {
props.onApi(useToasts());
return null;
}
function renderToasts(options: { readonly maxVisible?: number } = {}): ToastApi {
let api: ToastApi | undefined;
render(
<ToastProvider {...options}>
<ToastHarness
onApi={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
if (!api) {
throw new Error("no toast api");
}
return api;
}
function toastTitles(): string[] {
return [...container.querySelectorAll(".ek-toast .ek-toast__title")].map(
(title) => title.textContent?.replace(/^\S+\s/u, "") ?? "",
);
}
describe("toasts", () => {
it("renders a top-center region whose live regions exist before any toast arrives", () => {
renderToasts();
const region = container.querySelector(".ek-toast-region");
expect(region?.tagName).toBe("SECTION");
expect(region?.getAttribute("aria-label")).toBe("Notifications");
expect(region?.querySelector('[role="status"][aria-live="polite"]')).not.toBeNull();
expect(region?.querySelector('[role="alert"][aria-live="assertive"]')).not.toBeNull();
expect(EKKLESIA_UI_STYLESHEET).toMatch(/\.ek-toast-region \{[^}]*position: fixed;[^}]*top:/su);
});
it("shows each severity with its symbol, errors assertively and the rest politely", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
api.show({ title: "Heads up", tone: "info" });
api.show({ title: "Storage is nearly full", tone: "warning" });
api.show({ title: "The display could not be revoked", tone: "danger" });
});
const polite = container.querySelector('[aria-live="polite"]') as HTMLElement;
const assertive = container.querySelector('[aria-live="assertive"]') as HTMLElement;
expect(assertive.textContent).toContain("The display could not be revoked");
expect(polite.textContent).not.toContain("could not be revoked");
for (const tone of ["success", "info", "warning", "danger"]) {
const toast = container.querySelector(`.ek-toast--${tone}`);
expect(toast, tone).not.toBeNull();
// Its own role would nest one live region in another.
expect(toast?.getAttribute("role"), tone).toBeNull();
expect(toast?.querySelector('[aria-hidden="true"]')?.textContent?.trim(), tone).not.toBe("");
}
});
it("dismisses a routine toast on its own once there has been time to read it", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
});
act(() => {
vi.advanceTimersByTime(TOAST_MINIMUM_DURATION_MS - 1);
});
expect(toastTitles()).toEqual(["Saved"]);
act(() => {
vi.advanceTimersByTime(5000);
});
expect(toastTitles()).toEqual([]);
});
it("never times a toast out in under three seconds, and gives longer messages longer", () => {
const tooShort = toastDuration({ duration: 500, title: "Saved" }) as number;
expect(tooShort).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
expect(tooShort).toBeLessThan(TOAST_MINIMUM_DURATION_MS + 1000);
const short = toastDuration({ title: "Saved", tone: "success" }) as number;
const long = toastDuration({
description:
"The timeline was published to every screen in the Sanctuary and Foyer groups, and the next slide starts in a minute.",
title: "Published",
tone: "success",
}) as number;
expect(short).toBeGreaterThanOrEqual(TOAST_MINIMUM_DURATION_MS);
expect(long).toBeGreaterThan(short);
expect(toastDuration({ title: "Careful", tone: "warning" })).toBeGreaterThan(short);
});
it("keeps an error until it is dismissed, unless its caller gives it a duration", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "The display could not be revoked", tone: "danger" });
api.show({ duration: 4000, title: "Enter a name", tone: "danger" });
});
act(() => {
vi.advanceTimersByTime(60_000);
});
expect(toastTitles()).toEqual(["The display could not be revoked"]);
expect(toastDuration({ title: "x", tone: "danger" })).toBe("persistent");
});
it("dismisses on its named dismiss control", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "The display could not be revoked", tone: "danger" });
});
act(() => buttonNamed("Dismiss notification: The display could not be revoked").click());
expect(toastTitles()).toEqual([]);
});
it("dismisses programmatically by the id it returned", () => {
vi.useFakeTimers();
const api = renderToasts();
let id = "";
act(() => {
id = api.show({ title: "Uploading", tone: "info" });
});
expect(id).toMatch(/^ek-toast-/u);
act(() => api.dismiss(id));
expect(toastTitles()).toEqual([]);
});
it("dismisses the focused toast on Escape and returns focus where it was", () => {
vi.useFakeTimers();
let api: ToastApi | undefined;
render(
<ToastProvider>
<button data-testid="work" type="button">
Work
</button>
<ToastHarness
onApi={(value) => {
api = value;
}}
/>
</ToastProvider>,
);
act(() => {
api?.show({ title: "Saved", tone: "success" });
});
const work = byTestId("work");
act(() => work.focus());
const dismiss = buttonNamed("Dismiss notification: Saved");
act(() => {
dismiss.dispatchEvent(new FocusEvent("focusin", { bubbles: true, relatedTarget: work }));
dismiss.focus();
});
press(dismiss, "Escape");
expect(toastTitles()).toEqual([]);
expect(document.activeElement).toBe(work);
});
it("stacks newest first, refreshes a duplicate, and never lets routine toasts displace an error", () => {
vi.useFakeTimers();
const api = renderToasts({ maxVisible: 2 });
act(() => {
api.show({ title: "Storage is unavailable", tone: "danger" });
api.show({ title: "One", tone: "info" });
api.show({ title: "Two", tone: "info" });
api.show({ title: "Three", tone: "info" });
api.show({ title: "Three", tone: "info" });
});
// Errors are listed first; then routine toasts, newest first, at most two of them.
expect(toastTitles()).toEqual(["Storage is unavailable", "Three", "Two"]);
expect(container.querySelectorAll(".ek-toast")).toHaveLength(3);
});
it("pauses while the pointer is over it, so a toast being read does not vanish", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "Saved", tone: "success" });
});
const region = container.querySelector(".ek-toast-region") as HTMLElement;
act(() => {
region.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(toastTitles()).toEqual(["Saved"]);
act(() => {
region.dispatchEvent(new MouseEvent("mouseout", { bubbles: true }));
});
act(() => {
vi.advanceTimersByTime(30_000);
});
expect(toastTitles()).toEqual([]);
});
it("clears every timer when the provider goes away", () => {
vi.useFakeTimers();
const api = renderToasts();
act(() => {
api.show({ title: "One", tone: "info" });
api.show({ title: "Two", tone: "warning" });
});
expect(vi.getTimerCount()).toBe(2);
act(() => root.unmount());
expect(vi.getTimerCount()).toBe(0);
root = createRoot(container);
});
it("is per React tree: outside a provider there is no toast API to reach for", () => {
function Orphan(): null {
useToasts();
return null;
}
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
expect(() => render(<Orphan />)).toThrow("useToasts must be used inside a ToastProvider.");
consoleError.mockRestore();
});
});
describe("ErrorState", () => {
it("leads with a plain message, and shows no disclosure when there is no detail", () => {
render(<ErrorState description="Check the connection, then retry." title="Could not load" />);
const element = container.querySelector(".ek-message--error") as HTMLElement;
expect(element.getAttribute("role")).toBe("alert");
expect(element.querySelector(".ek-message__title")?.textContent).toBe("Could not load");
expect(element.querySelector("details")).toBeNull();
});
it("keeps technical detail and a reference collapsed behind a disclosure", () => {
render(
<ErrorState
details={"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s."}
reference="req-7f3a"
title="The image could not be uploaded"
/>,
);
const details = container.querySelector("details") as HTMLDetailsElement;
expect(details.open).toBe(false);
expect(details.querySelector("summary")?.textContent).toBe("Technical details");
expect(details.querySelector("pre")?.textContent).toBe(
"STORAGE_PROVIDER_UNAVAILABLE\nThe object store did not answer within 5 s.",
);
expect(details.querySelector("code")?.textContent).toBe("req-7f3a");
});
it("offers recovery and remedy actions beside the message", () => {
const retry = vi.fn();
render(
<ErrorState
actions={
<>
<button onClick={retry} type="button">
Retry
</button>
<a href="/settings">Open storage settings</a>
</>
}
title="Could not load"
/>,
);
act(() => buttonNamed("Retry").click());
expect(retry).toHaveBeenCalledTimes(1);
expect(container.querySelector(".ek-message__actions a")?.getAttribute("href")).toBe(
"/settings",
);
});
it("renders an arbitrary server string as inert, bounded text", () => {
const hostile = `<img src=x onerror="alert(1)"><script>steal()</script>\u0007\u001b[31m${"x".repeat(5000)}`;
render(<ErrorState details={hostile} title="Something went wrong" />);
const pre = container.querySelector("pre") as HTMLElement;
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("script")).toBeNull();
expect(pre.textContent?.startsWith('<img src=x onerror="alert(1)">')).toBe(true);
expect(pre.textContent).not.toContain("\u0007");
expect(pre.textContent?.length).toBe(ERROR_DETAILS_MAX_LENGTH + 1);
expect(errorDetailsText(" line one\n\tline two ")).toBe("line one\n\tline two");
});
it("can be part of a page's initial content without being announced", () => {
render(<ErrorState announce={false} title="Not available" />);
expect(container.querySelector(".ek-message--error")?.getAttribute("role")).toBeNull();
});
});
describe("Skeleton", () => {
it("is a busy, labelled region announced once, with every placeholder hidden from it", () => {
render(
<SkeletonGroup label="Loading displays">
<Skeleton shape="circle" width="2rem" />
<Skeleton width="60%" />
<Skeleton shape="block" />
</SkeletonGroup>,
);
const group = container.querySelector(".ek-skeleton-group") as HTMLElement;
expect(group.getAttribute("role")).toBe("status");
expect(group.getAttribute("aria-busy")).toBe("true");
expect(container.querySelector(`#${group.getAttribute("aria-labelledby")}`)?.textContent).toBe(
"Loading displays",
);
const placeholders = [...group.querySelectorAll(".ek-skeleton")] as HTMLElement[];
expect(placeholders).toHaveLength(3);
for (const placeholder of placeholders) {
expect(placeholder.getAttribute("aria-hidden")).toBe("true");
expect(placeholder.textContent).toBe("");
}
expect(placeholders[0]?.style.height).toBe("2rem");
// No percentage, no progress: there is none to report.
expect(group.textContent).toBe("Loading displays");
});
it("stops moving for a person who asked for reduced motion, through the shared motion tokens", () => {
// The pulse lasts one ambient cycle, which the token stylesheet zeroes when motion is reduced —
// by the system or by an explicit preference (ADR 0060) — so the element needs no rule of its own.
expect(EKKLESIA_UI_STYLESHEET).toMatch(
/\.ek-skeleton \{[^}]*animation: ek-skeleton-pulse var\(--ekklesia-motion-duration-ambient\)/su,
);
expect(EKKLESIA_UI_STYLESHEET).not.toContain("prefers-reduced-motion");
});
});
describe("PageHeader", () => {
it("names the page with its one h1 and keeps page actions beside it", () => {
render(<PageHeader actions={<button type="button">Add display</button>} title="Displays" />);
expect(container.querySelector("h1")?.textContent).toBe("Displays");
expect(container.querySelector(".ek-page-header__actions button")?.textContent).toBe(
"Add display",
);
expect(container.querySelector(".ek-page-header__back")).toBeNull();
});
it("puts a Back link above the title when Back is a real place", () => {
const onBack = vi.fn();
render(
<PageHeader
back={{ href: "/signage", label: "Back to Signage", onBack }}
title="Add display"
/>,
);
const back = container.querySelector(".ek-page-header__back") as HTMLAnchorElement;
expect(back.tagName).toBe("A");
expect(back.getAttribute("href")).toBe("/signage");
expect(back.textContent).toBe("←Back to Signage");
expect(back.querySelector('[aria-hidden="true"]')?.textContent).toBe("←");
const copy = back.parentElement as HTMLElement;
expect([...copy.children].indexOf(back)).toBeLessThan(
[...copy.children].indexOf(copy.querySelector("h1") as Element),
);
const click = new MouseEvent("click", { bubbles: true, button: 0, cancelable: true });
act(() => {
back.dispatchEvent(click);
});
expect(click.defaultPrevented).toBe(true);
expect(onBack).toHaveBeenCalledTimes(1);
const newTab = new MouseEvent("click", {
bubbles: true,
button: 0,
cancelable: true,
ctrlKey: true,
});
act(() => {
back.dispatchEvent(newTab);
});
expect(newTab.defaultPrevented).toBe(false);
expect(onBack).toHaveBeenCalledTimes(1);
});
it("renders Back as a button when it has no address", () => {
const onBack = vi.fn();
render(<PageHeader back={{ label: "Back", onBack }} title="Details" />);
const back = container.querySelector(".ek-page-header__back") as HTMLButtonElement;
expect(back.tagName).toBe("BUTTON");
expect(back.type).toBe("button");
act(() => back.click());
expect(onBack).toHaveBeenCalledTimes(1);
});
});