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
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]>
799 lines
28 KiB
TypeScript
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);
|
|
});
|
|
});
|