Patterns
Destructive action
The playbook for anything a user can't take back. Delete, revoke, purge — dangerous controls get the solid critical color, an icon right next to it, and friction that matches what it destroys — a quick confirm for one record, a typed name for a whole scope, and an Undo toast instead of a dialog when nothing is really lost.
BetaDestructive action is Beta — the escalation ladder is settled (ConfirmDialog · TypeToConfirmDialog · undo toast); TypeToConfirmDialog is new and its prop surface may still move.
Principles
- Critical color, never alone. A destructive control takes the solid critical fill — the metal name (rust) belongs to the proposed system — and per ADR-0017 the color always travels with an icon (octagon-alert, trash) and an explicit verb, so severity survives grayscale.
- Never one-click. An irreversible action always confirms through a ConfirmDialog (an alertdialog). The one exception: skip the dialog when an undo window genuinely restores everything.
- The cost is named. The confirm body says what is destroyed and what survives — never a bare "Are you sure?".
- No danger zone. The record doctrine subsumes it: delete is a critical intent in the ActionBar beside the other intents, gated by its dialog — not a separate dread section at the bottom of the page.
The standard gate
One record, no take-backs: the critical intent in the bar pops a ConfirmDialog that tells you exactly what it costs. Cancel is the easy out; the confirm carries the solid critical fill (the rust metal is the proposed system's treatment).
Type to confirm
When it's bigger than one record — a bulk delete, a whole rulebook — the gate steps up: the confirm stays locked until you type the name of the thing you're deleting. Reading the name is the point; the button only unlocks on an exact match. This tier is TypeToConfirmDialog, ConfirmDialog's bigger sibling.
The undo exception
Archive, suppress, unassign — when the operation is fully recoverable, a dialog is the wrong friction. Act immediately and offer Undo in the toast; the window is the safeguard. Never offer undo as cover for actual data loss.
The escalation ladder
Friction is proportional to blast radius. Each tier adds to the one below; pick the lowest tier that honestly covers the loss.
- tier 0 — staged
- The commit doctrine itself: nothing autosaves, so most mistakes die at Discard before they ever need a gate.
- tier 1 — confirm
- Irreversible, one record: a critical ConfirmDialog that names the cost. The floor for any real deletion.
- tier 2 — type to confirm
- Irreversible at scope: bulk deletes, a whole rulebook, anything that fans out. The exact name is typed before the confirm unlocks.
- the exception — undo
- Recoverable operations skip the dialog: act now, toast with Undo. The contract is a real restore, not a fig leaf.
Placement
Where the destructive control sits is part of the safety.
- in a record
- Delete is a critical intent in the ActionBar, beside the other intents and gated by its dialog. There is no Danger Zone section; the commit doctrine subsumed it.
- in a toolbar
- Destructive bulk actions sit rightmost, separated from constructive controls by whitespace — and escalate to tier 2 when the selection fans out.
- in a menu
- Destructive items go last, below a divider, in the critical register with their glyph.
Accessibility
- An alertdialog, by role. Both gates render role=alertdialog — focus is trapped, Escape backs out safely, and focus returns to the trigger (React Aria's wiring, never hand-rolled).
- Severity is never colour alone. The critical color always pairs with an icon and an explicit verb (ADR-0017) — grayscale and color-blind users read the same danger.
- Backing out is the easy path. Cancel is a plain button, Escape works, the scrim clicks away; only the confirm carries the critical weight.
- Type-to-confirm stays honest. The field is a real labelled Input; paste is allowed deliberately (the friction is reading the name, not typing it), and the confirm is a real disabled→enabled button, never a hidden one. TypeToConfirmDialog wires all of this by construction.
API
The pattern's components: a critical Button intent in the ActionBar, ConfirmDialog as the tier-1 gate, TypeToConfirmDialog as the tier-2 gate, and the toast undo window. TypeToConfirmDialog is the one unit this pattern ships itself — its props:
- isOpen · onOpenChange
- Controlled open state — the same contract as Modal and ConfirmDialog.
- title
- The question — it names what's being destroyed.
- children
- The scope body: what is destroyed and what survives — never a bare "Are you sure?".
- name
- The exact text that unlocks the confirm — the name of the thing being destroyed. The match trims surrounding whitespace and is otherwise exact, and the typed value resets every time the dialog opens.
- confirmLabel · cancelLabel
- The footer buttons. Cancel stays the plain path out; only the confirm carries the critical register.
- onConfirm
- Fires only while the gate is unlocked. Closing afterwards is the caller's job.
- isPending
- Locks the field and both buttons while the operation runs.
- confirmIcon
- Overrides the trash glyph on the confirm button.
The instruction line ("Type … to confirm.") and the irreversibility statement ship inside the component as chrome strings (EN and ES) — the safety copy can't be forgotten or paraphrased away.
Usage
import { ActionBar, ConfirmDialog } from '@halo-compliance/ui'
import { Check, Trash2 } from 'lucide-react'
<ActionBar
dirty={dirty}
intents={[
{ id: 'discard', label: 'Discard', tone: 'ghost',
disabled: !dirty, onPress: form.reset },
{ id: 'delete', label: 'Delete report', tone: 'critical',
icon: <Trash2 />, onPress: () => setConfirmOpen(true) },
{ id: 'save', label: 'Save changes', tone: 'primary',
icon: <Check />, disabled: !dirty, onPress: form.handleSubmit },
]}
/>
<ConfirmDialog
isOpen={confirmOpen}
onOpenChange={setConfirmOpen}
tone="critical"
title="Delete this report?"
confirmLabel="Delete report"
cancelLabel="Cancel"
onConfirm={deleteReport}
>
This permanently removes the report and its findings. It can't be undone.
</ConfirmDialog>import { TypeToConfirmDialog } from '@halo-compliance/ui'
<TypeToConfirmDialog
isOpen={open}
onOpenChange={setOpen}
title={`Delete the ${rulebook.name} rulebook?`}
name={rulebook.name} // the exact match that unlocks the confirm
confirmLabel="Delete rulebook"
cancelLabel="Cancel"
isPending={deletion.isPending}
onConfirm={deleteRulebook}
>
This deletes the rulebook and its 38 rules from every channel it
governs. Evaluations already recorded keep their history.
</TypeToConfirmDialog>Anti-patterns
- One-click destruction. A bare critical button that fires the API is never acceptable — even with the staged doctrine, irreversible confirms.
- "Are you sure?" A confirm that names no cost teaches users to click through. Say what is destroyed.
- Friction inflation. Type-to-confirm on an everyday single-record delete trains contempt for the gate; save it for scope.
- Undo as a fig leaf. Offering Undo on a hard delete the backend can't restore is dishonest; the toast contract is a real restore.
Related
- /action-bar — the commit bar a critical intent lives in.
- /modal — Modal and the ConfirmDialog convenience, the gates themselves.
- /button — the solid critical fill the control wears.
- /toast — the undo window's vehicle.