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 @@
---
---
2 changes: 2 additions & 0 deletions .changeset/mosaic-alert-dialog.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
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
input: dynamic(() => import('../stories/input.mdx')),
item: dynamic(() => import('../stories/item.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
'alert-dialog': dynamic(() => import('../stories/alert-dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
Expand Down
12 changes: 12 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// Import stories explicitly to control order and avoid type casting through unknown.
import { meta as accordionMeta } from '../stories/accordion.stories';
import {
Default as AlertDialogDefault,
DiscardChanges as AlertDialogDiscardChanges,
meta as alertDialogComponentMeta,
} from '../stories/alert-dialog.component.stories';
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
import {
Fallback as AvatarFallbackStory,
Expand Down Expand Up @@ -128,6 +133,12 @@ const sectionModule: StoryModule = {
};
const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault };

const alertDialogComponentModule: StoryModule = {
meta: alertDialogComponentMeta,
Default: AlertDialogDefault,
DiscardChanges: AlertDialogDiscardChanges,
};

const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered };

const avatarModule: StoryModule = {
Expand Down Expand Up @@ -261,6 +272,7 @@ export const registry: StoryModule[] = [
inputModule,
itemModule,
dialogComponentModule,
alertDialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
Expand Down
Loading
Loading