Component contract
A confirmation dialog for destructive actions. Built on ui-modal; layers in friction-by-design.
open: boolean— controlled.onCancel: () => void— required.onConfirm: () => void | Promise<void>— required. Async returns gate the modal close until settled.title: ReactNode— required. Short statement of what will happen.body: ReactNode— required. Describes consequences. Should mention reversibility (or non-) explicitly.confirmLabel?: string— defaults to "Delete" or matches the action verb.cancelLabel?: string— defaults to "Cancel".confirmTone?: "danger" | "warning"— defaults to "danger" (red); "warning" for less-irreversible cases.typeToConfirm?: { value: string; label?: string }— when set, user must type the exactvaluein a text field before confirm enables. Used for high-stakes ops (delete event, drop tenant data).busyLabel?: string— text on confirm button while async confirm is in flight; defaults to "Working…".
Interaction surface
-
Open: focus defaults to Cancel.
When the dialog opens, initial focus is on the Cancel button (NOT Confirm). This is intentional friction — the user must move focus to confirm a destructive action.
-
Confirm button visually distinct.
Danger tone uses red fill; warning tone uses amber fill. Both have white text and a focus ring with ≥ 3:1 contrast. Cancel is a neutral secondary button.
-
Enter does NOT confirm.
Pressing Enter while focused on the dialog body or Cancel does NOT trigger Confirm. Enter on the Confirm button itself does (standard button behavior). This prevents the muscle-memory confirmation that destroys data.
-
Esc cancels.
Esc from anywhere in the dialog calls onCancel. Inherited from ui-modal.
-
Type-to-confirm field.
When typeToConfirm is set, render a text field below the body. Confirm button stays disabled until the input value === typeToConfirm.value (case-sensitive). Field has clear label like "Type ‘DELETE’ to confirm".
-
Async confirm gates modal.
If onConfirm returns a Promise, the modal stays open with confirm button replaced by a busy state until resolved. On resolve, modal closes via parent setting open=false. On reject, the dialog stays open, surfaces an inline error, confirm button re-enables.
Failure modes
Initial focus on Confirm
Trigger: developer used a generic modal + autofocus on the primary action.
Initial focus MUST be on Cancel. Harness: open dialog, assert document.activeElement matches Cancel button.
Enter triggers Confirm
Trigger: form-style implicit submit; pressing Enter on the body fires the confirm action.
Confirmation is NOT a form. No <form> wrapping. Enter on body or Cancel does nothing. Harness: open, focus body, press Enter, assert onConfirm NOT called.
Type-to-confirm matches case-insensitively
Trigger: typeToConfirm.value="DELETE", user types "delete", Confirm enables.
Match is strict equality, case-sensitive. Harness: typeToConfirm="DELETE", type "delete", assert Confirm disabled. Type "DELETE", assert Confirm enabled.
Modal closes before async confirm settles
Trigger: onConfirm returns a Promise; parent immediately sets open=false; user sees the action incomplete and tries again, double-firing.
Component does NOT close itself; it surfaces busy state. Parent should keep open=true until the promise settles. The harness's branch-level test asserts: when onConfirm is Promise-bound, the modal remains visible during the request lifetime.
Async confirm rejection silent
Trigger: onConfirm rejects; modal closes anyway (or stays open with no error indication).
On rejection, modal stays open, error surfaces inline near the confirm button. Confirm re-enables (or stays disabled if typeToConfirm input was cleared by the rejection). Harness: stub onConfirm to reject, assert modal still visible AND error message visible.
Body doesn't mention reversibility
Trigger: body just says "Are you sure you want to delete this event?".
Soft contract — the linter scans body text for keywords like "permanently", "cannot", "irreversible", "remove", and warns if none are present. This is enforced at the branch level, not the component level (the parent supplies the body). Listed here so branch authors know.
Cancel and Confirm visually similar
Trigger: both are secondary-style buttons; user clicks the wrong one.
Confirm is danger/warning tone (red/amber fill). Cancel is neutral. The contrast between the two is meaningful, not decorative. Harness: render with confirmTone=danger, screenshot, assert distinct background colors AND ≥ 3:1 contrast between confirm bg and cancel bg.
Type-to-confirm hint shows a different value than required
Trigger: copy says "Type DELETE to confirm" but typeToConfirm.value is actually "delete".
The label is derived from typeToConfirm.value when typeToConfirm.label is omitted. Harness: typeToConfirm={value: "delete-event-001"}, assert label contains "delete-event-001" verbatim.
Accessibility
- Inherits from ui-modal: role=dialog, aria-modal=true, focus trap, esc-closes, returns focus to trigger.
- aria-labelledby points at the title; aria-describedby points at the body.
- Confirm button has descriptive aria-label including the action verb (e.g., "Delete event").
- When confirmTone=danger, button has
aria-describedbypointing at a visually-hidden span: "This action cannot be undone". - Type-to-confirm input has its own label and aria-describedby pointing at the value-to-type hint.
- Color contrast: title ≥ 4.5:1, body ≥ 4.5:1, confirm button text ≥ 4.5:1.
- axe-clean at severity ≥ serious.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-destructive-confirmation | Outer dialog | Inherits ui-modal attrs; data-tone=danger|warning |
ui-destructive-confirmation-title | Title | aria-labelledby target |
ui-destructive-confirmation-body | Body | aria-describedby target |
ui-destructive-confirmation-cancel | Cancel button | Initial focus target |
ui-destructive-confirmation-confirm | Confirm button | Disabled until type-to-confirm matches |
ui-destructive-confirmation-type-input | Type-to-confirm input | Visible only when typeToConfirm is set |
ui-destructive-confirmation-error | Inline error | Visible when async confirm rejected |
Agent test plan
Standalone probes against /admin-test/ui-destructive-confirmation-fixture with variants: simple, danger-tone, warning-tone, type-to-confirm, async-confirm-success, async-confirm-reject.
Probe list
- initial-focus-cancel: open dialog, activeElement = ui-destructive-confirmation-cancel
- enter-on-body-noop: focus body, press Enter, onConfirm NOT called
- enter-on-cancel-cancels: focus cancel, press Enter, onCancel called
- enter-on-confirm-confirms: focus confirm, press Enter, onConfirm called
- esc-cancels: press Esc anywhere, onCancel called
- type-to-confirm-disables-until-match: typeToConfirm=DELETE, type "del", confirm disabled
- type-to-confirm-enables-on-match: typeToConfirm=DELETE, type "DELETE", confirm enabled
- type-to-confirm-case-sensitive: typeToConfirm=DELETE, type "delete", confirm disabled
- type-to-confirm-label-shows-value: typeToConfirm.value=delete-event-001, label includes "delete-event-001"
- async-confirm-busy-state: onConfirm returns pending promise, assert ui-destructive-confirmation-confirm shows busy text + disabled
- async-confirm-reject-shows-error: onConfirm rejects, ui-destructive-confirmation-error visible, dialog still open
- async-confirm-reject-reenables: after reject, confirm button enabled (or pending typeToConfirm)
- danger-tone-distinct: confirmTone=danger, confirm bg distinct from cancel bg, ≥ 3:1 contrast
- warning-tone-distinct: confirmTone=warning, confirm bg distinct from cancel bg, ≥ 3:1 contrast
- danger-aria-describedby: danger tone, confirm has aria-describedby pointing at "cannot be undone" text
- focus-trap-inherited: open, Tab through, focus stays within dialog
- focus-returns-on-cancel: trigger had focus, cancel, focus returns to trigger
- focus-returns-on-confirm: trigger had focus, confirm, focus returns to trigger after close
- color-contrast-title: ≥ 4.5:1
- color-contrast-confirm: ≥ 4.5:1
- axe-clean-serious: no serious violations