diff --git a/.ai/rules/dialogs.md b/.ai/rules/dialogs.md
index bf1bf20..59a5700 100644
--- a/.ai/rules/dialogs.md
+++ b/.ai/rules/dialogs.md
@@ -140,6 +140,32 @@ import { DialogButtons, DialogResult, useDialogContext } from '@cratis/arc.react
| `DialogButtons.Ok` | Ok only |
| `null` | No buttons (content-only dialog) |
+A custom `buttons` ReactNode is not just a different footer — the dialog can no longer tell which of your buttons means confirm and which means dismiss, so it also **removes the close (X), stops `Escape` closing the dialog, and never calls `onConfirm` / `onCancel` / `onClose`** (including the confirm handler `CommandDialog` uses to execute its command). A custom footer must close the dialog itself via `useDialogContext().closeDialog(...)`.
+
+## Initial Focus on Destructive Dialogs
+
+`Dialog` (and `CommandDialog`, which forwards it) focuses the confirm button when the dialog opens. A focused native button fires `click` from the **keydown** of `Enter`, so a key still held from the control that opened the dialog — or the ordinary habit of pressing `Enter` twice — confirms it immediately.
+
+A dialog that collects input is protected for free, because `isValid` / `isCommandFormValid` keeps confirm disabled until the form is complete. A dialog that needs **no** input is not — which is backwards when the action is irreversible. Say where focus should go instead:
+
+```tsx
+import { DialogInitialFocus } from '@cratis/components/Dialogs';
+
+
+```
+
+| `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 |
+
+`Cancel` falls back to `Content` when there is no dismissing button. Use `initialFocus` rather than a custom footer for this — it changes focus and nothing else.
+
## Customizing Built-in Buttons
Use `okLabel`/`cancelLabel` to rename the buttons, and `isValid` to disable the confirm button:
@@ -213,7 +239,8 @@ Use `buttons={null}` for dialogs that contain their own internal actions (e.g. a
|---|---|---|
| `title` | `string` | Header text (replaces PrimeReact `header`) |
| `visible` | `boolean` | Controls visibility |
-| `buttons` | `DialogButtons \| ReactNode \| null` | Prefer `DialogButtons` enum; `null` for no footer |
+| `buttons` | `DialogButtons \| ReactNode \| null` | Prefer `DialogButtons` enum; `null` for no footer. Anything but a `DialogButtons` value also drops the close (X), `Escape`, and the confirm/cancel callbacks |
+| `initialFocus` | `DialogInitialFocus` | Where focus lands on open — `Confirm` (default), `Cancel`, `Content` |
| `isValid` | `boolean` | Disables the confirm button when `false` |
| `okLabel` | `string` | Override the Ok/Confirm button label |
| `cancelLabel` | `string` | Override the Cancel button label |
diff --git a/Documentation/CommandDialog/index.md b/Documentation/CommandDialog/index.md
index 5b7b70d..2eb0580 100644
--- a/Documentation/CommandDialog/index.md
+++ b/Documentation/CommandDialog/index.md
@@ -104,6 +104,7 @@ function MyComponent() {
- `cancelLabel`: Custom text for cancel button (default: "Cancel")
- `yesLabel`, `noLabel`: Labels for `YesNo` and `YesNoCancel` button modes
- `buttons`: `DialogButtons` value or custom footer content
+- `initialFocus`: Where keyboard focus lands when the dialog opens — forwarded to `Dialog` (see below)
- `resizable`: Whether dialog can be resized
- `isValid`: Additional validity gate combined with command form validity
- `onFieldValidate`: Custom validation function for fields
@@ -135,6 +136,33 @@ Multiple callbacks may fire for the same execution. For example, both `onFailed`
- `onCancel` follows the same behavior as `Dialog` (`true` closes).
- `onClose` closes unless it returns `false`.
+## Destructive Commands and Initial Focus
+
+The confirm button is focused when the dialog opens, and a focused native button
+fires `click` from the `keydown` of `Enter`. A command whose form has required
+fields is protected from a held or double-tapped `Enter` for free, because the
+form's validity keeps confirm disabled until something is filled in. A command
+that takes **no** input — the typical "delete this, permanently" command — has
+no such gate, so its confirm button is armed the instant the dialog appears.
+
+Pass `initialFocus` for those. It is forwarded straight to
+[`Dialog`](../Dialogs/dialog.md#initial-focus) and changes nothing else — the
+footer, the close (X), `Escape`, and the confirm wiring that runs the command
+all stay intact.
+
+```tsx
+import { DialogInitialFocus } from '@cratis/components/Dialogs';
+
+
+ command={DeletePersonalData}
+ title="Delete personal data?"
+ okLabel="Delete"
+ initialFocus={DialogInitialFocus.Cancel}
+ onSuccess={() => closeDialog(DialogResult.Ok)}>
+ This cannot be undone.
+
+```
+
## Busy State
`CommandDialog` automatically manages a busy state during command execution:
diff --git a/Documentation/Dialogs/dialog.md b/Documentation/Dialogs/dialog.md
index 8719c04..47f8142 100644
--- a/Documentation/Dialogs/dialog.md
+++ b/Documentation/Dialogs/dialog.md
@@ -66,15 +66,71 @@ const MyComponent = () => {
- `onConfirm`: Callback for confirm actions
- `onCancel`: Callback for cancel actions
- `onClose`: Fallback close callback
-- `buttons`: Predefined `DialogButtons` or custom footer content
+- `buttons`: Predefined `DialogButtons` or custom footer content. A custom
+ footer also removes the close (X), stops `Escape` closing the dialog, and
+ leaves `onConfirm` / `onCancel` / `onClose` uncalled — the dialog cannot tell
+ which of your buttons means what, so a custom footer must close the dialog
+ itself through `useDialogContext().closeDialog(...)`
- `width`: Dialog width
- `style`: Custom dialog style forwarded to PrimeReact `Dialog`
- `contentStyle`: Custom content area style forwarded to PrimeReact `Dialog`
- `resizable`: Enables resize
- `isValid`: Enables or disables confirm actions
- `isBusy`: When `true`, disables all buttons and shows a loading spinner on the primary action button
+- `initialFocus`: Where keyboard focus lands when the dialog opens (see below)
- `okLabel`, `cancelLabel`, `yesLabel`, `noLabel`: Button labels
+## 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 dialog with input is protected from this for free, because `isValid` keeps
+confirm disabled until the form is complete. A dialog that needs **no** input
+is not, 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 |
+
+```typescript
+import { Dialog, DialogInitialFocus } from '@cratis/components/Dialogs';
+import { DialogButtons, DialogResult, useDialogContext } from '@cratis/arc.react/dialogs';
+
+const DeletePersonalDataDialog = () => {
+ const { closeDialog } = useDialogContext();
+
+ return (
+
+ );
+};
+```
+
+`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.
+
## Notes
- Prefer `onConfirm` and `onCancel` over `onClose` for clear intent.
diff --git a/Source/CommandDialog/CommandDialog.stories.tsx b/Source/CommandDialog/CommandDialog.stories.tsx
index 6300e8d..166cb3b 100644
--- a/Source/CommandDialog/CommandDialog.stories.tsx
+++ b/Source/CommandDialog/CommandDialog.stories.tsx
@@ -8,6 +8,7 @@ import { Command, CommandResult, CommandValidator } from '@cratis/arc/commands';
import { PropertyDescriptor } from '@cratis/arc/reflection';
import { InputTextField, NumberField, TextAreaField } from '../CommandForm/fields';
import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs';
+import { DialogInitialFocus } from '../Dialogs/DialogInitialFocus';
import '@cratis/arc/validation';
const meta: Meta = {
@@ -849,3 +850,97 @@ export const WithResponseTypeAndCallbacks: Story = {
);
},
};
+
+/**
+ * A destructive command that needs **no** input. Every other story here is
+ * protected from a held or double-tapped `Enter` for free, because
+ * `isCommandFormValid` keeps the confirm button disabled until its fields are
+ * filled in — but a command with no fields is valid the moment it appears, so
+ * its confirm button is armed on mount.
+ *
+ * `initialFocus` moves the keyboard off it without giving up the footer, the
+ * close (X), `Escape`, or the confirm wiring that runs the command.
+ */
+export const DestructiveCommandFocusesDismiss: Story = {
+ render: () => {
+ const [result, setResult] = useState('');
+
+ class NothingToValidate extends CommandValidator {
+ }
+
+ class DeletePersonalDataCommand extends Command