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>
);
}