feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m6s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m44s
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m6s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m44s
Stage 1's later screens and modules need the interactions DESIGN.md fixes once for the product (ADR 0059). @ekklesia/ui gains them, additively: - ConfirmationDialog: simple, destructive, or typed-phrase confirmation, with severity chosen by the caller. Destructive is marked by symbol and frame, not colour alone, and starts on Cancel. A required phrase must match exactly (forgiving only surrounding whitespace and NFC). While busy, Cancel and Escape are disabled. - ToastProvider / useToasts: a top-center toast system per React tree, never a package singleton (ADR 0040). Live regions are mounted before any toast arrives; errors are assertive and persist until dismissed; routine toasts last at least three seconds and longer for longer text. Timers pause on hover and focus; duplicates refresh; errors are never displaced; every timer is cleared on unmount. - ErrorState: recovery actions, plus collapsed technical details and a reference, rendered as bounded inert text. - Skeleton / SkeletonGroup: an aria-busy region labelled once, with every animation stopped under prefers-reduced-motion. - PageHeader `back` (link or button) above the page's one h1. - Dialog: the focus trap ADR 0039 deferred, plus focus restoration and an accessible description. Escape is now handled on the dialog itself, so a drawer it was opened from stays open. The shell validates Back targets (parseReturnTarget, dashboardBack): only a canonical Dashboard page on this origin is accepted, otherwise Back goes to the page's parent. It never uses history.back(). Biome's scope-aware suspicious/noAlert is now an error in the root biome.json. tests/native-dialogs.test.ts proves it catches real dialogs and not lookalikes, and scans for the forms the rule misses. It supersedes the shell-only regex test. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
651efa8829
commit
5a7d44781b
@@ -0,0 +1,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user