Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

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. Focus still comes back on close: to the button below, which only sets state, or — opened from a menu item, as in the code — to the menu’s trigger button, since the item unmounts with the menu.

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 is void 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.

Button labels and language

Unlike DataTable or TablePagination, this composite has no labels bag — three plain string props. Each one you leave out falls back to the confirmDialog table of the nearest ComitorLocaleProvider, then to English: “Confirm”, “Cancel” and “Working…” by default, “Xác nhận”, “Hủy” and “Đang xử lý…” under locale="vi" (see Set the language). Neither dialog below passes a label; confirm one and the request takes a second and a half — long enough to read the third string.

No provider — English

Vietnamese provider

Confirm either dialog — the request takes 1.5 s.

The right language is not the same as the right words. The default “Confirm” is as generic as the OK that Basic usage warns against, so pass a confirmLabel that repeats the verb, in whichever language. Once you do, pass pendingLabel with it on every dialog whose onConfirm returns a promise: otherwise the button drops from your “Delete workspace” to the table’s generic “Working…” for exactly the second or two the reader is watching it — which is why the third string is overridable at all.

Props

PropKiểuMặc địnhMô tả
titlebắt buộcReactNode—Dialog heading. Rendered as AlertDialogTitle — Radix uses it as the accessible name of the dialog, so it is required.
onConfirmbắt buộc() => 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.
descriptionReactNode—Secondary line under the title, wired as AlertDialogDescription (aria-describedby).
childrenReactNode—Extra content between the header and the footer — a reason textarea, the list of records about to be affected, a type-to-confirm field.
iconIconComponent—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.
confirmLabelstring'Confirm'Text of the confirm button. Left out, it comes from the nearest ComitorLocaleProvider ('Xác nhận' in Vietnamese), then English. The default is generic in any language — pass a verb phrase that repeats the action.
cancelLabelstring'Cancel'Text of the cancel button. Follows the provider ('Hủy' in Vietnamese) unless you pass it.
pendingLabelstring'Working…'Confirm-button text while an async onConfirm is in flight. Follows the provider ('Đang xử lý…' in Vietnamese). Pass it whenever you pass a specific confirmLabel and onConfirm returns a promise, or the button drops to the generic phrase mid-request.
confirmDisabledbooleanfalseLocks the confirm button — the gate for type-to-confirm flows and unmet preconditions.
openboolean—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.
triggerReactNode—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.
classNamestring—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/15 with text-destructive-ink.
  • default — bg-muted with text-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 renders role="alertdialog" with aria-modal and traps focus inside the panel. Unlike a plain dialog it cannot be dismissed by clicking the overlay — the reader has to answer the question.
  • On close, focus returns to the trigger — or, with none, to the element that had focus just before the dialog opened: the button that set open, an ActionBar action, or the menu’s trigger button when the opener was a menu item. Since 3.3.1 that comes from AlertDialogContent, and it holds with a field that has autoFocus in children. When the opener no longer exists — the row the dialog just deleted — nothing is focused, and ConfirmDialogProps has no onCloseAutoFocus: for that flow, compose the AlertDialog parts and send focus to the next row there.
  • title becomes the accessible name (aria-labelledby) and description the 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 onConfirm is pending, both buttons carry disabled and 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).
  • confirmDisabled uses the real disabled attribute, which removes the button from the tab order. Always explain the gate in children — a button that is dark and unreachable with no visible reason is a dead end.
  • Content you place in children is inside the focus trap, so label every field properly; the reader will Tab through it before reaching the footer.