From 0e4cfd19b250558a4cb8652ccd9b23c64d6732a8 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 18:15:59 -0600 Subject: [PATCH 1/3] feat(headless): dialog stacking state and alertdialog role Adds the two signals the Mosaic stacking styles need, and the role an AlertDialog preset needs. `data-stacked` / `data-stack-base` describe dialog-on-dialog specifically, in both directions of the relationship. `data-nested` could not: it reports any floating ancestor, so a dialog opened from a menu item reads as nested while sitting on the bare page. Under the incoming rule that a stacked dialog paints no backdrop, styling off `data-nested` would leave that dialog with no scrim at all. The child registers with its parent while OPEN rather than while mounted, so a dialog beneath comes forward with its child's exit transition rather than after it. The count is of direct children only, which is enough for the single recede step that exists. --- .changeset/dialog-stacking-state.md | 2 + .../headless/src/primitives/dialog/README.md | 44 +++-- .../src/primitives/dialog/dialog-backdrop.tsx | 8 +- .../src/primitives/dialog/dialog-context.ts | 8 + .../src/primitives/dialog/dialog-nesting.ts | 85 +++++++++ .../src/primitives/dialog/dialog-popup.tsx | 7 + .../src/primitives/dialog/dialog-root.tsx | 23 ++- .../src/primitives/dialog/dialog.test.tsx | 161 ++++++++++++++++++ .../headless/src/primitives/dialog/index.ts | 1 + .../headless/src/primitives/dialog/parts.ts | 8 +- 10 files changed, 326 insertions(+), 21 deletions(-) create mode 100644 .changeset/dialog-stacking-state.md create mode 100644 packages/headless/src/primitives/dialog/dialog-nesting.ts diff --git a/.changeset/dialog-stacking-state.md b/.changeset/dialog-stacking-state.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/dialog-stacking-state.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index 51ae61f9227..e160414777a 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -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` @@ -218,10 +219,23 @@ 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. The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above. @@ -229,7 +243,7 @@ The headless parts are unstyled. Target a part with your own className (or `rend - **`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 @@ -238,5 +252,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` diff --git a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx index c934d4bfe6d..91f22b5a35d 100644 --- a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx +++ b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx @@ -12,9 +12,12 @@ export type DialogBackdropProps = ComponentProps<'div'>; export const DialogBackdrop = React.forwardRef( function DialogBackdrop(props, ref) { const { render, ...otherProps } = props; - const { open, mounted, isNested, transitionProps } = useDialogContext(); + const { open, mounted, isNested, isStacked, transitionProps } = useDialogContext(); - 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, @@ -29,6 +32,7 @@ export const DialogBackdrop = React.forwardRef | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), nested: (v: boolean): Record | null => (v ? { 'data-nested': '' } : null), + stacked: (v: boolean): Record | null => (v ? { 'data-stacked': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/dialog/dialog-context.ts b/packages/headless/src/primitives/dialog/dialog-context.ts index 50101526501..a20561a2e64 100644 --- a/packages/headless/src/primitives/dialog/dialog-context.ts +++ b/packages/headless/src/primitives/dialog/dialog-context.ts @@ -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; diff --git a/packages/headless/src/primitives/dialog/dialog-nesting.ts b/packages/headless/src/primitives/dialog/dialog-nesting.ts new file mode 100644 index 00000000000..2fff5e4fae4 --- /dev/null +++ b/packages/headless/src/primitives/dialog/dialog-nesting.ts @@ -0,0 +1,85 @@ +'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 itself open. */ + 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(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): 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 context = useMemo( + () => ({ open, registerStackedChild }), + [open, registerStackedChild], + ); + + return { + // A closed parent is not something to sit on top of: the child owns the scrim in that case, + // which is what a confirmation root mounted beside its dialog's portal relies on. + isStacked: parent !== null && parent.open, + stackedChildCount, + context, + }; +} diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index fcb6023e59e..587062ecf2b 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -137,6 +137,8 @@ export const DialogPopup = React.forwardRef(fu floatingContext, modal, isNested, + isStacked, + stackedChildCount, returnFocusRef, labelId, descriptionId, @@ -155,6 +157,11 @@ export const DialogPopup = React.forwardRef(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, }; diff --git a/packages/headless/src/primitives/dialog/dialog-root.tsx b/packages/headless/src/primitives/dialog/dialog-root.tsx index 187ba660bc1..dd32d1ae976 100644 --- a/packages/headless/src/primitives/dialog/dialog-root.tsx +++ b/packages/headless/src/primitives/dialog/dialog-root.tsx @@ -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 `` attribute. @@ -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 { /** @@ -53,6 +60,8 @@ export interface DialogProps { 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`. @@ -71,7 +80,7 @@ export interface DialogProps { function DialogInner(props: DialogProps & { 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(), []); const store = props.handle ?? fallbackStore; @@ -80,6 +89,8 @@ function DialogInner(props: DialogProps & { isNested: boolean const [activeTriggerId, setActiveTriggerId] = useControllableState(props.triggerId, null); const [activePayload, setActivePayload] = useState(undefined); + const nesting = useDialogNesting(open); + const labelId = useId(); const descriptionId = useId(); @@ -172,7 +183,7 @@ function DialogInner(props: DialogProps & { isNested: boolean escapeKey: closedBy !== 'none', outsidePress: closedBy === 'any', }); - const role = useRole(floatingContext); + const role = useRole(floatingContext, { role: ariaRole }); const { getFloatingProps } = useInteractions([dismiss, role]); @@ -193,6 +204,8 @@ function DialogInner(props: DialogProps & { isNested: boolean store, modal, isNested, + isStacked: nesting.isStacked, + stackedChildCount: nesting.stackedChildCount, labelId, descriptionId, mounted, @@ -208,6 +221,8 @@ function DialogInner(props: DialogProps & { isNested: boolean store, modal, isNested, + nesting.isStacked, + nesting.stackedChildCount, labelId, descriptionId, mounted, @@ -219,7 +234,9 @@ function DialogInner(props: DialogProps & { isNested: boolean return ( - {content} + + {content} + ); } diff --git a/packages/headless/src/primitives/dialog/dialog.test.tsx b/packages/headless/src/primitives/dialog/dialog.test.tsx index 98441eae9c6..4b3c99f599f 100644 --- a/packages/headless/src/primitives/dialog/dialog.test.tsx +++ b/packages/headless/src/primitives/dialog/dialog.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../../test-utils/axe'; +import { Popover } from '../popover'; import { Dialog } from './index'; afterEach(() => cleanup()); @@ -671,6 +672,166 @@ describe('Dialog', () => { }); }); + describe('role', () => { + it('renders role=alertdialog when asked', () => { + renderDialog({ defaultOpen: true, role: 'alertdialog' }); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('stacking', () => { + // Two dialogs, the inner one rendered inside the outer's popup. `outer`/`inner` prefixes on + // the test ids because both levels render the same parts. + function renderStack({ outerOpen = true, innerOpen = true }: { outerOpen?: boolean; innerOpen?: boolean } = {}) { + return render( + + + + + Outer + + + + + Inner + + + + + + , + ); + } + + it('marks the dialog on top stacked and the one beneath a stack base', () => { + renderStack(); + + expect(screen.getByTestId('inner-popup')).toHaveAttribute('data-stacked', ''); + expect(screen.getByTestId('inner-popup')).not.toHaveAttribute('data-stack-base'); + expect(screen.getByTestId('outer-popup')).toHaveAttribute('data-stack-base', ''); + expect(screen.getByTestId('outer-popup')).not.toHaveAttribute('data-stacked'); + }); + + it('marks the stacked backdrop, so only one scrim in the stack paints', () => { + renderStack(); + + expect(screen.getByTestId('inner-backdrop')).toHaveAttribute('data-stacked', ''); + expect(screen.getByTestId('outer-backdrop')).not.toHaveAttribute('data-stacked'); + }); + + it('drops the stack base marking when the dialog on top closes', () => { + const { rerender } = renderStack(); + expect(screen.getByTestId('outer-popup')).toHaveAttribute('data-stack-base', ''); + + rerender( + + + + Outer + + + + Inner + + + + + + , + ); + + expect(screen.getByTestId('outer-popup')).not.toHaveAttribute('data-stack-base'); + }); + + it('is not stacked on a dialog that is closed', () => { + // A confirmation root mounted beside its dialog's portal is inside the root but outlives + // the open state; on its own it owns the scrim like any root-level dialog. + render( + + + + + + Inner + + + + , + ); + + expect(screen.getByTestId('inner-popup')).not.toHaveAttribute('data-stacked'); + expect(screen.getByTestId('inner-backdrop')).not.toHaveAttribute('data-stacked'); + }); + + it('marks the middle of a three-deep stack as both', () => { + render( + + + + Bottom + + + + Middle + + + + Top + + + + + + + + + , + ); + + const middle = screen.getByTestId('middle-popup'); + expect(middle).toHaveAttribute('data-stacked', ''); + expect(middle).toHaveAttribute('data-stack-base', ''); + expect(screen.getByTestId('bottom-popup')).not.toHaveAttribute('data-stacked'); + expect(screen.getByTestId('top-popup')).not.toHaveAttribute('data-stack-base'); + }); + + it('does not treat a floating but non-dialog ancestor as a stack', async () => { + // The distinction `data-nested` cannot make: a dialog opened from a popover has a floating + // ancestor, but it sits on the bare page and still owns its scrim. + const user = userEvent.setup(); + render( + + Open popover + + + + + Open dialog + + + + Dialog in a popover + + + + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + + const popup = screen.getByTestId('dialog-popup'); + expect(popup).toHaveAttribute('data-nested', ''); + expect(popup).not.toHaveAttribute('data-stacked'); + expect(screen.getByTestId('dialog-backdrop')).not.toHaveAttribute('data-stacked'); + }); + }); + describe('accessibility (axe)', () => { it('has no violations when closed', async () => { const { container } = renderDialog(); diff --git a/packages/headless/src/primitives/dialog/index.ts b/packages/headless/src/primitives/dialog/index.ts index c8e9789136d..e51ebc56558 100644 --- a/packages/headless/src/primitives/dialog/index.ts +++ b/packages/headless/src/primitives/dialog/index.ts @@ -14,6 +14,7 @@ export type { DialogPopupProps, DialogPortalProps, DialogProps, + DialogRole, DialogTitleProps, DialogTriggerProps, DialogViewportProps, diff --git a/packages/headless/src/primitives/dialog/parts.ts b/packages/headless/src/primitives/dialog/parts.ts index 3c9534100e6..a2af980ca40 100644 --- a/packages/headless/src/primitives/dialog/parts.ts +++ b/packages/headless/src/primitives/dialog/parts.ts @@ -1,4 +1,10 @@ -export { type DialogClosedBy, type DialogOpenChangeDetails, type DialogProps, DialogRoot as Root } from './dialog-root'; +export { + type DialogClosedBy, + type DialogOpenChangeDetails, + type DialogProps, + type DialogRole, + DialogRoot as Root, +} from './dialog-root'; export { type DialogTriggerProps, DialogTrigger as Trigger } from './dialog-trigger'; export { createDialogHandle as createHandle, type DialogHandle } from './dialog-handle'; export { type DialogPortalProps, DialogPortal as Portal } from './dialog-portal'; From b626033070c21cce0421bf28af0b04e1f42c74f8 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 21:51:59 -0600 Subject: [PATCH 2/3] fix(headless): keep Drawer's context off the dialog stacking members MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DrawerContextValue` inherits from `DialogContextValue`, so adding `isStacked` and `stackedChildCount` there made every drawer root fail to satisfy its own context — `tsc --noEmit` was red for the whole package, and for swingset, which typechecks headless from source. They belong in the same `Omit` as `store`. A drawer already tracks its nesting as `nestedOpenCount`, and `isStacked` asks a question about DIALOGS that a drawer has nothing to answer with. --- packages/headless/src/primitives/drawer/drawer-context.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/headless/src/primitives/drawer/drawer-context.ts b/packages/headless/src/primitives/drawer/drawer-context.ts index 0cdd7357352..30cec1dfafc 100644 --- a/packages/headless/src/primitives/drawer/drawer-context.ts +++ b/packages/headless/src/primitives/drawer/drawer-context.ts @@ -30,7 +30,12 @@ export interface NestedDrawerCallbacks { // The dialog-only members are dropped: the drawer has no trigger registry (its detached // triggers go through `DrawerHandle`), and its triggers still wire through floating-ui's // reference props, which the dialog's no longer do. -export interface DrawerContextValue extends Omit { +// +// The stacking pair goes with them. A drawer already counts its own nesting as +// `nestedOpenCount` / `onNested`, which is a different question from the dialog's: `isStacked` +// asks whether a DIALOG sits above, and a drawer's stacked-child styling has nothing to read it +// from. Inheriting them would oblige every drawer root to publish two values no drawer part uses. +export interface DrawerContextValue extends Omit { getReferenceProps: UseInteractionsReturn['getReferenceProps']; backdropRef: React.RefObject; drag: DrawerDrag; From a0b22cc79c5da0bcbb23d5814afddfa2c809daf0 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 13 Aug 2026 13:32:16 -0600 Subject: [PATCH 3/3] fix(ui): address review feedback on #9427 Publish "still covering" rather than the raw `open` flag from `DialogNestingContext`, so a stacked child keeps `data-stacked` for the length of the parent's exit instead of painting a second scrim over the parent's fading one. Co-Authored-By: Claude Opus 5 (1M context) --- .../headless/src/primitives/dialog/README.md | 3 ++ .../src/primitives/dialog/dialog-nesting.ts | 17 +++++--- .../src/primitives/dialog/dialog-root.tsx | 6 ++- .../src/primitives/dialog/dialog.test.tsx | 43 +++++++++++++++++++ 4 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index e160414777a..1ef84c34301 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -237,6 +237,9 @@ alert stack, the middle dialog is stacked on one surface while another is stacke 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 diff --git a/packages/headless/src/primitives/dialog/dialog-nesting.ts b/packages/headless/src/primitives/dialog/dialog-nesting.ts index 2fff5e4fae4..4521a78549a 100644 --- a/packages/headless/src/primitives/dialog/dialog-nesting.ts +++ b/packages/headless/src/primitives/dialog/dialog-nesting.ts @@ -11,7 +11,12 @@ import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useSt * floating ancestor but sits on the bare page, and must still paint its own scrim. */ export interface DialogNestingContextValue { - /** Whether the surrounding dialog is itself open. */ + /** + * 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. @@ -41,7 +46,7 @@ export interface DialogNesting { * 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): DialogNesting { +export function useDialogNesting(open: boolean, mounted: boolean): DialogNesting { const parent = useContext(DialogNestingContext); const [stackedChildCount, setStackedChildCount] = useState(0); @@ -70,13 +75,15 @@ export function useDialogNesting(open: boolean): DialogNesting { return registerWithParent(); }, [open, registerWithParent]); + const covering = open || mounted; + const context = useMemo( - () => ({ open, registerStackedChild }), - [open, registerStackedChild], + () => ({ open: covering, registerStackedChild }), + [covering, registerStackedChild], ); return { - // A closed parent is not something to sit on top of: the child owns the scrim in that case, + // 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, diff --git a/packages/headless/src/primitives/dialog/dialog-root.tsx b/packages/headless/src/primitives/dialog/dialog-root.tsx index dd32d1ae976..66bb2fb0f72 100644 --- a/packages/headless/src/primitives/dialog/dialog-root.tsx +++ b/packages/headless/src/primitives/dialog/dialog-root.tsx @@ -89,8 +89,6 @@ function DialogInner(props: DialogProps & { isNested: boolean const [activeTriggerId, setActiveTriggerId] = useControllableState(props.triggerId, null); const [activePayload, setActivePayload] = useState(undefined); - const nesting = useDialogNesting(open); - const labelId = useId(); const descriptionId = useId(); @@ -178,6 +176,10 @@ function DialogInner(props: DialogProps & { 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', diff --git a/packages/headless/src/primitives/dialog/dialog.test.tsx b/packages/headless/src/primitives/dialog/dialog.test.tsx index 4b3c99f599f..5a65d30c165 100644 --- a/packages/headless/src/primitives/dialog/dialog.test.tsx +++ b/packages/headless/src/primitives/dialog/dialog.test.tsx @@ -745,6 +745,49 @@ describe('Dialog', () => { expect(screen.getByTestId('outer-popup')).not.toHaveAttribute('data-stack-base'); }); + it('stays stacked while the dialog beneath is exiting', () => { + // Keep an animation pending so the one beneath stays mounted for its exit instead of + // unmounting in the same commit. + const original = (Element.prototype as { getAnimations?: unknown }).getAnimations; + (Element.prototype as { getAnimations?: unknown }).getAnimations = () => [ + { finished: new Promise(() => {}) }, + ]; + try { + const { rerender } = renderStack(); + + // The one beneath closes first. Its backdrop is still on screen for the length of the + // exit, so the one on top has to keep suppressing its own scrim rather than paint a + // second one over the fading original. + rerender( + + + + + Outer + + + + + Inner + + + + + + , + ); + + expect(screen.getByTestId('outer-backdrop')).toBeInTheDocument(); + expect(screen.getByTestId('inner-backdrop')).toHaveAttribute('data-stacked', ''); + } finally { + if (original) { + (Element.prototype as { getAnimations?: unknown }).getAnimations = original; + } else { + delete (Element.prototype as { getAnimations?: unknown }).getAnimations; + } + } + }); + it('is not stacked on a dialog that is closed', () => { // A confirmation root mounted beside its dialog's portal is inside the root but outlives // the open state; on its own it owns the scrim like any root-level dialog.