Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.
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. 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 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.
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
| Prop | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
titlebắt buộc | ReactNode | — | 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. |
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 | '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. |
cancelLabel | string | 'Cancel' | Text of the cancel button. Follows the provider ('Hủy' in Vietnamese) unless you pass it. |
pendingLabel | string | '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. |
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-modaland 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 setopen, anActionBaraction, 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 hasautoFocusinchildren. When the opener no longer exists — the row the dialog just deleted — nothing is focused, andConfirmDialogPropshas noonCloseAutoFocus: for that flow, compose theAlertDialogparts and send focus to the next row there. 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.