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-stacking-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
47 changes: 32 additions & 15 deletions packages/headless/src/primitives/dialog/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,16 +137,17 @@ the close was pointer-driven, where focus is left where the pointer put it (see

### `Dialog.Root`

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

#### `closedBy`

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

`data-nested` is what a stacked overlay styles itself from — chiefly so backdrops don't composite
into an ever-darker scrim as the stack grows. It reflects any floating ancestor, not strictly a
dialog one: the `FloatingTree` a Menu or Popover establishes counts too.
`data-nested` reflects any floating ancestor: the `FloatingTree` a Menu or Popover establishes
counts too.

`data-stacked` and `data-stack-base` are narrower, and are what stacking styles should use. They
describe dialog-on-dialog specifically, in the two directions of the same relationship — the one
on top, and the one it covers. A dialog opened from a menu item is `data-nested` but not
`data-stacked`: it has a floating ancestor, yet it sits on the bare page and still owns its scrim.

Both can be set at once, and that is the ordinary case rather than an edge — in a panel → prompt →
alert stack, the middle dialog is stacked on one surface while another is stacked on it.

`data-stacked` exists chiefly so the stack shows one scrim: the dialog on top drops its own
backdrop instead of compositing a darker one per level. `data-stack-base` is for whatever the
surface underneath does to signal depth.

`data-stacked` holds for as long as the dialog underneath is on screen, exit transition included —
otherwise the one on top would paint a second scrim over the fading original.

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

## Important Notes

- **`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.
- **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup.
- **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.
- **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`.
- **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning.

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

## ARIA

- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description)
- Popup: `role="dialog"` (or `"alertdialog"`, via the root's `role`), `aria-labelledby` (from Title), `aria-describedby` (from Description)
- Trigger: `aria-expanded`, `aria-haspopup="dialog"`, `aria-controls`
8 changes: 6 additions & 2 deletions packages/headless/src/primitives/dialog/dialog-backdrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ export type DialogBackdropProps = ComponentProps<'div'>;
export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropProps>(
function DialogBackdrop(props, ref) {
const { render, ...otherProps } = props;
const { open, mounted, isNested, transitionProps } = useDialogContext();
const { open, mounted, isNested, isStacked, transitionProps } = useDialogContext();
Comment thread
maxyinger marked this conversation as resolved.

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

const defaultProps = {
...transitionProps,
Expand All @@ -29,6 +32,7 @@ export const DialogBackdrop = React.forwardRef<HTMLDivElement, DialogBackdropPro
stateAttributesMapping: {
open: (v: boolean): Record<string, string> | null => (v ? { 'data-open': '' } : { 'data-closed': '' }),
nested: (v: boolean): Record<string, string> | null => (v ? { 'data-nested': '' } : null),
stacked: (v: boolean): Record<string, string> | null => (v ? { 'data-stacked': '' } : null),
},
props: mergeProps<'div'>(defaultProps, otherProps),
});
Expand Down
8 changes: 8 additions & 0 deletions packages/headless/src/primitives/dialog/dialog-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ export interface DialogContextValue {
* cases coincide in practice.
*/
isNested: boolean;
/**
* Whether this dialog is layered over an open DIALOG — the signal the stacking styles key on,
* where `isNested` is too broad to use. A stacked dialog drops its own backdrop so the stack
* shows one scrim rather than compositing a darker one per level.
*/
isStacked: boolean;
/** How many open dialogs are stacked directly on this one. See `useDialogNesting`. */
stackedChildCount: number;
labelId: string;
descriptionId: string;
mounted: boolean;
Expand Down
92 changes: 92 additions & 0 deletions packages/headless/src/primitives/dialog/dialog-nesting.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
'use client';

import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react';

/**
* How a dialog root reaches the dialog root it renders inside, so the two can style the stack
* they form: the one on top drops its backdrop, the one beneath recedes behind it.
*
* Deliberately separate from `isNested`, which reports any FLOATING ancestor — a Menu or a
* Popover counts. Stacking styles cannot key on that: a dialog opened from a menu item has a
* floating ancestor but sits on the bare page, and must still paint its own scrim.
*/
export interface DialogNestingContextValue {
/**
* Whether the surrounding dialog is still covering the page — open, or closed but still
* mounted for its exit transition. Not the raw `open` flag: a child that un-suppressed its
* backdrop the instant the parent started closing would paint a second scrim over the
* parent's still-fading one.
*/
open: boolean;
/**
* Called by a dialog rendered inside this one, for as long as it is open. Returns the release.
* Stable for the lifetime of the root, so registering never churns.
*/
registerStackedChild: () => () => void;
}

export const DialogNestingContext = createContext<DialogNestingContextValue | null>(null);

/** What a root learns about the stack it belongs to. */
export interface DialogNesting {
/** Whether this dialog is layered over an open dialog. */
isStacked: boolean;
/**
* How many open dialogs are stacked directly on this one. Counts DIRECT children only — a
* three-deep stack reports 1 at both lower levels rather than 2 and 1 — which is enough for
* the single recede step that exists today. Making it cumulative means propagating the count
* back up the chain, and getting that to settle when two levels mount in one commit.
*/
stackedChildCount: number;
/** Provided to this root's children, so a dialog inside it registers against this one. */
context: DialogNestingContextValue;
}

/**
* Joins a dialog root to the stack it belongs to, in both directions: up, to report itself to
* the dialog it renders inside, and down, to count the dialogs that render inside it.
*/
export function useDialogNesting(open: boolean, mounted: boolean): DialogNesting {
const parent = useContext(DialogNestingContext);
const [stackedChildCount, setStackedChildCount] = useState(0);

const registerStackedChild = useCallback(() => {
setStackedChildCount(count => count + 1);
let released = false;
return () => {
if (released) {
return;
}
released = true;
setStackedChildCount(count => count - 1);
};
}, []);

const registerWithParent = parent?.registerStackedChild;

// Gated on `open` rather than on being mounted: a closing dialog stays mounted for the length
// of its exit transition, and the surface beneath has to come forward WITH it rather than
// after it. Depends on the registration function, not the whole context value, so a parent
// opening or closing does not re-register.
useLayoutEffect(() => {
if (!open || !registerWithParent) {
return;
}
return registerWithParent();
}, [open, registerWithParent]);

const covering = open || mounted;

const context = useMemo<DialogNestingContextValue>(
() => ({ open: covering, registerStackedChild }),
[covering, registerStackedChild],
);

return {
// A parent that is closed AND gone is not something to sit on top of: the child owns the scrim,
// which is what a confirmation root mounted beside its dialog's portal relies on.
isStacked: parent !== null && parent.open,
stackedChildCount,
context,
};
}
7 changes: 7 additions & 0 deletions packages/headless/src/primitives/dialog/dialog-popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
floatingContext,
modal,
isNested,
isStacked,
stackedChildCount,
returnFocusRef,
labelId,
descriptionId,
Expand All @@ -155,6 +157,11 @@ export const DialogPopup = React.forwardRef<HTMLDivElement, DialogPopupProps>(fu
const defaultProps = {
...ownProps,
...(isNested ? { 'data-nested': '' } : {}),
// Both can be set at once, and that is the ordinary case rather than an edge: in a
// panel -> prompt -> alert stack the middle dialog is stacked on one surface while another
// is stacked on it.
...(isStacked ? { 'data-stacked': '' } : {}),
...(stackedChildCount > 0 ? { 'data-stack-base': '' } : {}),
...getFloatingProps(),
...transitionProps,
};
Expand Down
25 changes: 22 additions & 3 deletions packages/headless/src/primitives/dialog/dialog-root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useReturnFocus } from '../../hooks/use-return-focus';
import { useTransition } from '../../hooks/use-transition';
import { DialogContext, type DialogContextValue } from './dialog-context';
import { createDialogHandle, type DialogHandle } from './dialog-handle';
import { DialogNestingContext, useDialogNesting } from './dialog-nesting';

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

/**
* The popup's ARIA role. `alertdialog` is for a dialog interrupting the user to confirm or warn,
* which assistive technology announces more urgently; everything else is a `dialog`.
*/
export type DialogRole = 'dialog' | 'alertdialog';

/** What accompanies an `onOpenChange` call, mirroring Base UI's event details. */
export interface DialogOpenChangeDetails {
/**
Expand All @@ -53,6 +60,8 @@ export interface DialogProps<Payload = unknown> {
modal?: boolean;
/** Which gestures dismiss the dialog. Default: `any` */
closedBy?: DialogClosedBy;
/** The popup's ARIA role. Default: `dialog` */
role?: DialogRole;
/**
* Connects this root to triggers rendered outside it. Create with `Dialog.createHandle()`
* and pass the same handle to each `Dialog.Trigger`.
Expand All @@ -71,7 +80,7 @@ export interface DialogProps<Payload = unknown> {

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

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

// Below `useTransition` because it needs `mounted`: what a stacked child has to key off is
// whether this dialog is still on screen, not whether it is still open.
const nesting = useDialogNesting(open, mounted);

const dismiss = useDismiss(floatingContext, {
outsidePressEvent: 'mousedown',
escapeKey: closedBy !== 'none',
outsidePress: closedBy === 'any',
});
const role = useRole(floatingContext);
const role = useRole(floatingContext, { role: ariaRole });

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

Expand All @@ -193,6 +206,8 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
store,
modal,
isNested,
isStacked: nesting.isStacked,
stackedChildCount: nesting.stackedChildCount,
labelId,
descriptionId,
mounted,
Expand All @@ -208,6 +223,8 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean
store,
modal,
isNested,
nesting.isStacked,
nesting.stackedChildCount,
labelId,
descriptionId,
mounted,
Expand All @@ -219,7 +236,9 @@ function DialogInner<Payload>(props: DialogProps<Payload> & { isNested: boolean

return (
<FloatingNode id={nodeId}>
<DialogContext.Provider value={contextValue}>{content}</DialogContext.Provider>
<DialogContext.Provider value={contextValue}>
<DialogNestingContext.Provider value={nesting.context}>{content}</DialogNestingContext.Provider>
</DialogContext.Provider>
</FloatingNode>
);
}
Expand Down
Loading
Loading