Dialog
Base dialog component for creating typed dialogs that can be awaited.
Recommended Pattern
Section titled “Recommended Pattern”Use useDialog<TResult>() at the call site and useDialogContext<TRequest, TResult>() inside the dialog component. Both hooks come from @cratis/arc.react/dialogs.
- The caller opens the dialog with
awaitand receives[dialogResult, value] - The dialog closes itself through
closeDialog(result, value?) TResultis the value returned from the dialog.useDialogContexttakes the request (the dialog’s input props) type first, so passobjectthere when the dialog has no input
This pattern gives strongly typed dialog results and a simple async flow.
Example
Section titled “Example”import { useState } from 'react';import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs';import { Dialog } from '@cratis/components/Dialogs';
type Project = { id: string; name: string;};
const AddProjectDialog = () => { const { closeDialog } = useDialogContext<object, Project>(); const [name, setName] = useState('');
return ( <Dialog title='Add project' isValid={name.trim().length > 0} onConfirm={() => closeDialog(DialogResult.Ok, { id: crypto.randomUUID(), name })} > <label> Name <input value={name} onChange={(event) => setName(event.target.value)} /> </label> </Dialog> );};
const MyComponent = () => { const [AddProjectDialogWrapper, showAddProjectDialog] = useDialog<Project>(AddProjectDialog);
const handleAddProject = async () => { const [result, project] = await showAddProjectDialog(); if (result === DialogResult.Ok && project) { console.log('Added', project.name); } };
return ( <> <button type='button' onClick={handleAddProject}> Add project </button> <AddProjectDialogWrapper /> </> );};onConfirm calls closeDialog with the new project, which resolves the caller’s await. Cancel, the header close (X), Escape, and a backdrop click need no callback: they close the dialog with DialogResult.Cancelled. useDialog unmounts the dialog when it closes, so it starts with an empty name the next time it opens.
How the dialog closes
Section titled “How the dialog closes”The footer buttons, the header close (X), Escape, and a backdrop click all go through the same rules. “Closes” means that the dialog calls closeDialog(result) from the surrounding useDialog context, without a value.
| User action | Dedicated callback present | Only onClose present | No callback |
|---|---|---|---|
Ok / Yes | onConfirm() runs; closes only if it returns true | onClose(result) runs; closes unless it returns false | Closes with DialogResult.Ok / Yes |
Cancel / No, X, Escape, backdrop | onCancel() runs; closes only if it returns true | onClose(result) runs; closes unless it returns false | Closes with DialogResult.Cancelled / No |
The X, Escape, and backdrop report DialogResult.Cancelled, even in a YesNo dialog. Callbacks may be async; the dialog awaits them before deciding.
A callback that calls closeDialog(...) itself has already closed the dialog, whatever it returns. That is how you return a value, as in the example above.
Outside useDialog there is no dialog context, so nothing closes automatically. Control visible yourself and hide the dialog from the callbacks:
const [visible, setVisible] = useState(false);
<Dialog title='Details' visible={visible} buttons={DialogButtons.Ok} onClose={() => setVisible(false)}> Details go here.</Dialog>;title: Dialog header textsubtitle: Optional secondary line under the title — a reference, a status, a short summary. It is rendered in the header as thesubtitlepart and announced as the dialog’s descriptionplacement:'center'(default),'start'or'end'— see PlacementcloseIcon: Glyph for the header close (X). Defaults to a multiplication sign; pass your icon set’s close icon to match the rest of the productvisible: Controls visibility (defaults totrue)onConfirm: Callback for confirm actions; returntrueto close. See How the dialog closesonCancel: Callback for cancel actions, the X,Escape, and a backdrop click; returntrueto closeonClose: Fallback close callback for whichever ofonConfirm/onCancelis absent; closes unless it returnsfalsebuttons: PredefinedDialogButtons(Ok,OkCancel,YesNo,YesNoCancel),nullfor no footer, or a custom React node. Defaults toDialogButtons.OkCancel. Anything other than aDialogButtonsvalue also removes the close (X), stopsEscapeclosing the dialog, and leavesonConfirm/onCancel/onCloseuncalled — the dialog cannot tell which of your buttons means what, so a custom footer must close the dialog itself throughuseDialogContext().closeDialog(...), or opt the dismiss affordances back in explicitly withdismissabledismissable: Whether the header close (X), a backdrop click andEscapeare offered (see below)closeAriaLabel: Accessible name for the header close button. Defaults to theCratisComponentsProvider’smessages.dialog.close, then'Close'— override it to localize a single dialogwidth: Dialog width (defaults to'450px')style: Custom dialog stylecontentStyle: Custom content area styleresizable: Accepted for source compatibility; the viewport-bounded Cratis dialog has no resize handle. Existing code that passes it keeps compiling; it simply has no effect.isValid: Enables or disables confirm actions (defaults totrue)isBusy: Whentrue, disables all buttons, blocks Escape/backdrop dismissal, and shows a loading spinner on the primary action buttoninitialFocus: Where keyboard focus lands when the dialog opens (see below)okLabel,cancelLabel,yesLabel,noLabel: Button labels. Each resolves from the prop, then theCratisComponentsProvider’s matchingmessages.dialogentry (ok,cancel,yes,no), then its English default ('Ok','Cancel','Yes','No') — localize every dialog at once through the provider, or one dialog through the prop. Footer icons are decorative and hidden from accessibility APIs, so each button’s accessible name is exactly its configured label.CommandDialogforwards these same props straight through toDialog, so the same precedence covers it.StepperCommandDialogusesokLabelfor its Submit button (falling back tomessages.stepper.submit) andcancelLabelfor its optional footer Cancel.className,pt: Styling hooks for the Cratis-owned dialog root and stable parts — see the pass-through cheat sheetptOptions,unstyled: Retained temporarily for source compatibility; ignored because Cratis part attributes always merge and styling is CSS-owned
Dismissing
Section titled “Dismissing”A dialog is dismissable when the header close (X), a backdrop click and
Escape are all offered. Those three affordances are controlled by one switch, so they
are always on or off together.
By default the dialog works out which it should be from buttons: a predefined
DialogButtons set is dismissable, a custom ReactNode footer or null is
not. That mirrors the v10 behavior and is usually what you want — if the
dialog renders your buttons, it does not know which one means “get me out of
here”, so it declines to invent one.
Set dismissable explicitly to override that:
<Dialog title="Choose a plan" buttons={<MyOwnFooter />} dismissable closeAriaLabel="Close plan chooser" onCancel={() => closeDialog(DialogResult.Cancelled)}> …</Dialog>This is what StepperCommandDialog does for its wizard chrome: it renders a
custom footer and still keeps a header X — and withdraws it again, along with
Escape and the backdrop, for the whole window a command is executing in.
Placement
Section titled “Placement”placement decides where the dialog sits. center is the classic modal. start and end turn the
same dialog into a full-height side sheet against that inline edge of the viewport — a detail pane,
an internal note, a navigation list on a small screen — sliding in from the edge (and not at all under
prefers-reduced-motion). width is the sheet’s width; the sheet never exceeds the viewport.
The excerpt assumes closeDialog from useDialogContext(), DialogInitialFocus from @cratis/components/Dialogs, and your own ActivityTimeline component.
<Dialog title='Activity' subtitle='Example Project - Demo Organization' placement='end' width='440px' buttons={null} dismissable initialFocus={DialogInitialFocus.Content} onCancel={() => closeDialog(DialogResult.Cancelled)}> <ActivityTimeline /></Dialog>Nothing else changes with placement: the focus trap, Escape, backdrop dismissal, focus restore,
isBusy and initialFocus behave identically, and the same parts are there to style. The backdrop,
positioner and root parts carry data-placement so CSS can target one placement:
.cratis-dialog[data-placement='end'] { box-shadow: var(--product-shadow-sheet);}Initial focus
Section titled “Initial focus”By default the confirm button is focused when a dialog opens, which makes the
common “read it, press Enter” flow cost one keystroke. That default also arms
the confirm button: browsers fire click from the keydown of Enter, so a
key still held down from the control that opened the dialog — or the ordinary
habit of pressing Enter twice — confirms it immediately.
A plain Dialog does not validate its content: confirm is enabled unless you
pass isValid={false}. Derive isValid from your own input state so confirm
stays disabled until the input is complete. (CommandDialog does this for you
from the command’s validation.) A dialog that needs no input has no such
gate, which is exactly backwards when the action is destructive. Say where
focus should go with initialFocus:
DialogInitialFocus | Focuses |
|---|---|
Confirm (default) | The Ok / Yes button |
Cancel | The dismissing button — Cancel, or No when the set has no Cancel |
Content | The dialog’s own title, so nothing is armed |
import { Dialog, DialogInitialFocus } from '@cratis/components/Dialogs';import { DialogButtons } from '@cratis/arc.react/dialogs';
export const DeletePersonalDataDialog = () => ( <Dialog title='Delete personal data?' buttons={DialogButtons.YesNo} initialFocus={DialogInitialFocus.Cancel} > This permanently removes the person and every record about them. </Dialog>);Opened through useDialog, the caller receives DialogResult.Yes or DialogResult.No from the buttons, and DialogResult.Cancelled from the X, Escape, or a backdrop click.
Cancel falls back to Content when the button set has nothing to dismiss
with (DialogButtons.Ok, a custom footer, or no footer). Focus never stays on
document.body: a modal that does not move focus into itself leaves keyboard
and screen-reader users stranded outside the content that just interrupted
them.
initialFocus is forwarded by CommandDialog, and it changes only focus —
the footer, the close (X), Escape, and every callback keep working. That is
the difference from the older workaround of replacing buttons with a custom
node, which silently gives all of those up.
- Prefer
onConfirmandonCanceloveronClosefor clear intent. onConfirmandonCancelmust returntrue, or callcloseDialogthemselves, to close the dialog.onClosecloses unless it returnsfalse.- For typed, awaitable dialogs, let the dialog call
closeDialog(...)fromuseDialogContext<TRequest, TResult>(). - While
isBusyistrue, the X, the secondary buttons,Escape, and the backdrop are disabled, and the content is disabled and inert. The confirm button stays focusable but isaria-disabledand ignores activation, so keyboard focus is never stranded on the page body and the dialog cannot be submitted twice. A custom footer is disabled and inert as a whole. - When
onConfirm(oronClose) keeps the dialog open after a confirm, focus returns to the confirm button (to the title when confirm is disabled byisValid) and a polite live region inside the dialog announces the outcome. The text resolves from the provider’smessages.dialog.refused, then'The action was not completed. Review the dialog and try again.'. A heldEnterorSpaceis ignored on the confirm button, so returning focus never re-submits.