CI / verify (pull_request) Successful in 1m12s
CI / infrastructure-smoke (pull_request) Successful in 49s
CI / package-release (pull_request) Successful in 47s
CI / migration-smoke (pull_request) Successful in 2m18s
CI / image-build (pull_request) Successful in 1m3s
CI / deploy-smoke (same-origin) (pull_request) Successful in 49s
CI / package-publication (pull_request) Successful in 54s
CI / deploy-smoke (split) (pull_request) Successful in 3m38s
A second real-iPhone review of the Stage 1 Dashboard found three defects. This fixes them, and ADR 0065 records the decisions. - Settings -> Displays now sets up a screen in one place. A "Set up a new screen" panel shows the platform display page to open on the screen, a Copy link button, and Add display directly beneath it; the step-by-step version is folded into a <details>. The addresses come from a new GET /api/v1/devices/setup, which requires core.devices.manage and lists each running display module's /display/<module-id> page. Nothing names Signage. With no display module installed, the panel says so and offers no Add display. - iOS Safari zoomed into the pairing field because the shared .ek-control rendered at 14px. On phones and touch screens it now renders at max(1rem, 16px). The viewport is untouched, so zoom stays available, and a test guards that. The shared focus trap now returns focus with preventScroll, so closing a dialog no longer moves the page under the phone header. - While the phone drawer is open, the page behind it no longer scrolls. The root's overflow is hidden, a non-passive touchmove listener cancels drags outside the drawer's list and overscroll past its ends, and root overscroll is turned off. The list itself still scrolls. Closing restores the exact scroll position unless another page opened. Co-Authored-By: Claude Opus 5.5 <[email protected]>
812 lines
28 KiB
TypeScript
812 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("returns focus without scrolling the page (ADR 0065)", () => {
|
|
openHarness({ onCancel: vi.fn() });
|
|
const opener = byTestId("opener");
|
|
const focus = vi.spyOn(opener, "focus");
|
|
|
|
press(dialog(), "Escape");
|
|
|
|
// The opener was on screen when it opened the dialog; scrolling it into view again would move a
|
|
// page with a sticky header under the person who just closed the dialog.
|
|
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
|
expect(document.activeElement).toBe(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);
|
|
});
|
|
});
|