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
2 changes: 2 additions & 0 deletions .changeset/lucky-donuts-invite.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
35 changes: 29 additions & 6 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 thread
coderabbitai[bot] marked this conversation as resolved.
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
2 changes: 2 additions & 0 deletions .changeset/lucky-donuts-invite.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
35 changes: 29 additions & 6 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 thread
coderabbitai[bot] marked this conversation as resolved.
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 2 additions & 0 deletions .changeset/lucky-donuts-invite.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
35 changes: 29 additions & 6 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 thread
coderabbitai[bot] marked this conversation as resolved.
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 2 additions & 0 deletions .changeset/lucky-donuts-invite.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
35 changes: 29 additions & 6 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 thread
coderabbitai[bot] marked this conversation as resolved.
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
2 changes: 2 additions & 0 deletions .changeset/lucky-donuts-invite.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
35 changes: 29 additions & 6 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 thread
coderabbitai[bot] marked this conversation as resolved.
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 2 additions & 0 deletions .changeset/lucky-donuts-invite.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
35 changes: 29 additions & 6 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 thread
coderabbitai[bot] marked this conversation as resolved.
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
2 changes: 2 additions & 0 deletions .changeset/lucky-donuts-invite.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
35 changes: 29 additions & 6 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 thread
coderabbitai[bot] marked this conversation as resolved.
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
2 changes: 2 additions & 0 deletions .changeset/lucky-donuts-invite.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---
---
35 changes: 29 additions & 6 deletions packages/headless/src/primitives/dialog/README.md
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 thread
coderabbitai[bot] marked this conversation as resolved.
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 numberDiff line numberDiff 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