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(`[data-testid="${id}"]`); if (!element) { throw new Error(`missing ${id}`); } return element; } function dialog(): HTMLElement { const element = container.querySelector('[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; }): ReactElement { const [open, setOpen] = useState(false); return (
{ props.onCancel?.(); setOpen(false); }} onConfirm={() => { props.onConfirm?.(); setOpen(false); }} open={open} title="Delete Foyer-1?" {...props.dialog} />
); } function openHarness(options: Parameters[0] = {}): void { render(); 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(); 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.
{ if (event.key === "Escape") { outer(); } }} >
, ); 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 ( <> setOpen(false)} type="button"> Done } description="Details about the dialog." onDismiss={() => setOpen(false)} open={open} title="Information" /> ); } render(); 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(); 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( { api = value; }} /> , ); 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( { api = value; }} /> , ); 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()).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(); 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( , ); 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( Open storage settings } 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 = `\u0007\u001b[31m${"x".repeat(5000)}`; render(); const pre = container.querySelector("pre") as HTMLElement; expect(container.querySelector("img")).toBeNull(); expect(container.querySelector("script")).toBeNull(); expect(pre.textContent?.startsWith('')).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(); 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( , ); 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(Add display} 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( , ); 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(); 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); }); });