CI / verify (pull_request) Successful in 1m10s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 52s
CI / migration-smoke (pull_request) Successful in 2m20s
CI / image-build (pull_request) Successful in 1m5s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 58s
CI / deploy-smoke (split) (pull_request) Successful in 2m38s
The theme foundation DESIGN.md requires (ADR 0060): - A design token may carry a `dark` value and a `reduced` value. The token stylesheet selects them in CSS alone: dark under prefers-color-scheme unless the host chose light, or when it chose dark; motion durations zero under prefers-reduced-motion unless it allowed motion, or when it chose reduce. `color-scheme` travels with the tokens. System is live, an explicit choice wins, and every module root follows through custom-property inheritance, with no module contract change. - @ekklesia/design-tokens adds a small appearance API: system/light/dark and system/reduce/allow, parsers for untrusted stored values, resolvers, and a controller that applies the attributes immediately. The shell applies it before React renders. That is the hook the account preferences work will feed; nothing is persisted. The Dashboard page declares `color-scheme: light dark` so a dark system never flashes white. Displays stay light. - A designed dark theme: teal-tinted charcoal, never pure black or white, the same teal accent. - WCAG AA contrast is tested for 41 element pairings in both schemes. Two light pairings failed and are fixed visibly: text.muted darkens, and form controls get a new border.control (3:1). background.placeholder keeps skeletons visible in dark. - Motion tokens (duration-short, duration-ambient) replace literal durations and every per-element reduced-motion rule. radius.scale multiplies sm/md/lg, unchanged at its default of 1. - The sign-in card and module loading/error text move from literal colours to tokens. Guards keep raw colours and literal radii out of Dashboard product source and @ekklesia/ui. - Fixes ConfirmationDialog's phrase input, which used a class that does not exist (`ek-input`) and so was unstyled. Co-Authored-By: Claude Opus 5.5 <[email protected]>
192 lines
7.0 KiB
TypeScript
192 lines
7.0 KiB
TypeScript
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-control 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>
|
|
);
|
|
}
|