Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/lucky-donuts-invite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@clerk/ui': patch
---

Add a `closedBy` prop to the Mosaic `Dialog`, controlling which gestures dismiss it: `any` (Escape and outside press, the default), `closerequest` (Escape only), or `none` (neither — the dialog closes only programmatically). Use `closerequest` for dialogs holding user input so a stray backdrop click cannot discard it.

The `trigger` prop is now optional, so a dialog driven entirely by `open` no longer has to render a trigger button it does not need.
35 changes: 29 additions & 6 deletions packages/headless/src/primitives/dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,35 @@ const [open, setOpen] = useState(false);

### `Dialog.Root`

| Prop | Type | Default | Description |
| -------------- | ------------------------- | ------- | --------------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
| Prop | Type | Default | Description |
| -------------- | ----------------------------------- | ------- | --------------------------------------- |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes |
| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog |

#### `closedBy`

Mirrors the native `<dialog closedby>` attribute.

| Value | Escape | Outside press | Programmatic |
| -------------- | ------ | ------------- | ------------ |
| `any` | ✅ | ✅ | ✅ |
| `closerequest` | ✅ | ❌ | ✅ |
| `none` | ❌ | ❌ | ✅ |

```tsx
// A form dialog: Escape backs out, a stray backdrop click doesn't discard input.
<Dialog.Root closedBy='closerequest'>{/* ... */}</Dialog.Root>
```

Reach for `closerequest` on anything holding user input or confirming a destructive action.
Reserve `none` for flows the user genuinely must complete or explicitly acknowledge — it removes
the keyboard exit, so it fails the usual expectation that Escape dismisses a modal.

A single ordered enum rather than two booleans: it keeps the fourth combination — outside press
dismisses but Escape does not — unrepresentable.

### `Dialog.Portal`

Expand Down
19 changes: 18 additions & 1 deletion packages/headless/src/primitives/dialog/dialog-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,33 @@ import { useReturnFocus } from '../../hooks/use-return-focus';
import { useTransition } from '../../hooks/use-transition';
import { DialogContext, type DialogContextValue } from './dialog-context';

/**
* Which gestures dismiss the dialog, mirroring the native `<dialog closedby>` attribute.
*
* - `any` — Escape and outside press
* - `closerequest` — Escape only
* - `none` — neither; the dialog closes only programmatically
*
* A single ordered enum rather than two booleans, so the fourth combination — outside press
* dismisses but Escape does not — stays unrepresentable. Dismissing by pointer but not by
* keyboard is not something to offer.
*/
Comment on lines +21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the shared none dismissal description.

closedBy='none' disables Escape and outside press. It does not disable explicit close controls such as Dialog.Close.

  • packages/headless/src/primitives/dialog/dialog-root.tsx#L21-L31: replace “only programmatically” with wording that preserves explicit close controls.
  • packages/headless/src/primitives/dialog/README.md#L74-L94: describe the disabled dismissal gestures and explicit close behavior.
  • .changeset/lucky-donuts-invite.md#L5-L5: use the same corrected contract in the release note.
📍 Affects 3 files
  • packages/headless/src/primitives/dialog/dialog-root.tsx#L21-L31 (this comment)
  • packages/headless/src/primitives/dialog/README.md#L74-L94
  • .changeset/lucky-donuts-invite.md#L5-L5
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/dialog/dialog-root.tsx` around lines 21 -
31, Update the shared closedBy='none' contract in
packages/headless/src/primitives/dialog/dialog-root.tsx lines 21-31,
packages/headless/src/primitives/dialog/README.md lines 74-94, and
.changeset/lucky-donuts-invite.md line 5: state that Escape and outside-press
dismissal are disabled while explicit close controls such as Dialog.Close remain
available. Use consistent wording across all three sites.

export type DialogClosedBy = 'any' | 'closerequest' | 'none';

export interface DialogProps {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** When true, the dialog traps focus and blocks interaction with the rest of the page. Default: true */
modal?: boolean;
/** Which gestures dismiss the dialog. Default: `any` */
closedBy?: DialogClosedBy;
children: ReactNode;
}

function DialogInner(props: DialogProps) {
const nodeId = useFloatingNodeId();
const { modal = true, children } = props;
const { modal = true, closedBy = 'any', children } = props;

const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange);

Expand All @@ -54,6 +69,8 @@ function DialogInner(props: DialogProps) {
const click = useClick(floatingContext);
const dismiss = useDismiss(floatingContext, {
outsidePressEvent: 'mousedown',
escapeKey: closedBy !== 'none',
outsidePress: closedBy === 'any',
});
const role = useRole(floatingContext);

Expand Down
60 changes: 60 additions & 0 deletions packages/headless/src/primitives/dialog/dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,66 @@ describe('Dialog', () => {
});
});

describe('closedBy', () => {
// The viewport is the element outside the popup that a light dismiss lands on.
const pressOutside = async (user: ReturnType<typeof userEvent.setup>) =>
user.click(screen.getByTestId('dialog-viewport'));

it('defaults to dismissing on both Escape and outside press', async () => {
const user = userEvent.setup();
renderDialog({ defaultOpen: true });

await pressOutside(user);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();

await user.click(screen.getByRole('button', { name: 'Open dialog' }));
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('closerequest dismisses on Escape but not outside press', async () => {
const user = userEvent.setup();
renderDialog({ defaultOpen: true, closedBy: 'closerequest' });

await pressOutside(user);
expect(screen.getByRole('dialog')).toBeInTheDocument();

await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('none dismisses on neither', async () => {
const user = userEvent.setup();
renderDialog({ defaultOpen: true, closedBy: 'none' });

await pressOutside(user);
await user.keyboard('{Escape}');

expect(screen.getByRole('dialog')).toBeInTheDocument();
});

it('leaves the Close button working regardless of closedBy', async () => {
const user = userEvent.setup();
renderDialog({ defaultOpen: true, closedBy: 'none' });

await user.click(screen.getByRole('button', { name: 'Close' }));

expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});

it('leaves controlled open authoritative regardless of closedBy', async () => {
const onOpenChange = vi.fn();
const user = userEvent.setup();
renderDialog({ open: true, closedBy: 'none', onOpenChange });

await pressOutside(user);
await user.keyboard('{Escape}');

expect(onOpenChange).not.toHaveBeenCalled();
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
});

describe('focus management', () => {
it('moves focus into dialog on open', async () => {
const user = userEvent.setup();
Expand Down
1 change: 1 addition & 0 deletions packages/headless/src/primitives/dialog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type { DialogContextValue } from './dialog-context';

export type {
DialogBackdropProps,
DialogClosedBy,
DialogCloseProps,
DialogDescriptionProps,
DialogPopupProps,
Expand Down
2 changes: 1 addition & 1 deletion packages/headless/src/primitives/dialog/parts.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { type DialogProps, DialogRoot as Root } from './dialog-root';
export { type DialogClosedBy, type DialogProps, DialogRoot as Root } from './dialog-root';
export { type DialogTriggerProps, DialogTrigger as Trigger } from './dialog-trigger';
export { type DialogPortalProps, DialogPortal as Portal } from './dialog-portal';
export { type DialogBackdropProps, DialogBackdrop as Backdrop } from './dialog-backdrop';
Expand Down
35 changes: 29 additions & 6 deletions packages/swingset/src/stories/dialog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ const [open, setOpen] = useState(false);
<Dialog.Root modal={false}>{/* focus is not trapped and the rest of the page stays interactive */}</Dialog.Root>
```

### Controlling dismissal

```tsx
// Escape still backs out, but a stray click on the backdrop won't discard the form.
<Dialog.Root closedBy='closerequest'>{/* trigger + portal */}</Dialog.Root>
```

Reach for `closerequest` on anything holding user input or confirming a destructive action.
Reserve `none` for flows the user must complete or explicitly acknowledge — it removes the
keyboard exit, so it breaks the usual expectation that Escape dismisses a modal.

## Parts

| Part | Default Element | Description |
Expand All @@ -80,12 +91,24 @@ for centered, scroll-locked modal behavior nest `Dialog.Popup` inside `Dialog.Vi

### `Dialog.Root`

| Prop | Type | Default | Description |
| ------------------------- | ------------------------------------ | ------------------ | ---------------------------------------------- |
| <code>open</code> | <code>boolean</code> | — | Controlled open state |
| <code>defaultOpen</code> | <code>boolean</code> | <code>false</code> | Initial open state (uncontrolled) |
| <code>onOpenChange</code> | <code>(open: boolean) => void</code> | — | Called when the open state changes |
| <code>modal</code> | <code>boolean</code> | <code>true</code> | Trap focus and make the rest of the page inert |
| Prop | Type | Default | Description |
| ------------------------- | ---------------------------------------------- | ------------------ | ---------------------------------------------- |
| <code>open</code> | <code>boolean</code> | — | Controlled open state |
| <code>defaultOpen</code> | <code>boolean</code> | <code>false</code> | Initial open state (uncontrolled) |
| <code>onOpenChange</code> | <code>(open: boolean) => void</code> | — | Called when the open state changes |
| <code>modal</code> | <code>boolean</code> | <code>true</code> | Trap focus and make the rest of the page inert |
| <code>closedBy</code> | <code>'any' \| 'closerequest' \| 'none'</code> | <code>'any'</code> | Which gestures dismiss the dialog |

<code>closedBy</code> mirrors the native <code>&lt;dialog closedby&gt;</code> attribute:

| Value | Escape | Outside press | Programmatic |
| ------------------------- | ------ | ------------- | ------------ |
| <code>any</code> | yes | yes | yes |
| <code>closerequest</code> | yes | no | yes |
| <code>none</code> | no | no | yes |

A single ordered enum rather than two booleans, so the fourth combination — outside press
dismisses but Escape does not — stays unrepresentable.

### `Dialog.Portal`

Expand Down
5 changes: 3 additions & 2 deletions packages/swingset/src/stories/dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ export function Default() {
<Dialog.Root>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop>
<Dialog.Backdrop />
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>Confirm action</Dialog.Title>
<Dialog.Description>
This is an unstyled dialog. Press Escape or click outside to dismiss.
</Dialog.Description>
<Dialog.Close>Cancel</Dialog.Close>
</Dialog.Popup>
</Dialog.Backdrop>
</Dialog.Viewport>
</Dialog.Portal>
</Dialog.Root>
);
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/mosaic/block/destructive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function Destructive({

return (
<Dialog
closedBy='closerequest'
open={open}
onOpenChange={onOpenChange}
trigger={trigger}
Expand Down
16 changes: 12 additions & 4 deletions packages/ui/src/mosaic/components/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,15 @@ const Popup = React.forwardRef<HTMLDivElement, DialogPopupProps>(function Dialog
);
});

interface DialogProps extends Pick<HeadlessDialogProps, 'open' | 'defaultOpen' | 'onOpenChange' | 'modal'> {
trigger: MosaicComponentProps<'button'>['render'];
interface DialogProps extends Pick<
HeadlessDialogProps,
'open' | 'defaultOpen' | 'onOpenChange' | 'modal' | 'closedBy'
> {
/**
* Renders the button that opens the dialog. Omit for dialogs driven entirely by `open` —
* opened from a menu item, a route, or a state machine — where there is no trigger to render.
*/
trigger?: MosaicComponentProps<'button'>['render'];
children: ReactNode | ((ctx: { close: () => void }) => ReactNode);
size?: DialogVariantProps['size'];
}
Expand All @@ -134,16 +141,17 @@ function DialogContent({ children }: { children: DialogProps['children'] }) {
return <>{children({ close: () => setOpen(false) })}</>;
}

export function Dialog({ trigger, children, size, open, defaultOpen, onOpenChange, modal }: DialogProps) {
export function Dialog({ trigger, children, size, open, defaultOpen, onOpenChange, modal, closedBy }: DialogProps) {
return (
<DialogVariantContext.Provider value={{ size }}>
<Primitive.Root
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
modal={modal}
closedBy={closedBy}
>
<Primitive.Trigger render={trigger} />
{trigger ? <Primitive.Trigger render={trigger} /> : null}
<Primitive.Portal>
<Backdrop />
<Viewport>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export function OrganizationProfileDomainsSectionAddVerifyView({

return (
<Dialog.Root
closedBy='closerequest'
open={isOpen}
onOpenChange={open => {
if (!open) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function OrganizationProfileDomainsSectionEnrollmentView({

return (
<Dialog.Root
closedBy='closerequest'
open={isOpen}
onOpenChange={open => {
if (!open) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function OrganizationProfileDomainsSectionRemoveView({

return (
<Dialog.Root
closedBy='closerequest'
open={isOpen}
onOpenChange={open => {
if (!open) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export function OrganizationProfileProfileSectionView({
)}
</Box>
<Dialog
closedBy='closerequest'
open={isOpen}
onOpenChange={open => send({ type: open ? 'OPEN' : 'CANCEL' })}
trigger={props => (
Expand Down
Loading