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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/dialog-close-confirmation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
11 changes: 11 additions & 0 deletions packages/headless/src/primitives/dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,17 @@ const detail = Dialog.createHandle<{ name: string }>();
</Dialog.Root>
```

An open with no trigger behind it can supply the payload directly: `handle.open(payload)` is the
programmatic counterpart, for a dialog raised by something that happened rather than by an element
— a confirmation that has to say what it is asking. A trigger-driven open supersedes it, since a
trigger names its own payload.

```tsx
const confirmation = Dialog.createHandle<{ question: string }>();

confirmation.open({ question: 'Discard changes?' });
```

In controlled mode, track which trigger is active with `triggerId` — `onOpenChange`'s second
argument reports the trigger behind each change:

Expand Down
24 changes: 16 additions & 8 deletions packages/headless/src/primitives/dialog/dialog-handle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ export interface DialogTriggerRegistration<Payload = unknown> {
* requests made with no root attached are ignored, matching Base UI.
* @internal
*/
export interface DialogRootController {
export interface DialogRootController<Payload = unknown> {
openFromTrigger: (id: string, event: Event) => void;
closeFromTrigger: (id: string, event: Event) => void;
setOpen: (open: boolean) => void;
setOpen: (open: boolean, payload?: Payload) => void;
}

/** The slice of root state a trigger renders from: its `data-open` / ARIA wiring. */
Expand All @@ -45,8 +45,16 @@ const CLOSED_STATE: DialogHandleState = { open: false, triggerId: null, popupId:
* lets a `DialogHandle<Payload>` flow into contexts typed `DialogHandle<unknown>`.
*/
export interface DialogHandle<Payload = unknown> {
/** Opens the attached root. Ignored while no root is mounted. */
open(): void;
/**
* Opens the attached root. Ignored while no root is mounted.
*
* The optional `payload` is the programmatic counterpart of a trigger's: it reaches the root's
* children-as-function as `{ payload }`, so an imperative open can carry the content the dialog
* is about — what a confirmation is asking, which record is being deleted — without the caller
* holding a second piece of state alongside `open`. A trigger-driven open supersedes it, since
* a trigger names its own payload.
*/
open(payload?: Payload): void;
/** Closes the attached root. Ignored while no root is mounted. */
close(): void;
/** Whether the attached root is open. `false` while no root is mounted. */
Expand All @@ -58,7 +66,7 @@ export interface DialogHandle<Payload = unknown> {
/** @internal */
getFirstTrigger(): DialogTriggerRegistration<Payload> | undefined;
/** @internal */
setRoot(controller: DialogRootController): () => void;
setRoot(controller: DialogRootController<Payload>): () => void;
/** @internal */
requestOpen(id: string, event: Event): void;
/** @internal */
Expand All @@ -79,14 +87,14 @@ export interface DialogHandle<Payload = unknown> {
export function createDialogHandle<Payload = unknown>(): DialogHandle<Payload> {
const triggers = new Map<string, DialogTriggerRegistration<Payload>>();
const listeners = new Set<() => void>();
let root: DialogRootController | null = null;
let root: DialogRootController<Payload> | null = null;
let state = CLOSED_STATE;

const notify = () => listeners.forEach(listener => listener());

return {
open() {
root?.setOpen(true);
open(payload) {
root?.setOpen(true, payload);
},
close() {
root?.setOpen(false);
Expand Down
37 changes: 35 additions & 2 deletions packages/headless/src/primitives/dialog/dialog-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
// consumed by the floating `onOpenChange` the request funnels into.
const pendingDetailsRef = useRef<DialogOpenChangeDetails | null>(null);

// The payload of the most recent programmatic `handle.open(payload)`, kept so the registry
// lookup below has something to fall back to when no trigger is involved.
const directPayloadRef = useRef<Payload | undefined>(undefined);

// Every open/close funnels through `floatingContext.onOpenChange` — trigger activations,
// dismissals, and programmatic `setOpen` alike. floating-ui emits its `openchange` event
// synchronously before invoking this callback, which is what lets listeners (`useReturnFocus`,
Expand All @@ -119,6 +123,9 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
openFromTrigger: (id, event) => {
const registration = store.getTrigger(id);
setActiveTriggerId(id);
// A trigger names its own payload, so it supersedes anything a previous programmatic
// open supplied — otherwise the stale one would resurface through the effect below.
directPayloadRef.current = undefined;
setActivePayload(registration?.getPayload());
if (registration) {
refs.setReference(registration.element);
Expand All @@ -131,7 +138,21 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
pendingDetailsRef.current = { trigger: registration?.element ?? null, triggerId: id, event };
floatingContext.onOpenChange(false, event, 'click');
},
setOpen: nextOpen => floatingContext.onOpenChange(nextOpen),
setOpen: (nextOpen, payload) => {
// Held in a ref as well as in state because the payload effect below re-runs on `open`
// and would otherwise resolve a trigger-less open to `undefined`, wiping this a commit
// after it was set. Cleared on close as well, so the next payload-less open does not
// resurface this one.
directPayloadRef.current = payload;
// Published only on the way IN. A close carries no payload, and writing it would blank
// the children-as-function while the popup is still mounted for its exit transition —
// rendering the dialog empty as it leaves, or throwing in a consumer that dereferences
// the payload. The next open sets it afresh.
if (nextOpen) {
setActivePayload(payload);
}
floatingContext.onOpenChange(nextOpen);
},
});
// `floatingContext` is rebuilt on open/element changes; re-registering is an idempotent swap.
}, [store, refs, floatingContext, setActiveTriggerId]);
Expand All @@ -155,9 +176,21 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
// `defaultOpen` — the payload is looked up from the registry once the dialog is open. Runs
// after the children's layout effects, so triggers rendered inside the root are registered by
// the time it reads, and the pre-paint re-render delivers their payload on the first frame.
//
// An explicit programmatic payload wins over the registry lookup: `activeTriggerId` is never
// reset on close, so once any trigger has opened the dialog the lookup would otherwise overwrite
// every later `handle.open(payload)` with that trigger's payload. The mirror already holds —
// `openFromTrigger` clears `directPayloadRef`, so a trigger wins the other way.
useLayoutEffect(() => {
if (open) {
setActivePayload(activeTriggerId != null ? store.getTrigger(activeTriggerId)?.getPayload() : undefined);
const direct = directPayloadRef.current;
setActivePayload(
direct !== undefined
? direct
: activeTriggerId != null
? store.getTrigger(activeTriggerId)?.getPayload()
: undefined,
);
}
}, [store, open, activeTriggerId]);

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

expect(screen.getByRole('dialog', { name: 'payload-b' })).toBeInTheDocument();
});

// The programmatic counterpart of a trigger's payload, for an open that no element initiated —
// a confirmation raised by a close request, say, which has to say what it is asking.
describe('handle.open(payload)', () => {
function renderDetached() {
const handle = Dialog.createHandle<string>();
render(
<Dialog.Root handle={handle}>
{({ payload }) => (
<>
<Dialog.Trigger id='trigger-a'>Open A</Dialog.Trigger>
<Dialog.Trigger
id='trigger-b'
payload='from-trigger-b'
>
Open B
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Viewport>
<Dialog.Popup>
<Dialog.Title>{payload ?? 'no payload'}</Dialog.Title>
</Dialog.Popup>
</Dialog.Viewport>
</Dialog.Portal>
</>
)}
</Dialog.Root>,
);
return handle;
}

it('delivers it to the children render function', () => {
const handle = renderDetached();

act(() => handle.open('from-handle'));

expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument();
});

it('survives the registry lookup that runs once the dialog is open', async () => {
const handle = renderDetached();

act(() => handle.open('from-handle'));
// The lookup effect re-runs on `open`; without a fallback it would resolve to `undefined`
// a commit later and blank the dialog.
await act(async () => {
await Promise.resolve();
});

expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument();
});

it('is superseded by a trigger, which names its own payload', async () => {
const user = userEvent.setup();
const handle = renderDetached();

act(() => handle.open('from-handle'));
act(() => handle.close());
await user.click(screen.getByRole('button', { name: 'Open A' }));

expect(screen.getByRole('dialog', { name: 'no payload' })).toBeInTheDocument();
});

it('supersedes the trigger that opened the dialog last', async () => {
const user = userEvent.setup();
const handle = renderDetached();

// `activeTriggerId` is never reset on close, so without an explicit precedence the
// registry lookup would hand this open the previous trigger's payload back.
await user.click(screen.getByRole('button', { name: 'Open B' }));
act(() => handle.close());
act(() => handle.open('from-handle'));
await act(async () => {
await Promise.resolve();
});

expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument();
});

it('survives a `handle.close()` for the length of the exit transition', () => {
// Keep an animation pending so the popup stays mounted after the close.
const original = (Element.prototype as { getAnimations?: unknown }).getAnimations;
(Element.prototype as { getAnimations?: unknown }).getAnimations = () => [
{ finished: new Promise<void>(() => {}) },
];
try {
const handle = renderDetached();

act(() => handle.open('from-handle'));
act(() => handle.close());

// `close()` carries no payload; blanking it here would render the dialog empty on its
// way out, or throw in a children function that dereferences it.
expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument();
} finally {
if (original) {
(Element.prototype as { getAnimations?: unknown }).getAnimations = original;
} else {
delete (Element.prototype as { getAnimations?: unknown }).getAnimations;
}
}
});
});
});

describe('initialFocus', () => {
Expand Down
65 changes: 65 additions & 0 deletions packages/swingset/src/stories/alert-dialog.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,70 @@ the same reason: a corner X is a way out without answering.
Every close request — Escape or `AlertDialog.Close` — routes through `onOpenChange`, so a controlled
consumer can decline one by not committing the state.

## Confirming a close

A dialog holding unsaved work should ask before discarding it. That is three pieces: a handle, a
hook that guards the close, and the confirmation itself.

```tsx
import { AlertDialog, createConfirmHandle, useConfirmedClose } from '@clerk/ui/mosaic/components/alert-dialog';

const confirm = React.useMemo(() => createConfirmHandle(), []);

const onOpenChange = useConfirmedClose({
handle: confirm,
when: () => value !== '',
onOpenChange: setOpen,
confirm: {
title: 'Discard changes?',
description: 'You have not finished adding this address.',
actionLabel: 'Discard',
cancelLabel: 'Keep editing',
destructive: true,
},
});

<Dialog open={open} onOpenChange={onOpenChange} closedBy='closerequest'>
{/* … */}
<AlertDialog.Confirm handle={confirm} finalFocus={inputRef} />
</Dialog>
```

**Render `AlertDialog.Confirm` inside the dialog it guards** — anywhere in its children. That is
what puts the two in one floating tree, and escape ordering, the stacking styles and the refcounted
scroll lock all read that tree. A confirmation mounted app-globally would be a sibling of the dialog
rather than a child of it, and all three would break.

**The guarded dialog must be controlled.** A veto is the absence of a commit, and an uncontrolled
dialog has already committed by the time `onOpenChange` runs.

**What it covers is every close the dialog owns**: Escape, an outside press where `closedBy` allows
one, `Dialog.CloseButton`, `Dialog.Close`, and the `close` the `Dialog` wrapper hands its children.
A button wired to your own `setOpen(false)` never reaches the dialog, so it bypasses the question
silently — route those through `Dialog.Close`.

`when()` is evaluated at each close request, so a close that no longer needs guarding (the form has
just been submitted, the field cleared) passes straight through.

### Asking without a close

`show()` is the same confirmation, awaited directly — for a decision that is not about closing:

```tsx
if (await confirm.show({ title: 'Delete this key?', description: 'Applications using it stop working.' })) {
await deleteKey();
}
```

It resolves `true` for the action and `false` for cancel or any dismissal. Calling it while a
confirmation is already showing returns the in-flight promise rather than opening a second one, so
repeated close requests ask once.

**`AlertDialog.Confirm` must be mounted when `show()` is called** — it is the thing that opens, and
a `show()` with nothing mounted to answer it never resolves. Since the confirmation lives inside the
dialog it guards, that means asking from inside that dialog, while it is open. A confirmation that
unmounts with a question in flight answers `false` rather than leaving the `await` hanging.

## Parts

| Part | Slot | Description |
Expand All @@ -93,6 +157,7 @@ consumer can decline one by not committing the state.
| `AlertDialog.Description` | — | Description; wired to the popup's `aria-describedby`. Required. |
| `AlertDialog.Close` | — | Dismisses the alert; unstyled, accepts a `render` prop. |
| `AlertDialog.Actions` | `alert-dialog-actions` | The response row. Cancel first. |
| `AlertDialog.Confirm` | `dialog-popup` | A whole confirmation rendered from a `show()` call. See below. |

Every part except `Popup` and `Actions` is `Dialog`'s own component, not a wrapper around it — one
implementation, so the two cannot drift. `Title` and `Description` are unstyled passthroughs from the
Expand Down
Loading
Loading