Composites
Confirm Dialog
One way to ask “are you sure?” across every Comitor app. ConfirmDialog wraps the AlertDialog primitive and adds the part everyone re-implements badly: an async confirm that keeps the dialog open, disabled and honest while the request is in flight.
Basic usage
Hand it a trigger and it manages its own open state. The title should be the question and the confirm label should repeat the verb — never OK, which reads the same for “archive” and “delete forever”.
No action taken yet.
Destructive tone and the icon slot
variant="destructive" turns the confirm button red and tints the icon tile. icon is an IconComponent — pass the component itself, icon={Trash2}, never icon={<Trash2 />}. A Lucide icon is a forwardRef object, so nothing at runtime can tell a component from an already-rendered element; the type is narrow on purpose so the compiler catches it instead of the browser.
Invoice INV-2048 is live.
The third dialog above keeps the default tone: signing out is disruptive but reversible, so it does not deserve red. Reserve destructive for actions that destroy data or revoke access, or it stops meaning anything.
Controlled vs uncontrolled
The component reads its state as open ?? internalOpen. Omit open and it keeps its own; pass it — even once — and the internal state is never written again, so a missing onOpenChange means a dialog that opens and never closes. Controlled mode is what you need when the opener is a menu item, a table row action, or a keyboard shortcut rather than a button sitting next to the dialog.
open = false · Trần Minh has access.
onOpenChange fires in both modes, so an uncontrolled dialog can still tell you when it closed — useful for resetting the fields you put in children.
Async confirm
This is the reason the composite exists. Return a promise from onConfirm and four things happen at once, none of which you have to wire:
- The confirm button disables itself and swaps its label for a pending one.
- Cancel disables too, and the dialog refuses every close while pending — Esc included. The guard is literally
if (pending && !next) return. - Radix’s own auto-close on the action button is suppressed with
event.preventDefault(), so the dialog closes only after the promise resolves. Closing first and calling the API afterwards is the surest way to collect a double delete. - A rejection leaves the dialog open so the reader can retry, and the error is handed to
onError. It is deliberately not re-thrown: the internal call isvoid handleConfirm(), so re-throwing would produce an unhandled rejection nothing in your app could catch.
Waiting.
Extra content and gated confirmation
children render between the header and the footer. Pair them with confirmDisabled for the highest-stakes deletions: the reader has to type the record’s name before the red button unlocks.
atlas-migration exists.
Writing it in English
Unlike DataTable or TablePagination, this composite has no labels bag — three plain string props, all defaulting to Vietnamese ("Xác nhận", "Hủy" and "Đang xử lý…"). Pass them every time.
import { ConfirmDialog, Button } from '@comitor/ui'
// The package ships Vietnamese defaults: confirmLabel "Xác nhận", cancelLabel "Hủy".
// They are plain string props, not a `labels` bag — pass both on every dialog in an
// English app, and prefer a verb that repeats the action over a bare "OK".
export function Example({ publish }: { publish: () => void }) {
return (
<ConfirmDialog
trigger={<Button>Publish</Button>}
title="Publish this release?"
description="Everyone on the workspace gets the new version on their next reload."
confirmLabel="Publish release"
cancelLabel="Not yet"
onConfirm={publish}
/>
)
}
// ⚠ A THIRD string: while `onConfirm` is pending the button reads "Đang xử lý…" unless you
// pass `pendingLabel`. Set it on every dialog whose onConfirm returns a promise.A third string hides behind the async path: while an onConfirm promise is in flight the confirm button swaps to pendingLabel, which defaults to "Đang xử lý…". It only shows for the second or two the request takes, which is exactly why it gets forgotten — pass it on every dialog whose onConfirm returns a promise.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | ReactNode | — | Dialog heading. Rendered as AlertDialogTitle — Radix uses it as the accessible name of the dialog, so it is required. |
onConfirmrequired | () => void | Promise<void> | — | Runs when the confirm button is pressed. Return a promise and the dialog holds itself open, disables both buttons and shows a pending label until it settles. |
description | ReactNode | — | Secondary line under the title, wired as AlertDialogDescription (aria-describedby). |
children | ReactNode | — | Extra content between the header and the footer — a reason textarea, the list of records about to be affected, a type-to-confirm field. |
icon | IconComponent | — | Icon COMPONENT (e.g. Trash2), not an element. Renders in a 36px rounded tile to the left of the title. |
variant | 'default' | 'destructive' | 'default' | destructive paints the confirm button red and tints the icon tile with bg-destructive/15 + text-destructive-ink. |
confirmLabel | string | 'Xác nhận' | Text of the confirm button. Vietnamese by default — pass an English verb phrase. |
cancelLabel | string | 'Hủy' | Text of the cancel button. Vietnamese by default. |
pendingLabel | string | 'Đang xử lý…' | Confirm-button text while an async onConfirm is in flight. Vietnamese by default — pass it on every dialog whose onConfirm returns a promise. |
confirmDisabled | boolean | false | Locks the confirm button — the gate for type-to-confirm flows and unmet preconditions. |
open | boolean | — | Controlled open state. Omit it and the dialog keeps its own state (use with trigger); pass it and every close is routed through onOpenChange. |
onOpenChange | (open: boolean) => void | — | Fires on every open/close attempt, in both controlled and uncontrolled mode. Called with false after a successful confirm. |
trigger | ReactNode | — | Element that opens the dialog. Wrapped in AlertDialogTrigger asChild, so it must be a single element that forwards ref and props. |
onError | (error: unknown) => void | — | Receives anything onConfirm throws. Without it the error only reaches console.error — it is never re-thrown. |
className | string | — | Classes merged onto AlertDialogContent (the panel itself), not onto the overlay. |
ConfirmDialogProps is a closed interface — it does not spread native div props onto the panel. Anything the list above does not cover means reaching for the AlertDialog parts directly.
Why the destructive icon is -ink
The icon tile follows the same recipe as statCardIconVariants: a tint of the role colour as the background, and the -ink step for the glyph on top.
destructive—bg-destructive/15withtext-destructive-ink.default—bg-mutedwithtext-muted-foreground.
In the default palette that choice looks like a no-op: --destructive-ink is declared as var(--destructive), the same #d64545 in both themes, so the glyph measures 4.06:1 in light and 3.14:1 in dark on the tint — over the 3:1 floor for non-text content, with no margin to spare. The point is the seam: under data-contrast="high" the ink token re-points at --red-ink and the identical markup measures 5.76:1 and 5.71:1. Writing text-destructive instead would freeze the first pair of numbers into both palettes — the whole reason the three-role contract splits x from xInk. Either token flips with the theme on its own, so the tile needs no dark: override, and it is aria-hidden regardless: it repeats what the title already says.
Accessibility
- Built on Radix
AlertDialog, which rendersrole="alertdialog"witharia-modal, traps focus inside the panel, and returns focus to the trigger on close. Unlike a plain dialog it cannot be dismissed by clicking the overlay — the reader has to answer the question. titlebecomes the accessible name (aria-labelledby) anddescriptionthe accessible description (aria-describedby). Write the title as a question that stands alone — a screen reader announces it the moment the dialog opens, before any of the surrounding text.- Radix gives initial focus to the cancel button, so the safe answer is the one under the reader’s finger. Tab moves between Cancel and Confirm; Esc cancels.
- While an async
onConfirmis pending, both buttons carrydisabledand Esc is swallowed. This is a deliberate, brief trap: an in-flight destructive request that the reader can walk away from is worse than two seconds of a locked dialog. - The icon tile is
aria-hidden="true"— it is decoration, so the message must survive without it. Never let red be the only thing that says “this deletes data”; say it in the title or the confirm label (WCAG 1.4.1). confirmDisableduses the realdisabledattribute, which removes the button from the tab order. Always explain the gate inchildren— a button that is dark and unreachable with no visible reason is a dead end.- Content you place in
childrenis inside the focus trap, so label every field properly; the reader will Tab through it before reaching the footer.