Skip to content

Commit df92efe

Browse files
committed
feat(ui): dialog close confirmation
A dialog holding unsaved work should ask before discarding it. Three pieces, each with one job: `createConfirmHandle()` links a question to its answer, `useConfirmedClose` guards the close path, and `<AlertDialog.Confirm>` is the dialog, rendered inside the one it guards so the two share a floating tree — escape ordering, the stacking styles and the refcounted scroll lock all read that tree, and a globally mounted confirmation would break every one of them. `show()` returns a promise resolving to the answer, so a confirmation reads as `if (await confirm.show({…}))` rather than as a pair of state variables and a callback. Calling it while one is already showing returns the IN-FLIGHT promise instead of opening a second: holding Escape against a guarded dialog would otherwise stack a confirmation per keypress. The veto is the absence of a commit. `useConfirmedClose` wraps the consumer's own `onOpenChange`, so it covers every close the dialog owns — Escape, outside press, `Dialog.CloseButton`, `Dialog.Close`, and the `close` the wrapper hands its children all funnel through it. A button wired to the consumer's own `setOpen(false)` never reaches the dialog and so bypasses the question; that is inherent, and both the hook's JSDoc and the docs page say so. Two ordering details that are load-bearing. The action settles `true` before closing, and `settle` is a no-op once a question is answered, so the close that follows cannot overwrite the answer with `false`. And the hook reads `when` and `onOpenChange` through a ref, so the callback identity is stable across the keystrokes of the very form whose dirtiness `when` reports on. Headless gains `handle.open(payload)` — the programmatic counterpart of a trigger's payload, which is how the confirmation's own text reaches it. The root holds it in a ref as well as in state: the registry lookup that runs once the dialog is open resolves a trigger-less open to `undefined`, and would otherwise blank the dialog a commit after it was filled.
1 parent d768246 commit df92efe

14 files changed

Lines changed: 761 additions & 69 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

packages/headless/src/primitives/dialog/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,17 @@ const detail = Dialog.createHandle<{ name: string }>();
7878
</Dialog.Root>
7979
```
8080

81+
An open with no trigger behind it can supply the payload directly: `handle.open(payload)` is the
82+
programmatic counterpart, for a dialog raised by something that happened rather than by an element
83+
— a confirmation that has to say what it is asking. A trigger-driven open supersedes it, since a
84+
trigger names its own payload.
85+
86+
```tsx
87+
const confirmation = Dialog.createHandle<{ question: string }>();
88+
89+
confirmation.open({ question: 'Discard changes?' });
90+
```
91+
8192
In controlled mode, track which trigger is active with `triggerId``onOpenChange`'s second
8293
argument reports the trigger behind each change:
8394

packages/headless/src/primitives/dialog/dialog-handle.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ export interface DialogTriggerRegistration<Payload = unknown> {
2020
* requests made with no root attached are ignored, matching Base UI.
2121
* @internal
2222
*/
23-
export interface DialogRootController {
23+
export interface DialogRootController<Payload = unknown> {
2424
openFromTrigger: (id: string, event: Event) => void;
2525
closeFromTrigger: (id: string, event: Event) => void;
26-
setOpen: (open: boolean) => void;
26+
setOpen: (open: boolean, payload?: Payload) => void;
2727
}
2828

2929
/** The slice of root state a trigger renders from: its `data-open` / ARIA wiring. */
@@ -45,8 +45,16 @@ const CLOSED_STATE: DialogHandleState = { open: false, triggerId: null, popupId:
4545
* lets a `DialogHandle<Payload>` flow into contexts typed `DialogHandle<unknown>`.
4646
*/
4747
export interface DialogHandle<Payload = unknown> {
48-
/** Opens the attached root. Ignored while no root is mounted. */
49-
open(): void;
48+
/**
49+
* Opens the attached root. Ignored while no root is mounted.
50+
*
51+
* The optional `payload` is the programmatic counterpart of a trigger's: it reaches the root's
52+
* children-as-function as `{ payload }`, so an imperative open can carry the content the dialog
53+
* is about — what a confirmation is asking, which record is being deleted — without the caller
54+
* holding a second piece of state alongside `open`. A trigger-driven open supersedes it, since
55+
* a trigger names its own payload.
56+
*/
57+
open(payload?: Payload): void;
5058
/** Closes the attached root. Ignored while no root is mounted. */
5159
close(): void;
5260
/** Whether the attached root is open. `false` while no root is mounted. */
@@ -58,7 +66,7 @@ export interface DialogHandle<Payload = unknown> {
5866
/** @internal */
5967
getFirstTrigger(): DialogTriggerRegistration<Payload> | undefined;
6068
/** @internal */
61-
setRoot(controller: DialogRootController): () => void;
69+
setRoot(controller: DialogRootController<Payload>): () => void;
6270
/** @internal */
6371
requestOpen(id: string, event: Event): void;
6472
/** @internal */
@@ -79,14 +87,14 @@ export interface DialogHandle<Payload = unknown> {
7987
export function createDialogHandle<Payload = unknown>(): DialogHandle<Payload> {
8088
const triggers = new Map<string, DialogTriggerRegistration<Payload>>();
8189
const listeners = new Set<() => void>();
82-
let root: DialogRootController | null = null;
90+
let root: DialogRootController<Payload> | null = null;
8391
let state = CLOSED_STATE;
8492

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

8795
return {
88-
open() {
89-
root?.setOpen(true);
96+
open(payload) {
97+
root?.setOpen(true, payload);
9098
},
9199
close() {
92100
root?.setOpen(false);

packages/headless/src/primitives/dialog/dialog-root.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
100100
// consumed by the floating `onOpenChange` the request funnels into.
101101
const pendingDetailsRef = useRef<DialogOpenChangeDetails | null>(null);
102102

103+
// The payload of the most recent programmatic `handle.open(payload)`, kept so the registry
104+
// lookup below has something to fall back to when no trigger is involved.
105+
const directPayloadRef = useRef<Payload | undefined>(undefined);
106+
103107
// Every open/close funnels through `floatingContext.onOpenChange` — trigger activations,
104108
// dismissals, and programmatic `setOpen` alike. floating-ui emits its `openchange` event
105109
// synchronously before invoking this callback, which is what lets listeners (`useReturnFocus`,
@@ -121,6 +125,9 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
121125
openFromTrigger: (id, event) => {
122126
const registration = store.getTrigger(id);
123127
setActiveTriggerId(id);
128+
// A trigger names its own payload, so it supersedes anything a previous programmatic
129+
// open supplied — otherwise the stale one would resurface through the effect below.
130+
directPayloadRef.current = undefined;
124131
setActivePayload(registration?.getPayload());
125132
if (registration) {
126133
refs.setReference(registration.element);
@@ -133,7 +140,14 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
133140
pendingDetailsRef.current = { trigger: registration?.element ?? null, triggerId: id, event };
134141
floatingContext.onOpenChange(false, event, 'click');
135142
},
136-
setOpen: nextOpen => floatingContext.onOpenChange(nextOpen),
143+
setOpen: (nextOpen, payload) => {
144+
// Held in a ref as well as in state because the payload effect below re-runs on `open`
145+
// and would otherwise resolve a trigger-less open to `undefined`, wiping this a commit
146+
// after it was set.
147+
directPayloadRef.current = payload;
148+
setActivePayload(payload);
149+
floatingContext.onOpenChange(nextOpen);
150+
},
137151
});
138152
// `floatingContext` is rebuilt on open/element changes; re-registering is an idempotent swap.
139153
}, [store, refs, floatingContext, setActiveTriggerId]);
@@ -159,7 +173,9 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
159173
// the time it reads, and the pre-paint re-render delivers their payload on the first frame.
160174
useLayoutEffect(() => {
161175
if (open) {
162-
setActivePayload(activeTriggerId != null ? store.getTrigger(activeTriggerId)?.getPayload() : undefined);
176+
setActivePayload(
177+
activeTriggerId != null ? store.getTrigger(activeTriggerId)?.getPayload() : directPayloadRef.current,
178+
);
163179
}
164180
}, [store, open, activeTriggerId]);
165181

packages/headless/src/primitives/dialog/dialog.test.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,63 @@ describe('Dialog', () => {
528528

529529
expect(screen.getByRole('dialog', { name: 'payload-b' })).toBeInTheDocument();
530530
});
531+
532+
// The programmatic counterpart of a trigger's payload, for an open that no element initiated —
533+
// a confirmation raised by a close request, say, which has to say what it is asking.
534+
describe('handle.open(payload)', () => {
535+
function renderDetached() {
536+
const handle = Dialog.createHandle<string>();
537+
render(
538+
<Dialog.Root handle={handle}>
539+
{({ payload }) => (
540+
<>
541+
<Dialog.Trigger id='trigger-a'>Open A</Dialog.Trigger>
542+
<Dialog.Portal>
543+
<Dialog.Viewport>
544+
<Dialog.Popup>
545+
<Dialog.Title>{payload ?? 'no payload'}</Dialog.Title>
546+
</Dialog.Popup>
547+
</Dialog.Viewport>
548+
</Dialog.Portal>
549+
</>
550+
)}
551+
</Dialog.Root>,
552+
);
553+
return handle;
554+
}
555+
556+
it('delivers it to the children render function', () => {
557+
const handle = renderDetached();
558+
559+
act(() => handle.open('from-handle'));
560+
561+
expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument();
562+
});
563+
564+
it('survives the registry lookup that runs once the dialog is open', async () => {
565+
const handle = renderDetached();
566+
567+
act(() => handle.open('from-handle'));
568+
// The lookup effect re-runs on `open`; without a fallback it would resolve to `undefined`
569+
// a commit later and blank the dialog.
570+
await act(async () => {
571+
await Promise.resolve();
572+
});
573+
574+
expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument();
575+
});
576+
577+
it('is superseded by a trigger, which names its own payload', async () => {
578+
const user = userEvent.setup();
579+
const handle = renderDetached();
580+
581+
act(() => handle.open('from-handle'));
582+
act(() => handle.close());
583+
await user.click(screen.getByRole('button', { name: 'Open A' }));
584+
585+
expect(screen.getByRole('dialog', { name: 'no payload' })).toBeInTheDocument();
586+
});
587+
});
531588
});
532589

533590
describe('initialFocus', () => {

packages/swingset/src/stories/alert-dialog.component.mdx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,65 @@ the same reason: a corner X is a way out without answering.
7979
Every close request — Escape or `AlertDialog.Close` — routes through `onOpenChange`, so a controlled
8080
consumer can decline one by not committing the state.
8181

82+
## Confirming a close
83+
84+
A dialog holding unsaved work should ask before discarding it. That is three pieces: a handle, a
85+
hook that guards the close, and the confirmation itself.
86+
87+
```tsx
88+
import { AlertDialog, createConfirmHandle, useConfirmedClose } from '@clerk/ui/mosaic/components/alert-dialog';
89+
90+
const confirm = React.useMemo(() => createConfirmHandle(), []);
91+
92+
const onOpenChange = useConfirmedClose({
93+
handle: confirm,
94+
when: () => value !== '',
95+
onOpenChange: setOpen,
96+
confirm: {
97+
title: 'Discard changes?',
98+
description: 'You have not finished adding this address.',
99+
actionLabel: 'Discard',
100+
cancelLabel: 'Keep editing',
101+
destructive: true,
102+
},
103+
});
104+
105+
<Dialog open={open} onOpenChange={onOpenChange} closedBy='closerequest'>
106+
{/**/}
107+
<AlertDialog.Confirm handle={confirm} finalFocus={inputRef} />
108+
</Dialog>
109+
```
110+
111+
**Render `AlertDialog.Confirm` inside the dialog it guards** — anywhere in its children. That is
112+
what puts the two in one floating tree, and escape ordering, the stacking styles and the refcounted
113+
scroll lock all read that tree. A confirmation mounted app-globally would be a sibling of the dialog
114+
rather than a child of it, and all three would break.
115+
116+
**The guarded dialog must be controlled.** A veto is the absence of a commit, and an uncontrolled
117+
dialog has already committed by the time `onOpenChange` runs.
118+
119+
**What it covers is every close the dialog owns**: Escape, an outside press where `closedBy` allows
120+
one, `Dialog.CloseButton`, `Dialog.Close`, and the `close` the `Dialog` wrapper hands its children.
121+
A button wired to your own `setOpen(false)` never reaches the dialog, so it bypasses the question
122+
silently — route those through `Dialog.Close`.
123+
124+
`when()` is evaluated at each close request, so a close that no longer needs guarding (the form has
125+
just been submitted, the field cleared) passes straight through.
126+
127+
### Asking without a close
128+
129+
`show()` is the same confirmation, awaited directly — for a decision that is not about closing:
130+
131+
```tsx
132+
if (await confirm.show({ title: 'Delete this key?', description: 'Applications using it stop working.' })) {
133+
await deleteKey();
134+
}
135+
```
136+
137+
It resolves `true` for the action and `false` for cancel or any dismissal. Calling it while a
138+
confirmation is already showing returns the in-flight promise rather than opening a second one, so
139+
repeated close requests ask once.
140+
82141
## Parts
83142

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

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

0 commit comments

Comments
 (0)