Skip to content

Commit c2bd42c

Browse files
maxyingerclaude
andauthored
feat(headless): dialog stacking state and alertdialog role (#9427)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 28bee80 commit c2bd42c

11 files changed

Lines changed: 387 additions & 22 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: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -137,16 +137,17 @@ the close was pointer-driven, where focus is left where the pointer put it (see
137137

138138
### `Dialog.Root`
139139

140-
| Prop | Type | Default | Description |
141-
| -------------- | ----------------------------------------------------------- | ------- | --------------------------------------------------------------------- |
142-
| `open` | `boolean` || Controlled open state |
143-
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
144-
| `onOpenChange` | `(open: boolean, details: DialogOpenChangeDetails) => void` || Called when open state changes; `details` names the trigger behind it |
145-
| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
146-
| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog |
147-
| `handle` | `DialogHandle` || Connects detached triggers (see `Dialog.createHandle()`) |
148-
| `triggerId` | `string \| null` || Controls which trigger the open is attributed to |
149-
| `children` | `ReactNode \| ({ payload }) => ReactNode` || Content, or a render function of the active trigger's `payload` |
140+
| Prop | Type | Default | Description |
141+
| -------------- | ----------------------------------------------------------- | ---------- | --------------------------------------------------------------------- |
142+
| `open` | `boolean` || Controlled open state |
143+
| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) |
144+
| `onOpenChange` | `(open: boolean, details: DialogOpenChangeDetails) => void` || Called when open state changes; `details` names the trigger behind it |
145+
| `modal` | `boolean` | `true` | Traps focus and blocks page interaction |
146+
| `role` | `'dialog' \| 'alertdialog'` | `'dialog'` | The popup's ARIA role |
147+
| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog |
148+
| `handle` | `DialogHandle` || Connects detached triggers (see `Dialog.createHandle()`) |
149+
| `triggerId` | `string \| null` || Controls which trigger the open is attributed to |
150+
| `children` | `ReactNode \| ({ payload }) => ReactNode` || Content, or a render function of the active trigger's `payload` |
150151

151152
#### `closedBy`
152153

@@ -218,18 +219,34 @@ No additional props beyond standard HTML attributes and the `render` prop.
218219
| --------------------------- | ---------------------------------- | ------------------------------------------- |
219220
| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state |
220221
| `data-nested` | Backdrop, Viewport, Popup | Opened from inside another floating element |
222+
| `data-stacked` | Backdrop, Popup | Layered over an open dialog |
223+
| `data-stack-base` | Popup | Has an open dialog layered over it |
221224

222-
`data-nested` is what a stacked overlay styles itself from — chiefly so backdrops don't composite
223-
into an ever-darker scrim as the stack grows. It reflects any floating ancestor, not strictly a
224-
dialog one: the `FloatingTree` a Menu or Popover establishes counts too.
225+
`data-nested` reflects any floating ancestor: the `FloatingTree` a Menu or Popover establishes
226+
counts too.
227+
228+
`data-stacked` and `data-stack-base` are narrower, and are what stacking styles should use. They
229+
describe dialog-on-dialog specifically, in the two directions of the same relationship — the one
230+
on top, and the one it covers. A dialog opened from a menu item is `data-nested` but not
231+
`data-stacked`: it has a floating ancestor, yet it sits on the bare page and still owns its scrim.
232+
233+
Both can be set at once, and that is the ordinary case rather than an edge — in a panel → prompt →
234+
alert stack, the middle dialog is stacked on one surface while another is stacked on it.
235+
236+
`data-stacked` exists chiefly so the stack shows one scrim: the dialog on top drops its own
237+
backdrop instead of compositing a darker one per level. `data-stack-base` is for whatever the
238+
surface underneath does to signal depth.
239+
240+
`data-stacked` holds for as long as the dialog underneath is on screen, exit transition included —
241+
otherwise the one on top would paint a second scrim over the fading original.
225242

226243
The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above.
227244

228245
## Important Notes
229246

230247
- **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock.
231248
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
232-
- **Nested dialogs are supported**, and covered by tests. The `FloatingTree` pattern handles it: `useDismiss` blocks both Escape and outside-press on a parent while any child is open, and `FloatingOverlay`'s scroll lock is refcounted, so the body stays locked until the last dialog closes.
249+
- **Nested dialogs are supported**, and covered by tests. The `FloatingTree` pattern handles it: `useDismiss` blocks both Escape and outside-press on a parent while any child is open, and `FloatingOverlay`'s scroll lock is refcounted, so the body stays locked until the last dialog closes. Style the stack with `data-stacked` / `data-stack-base`, not `data-nested`.
233250
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.
234251

235252
## Authoring rule for new primitives
@@ -238,5 +255,5 @@ Each styleable surface = one part. Layout infrastructure (overlay, scroll lock,
238255

239256
## ARIA
240257

241-
- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
258+
- Popup: `role="dialog"` (or `"alertdialog"`, via the root's `role`), `aria-labelledby` (from Title), `aria-describedby` (from Description)
242259
- Trigger: `aria-expanded`, `aria-haspopup="dialog"`, `aria-controls`

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,12 @@ export type DialogBackdropProps = ComponentProps<'div'>;
1212
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
1313
function DialogBackdrop(props, ref) {
1414
const { render, ...otherProps } = props;
15-
const { open, mounted, isNested, transitionProps } = useDialogContext();
15+
const { open, mounted, isNested, isStacked, transitionProps } = useDialogContext();
1616

17-
const state = { open, nested: isNested };
17+
// No `stacked` counterpart to `data-stack-base` here: what a dialog beneath the stack does is
18+
// recede, and that is the popup's business. The backdrop only needs to know to get out of the
19+
// way when it is not the one scrim the stack shows.
20+
const state = { open, nested: isNested, stacked: isStacked };
1821

1922
const defaultProps = {
2023
...transitionProps,
@@ -29,6 +32,7 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
2932
stateAttributesMapping: {
3033
open: (v: boolean): Record<string, string> | null => (v ? { 'data-open': '' } : { 'data-closed': '' }),
3134
nested: (v: boolean): Record<string, string> | null => (v ? { 'data-nested': '' } : null),
35+
stacked: (v: boolean): Record<string, string> | null => (v ? { 'data-stacked': '' } : null),
3236
},
3337
props: mergeProps<'div'>(defaultProps, otherProps),
3438
});

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ export interface DialogContextValue {
3131
* cases coincide in practice.
3232
*/
3333
isNested: boolean;
34+
/**
35+
* Whether this dialog is layered over an open DIALOG — the signal the stacking styles key on,
36+
* where `isNested` is too broad to use. A stacked dialog drops its own backdrop so the stack
37+
* shows one scrim rather than compositing a darker one per level.
38+
*/
39+
isStacked: boolean;
40+
/** How many open dialogs are stacked directly on this one. See `useDialogNesting`. */
41+
stackedChildCount: number;
3442
labelId: string;
3543
descriptionId: string;
3644
mounted: boolean;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
'use client';
2+
3+
import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';
4+
5+
/**
6+
* How a dialog root reaches the dialog root it renders inside, so the two can style the stack
7+
* they form: the one on top drops its backdrop, the one beneath recedes behind it.
8+
*
9+
* Deliberately separate from `isNested`, which reports any FLOATING ancestor — a Menu or a
10+
* Popover counts. Stacking styles cannot key on that: a dialog opened from a menu item has a
11+
* floating ancestor but sits on the bare page, and must still paint its own scrim.
12+
*/
13+
export interface DialogNestingContextValue {
14+
/**
15+
* Whether the surrounding dialog is still covering the page — open, or closed but still
16+
* mounted for its exit transition. Not the raw `open` flag: a child that un-suppressed its
17+
* backdrop the instant the parent started closing would paint a second scrim over the
18+
* parent's still-fading one.
19+
*/
20+
open: boolean;
21+
/**
22+
* Called by a dialog rendered inside this one, for as long as it is open. Returns the release.
23+
* Stable for the lifetime of the root, so registering never churns.
24+
*/
25+
registerStackedChild: () => () => void;
26+
}
27+
28+
export const DialogNestingContext = createContext<DialogNestingContextValue | null>(null);
29+
30+
/** What a root learns about the stack it belongs to. */
31+
export interface DialogNesting {
32+
/** Whether this dialog is layered over an open dialog. */
33+
isStacked: boolean;
34+
/**
35+
* How many open dialogs are stacked directly on this one. Counts DIRECT children only — a
36+
* three-deep stack reports 1 at both lower levels rather than 2 and 1 — which is enough for
37+
* the single recede step that exists today. Making it cumulative means propagating the count
38+
* back up the chain, and getting that to settle when two levels mount in one commit.
39+
*/
40+
stackedChildCount: number;
41+
/** Provided to this root's children, so a dialog inside it registers against this one. */
42+
context: DialogNestingContextValue;
43+
}
44+
45+
/**
46+
* Joins a dialog root to the stack it belongs to, in both directions: up, to report itself to
47+
* the dialog it renders inside, and down, to count the dialogs that render inside it.
48+
*/
49+
export function useDialogNesting(open: boolean, mounted: boolean): DialogNesting {
50+
const parent = useContext(DialogNestingContext);
51+
const [stackedChildCount, setStackedChildCount] = useState(0);
52+
53+
const registerStackedChild = useCallback(() => {
54+
setStackedChildCount(count => count + 1);
55+
let released = false;
56+
return () => {
57+
if (released) {
58+
return;
59+
}
60+
released = true;
61+
setStackedChildCount(count => count - 1);
62+
};
63+
}, []);
64+
65+
const registerWithParent = parent?.registerStackedChild;
66+
67+
// Gated on `open` rather than on being mounted: a closing dialog stays mounted for the length
68+
// of its exit transition, and the surface beneath has to come forward WITH it rather than
69+
// after it. Depends on the registration function, not the whole context value, so a parent
70+
// opening or closing does not re-register.
71+
useLayoutEffect(() => {
72+
if (!open || !registerWithParent) {
73+
return;
74+
}
75+
return registerWithParent();
76+
}, [open, registerWithParent]);
77+
78+
const covering = open || mounted;
79+
80+
const context = useMemo<DialogNestingContextValue>(
81+
() => ({ open: covering, registerStackedChild }),
82+
[covering, registerStackedChild],
83+
);
84+
85+
return {
86+
// A parent that is closed AND gone is not something to sit on top of: the child owns the scrim,
87+
// which is what a confirmation root mounted beside its dialog's portal relies on.
88+
isStacked: parent !== null && parent.open,
89+
stackedChildCount,
90+
context,
91+
};
92+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
137137
floatingContext,
138138
modal,
139139
isNested,
140+
isStacked,
141+
stackedChildCount,
140142
returnFocusRef,
141143
labelId,
142144
descriptionId,
@@ -155,6 +157,11 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
155157
const defaultProps = {
156158
...ownProps,
157159
...(isNested ? { 'data-nested': '' } : {}),
160+
// Both can be set at once, and that is the ordinary case rather than an edge: in a
161+
// panel -> prompt -> alert stack the middle dialog is stacked on one surface while another
162+
// is stacked on it.
163+
...(isStacked ? { 'data-stacked': '' } : {}),
164+
...(stackedChildCount > 0 ? { 'data-stack-base': '' } : {}),
158165
...getFloatingProps(),
159166
...transitionProps,
160167
};

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { useReturnFocus } from '../../hooks/use-return-focus';
1717
import { useTransition } from '../../hooks/use-transition';
1818
import { DialogContext, type DialogContextValue } from './dialog-context';
1919
import { createDialogHandle, type DialogHandle } from './dialog-handle';
20+
import { DialogNestingContext, useDialogNesting } from './dialog-nesting';
2021

2122
/**
2223
* Which gestures dismiss the dialog, mirroring the native `<dialog closedby>` attribute.
@@ -31,6 +32,12 @@ import { createDialogHandle, type DialogHandle } from './dialog-handle';
3132
*/
3233
export type DialogClosedBy = 'any' | 'closerequest' | 'none';
3334

35+
/**
36+
* The popup's ARIA role. `alertdialog` is for a dialog interrupting the user to confirm or warn,
37+
* which assistive technology announces more urgently; everything else is a `dialog`.
38+
*/
39+
export type DialogRole = 'dialog' | 'alertdialog';
40+
3441
/** What accompanies an `onOpenChange` call, mirroring Base UI's event details. */
3542
export interface DialogOpenChangeDetails {
3643
/**
@@ -53,6 +60,8 @@ export interface DialogProps<Payload = unknown> {
5360
modal?: boolean;
5461
/** Which gestures dismiss the dialog. Default: `any` */
5562
closedBy?: DialogClosedBy;
63+
/** The popup's ARIA role. Default: `dialog` */
64+
role?: DialogRole;
5665
/**
5766
* Connects this root to triggers rendered outside it. Create with `Dialog.createHandle()`
5867
* and pass the same handle to each `Dialog.Trigger`.
@@ -71,7 +80,7 @@ export interface DialogProps<Payload = unknown> {
7180

7281
function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean }) {
7382
const nodeId = useFloatingNodeId();
74-
const { modal = true, closedBy = 'any', isNested, children, onOpenChange } = props;
83+
const { modal = true, closedBy = 'any', role: ariaRole = 'dialog', isNested, children, onOpenChange } = props;
7584

7685
const fallbackStore = useMemo(() => createDialogHandle<Payload>(), []);
7786
const store = props.handle ?? fallbackStore;
@@ -167,12 +176,16 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
167176
ref: popupRef,
168177
});
169178

179+
// Below `useTransition` because it needs `mounted`: what a stacked child has to key off is
180+
// whether this dialog is still on screen, not whether it is still open.
181+
const nesting = useDialogNesting(open, mounted);
182+
170183
const dismiss = useDismiss(floatingContext, {
171184
outsidePressEvent: 'mousedown',
172185
escapeKey: closedBy !== 'none',
173186
outsidePress: closedBy === 'any',
174187
});
175-
const role = useRole(floatingContext);
188+
const role = useRole(floatingContext, { role: ariaRole });
176189

177190
const { getFloatingProps } = useInteractions([dismiss, role]);
178191

@@ -193,6 +206,8 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
193206
store,
194207
modal,
195208
isNested,
209+
isStacked: nesting.isStacked,
210+
stackedChildCount: nesting.stackedChildCount,
196211
labelId,
197212
descriptionId,
198213
mounted,
@@ -208,6 +223,8 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
208223
store,
209224
modal,
210225
isNested,
226+
nesting.isStacked,
227+
nesting.stackedChildCount,
211228
labelId,
212229
descriptionId,
213230
mounted,
@@ -219,7 +236,9 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
219236

220237
return (
221238
<FloatingNode id={nodeId}>
222-
<DialogContext.Provider value={contextValue}>{content}</DialogContext.Provider>
239+
<DialogContext.Provider value={contextValue}>
240+
<DialogNestingContext.Provider value={nesting.context}>{content}</DialogNestingContext.Provider>
241+
</DialogContext.Provider>
223242
</FloatingNode>
224243
);
225244
}

0 commit comments

Comments
 (0)