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 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(null); const cancelRef = useRef(null); const confirmRef = useRef(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): void => { event.preventDefault(); if (canConfirm) { props.onConfirm(); } }; return ( ); }