Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion .ai/rules/dialogs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';

<Dialog title="Delete personal data?" buttons={DialogButtons.YesNo}
initialFocus={DialogInitialFocus.Cancel}
onConfirm={...} onCancel={...}>
This permanently removes the person and every record about them.
</Dialog>
```

| `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:
Expand Down Expand Up @@ -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 |
Expand Down
28 changes: 28 additions & 0 deletions Documentation/CommandDialog/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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';

<CommandDialog<DeletePersonalData>
command={DeletePersonalData}
title="Delete personal data?"
okLabel="Delete"
initialFocus={DialogInitialFocus.Cancel}
onSuccess={() => closeDialog(DialogResult.Ok)}>
This cannot be undone.
</CommandDialog>
```

## Busy State

`CommandDialog` automatically manages a busy state during command execution:
Expand Down
58 changes: 57 additions & 1 deletion Documentation/Dialogs/dialog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Dialog
title='Delete personal data?'
buttons={DialogButtons.YesNo}
initialFocus={DialogInitialFocus.Cancel}
onConfirm={() => closeDialog(DialogResult.Yes)}
onCancel={() => closeDialog(DialogResult.No)}
>
This permanently removes the person and every record about them.
</Dialog>
);
};
```

`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.
Expand Down
95 changes: 95 additions & 0 deletions Source/CommandDialog/CommandDialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof CommandDialog> = {
Expand Down Expand Up @@ -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<string>('');

class NothingToValidate extends CommandValidator {
}

class DeletePersonalDataCommand extends Command<object> {
readonly route: string = '/api/people/delete';
readonly validation: CommandValidator = new NothingToValidate();
readonly propertyDescriptors: PropertyDescriptor[] = [
new PropertyDescriptor('personId', String),
];

personId = '';

constructor() {
super(Object, false);
}

get requestParameters(): string[] {
return [];
}

get properties(): string[] {
return ['personId'];
}

override async validate(): Promise<CommandResult<object>> {
return CommandResult.empty;
}

override async execute(): Promise<CommandResult<object>> {
await new Promise(resolve => setTimeout(resolve, 300));
return CommandResult.empty;
}
}

const DeletePersonalDataDialog = () => {
const { closeDialog } = useDialogContext<CommandResult<object>>();

return (
<CommandDialog<DeletePersonalDataCommand>
command={DeletePersonalDataCommand}
title="Delete personal data?"
okLabel="Delete"
cancelLabel="Keep"
autoServerValidate={false}
initialFocus={DialogInitialFocus.Cancel}
initialValues={{ personId: '8f1b9c1e-0000-4000-8000-000000000000' }}
onSuccess={() => closeDialog(DialogResult.Ok)}
onCancel={() => closeDialog(DialogResult.Cancelled)}
>
<p>This permanently removes the person and every record about them. It cannot be undone.</p>
</CommandDialog>
);
};

const [DeletePersonalDataDialogComponent, showDeletePersonalDataDialog] = useDialog<CommandResult<object>>(DeletePersonalDataDialog);

return (
<div className="storybook-wrapper">
<button
className="p-button p-component mb-3"
onClick={async () => {
const [dialogResult] = await showDeletePersonalDataDialog();
setResult(dialogResult === DialogResult.Ok ? 'Deleted' : 'Kept');
}}
>
Delete
</button>

{result && (
<div className="p-3 mt-3 bg-green-100 border-round">
<strong>Outcome:</strong> {result}
</div>
)}

<DeletePersonalDataDialogComponent />
</div>
);
},
};
31 changes: 31 additions & 0 deletions Source/CommandDialog/CommandDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const CommandDialogWrapper = <TCommand extends object, TResponse = object>({
contentStyle,
resizable,
buttons,
initialFocus,
okLabel,
cancelLabel,
yesLabel,
Expand All @@ -82,6 +83,7 @@ const CommandDialogWrapper = <TCommand extends object, TResponse = object>({
contentStyle?: DialogProps['contentStyle'];
resizable?: boolean;
buttons?: DialogProps['buttons'];
initialFocus?: DialogProps['initialFocus'];
okLabel?: string;
cancelLabel?: string;
yesLabel?: string;
Expand Down Expand Up @@ -176,6 +178,7 @@ const CommandDialogWrapper = <TCommand extends object, TResponse = object>({
contentStyle={contentStyle}
resizable={resizable}
buttons={buttons}
initialFocus={initialFocus}
onClose={onClose}
onConfirm={handleConfirm}
onCancel={onCancel}
Expand Down Expand Up @@ -231,6 +234,32 @@ const CommandDialogWrapper = <TCommand extends object, TResponse = object>({
* Throughout, the dialog is in the `isBusy` state — every action button is
* disabled and the confirm button shows a spinner.
*
* ## 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 `isCommandFormValid` 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` (forwarded straight to {@link Dialog}) for those:
*
* ```tsx
* <CommandDialog<DeletePerson>
* command={DeletePerson}
* title="Delete personal data?"
* okLabel="Delete"
* initialFocus={DialogInitialFocus.Cancel}
* onSuccess={() => closeDialog(DialogResult.Ok)}>
* This cannot be undone.
* </CommandDialog>
* ```
*
* Everything else — the footer, the close (X), `Escape`, and the confirm
* wiring that runs the command — is untouched.
*
* ## Field binding
*
* Children that are `CommandFormField` instances (`InputTextField`,
Expand Down Expand Up @@ -296,6 +325,7 @@ const CommandDialogComponent = <TCommand extends object = object, TResponse = ob
contentStyle,
resizable,
buttons = DialogButtons.OkCancel,
initialFocus,
okLabel,
cancelLabel,
yesLabel,
Expand Down Expand Up @@ -323,6 +353,7 @@ const CommandDialogComponent = <TCommand extends object = object, TResponse = ob
contentStyle={contentStyle}
resizable={resizable}
buttons={buttons}
initialFocus={initialFocus}
okLabel={okLabel}
cancelLabel={cancelLabel}
yesLabel={yesLabel}
Expand Down
Loading
Loading