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

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:
ekklesia
2026-10-14 18:24:07 -05:00
co-authored by Claude Opus 5.5
parent 651efa8829
commit 5a7d44781b
23 changed files with 2538 additions and 59 deletions
+191
View File
@@ -0,0 +1,191 @@
import {
type FormEvent,
type ReactElement,
type ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react";
import { Button } from "./controls.js";
import { Dialog } from "./surfaces.js";
export type ConfirmationTone = "default" | "destructive";
export interface ConfirmationDialogProps {
readonly open: boolean;
/** The question, as the dialog's accessible name: "Delete Foyer-1?" */
readonly title: ReactNode;
/** What will happen, announced with the name: name the resource and the consequence. */
readonly description?: ReactNode;
/** Anything more the person should weigh — whether it can be undone, what else it affects. */
readonly children?: ReactNode;
/** The confirming action, named for what it does: "Delete display", never "OK". */
readonly confirmLabel: string;
readonly cancelLabel?: string;
/**
* `destructive` for an action whose effect matters. It is marked by a symbol and wording as well
* as by colour, and focus starts on Cancel rather than on the action. The consuming feature
* decides the severity; this element never upgrades or downgrades it.
*/
readonly tone?: ConfirmationTone;
/**
* Typed confirmation, for the most serious actions (DESIGN.md §8.2): the exact phrase the person
* must type before Confirm is enabled — `DELETE`, or the resource's own name. Leave it unset for
* every action that does not need it.
*/
readonly requiredPhrase?: string;
/** The label of the typed-confirmation field. Default: "Type <phrase> to confirm". */
readonly requiredPhraseLabel?: ReactNode;
/**
* The confirmed action is in progress: both actions are disabled, Escape does nothing, and the
* dialog is marked busy, so the person cannot leave it half-done. The caller closes it when done.
*/
readonly busy?: boolean;
readonly onConfirm: () => void;
readonly onCancel: () => void;
readonly "data-testid"?: string;
}
/**
* Whether a typed value matches a required phrase.
*
* Exact and case-sensitive, because typing it is meant to be deliberate — `delete` is not `DELETE`.
* Only what a phone keyboard adds without being asked is forgiven: whitespace around the value, and
* a different Unicode composition of the same characters (NFC).
*/
export function matchesRequiredPhrase(value: string, phrase: string): boolean {
return value.trim().normalize("NFC") === phrase.trim().normalize("NFC");
}
/**
* The product-owned confirmation every Ekklesia surface uses instead of `window.confirm` (DESIGN.md
* §8, ADR 0059).
*
* Three severities from one element, chosen by the caller: a simple confirmation; a destructive one;
* and a destructive one that also requires a typed phrase. It is a {@link Dialog}: modal, named by
* its title and described by its description, with focus trapped inside and returned to the control
* that opened it. Cancel is always present and always safe. Enter confirms only once a required
* phrase matches.
*/
export function ConfirmationDialog(props: ConfirmationDialogProps): ReactElement | null {
const tone = props.tone ?? "default";
const busy = props.busy ?? false;
const phraseId = useId();
const phraseHintId = useId();
const [typed, setTyped] = useState("");
const phraseRef = useRef<HTMLInputElement | null>(null);
const cancelRef = useRef<HTMLButtonElement | null>(null);
const confirmRef = useRef<HTMLButtonElement | null>(null);
const requiresPhrase = props.requiredPhrase !== undefined;
const phraseMatches =
props.requiredPhrase === undefined || matchesRequiredPhrase(typed, props.requiredPhrase);
const canConfirm = phraseMatches && !busy;
// A phrase typed for one resource must never carry over to the next time the dialog opens.
useEffect(() => {
if (!props.open) {
setTyped("");
}
}, [props.open]);
// A required phrase starts at its field; a destructive action starts on the safe choice; a simple
// confirmation starts on the action itself.
const initialFocusRef = requiresPhrase
? phraseRef
: tone === "destructive"
? cancelRef
: confirmRef;
const cancel = (): void => {
if (busy) {
return;
}
setTyped("");
props.onCancel();
};
const submit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
if (canConfirm) {
props.onConfirm();
}
};
return (
<Dialog
{...(tone === "destructive" ? { className: "ek-confirmation--destructive" } : {})}
{...(props["data-testid"] === undefined ? {} : { "data-testid": props["data-testid"] })}
{...(props.description === undefined ? {} : { description: props.description })}
dismissible={!busy}
initialFocusRef={initialFocusRef}
onDismiss={cancel}
open={props.open}
title={
tone === "destructive" ? (
<>
<span aria-hidden="true" className="ek-confirmation__symbol" />
{props.title}
</>
) : (
props.title
)
}
>
<form aria-busy={busy || undefined} className="ek-confirmation" onSubmit={submit}>
{props.children}
{props.requiredPhrase === undefined ? undefined : (
<div className="ek-field">
<label className="ek-field__label" htmlFor={phraseId}>
{props.requiredPhraseLabel ?? (
<>
Type <strong className="ek-confirmation__phrase">{props.requiredPhrase}</strong>{" "}
to confirm
</>
)}
</label>
<input
aria-describedby={phraseHintId}
aria-invalid={typed.length > 0 && !phraseMatches ? true : undefined}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
className="ek-input ek-focusable"
disabled={busy}
id={phraseId}
onChange={(event) => setTyped(event.currentTarget.value)}
ref={phraseRef}
spellCheck={false}
type="text"
value={typed}
/>
<span aria-live="polite" className="ek-field__hint" id={phraseHintId}>
{phraseMatches
? "Matches. You can confirm."
: typed.length === 0
? "Confirm stays unavailable until this matches exactly."
: "Does not match yet. It must match exactly, including capitals."}
</span>
</div>
)}
<div className="ek-dialog__actions">
<Button disabled={busy} onClick={cancel} ref={cancelRef}>
{props.cancelLabel ?? "Cancel"}
</Button>
<Button
aria-describedby={requiresPhrase && !phraseMatches ? phraseHintId : undefined}
disabled={!canConfirm}
ref={confirmRef}
type="submit"
variant={tone === "destructive" ? "danger" : "primary"}
>
{tone === "destructive" ? (
<span aria-hidden="true" className="ek-confirmation__symbol" />
) : undefined}
{props.confirmLabel}
</Button>
</div>
</form>
</Dialog>
);
}
+173 -20
View File
@@ -1,4 +1,4 @@
import type { ReactElement, ReactNode } from "react";
import { type CSSProperties, type ReactElement, type ReactNode, useId } from "react";
import { classNames } from "./class-names.js";
/**
@@ -48,6 +48,17 @@ export interface ToastProps {
readonly title: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
/**
* Whether this toast is its own live region. Default `true`, for a toast a caller renders on its
* own. A {@link ToastProvider} renders toasts inside live regions it already owns and turns this
* off, so nothing is announced twice.
*/
readonly announce?: boolean;
/** Renders a dismiss control. */
readonly onDismiss?: () => void;
/** The dismiss control's accessible name. Default "Dismiss notification". */
readonly dismissLabel?: string;
readonly "data-toast-id"?: string;
}
export interface InlineNoticeProps {
@@ -79,27 +90,40 @@ export function InlineNotice(props: InlineNoticeProps): ReactElement {
}
/**
* One notification's presentation — and only its presentation.
* One notification's presentation.
*
* There is deliberately no toast *manager*: no queue, no timeout, no global store, no imperative
* `toast()` function. Queuing policy belongs to whichever application is rendering, and a
* package-level singleton would be shared mutable state reaching across every isolated artifact on
* a page. A caller renders these where it wants them, for as long as it wants them.
* The queue, timing, and placement belong to {@link ToastProvider} (ADR 0059), which is per React
* tree rather than a package singleton: a module-level store would be shared mutable state reaching
* across every isolated artifact on a page. A caller may still render one of these on its own.
*/
export function Toast(props: ToastProps): ReactElement {
const tone = props.tone ?? "info";
const announce = props.announce ?? true;
return (
<div
aria-live={tone === "danger" ? "assertive" : "polite"}
aria-live={announce ? (tone === "danger" ? "assertive" : "polite") : undefined}
className={classNames("ek-toast", `ek-toast--${tone}`, props.className)}
role="status"
data-toast-id={props["data-toast-id"]}
role={announce ? "status" : undefined}
>
<div className="ek-toast__title">
<span aria-hidden="true">{statusSymbols[tone]} </span>
{props.title}
<div className="ek-toast__content">
<div className="ek-toast__title">
<span aria-hidden="true">{statusSymbols[tone]} </span>
{props.title}
</div>
{props.children === undefined ? undefined : (
<div className="ek-toast__body">{props.children}</div>
)}
</div>
{props.children === undefined ? undefined : (
<div className="ek-toast__body">{props.children}</div>
{props.onDismiss === undefined ? undefined : (
<button
aria-label={props.dismissLabel ?? "Dismiss notification"}
className="ek-button ek-button--quiet ek-button--sm ek-focusable ek-toast__dismiss"
onClick={props.onDismiss}
type="button"
>
<span aria-hidden="true">✕</span>
</button>
)}
</div>
);
@@ -143,28 +167,157 @@ export function EmptyState(props: EmptyStateProps): ReactElement {
}
export interface ErrorStateProps {
/** What failed, in plain words: "The displays could not be loaded." */
readonly title: ReactNode;
/** What the person can do about it, and whether trying again makes sense. */
readonly description?: ReactNode;
/** A retry control, or whatever else the caller can honestly offer. */
/** One recovery control. Kept for existing callers; prefer `actions`. */
readonly action?: ReactNode;
/**
* Recovery and remedy controls — Retry, a link to the setting that fixes it. A caller that knows
* the viewer can administer the problem passes the administrator's remedies here as well; the
* element has no notion of roles.
*/
readonly actions?: ReactNode;
/**
* Technical detail for whoever needs it: an error code, the failing step, a likely cause. Plain
* text, collapsed behind a disclosure, so it never dominates the message. Rendered as text — markup
* in it is shown, never interpreted — with control characters removed and its length bounded.
*/
readonly details?: string;
/** The disclosure's label. Default "Technical details". */
readonly detailsLabel?: string;
/** A diagnostic id that can be quoted to an administrator or found in a log, when it is safe to
* show. Shown inside the details. */
readonly reference?: string;
/**
* Whether the error is announced when it appears (`role="alert"`). Default `true`. Turn it off for
* an error that is part of a page's initial content rather than a change the person caused.
*/
readonly announce?: boolean;
readonly className?: string;
}
/** Technical detail is bounded so an enormous server message cannot bury the page. */
export const ERROR_DETAILS_MAX_LENGTH = 2000;
/**
* The failure a person sees.
* Makes an arbitrary string safe to show as technical detail: strips control characters (keeping
* line breaks and tabs), trims, and bounds its length. React already renders it as text, so markup
* cannot execute; this keeps it legible.
*/
export function errorDetailsText(value: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: removing control characters is the point.
const cleaned = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/gu, "").trim();
return cleaned.length > ERROR_DETAILS_MAX_LENGTH
? `${cleaned.slice(0, ERROR_DETAILS_MAX_LENGTH)}…`
: cleaned;
}
/**
* The failure a person sees (DESIGN.md §7.3): what failed, what they can do, and — only if they want
* it — the technical detail.
*
* Callers pass a fixed, human message. **Never** pass a caught exception, a stack, a URL, a
* filesystem path, or a raw server response: every Ekklesia error boundary is sanitized, and this
* component is where that discipline becomes visible to a user.
* The message is the caller's fixed, human wording. **Never** pass a caught exception or its stack:
* `details` takes a string, chosen deliberately — a sanitized error code, the step that failed —
* and stays collapsed until opened.
*/
export function ErrorState(props: ErrorStateProps): ReactElement {
const details = props.details === undefined ? "" : errorDetailsText(props.details);
const reference = props.reference === undefined ? "" : errorDetailsText(props.reference);
const announce = props.announce ?? true;
return (
<div className={classNames("ek-message", "ek-message--error", props.className)} role="alert">
<div
className={classNames("ek-message", "ek-message--error", props.className)}
role={announce ? "alert" : undefined}
>
<p className="ek-message__title">{props.title}</p>
{props.description === undefined ? undefined : (
<p className="ek-message__body">{props.description}</p>
)}
{props.action}
{props.action === undefined && props.actions === undefined ? undefined : (
<div className="ek-message__actions">
{props.action}
{props.actions}
</div>
)}
{details === "" && reference === "" ? undefined : (
<details className="ek-message__details">
<summary className="ek-focusable">{props.detailsLabel ?? "Technical details"}</summary>
{details === "" ? undefined : <pre className="ek-message__details-text">{details}</pre>}
{reference === "" ? undefined : (
<p className="ek-message__reference">
Reference: <code>{reference}</code>
</p>
)}
</details>
)}
</div>
);
}
export interface SkeletonProps {
/** `text` is one line of text; `block` an image, chart, or card; `circle` an avatar. */
readonly shape?: "text" | "block" | "circle";
/** Any CSS length. Text defaults to the full width; a circle is as wide as it is tall. */
readonly width?: string;
readonly height?: string;
readonly className?: string;
}
/**
* A placeholder in the shape of content that is on its way (DESIGN.md §7.1), for content whose
* shape is known. Decorative: hidden from assistive technology, which hears its
* {@link SkeletonGroup}'s label instead. It pulses gently, and not at all for a person who asked
* their system for reduced motion. There is no progress in it, because there is none to report.
*/
export function Skeleton(props: SkeletonProps): ReactElement {
const shape = props.shape ?? "text";
const style: CSSProperties = {};
if (props.width !== undefined) {
style.width = props.width;
}
if (props.height !== undefined) {
style.height = props.height;
}
if (shape === "circle" && props.width !== undefined && props.height === undefined) {
style.height = props.width;
}
return (
<span
aria-hidden="true"
className={classNames("ek-skeleton", `ek-skeleton--${shape}`, props.className)}
style={style}
/>
);
}
export interface SkeletonGroupProps {
/** What is loading, announced once: "Loading displays". */
readonly label: string;
readonly children?: ReactNode;
readonly className?: string;
}
/**
* The loading region a set of {@link Skeleton}s stands in for. It is `aria-busy` and announces its
* label once, politely, rather than every placeholder; when the real content replaces the group,
* the busy region is gone with it.
*/
export function SkeletonGroup(props: SkeletonGroupProps): ReactElement {
const labelId = useId();
return (
<div
aria-busy="true"
aria-labelledby={labelId}
aria-live="polite"
className={classNames("ek-skeleton-group", props.className)}
role="status"
>
<span className="ek-visually-hidden" id={labelId}>
{props.label}
</span>
{props.children}
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { type KeyboardEvent, type RefObject, useCallback, useEffect, useRef } from "react";
/** Everything a keyboard can land on. Disabled controls and `tabindex="-1"` are skipped. */
const FOCUSABLE = [
"a[href]",
"area[href]",
"button:not([disabled])",
"input:not([disabled]):not([type='hidden'])",
"select:not([disabled])",
"textarea:not([disabled])",
"summary",
"[contenteditable='true']",
"[tabindex]:not([tabindex='-1'])",
].join(", ");
/** The focused element as seen from the container's own root: its shadow root inside an isolated
* module artifact (ADR 0040), where the document would only report the shadow host, or the document. */
function activeWithin(container: HTMLElement): Element | null {
const root = container.getRootNode() as Document | ShadowRoot;
return root.activeElement ?? null;
}
function focusableWithin(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(element) => !element.closest("[inert]"),
);
}
export interface FocusTrapOptions {
/** Where focus starts when the trap activates. Without one, the container itself. */
readonly initialFocus?: RefObject<HTMLElement | null>;
}
/**
* Keeps keyboard focus inside a modal surface while it is open, and puts it back afterwards.
*
* - On activation, remembers what had focus and moves focus to `initialFocus` (or the container).
* - While active, Tab from the last focusable element wraps to the first and Shift+Tab from the
* first wraps to the last, so focus cannot walk out into the page behind.
* - On deactivation or unmount, returns focus to whatever had it before — the control that opened
* the surface — if that control is still in the document.
*
* Returns the `onKeyDown` handler the container must carry. It works inside an isolated module root
* as well as in a document: it only ever looks inside its own container and at the container's own
* root node, never at a global. This is the one focus trap ADR 0039 deferred until the platform needed
* it (ADR 0059); every modal surface in `@ekklesia/ui` uses it.
*/
export function useFocusTrap(
containerRef: RefObject<HTMLElement | null>,
active: boolean,
options: FocusTrapOptions = {},
): (event: KeyboardEvent<HTMLElement>) => void {
const { initialFocus } = options;
const restoreTo = useRef<HTMLElement | null>(null);
useEffect(() => {
const container = containerRef.current;
if (!active || !container) {
return;
}
const previous = activeWithin(container);
restoreTo.current =
previous && previous !== container.ownerDocument.body && "focus" in previous
? (previous as HTMLElement)
: null;
(initialFocus?.current ?? container).focus();
return () => {
const target = restoreTo.current;
restoreTo.current = null;
if (target?.isConnected) {
target.focus();
}
};
}, [active, containerRef, initialFocus]);
return useCallback(
(event: KeyboardEvent<HTMLElement>) => {
const container = containerRef.current;
if (!active || event.key !== "Tab" || !container) {
return;
}
const focusable = focusableWithin(container);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) {
event.preventDefault();
container.focus();
return;
}
const current = activeWithin(container);
const outside = !(current instanceof Node) || !container.contains(current);
if (event.shiftKey && (current === first || current === container || outside)) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && (current === last || outside)) {
event.preventDefault();
first.focus();
}
},
[active, containerRef],
);
}
+18
View File
@@ -43,6 +43,11 @@ export {
type TableRowProps,
type TableSectionProps,
} from "./data.js";
export {
ConfirmationDialog,
type ConfirmationDialogProps,
type ConfirmationTone,
} from "./confirmation.js";
export { EkklesiaUiStyles } from "./EkklesiaUiStyles.js";
export {
Badge,
@@ -56,6 +61,10 @@ export {
type InlineNoticeProps,
LoadingState,
type LoadingStateProps,
Skeleton,
SkeletonGroup,
type SkeletonGroupProps,
type SkeletonProps,
Toast,
type ToastProps,
ToastStack,
@@ -75,8 +84,17 @@ export {
Panel,
type PanelProps,
PageHeader,
type PageHeaderBack,
type PageHeaderProps,
type TabDefinition,
Tabs,
type TabsProps,
} from "./surfaces.js";
export {
type ToastApi,
type ToastOptions,
ToastProvider,
type ToastProviderProps,
type ToastTone,
useToasts,
} from "./toasts.js";
+172 -1
View File
@@ -595,14 +595,185 @@ export const EKKLESIA_UI_STYLESHEET = `
width: ${designToken("spacing.xl")};
}
.ek-visually-hidden {
border: 0;
clip-path: inset(50%);
height: 1px;
margin: -1px;
overflow: hidden;
padding: 0;
position: absolute;
white-space: nowrap;
width: 1px;
}
.ek-dialog__description {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-sm")};
margin: 0;
}
.ek-confirmation {
display: grid;
gap: ${designToken("spacing.lg")};
margin: 0;
}
/* A destructive confirmation says so with a symbol and a heavier frame, never by colour alone. */
.ek-confirmation--destructive {
border-color: ${designToken("status.danger-border")};
border-width: calc(${designToken("border.width")} * 2);
}
.ek-confirmation__symbol::before {
content: ${designToken("status.warning-symbol")};
font-weight: ${designToken("typography.weight-bold")};
margin-inline-end: ${designToken("spacing.xs")};
}
.ek-confirmation__phrase {
font-family: ${designToken("typography.family-mono")};
white-space: pre-wrap;
}
.ek-toast-region {
box-sizing: border-box;
display: grid;
gap: ${designToken("spacing.sm")};
left: 50%;
max-width: calc(100vw - (${designToken("spacing.md")} * 2));
pointer-events: none;
position: fixed;
top: ${designToken("spacing.lg")};
transform: translateX(-50%);
width: 28rem;
z-index: 50;
}
.ek-toast-region__list {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-toast-region .ek-toast {
animation: ek-toast-enter 160ms ease-out;
pointer-events: auto;
}
.ek-toast {
align-items: start;
display: flex;
gap: ${designToken("spacing.sm")};
}
.ek-toast__content {
display: grid;
flex: 1 1 auto;
gap: ${designToken("spacing.2xs")};
min-width: 0;
}
.ek-toast__dismiss {
flex: 0 0 auto;
min-width: ${designToken("control.height-sm")};
}
.ek-message__actions {
display: flex;
flex-wrap: wrap;
gap: ${designToken("spacing.sm")};
justify-content: center;
}
.ek-message__details {
color: ${designToken("text.secondary")};
font-size: ${designToken("typography.size-xs")};
max-width: 100%;
text-align: start;
width: 100%;
}
.ek-message__details summary {
cursor: pointer;
min-height: ${designToken("control.height-sm")};
padding-block: ${designToken("spacing.2xs")};
}
.ek-message__details-text {
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.sm")};
font-family: ${designToken("typography.family-mono")};
margin: ${designToken("spacing.xs")} 0 0;
max-height: 16rem;
overflow: auto;
padding: ${designToken("spacing.sm")};
white-space: pre-wrap;
word-break: break-word;
}
.ek-message__reference {
margin: ${designToken("spacing.xs")} 0 0;
}
.ek-page-header__back {
justify-self: start;
margin-inline-start: calc(-1 * ${designToken("control.padding-inline")});
text-decoration: none;
}
.ek-skeleton-group {
display: grid;
gap: ${designToken("spacing.sm")};
}
.ek-skeleton {
animation: ek-skeleton-pulse 1.6s ease-in-out infinite;
background: ${designToken("background.inset")};
border-radius: ${designToken("radius.sm")};
display: block;
}
.ek-skeleton--text {
height: 1em;
width: 100%;
}
.ek-skeleton--block {
height: 6rem;
width: 100%;
}
.ek-skeleton--circle {
border-radius: ${designToken("radius.pill")};
height: 2.5rem;
width: 2.5rem;
}
@keyframes ek-spin {
to {
transform: rotate(360deg);
}
}
@keyframes ek-skeleton-pulse {
50% {
opacity: 0.55;
}
}
@keyframes ek-toast-enter {
from {
opacity: 0;
transform: translateY(-0.25rem);
}
}
/* Every animation in these elements stops for a person who asked for reduced motion; nothing in
them depends on movement to be understood. */
@media (prefers-reduced-motion: reduce) {
.ek-spinner {
.ek-spinner,
.ek-skeleton,
.ek-toast-region .ek-toast {
animation: none;
}
}
+126 -23
View File
@@ -1,29 +1,101 @@
import {
type HTMLAttributes,
type MouseEvent,
type ReactElement,
type ReactNode,
useEffect,
type RefObject,
useId,
useRef,
} from "react";
import { classNames } from "./class-names.js";
import { useFocusTrap } from "./focus-trap.js";
/** Containers: the panel a screen is built from, the dialog that interrupts it, and the tab strip
* that divides it. */
/**
* A page's Back action. The caller decides where Back goes: a host that knows its own routes
* validates any return target before handing it over (ADR 0059); this element only renders it.
*/
export interface PageHeaderBack {
/** Visible and accessible. Name the destination where it helps: "Back to Signage". */
readonly label: string;
/** When Back is a real place, its address: rendered as a link, so it can be opened in a new tab
* and is announced as a link. Without one, Back is a button. */
readonly href?: string;
/** Called on an ordinary activation. A click with a modifier key or the middle button on a link
* is left to the browser. */
readonly onBack: () => void;
}
export interface PageHeaderProps {
readonly title: ReactNode;
readonly description?: ReactNode;
readonly actions?: ReactNode;
readonly eyebrow?: ReactNode;
/** The Back action of a nested page (DESIGN.md §4.8): Back plus the page title is the baseline;
* breadcrumbs are not part of it. */
readonly back?: PageHeaderBack;
readonly className?: string;
}
/** The consistent top-level identity and primary-action region for an Ekklesia screen. */
function isBrowserHandledClick(event: MouseEvent<HTMLAnchorElement>): boolean {
return (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
);
}
function BackAction(props: { readonly back: PageHeaderBack }): ReactElement {
const { back } = props;
const content = (
<>
<span aria-hidden="true" className="ek-page-header__back-symbol">
←
</span>
{back.label}
</>
);
const className = "ek-button ek-button--quiet ek-button--sm ek-focusable ek-page-header__back";
if (back.href !== undefined) {
return (
<a
className={className}
href={back.href}
onClick={(event) => {
if (isBrowserHandledClick(event)) {
return;
}
event.preventDefault();
back.onBack();
}}
>
{content}
</a>
);
}
return (
<button className={className} onClick={back.onBack} type="button">
{content}
</button>
);
}
/**
* The consistent top-level identity and primary-action region for an Ekklesia screen.
*
* The title is the page's one `h1`; a section within the page is a `Panel` with its own `h2`. A
* nested page puts its Back action above the title, and page-level actions sit opposite it.
*/
export function PageHeader(props: PageHeaderProps): ReactElement {
return (
<header className={classNames("ek-page-header", props.className)}>
<div className="ek-page-header__copy">
{props.back === undefined ? undefined : <BackAction back={props.back} />}
{props.eyebrow === undefined ? undefined : (
<span className="ek-page-header__eyebrow">{props.eyebrow}</span>
)}
@@ -98,12 +170,23 @@ export function Panel(props: PanelProps): ReactElement {
export interface DialogProps {
readonly open: boolean;
/** The dialog's accessible name, rendered as its heading. */
readonly title: ReactNode;
/** A short statement of what the dialog is about, announced with its name (`aria-describedby`). */
readonly description?: ReactNode;
readonly onDismiss: () => void;
/**
* Whether Escape dismisses the dialog. Default `true`. A caller turns it off while something is
* in progress that dismissing would leave half-done.
*/
readonly dismissible?: boolean;
/** Where focus starts. Without one, the dialog itself, so its name and description are read. */
readonly initialFocusRef?: RefObject<HTMLElement | null>;
/** Buttons for the dialog's decisions, rendered in a trailing-aligned row. */
readonly actions?: ReactNode;
readonly className?: string;
readonly children?: ReactNode;
readonly "data-testid"?: string;
}
/**
@@ -112,43 +195,58 @@ export interface DialogProps {
* Portalling is what a document-scoped component library does. It is exactly wrong here: a module
* artifact's dialog portalled to the host document would escape the artifact's isolated root, lose
* its styles, and render over the Dashboard — which is the leak this package exists to prevent. The
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered.
* scrim is `position: fixed`, so it still covers the viewport from wherever it is rendered, and it
* takes every pointer event, so nothing behind the dialog can be clicked.
*
* Focus moves to the dialog when it opens and `Escape` dismisses it. A full focus trap is
* deliberately not implemented here rather than implemented badly; when the platform needs one it
* belongs in one place, tested, not sprinkled through call sites.
* While open, keyboard focus is trapped inside it ({@link useFocusTrap}): Tab and Shift+Tab wrap,
* and focus returns to the control that opened it when it closes. Escape dismisses it unless the
* caller says that is not safe right now. Escape is handled on the dialog itself and goes no
* further, so a drawer or menu the dialog was opened from does not close with it. Assistive
* technology is told it is modal (`aria-modal`), named by its title, and described by its
* description.
*/
export function Dialog(props: DialogProps): ReactElement | null {
const titleId = useId();
const descriptionId = useId();
const dialogRef = useRef<HTMLDivElement | null>(null);
const { onDismiss, open } = props;
useEffect(() => {
if (!open) {
return;
}
dialogRef.current?.focus();
const onKeyDown = (event: KeyboardEvent): void => {
if (event.key === "Escape") {
event.stopPropagation();
onDismiss();
}
};
const owner = dialogRef.current?.ownerDocument;
owner?.addEventListener("keydown", onKeyDown);
return () => owner?.removeEventListener("keydown", onKeyDown);
}, [onDismiss, open]);
const dismissible = props.dismissible ?? true;
const trapKeyDown = useFocusTrap(dialogRef, open, {
...(props.initialFocusRef ? { initialFocus: props.initialFocusRef } : {}),
});
if (!open) {
return null;
}
return (
<div className="ek-dialog__scrim">
<div
className="ek-dialog__scrim"
// The scrim is the barrier between the dialog and the page: a press on it reaches nothing
// behind it and does not dismiss the dialog, which only ever closes deliberately.
onPointerDown={(event) => {
if (event.target === event.currentTarget) {
event.preventDefault();
}
}}
>
<div
aria-describedby={props.description === undefined ? undefined : descriptionId}
aria-labelledby={titleId}
aria-modal="true"
className={classNames("ek-dialog", "ek-focusable", props.className)}
data-testid={props["data-testid"]}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
if (dismissible) {
onDismiss();
}
return;
}
trapKeyDown(event);
}}
ref={dialogRef}
role="dialog"
tabIndex={-1}
@@ -156,6 +254,11 @@ export function Dialog(props: DialogProps): ReactElement | null {
<h2 className="ek-dialog__title" id={titleId}>
{props.title}
</h2>
{props.description === undefined ? undefined : (
<div className="ek-dialog__description" id={descriptionId}>
{props.description}
</div>
)}
{props.children}
{props.actions === undefined ? undefined : (
<div className="ek-dialog__actions">{props.actions}</div>
+343
View File
@@ -0,0 +1,343 @@
import {
createContext,
type FocusEvent,
type ReactElement,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Toast } from "./feedback.js";
/** The four severities a toast can carry. `danger` is an error. */
export type ToastTone = "success" | "info" | "warning" | "danger";
export interface ToastOptions {
readonly tone?: ToastTone;
/** What happened, in a few words. Also names the toast's dismiss control. */
readonly title: string;
readonly description?: string;
/**
* How long it stays, in milliseconds, or `"persistent"` to stay until dismissed. Defaults by
* severity: an error stays until dismissed; anything else lasts long enough to read, and never
* less than three seconds (DESIGN.md §7.4).
*/
readonly duration?: number | "persistent";
}
export interface ToastApi {
/** Shows a toast and returns its id. An identical toast already showing is refreshed instead. */
show(options: ToastOptions): string;
dismiss(id: string): void;
}
/** DESIGN.md §7.4's floor: no toast disappears in under about three seconds. */
export const TOAST_MINIMUM_DURATION_MS = 3000;
const DEFAULT_DURATION_MS: Readonly<Record<Exclude<ToastTone, "danger">, number>> = Object.freeze({
info: 4000,
success: 4000,
warning: 6000,
});
const READING_MS_PER_CHARACTER = 50;
const LONGEST_TIMED_DURATION_MS = 12_000;
const DEFAULT_MAX_VISIBLE = 3;
/**
* How long a toast stays: its requested or default duration, but never less than it takes to read.
* Longer messages stay longer, up to a ceiling; an error stays until dismissed unless its caller
* deliberately gives it a duration (a routine validation error, say).
*/
export function toastDuration(options: ToastOptions): number | "persistent" {
const tone = options.tone ?? "info";
const requested =
options.duration ?? (tone === "danger" ? "persistent" : DEFAULT_DURATION_MS[tone]);
if (requested === "persistent") {
return "persistent";
}
const characters = options.title.length + (options.description?.length ?? 0);
const readingTime = Math.min(
LONGEST_TIMED_DURATION_MS,
TOAST_MINIMUM_DURATION_MS + characters * READING_MS_PER_CHARACTER,
);
return Math.max(TOAST_MINIMUM_DURATION_MS, requested, readingTime);
}
interface ToastRecord {
readonly id: string;
readonly tone: ToastTone;
readonly title: string;
readonly description?: string;
readonly duration: number | "persistent";
readonly key: string;
}
/** A running or paused countdown. `remaining` is what is left when it was last started. */
interface ToastTimer {
handle: ReturnType<typeof setTimeout> | undefined;
remaining: number;
started: number;
}
const ToastContext = createContext<ToastApi | null>(null);
/**
* The toast API of the nearest {@link ToastProvider}. Throws outside one, because a toast shown
* nowhere is a silent failure.
*/
export function useToasts(): ToastApi {
const api = useContext(ToastContext);
if (!api) {
throw new Error("useToasts must be used inside a ToastProvider.");
}
return api;
}
export interface ToastProviderProps {
readonly children?: ReactNode;
/** Names the notification region for assistive technology. */
readonly label?: string;
/** How many non-error toasts show at once. Errors are never displaced to make room. */
readonly maxVisible?: number;
}
/**
* The toast system of one React tree (ADR 0059): a top-center region and the queue behind it.
*
* **Per tree, never a package singleton.** A module mounts in its own isolated root (ADR 0040) with
* its own React tree; a module-level store shared through this package would be mutable state
* reaching across every artifact on the page. Each provider owns its toasts, its timers, and its
* region, and is torn down with its tree — every timer with it.
*
* **Calm under load.** At most `maxVisible` routine toasts show; the oldest routine one makes room.
* An error is never displaced by a routine toast, and stays until dismissed by default. Showing a
* toast identical to one already visible refreshes it rather than stacking a copy. Newest is on top.
*
* **Readable and accessible.** Timers pause while the pointer is over the region or focus is inside
* it (WCAG 2.2 §2.2.1). The region's two live regions are mounted before any toast arrives, so
* assistive technology announces what is added: routine toasts politely, errors assertively. Every
* toast has a dismiss control named for it, and Escape on a focused toast dismisses it; when the
* last one goes, focus returns to where it was before it entered the region.
*/
export function ToastProvider(props: ToastProviderProps): ReactElement {
const maxVisible = Math.max(1, props.maxVisible ?? DEFAULT_MAX_VISIBLE);
const [toasts, setToasts] = useState<readonly ToastRecord[]>([]);
// The synchronous source of truth: `show` must know at once whether a toast is a duplicate and
// what its id is, which a state updater (run later, during render) cannot tell it.
const current = useRef<readonly ToastRecord[]>([]);
const commit = useCallback((next: readonly ToastRecord[]) => {
current.current = next;
setToasts(next);
}, []);
const timers = useRef(new Map<string, ToastTimer>());
const paused = useRef(false);
const nextId = useRef(0);
const focusBeforeRegion = useRef<HTMLElement | null>(null);
const regionRef = useRef<HTMLDivElement | null>(null);
const clearTimer = useCallback((id: string) => {
const timer = timers.current.get(id);
if (timer?.handle !== undefined) {
clearTimeout(timer.handle);
}
timers.current.delete(id);
}, []);
const dismiss = useCallback(
(id: string) => {
clearTimer(id);
if (current.current.some((toast) => toast.id === id)) {
commit(current.current.filter((toast) => toast.id !== id));
}
},
[clearTimer, commit],
);
const startTimer = useCallback(
(id: string, remaining: number) => {
const existing = timers.current.get(id);
if (existing?.handle !== undefined) {
clearTimeout(existing.handle);
}
const timer: ToastTimer = { handle: undefined, remaining, started: Date.now() };
if (!paused.current) {
timer.handle = setTimeout(() => dismiss(id), remaining);
}
timers.current.set(id, timer);
},
[dismiss],
);
const show = useCallback(
(options: ToastOptions): string => {
const tone = options.tone ?? "info";
const duration = toastDuration(options);
const key = JSON.stringify([tone, options.title, options.description ?? ""]);
const duplicate = current.current.find((toast) => toast.key === key);
if (duplicate) {
// Showing it again restarts its time rather than stacking a copy.
if (duplicate.duration !== "persistent") {
startTimer(duplicate.id, duplicate.duration);
}
return duplicate.id;
}
nextId.current += 1;
const id = `ek-toast-${nextId.current}`;
const record: ToastRecord = {
duration,
id,
key,
title: options.title,
tone,
...(options.description === undefined ? {} : { description: options.description }),
};
const next = [record, ...current.current];
const routine = next.filter((toast) => toast.tone !== "danger");
const overflow = new Set(routine.slice(maxVisible).map((toast) => toast.id));
for (const removed of overflow) {
clearTimer(removed);
}
commit(next.filter((toast) => !overflow.has(toast.id)));
if (duration !== "persistent") {
startTimer(id, duration);
}
return id;
},
[clearTimer, commit, maxVisible, startTimer],
);
const pause = useCallback(() => {
if (paused.current) {
return;
}
paused.current = true;
const now = Date.now();
for (const timer of timers.current.values()) {
if (timer.handle !== undefined) {
clearTimeout(timer.handle);
timer.handle = undefined;
timer.remaining = Math.max(0, timer.remaining - (now - timer.started));
}
}
}, []);
const resume = useCallback(() => {
if (!paused.current) {
return;
}
paused.current = false;
for (const [id, timer] of [...timers.current.entries()]) {
startTimer(id, timer.remaining);
}
}, [startTimer]);
// Every timer dies with the provider.
useEffect(() => {
const active = timers.current;
return () => {
for (const timer of active.values()) {
if (timer.handle !== undefined) {
clearTimeout(timer.handle);
}
}
active.clear();
};
}, []);
const dismissFromRegion = (id: string): void => {
const remaining = current.current.filter((toast) => toast.id !== id);
dismiss(id);
const region = regionRef.current;
const focusWasInside =
region !== null &&
(region.getRootNode() as Document | ShadowRoot).activeElement !== null &&
region.contains((region.getRootNode() as Document | ShadowRoot).activeElement);
if (!focusWasInside) {
return;
}
// Keep a keyboard user in the region while toasts remain; otherwise return them to where they
// were before they entered it, rather than to nowhere.
if (remaining.length > 0) {
queueMicrotask(() => {
region.querySelector<HTMLElement>(".ek-toast__dismiss")?.focus();
});
} else {
const target = focusBeforeRegion.current;
focusBeforeRegion.current = null;
resume();
if (target?.isConnected) {
target.focus();
}
}
};
const api = useMemo<ToastApi>(() => ({ dismiss, show }), [dismiss, show]);
const routine = toasts.filter((toast) => toast.tone !== "danger");
const errors = toasts.filter((toast) => toast.tone === "danger");
const renderToast = (toast: ToastRecord): ReactElement => (
<Toast
announce={false}
data-toast-id={toast.id}
dismissLabel={`Dismiss notification: ${toast.title}`}
key={toast.id}
onDismiss={() => dismissFromRegion(toast.id)}
title={toast.title}
tone={toast.tone}
>
{toast.description}
</Toast>
);
return (
<ToastContext.Provider value={api}>
{props.children}
<section
aria-label={props.label ?? "Notifications"}
className="ek-toast-region"
onBlur={(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
resume();
}
}}
onFocus={(event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
focusBeforeRegion.current =
event.relatedTarget instanceof HTMLElement ? event.relatedTarget : null;
}
pause();
}}
onKeyDown={(event) => {
if (event.key !== "Escape") {
return;
}
const owner = (event.target as HTMLElement).closest<HTMLElement>("[data-toast-id]");
const id = owner?.dataset.toastId;
if (id) {
event.stopPropagation();
dismissFromRegion(id);
}
}}
onMouseEnter={pause}
onMouseLeave={() => {
const region = regionRef.current;
const root = region?.getRootNode() as Document | ShadowRoot | undefined;
if (!region || !root?.activeElement || !region.contains(root.activeElement)) {
resume();
}
}}
ref={regionRef}
>
{/* Errors are announced assertively and listed first, so nothing routine can hide one. */}
<div aria-live="assertive" className="ek-toast-region__list" role="alert">
{errors.map(renderToast)}
</div>
<div aria-live="polite" className="ek-toast-region__list" role="status">
{routine.map(renderToast)}
</div>
</section>
</ToastContext.Provider>
);
}
+2 -1
View File
@@ -131,8 +131,9 @@ describe("Ekklesia elements", () => {
// dialog must not escape its isolated root.
expect(document.body.querySelector(":scope > [role='dialog']")).toBeNull();
// Focus is inside the dialog, which handles Escape itself (ADR 0059).
act(() => {
document.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
dialog?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
});
expect(onDismiss).toHaveBeenCalledTimes(1);
});
+797
View File
@@ -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);
});
});