feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives
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
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]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
651efa8829
commit
5a7d44781b
@@ -0,0 +1,797 @@
|
||||
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", () => {
|
||||
const reduced = EKKLESIA_UI_STYLESHEET.slice(
|
||||
EKKLESIA_UI_STYLESHEET.indexOf("@media (prefers-reduced-motion: reduce)"),
|
||||
);
|
||||
expect(reduced).toMatch(/\.ek-skeleton[,\s]/u);
|
||||
expect(reduced).toContain("animation: none");
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user