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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user