diff --git a/.changeset/dialog-stack-motion.md b/.changeset/dialog-stack-motion.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/dialog-stack-motion.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx
index ebe7b975a50..0128da7fd06 100644
--- a/packages/swingset/src/stories/dialog.component.mdx
+++ b/packages/swingset/src/stories/dialog.component.mdx
@@ -146,6 +146,11 @@ The sheet fades over the full length of its slide, while the backdrop keeps its
the scrim answers the tap first, then the sheet arrives into an already-dimmed page. Under
`prefers-reduced-motion: reduce` the sheet holds flat and only the fade runs.
+A sheet arriving over another dialog takes the shorter desktop fade instead. The long one earns
+itself against the page, where it gives the travel somewhere to resolve into; over an opaque
+surface it just shows the dialog underneath through the one arriving, and the two read as one muddy
+surface. The slide is unchanged, and carries the arrival on its own.
+
Drag-to-dismiss is deliberately absent — `Drawer` owns the drag engine, and a second one should not
grow inside `Dialog`.
@@ -249,11 +254,26 @@ difference and adds it to its own bottom padding, which gives each size the righ
A card taller than the remaining space aligns to its top rather than losing its head. Pinch-zoom —
which also shrinks the visual viewport — is excluded.
-### Stacked dialogs
+### Nested dialogs and stacks
+
+Two different relationships, which look different on purpose.
+
+A **nested** dialog is one opened over a `panel` or a `card` — a new surface over a page-like one.
+It paints its own scrim, lighter than the base so the two composite to the intended darkness rather
+than doubling it. Nothing else changes.
-A dialog opened from inside another one carries `data-nested` and paints its own, lighter scrim, so
-each level reads as a step further from the page without the backdrops compounding toward an
-opaque wall.
+A **stack** is successive `prompt`s: the confirmation over the form it is confirming. The same
+conversation, one step further in. A stacked prompt paints **no** scrim — one backdrop serves the
+whole stack, so how dark the page goes never depends on how deep the stack is. Depth comes from the
+prompt beneath instead: its contents dim toward its own background, and it recedes, scaling down
+slightly and lifting, with its radius divided by the same factor so the corners render unchanged.
+
+Whichever it is, the thing that opens is always a `prompt`. `panel` and `card` are root-level
+surfaces — they host, they are never hosted — and a dialog opened inside another one warns in
+development if it is any other size.
+
+Under `prefers-reduced-motion: reduce` the recede still happens, it just arrives in a single frame
+with nothing interpolating — the setting asks for no animation, not for no distinction.
---
@@ -342,6 +362,22 @@ prompt.
storyModule={DialogStories}
/>
+This is the nested case, not a stack: the prompt paints its own scrim over the panel, and the panel
+neither dims nor recedes.
+
+Type into **Add email address** and then try to close it — Escape, the corner X, or Cancel — and a
+confirmation stacks on top instead, making the panel → prompt → prompt case reachable. The veto is
+a controlled `open` whose `onOpenChange` declines to commit; every close request routes through it,
+so one check covers all of them.
+
+Stack a prompt on a prompt and the relationship changes — the shape a close confirmation
+takes:
+
+
+
Nest by rendering a `Dialog` inside another one's children. Nothing else is required — the inner
dialog finds the outer through Floating UI's tree and wires up its own stacking:
diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx
index 2d5e857a58d..6dbb4001580 100644
--- a/packages/swingset/src/stories/dialog.component.stories.tsx
+++ b/packages/swingset/src/stories/dialog.component.stories.tsx
@@ -107,7 +107,14 @@ const sectionHeader = {
justifyContent: 'space-between',
} as const;
-/** A `prompt` dialog opened from inside the `panel` — the shape the account profile uses. */
+/**
+ * A `prompt` dialog opened from inside the `panel` — the shape the account profile uses.
+ *
+ * With `confirmDiscard`, closing it while the field holds anything opens a confirmation stacked on
+ * top rather than closing: `panel -> prompt -> prompt`, and the veto is nothing more than a
+ * controlled `open` whose `onOpenChange` declines to commit. Hand-rolled here on purpose — it is
+ * what the `AlertDialog` and close-confirmation work is meant to replace.
+ */
function AddValueDialog({
trigger,
title,
@@ -115,6 +122,7 @@ function AddValueDialog({
placeholder,
confirmLabel = 'Continue',
confirmColor,
+ confirmDiscard = false,
}: {
trigger: (props: RenderProps) => React.ReactElement;
title: string;
@@ -122,39 +130,87 @@ function AddValueDialog({
placeholder: string;
confirmLabel?: string;
confirmColor?: 'negative';
+ confirmDiscard?: boolean;
}) {
+ const [open, setOpen] = React.useState(false);
+ const [discardOpen, setDiscardOpen] = React.useState(false);
+ const [value, setValue] = React.useState('');
+
+ const dismiss = () => {
+ setValue('');
+ setOpen(false);
+ };
+
return (
);
}
-/** A `panel` account surface with `card` dialogs opened from inside it. */
+/** A `panel` account surface with `prompt` dialogs opened from inside it. */
export function Nested() {
return (
@@ -252,6 +309,73 @@ const SESSIONS = Array.from({ length: 40 }, (_, index) => ({
when: SESSION_TIMES[index % SESSION_TIMES.length],
}));
+const editProfileTrigger = (props: RenderProps) => ;
+
+const discardTrigger = (props: RenderProps) => (
+
+);
+
+/**
+ * A prompt stacked on a prompt — the shape a close confirmation takes. The second prompt paints
+ * no scrim of its own; the one beneath it recedes instead.
+ */
+export function StackedPrompts() {
+ return (
+
+ {({ close }) => (
+ <>
+
+ }>Update profile
+ }>Change the name people see on your account.
+
+
+
+ {({ close: closeConfirmation }) => (
+ <>
+ }>Discard changes?
+ }>Your edits will be lost.
+
+
+
+
+ >
+ )}
+
+
+
+ >
+ )}
+
+ );
+}
+
/** The panel clips rather than scrolling, so the scroll region is composed inside it. */
export function PanelSidebar() {
return (
diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts
index 181e0dc29f4..27c51dc0fc8 100644
--- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts
+++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts
@@ -2,6 +2,11 @@ import * as stylex from '@stylexjs/stylex';
import { colorVars, durationVars, easingVars, radiusVars, space } from '../../tokens.stylex';
+// How far the contents of a surface beneath a stacked prompt are veiled toward its own background.
+// Declared up here rather than beside `STACK_SCALE` further down because `sizes` reads it, and
+// StyleX requires a referenced constant to be declared before the `create()` call that reads it.
+const STACK_VEIL_OPACITY = 0.4;
+
export const styles = stylex.create({
// The scrim. A black wash over `transparent` rather than a percentage of a neutral
// token: it composites over whatever the host app renders, so the same value reads
@@ -10,13 +15,12 @@ export const styles = stylex.create({
// Black in both schemes. A grey veil was tried for dark mode — lightening a dark page rather
// than darkening it — and it read as haze over the page rather than as a surface lifting off it.
//
- // A stacked dialog paints its OWN scrim rather than deferring to the one beneath it, so each
- // level reads as a step further from the page. It is lighter than the base because the two
- // COMPOSITE: alpha over alpha is `1 − (1 − a)(1 − b)`, so the nested value is solved for the
- // intended total rather than picked by eye — `1 − 0.32/0.6 = 0.4667` lands two levels on 0.68.
- // Exact for a two-deep stack, which is the shape that exists; a third level would go darker
- // still, and wants its own value rather than a third application of this one.
- // `data-nested` comes from the headless layer.
+ // A dialog opened over a `panel` or a `card` paints its OWN scrim, lighter than the base because
+ // the two COMPOSITE: alpha over alpha is `1 − (1 − a)(1 − b)`, so the nested value is solved for
+ // the intended total rather than picked by eye — `1 − 0.32/0.6 = 0.4667` lands two levels on
+ // 0.68. That is what separates a surface from the one it was opened from.
+ //
+ // A STACK is the other case and wants the opposite — see `backdropStacked`.
backdrop: {
inset: 0,
backgroundColor: {
@@ -26,6 +30,26 @@ export const styles = stylex.create({
position: 'fixed',
},
+ /**
+ * A prompt stacked on a prompt paints NO scrim — one serves the whole stack.
+ *
+ * The two cases are different relationships, not one at two strengths. A prompt opened over a
+ * panel is a new surface over a page-like one, and a scrim of its own is what says so. A prompt
+ * over a prompt is the same conversation continuing one step further in, and darkening the page
+ * again for it makes depth a function of stack count: the composite compounds, so the
+ * three-deep `panel -> prompt -> alert` this exists for would land on 0.83 against the 0.68 the
+ * nested value above was solved for. The stack reads through the surface beneath receding and
+ * dimming instead.
+ *
+ * Applied by `Dialog.Backdrop` rather than keyed on `data-stacked`, because whether this is a
+ * stack depends on the size of the dialog beneath — which the headless layer has no notion of.
+ * It rides in the same `stylex.props` call as `backdrop`, so this `backgroundColor` replaces
+ * that one outright rather than the two both emitting.
+ */
+ backdropStacked: {
+ backgroundColor: 'transparent',
+ },
+
// Centering track inside the headless `FloatingOverlay`, which owns the fixed positioning and
// the scroll lock. Whether this box is a fixed height or grows with its content is the whole
// outside-scroll question, and it differs per size — see `viewportSizes` below.
@@ -81,6 +105,29 @@ export const styles = stylex.create({
// raw content rather than a `Card` and the surface has to come from somewhere. `sizes.card`
// nulls the painting properties back out — see the note there.
popup: {
+ /**
+ * The other half of the recede: while a prompt is stacked on this surface, its contents dim
+ * toward the surface's own background, so the layer beneath reads as further back rather than
+ * merely smaller.
+ *
+ * A veil rather than `opacity` on the popup, because those are different effects. Fading the
+ * popup fades the SURFACE — its background and its shadow — and the scrim shows through, which
+ * reads as the dialog dissolving. Painting the background colour back over the contents leaves
+ * the surface at full strength and dims only what sits on it.
+ *
+ * Driven by a private custom property rather than by a state branch on the pseudo-element:
+ * a `:where()` nested inside a `::after` block would describe the pseudo-element's own state,
+ * not the popup's. Setting the variable on the popup — where the state actually lives — and
+ * reading it here is the only shape that says what is meant.
+ *
+ * `zIndex` so it also covers `Dialog.CloseButton`, which is positioned and would otherwise
+ * paint over it and stay undimmed. Never interactive: the whole subtree is inert while a
+ * stacked dialog holds focus, and `pointer-events: none` keeps it that way regardless.
+ *
+ * The variable itself is set per size — only `prompt` sets it, in `sizes` below — so this
+ * reads `0` on a `panel` or a `card`, which have a scrim of their own to separate them from
+ * what they host and would double up.
+ */
padding: space['6'],
// Forced-colors mode discards `box-shadow` outright, and the ring above is the only thing
// separating the surface from the page — so in HCM the dialog would float edgeless over its
@@ -124,6 +171,26 @@ export const styles = stylex.create({
// The containing block for `Dialog.CloseButton`.
position: 'relative',
width: '100%',
+ '::after': {
+ inset: 0,
+ // Follows the popup's own radius, counter-scale included.
+ borderRadius: 'inherit',
+ backgroundColor: colorVars['--cl-color-card'],
+ content: '""',
+ opacity: 'var(--_cl-stack-veil, 0)',
+ pointerEvents: 'none',
+ position: 'absolute',
+ // Tracks the recede it accompanies rather than standing on its own: the two are halves of
+ // one gesture, and the phone band runs the transform at `slow`. Pinning the veil at `base`
+ // there finishes the dim 100ms before the surface stops moving, in both directions.
+ transitionDuration: {
+ default: durationVars['--cl-duration-base'],
+ '@media (max-width: 47.99rem)': durationVars['--cl-duration-slow'],
+ },
+ transitionProperty: 'opacity',
+ transitionTimingFunction: easingVars['--cl-ease-enter'],
+ zIndex: 1,
+ },
},
/**
@@ -237,6 +304,10 @@ export const viewportSizes = stylex.create({
export const sizes = stylex.create({
prompt: {
+ // Read by the veil on `styles.popup`. Set here rather than there so it applies to `prompt`
+ // alone: a `panel` or a `card` hosting a dialog gets a scrim between the two instead, and
+ // would otherwise dim as well as darken.
+ '--_cl-stack-veil': { default: 0, ':where([data-stack-base])': STACK_VEIL_OPACITY },
// Tighter than the popup's default 1.5rem. A prompt asks one thing, so its content box is
// small and a 1.5rem surround reads as a disproportionate frame around two lines of text.
// Overrides `styles.popup` by position — `sizes[size]` is spread after it in the same
@@ -424,6 +495,23 @@ export const backdropMotion = stylex.create({
const SHEET_EXIT_EASE = 'ease-out';
const ENTER_SCALE = 0.94;
+
+// How far a prompt recedes while another prompt is stacked on it, and the radius that survives
+// that scale — the same `r/s` correction `ENTER_SCALE` documents above, for the same reason.
+//
+// Shallower than the entrance scale on purpose: the entrance is a surface arriving from nowhere,
+// while this is a surface that stays legible the whole time and only has to read as further back.
+// The lift (`STACK_LIFT`, at the top of this file) is what separates it from the entrance rather
+// than the depth of the scale — a surface that only shrinks reads as being pushed away, one that
+// shrinks and rises reads as being layered over, which is the relationship this actually is.
+//
+// A single step rather than a `--cl-stack-index` formula: the headless layer counts DIRECT
+// children, so a third level would report the same 1 as the second and every level below the top
+// would recede identically anyway. The formula and the cumulative count belong in the same change,
+// whenever a stack deep enough to need them turns up.
+const STACK_SCALE = 0.96;
+const STACK_LIFT = '-0.5rem';
+
const popupRadius = radiusVars['--cl-radius-xl'];
export const popupMotion = stylex.create({
@@ -437,15 +525,22 @@ export const popupMotion = stylex.create({
prompt: {
borderRadius: {
default: popupRadius,
+ // The recede is the one scale that survives the phone band, so unlike the entrance its
+ // radius correction is NOT pinned flat there — see `transform` below.
+ ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`,
':where([data-starting-style], [data-ending-style])': `calc(${popupRadius} / ${ENTER_SCALE})`,
'@media (max-width: 47.99rem)': {
default: popupRadius,
+ ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`,
':where([data-starting-style], [data-ending-style])': popupRadius,
},
- // Both branches resolve to the same value, so their order relative to each other cannot
- // matter: there is no scale to counteract in either case.
+ // Both entrance branches resolve to the same value, so their order relative to each other
+ // cannot matter: there is no scale to counteract in either case. The recede is the
+ // exception — it still applies under `reduce`, just without a duration — so its correction
+ // has to come with it.
'@media (prefers-reduced-motion: reduce)': {
default: popupRadius,
+ ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`,
':where([data-starting-style], [data-ending-style])': popupRadius,
},
},
@@ -481,13 +576,37 @@ export const popupMotion = stylex.create({
*/
transform: {
default: 'scale(1)',
+ /**
+ * The recede: what a prompt does while another prompt is stacked on it. There is no second
+ * scrim, so this and the stacked surface's own shadow are the entire depth cue.
+ *
+ * Kept ON the phone band, where the entrance scale is pinned flat. Those are different
+ * gestures and the reasoning does not carry over: the entrance pin exists because stacking a
+ * shrink on top of a full-height slide makes the sheet arrive small and settle. A sheet
+ * receding under another sheet is the familiar one — it is what vaul does — and on a phone,
+ * where a stacked sheet covers most of what is beneath it, dropping the recede would leave
+ * the level below with no depth cue at all.
+ *
+ * `@stylexjs/sort-keys` puts this branch before the entrance one, so an exit that somehow
+ * begins while a child is still open renders the exit scale rather than the recede. Nothing
+ * ordinary reaches that state — floating-ui blocks the parent's own dismissal while a child
+ * is open — and the exit scale is the better of the two to see if anything ever does.
+ */
+ ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`,
':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE})`,
'@media (max-width: 47.99rem)': {
default: 'scale(1)',
+ ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`,
':where([data-starting-style], [data-ending-style])': 'scale(1)',
},
+ // The recede is NOT dropped here, unlike the entrance scale. `reduce` asks for no
+ // ANIMATION, not for no distinction: `transitionProperty` below narrows to `opacity` in
+ // this mode, so the recede lands in one frame with nothing interpolating. Dropping it
+ // outright leaves a stacked prompt sitting on an identical prompt with no scrim between
+ // them, which reads as a rendering fault rather than as a preference being honoured.
'@media (prefers-reduced-motion: reduce)': {
default: 'scale(1)',
+ ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`,
':where([data-starting-style], [data-ending-style])': 'scale(1)',
},
},
@@ -502,12 +621,29 @@ export const popupMotion = stylex.create({
// `fast` and lands with the scrim, since the scale it accompanies barely moves. The third slot
// is inert under the phone band (no scale, so no radius counter-scale) but still has to be
// filled — the list is positional.
+ //
+ // EXCEPT for a sheet arriving over another dialog, which takes the desktop `fast` fade back.
+ // The long fade earns itself on the first sheet, where it gives the travel somewhere to
+ // resolve into against the page. Over an opaque surface it does the opposite: for a quarter of
+ // a second the dialog underneath shows through the one arriving, and two stacked surfaces
+ // read as one muddy one. There is already a surface there, so the fade has nothing left to do
+ // and the slide can carry the arrival alone.
+ //
+ // Keyed on `data-stacked` — over any open dialog, panel included — rather than on the narrower
+ // prompt-on-prompt stack the backdrop cares about. What makes the long fade wrong here is
+ // arriving over something opaque, and a panel is as opaque as a prompt.
+ //
+ // The combined exiting branch restates `base` because `@stylexjs/sort-keys` puts it after the
+ // plain `data-stacked` one, which would otherwise hand a stacked sheet the four-value entrance
+ // list on its way out and slow its exit slide.
transitionDuration: {
default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`,
':where([data-ending-style])': durationVars['--cl-duration-fast'],
'@media (max-width: 47.99rem)': {
default: `${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`,
':where([data-ending-style])': durationVars['--cl-duration-base'],
+ ':where([data-stacked])': `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`,
+ ':where([data-stacked][data-ending-style])': durationVars['--cl-duration-base'],
},
},
transitionProperty: {
diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx
index 9b7ff5eb24e..8ff9af9dedb 100644
--- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx
+++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx
@@ -245,27 +245,107 @@ describe('stacked backdrops', () => {
);
- it('marks only the inner backdrop as nested, so the scrims do not compound', async () => {
+ function renderStack() {
+ return render(
+
+ Account
+
Outer body
+
+ Add email address
+
Inner body
+
+ ,
+ );
+ }
+
+ // The backdrop's two cases differ by a style rather than by an attribute, so the assertion is
+ // that the same tree with only the hosting size changed produces different classes. Comparing
+ // rather than matching a class: StyleX names are content hashes and would pin the value.
+ async function innerBackdropClass(hostSize: DialogSize) {
+ const user = userEvent.setup();
+ render(
+
+ Host
+
+ Add email address
+
+ ,
+ );
+ await user.click(screen.getByRole('button', { name: 'Add email' }));
+ const className = document.querySelectorAll('.cl-dialog-backdrop')[1].className;
+ cleanup();
+ return className;
+ }
+
+ it('drops the scrim for a prompt over a prompt, and keeps it for one over a panel', async () => {
+ const overPrompt = await innerBackdropClass('prompt');
+ const overPanel = await innerBackdropClass('panel');
+
+ expect(overPrompt).not.toBe(overPanel);
+ });
+
+ it('keeps a prompt over a card on the nested scrim, same as over a panel', async () => {
+ const overCard = await innerBackdropClass('card');
+ const overPanel = await innerBackdropClass('panel');
+
+ expect(overCard).toBe(overPanel);
+ });
+
+ it('marks the popup beneath as the stack base, so it can recede', async () => {
+ const user = userEvent.setup();
+ renderStack();
+
+ const outerPopup = document.querySelector('.cl-dialog-popup');
+ expect(outerPopup).not.toHaveAttribute('data-stack-base');
+
+ await user.click(screen.getByRole('button', { name: 'Add email' }));
+
+ const popups = document.querySelectorAll('.cl-dialog-popup');
+ expect(popups[0]).toHaveAttribute('data-stack-base', '');
+ expect(popups[1]).not.toHaveAttribute('data-stack-base');
+ });
+
+ it('warns when a stacked dialog is not a prompt', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const user = userEvent.setup();
render(
+ Account
Outer body
-
+
+ Add email address
Inner body
,
);
- expect(document.querySelector('.cl-dialog-backdrop')).not.toHaveAttribute('data-nested');
+ await user.click(screen.getByRole('button', { name: 'Add email' }));
+
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('size="card"'));
+ warn.mockRestore();
+ });
+
+ it('does not warn for a stacked prompt, or for a root-level panel', async () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const user = userEvent.setup();
+ renderStack();
await user.click(screen.getByRole('button', { name: 'Add email' }));
- const backdrops = document.querySelectorAll('.cl-dialog-backdrop');
- expect(backdrops[0]).not.toHaveAttribute('data-nested');
- expect(backdrops[1]).toHaveAttribute('data-nested', '');
+ expect(warn).not.toHaveBeenCalled();
+ warn.mockRestore();
});
});
diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx
index 14d08dabf45..c235c14965d 100644
--- a/packages/ui/src/mosaic/components/dialog/dialog.tsx
+++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx
@@ -29,6 +29,24 @@ export interface DialogRootProps extends HeadlessDialogProps<
*/
const DialogSizeContext = React.createContext('prompt');
+/**
+ * The size of the dialog this one was opened from, which is what decides whether the two form a
+ * STACK — successive prompts — or a nested dialog over a `panel` or `card`. The two want opposite
+ * backdrops, so the distinction has to be reachable from the parts.
+ *
+ * Read from `DialogSizeContext` before a root overwrites it with its own size. Meaningless on its
+ * own, since a root-level dialog reads the context default: pair it with the headless `isStacked`,
+ * which is what reports that there is a dialog above at all.
+ */
+const DialogParentSizeContext = React.createContext('prompt');
+
+/** Whether this dialog is a prompt stacked on a prompt — see {@link DialogParentSizeContext}. */
+function useIsStacked() {
+ const { isStacked } = useDialogContext();
+ const parentSize = React.useContext(DialogParentSizeContext);
+ return isStacked && parentSize === 'prompt';
+}
+
/**
* The headless parts type their props (and the `render` callback's argument) against
* the raw tag props, which carry the non-standard HTML `color` attribute typed
@@ -74,10 +92,13 @@ export type DialogPopupProps = MosaicComponentProps<'div'> & {
/** Owns the open state and the size both the backdrop and the popup read. */
function Root({ size = 'prompt', children, ...rest }: DialogRootProps) {
+ const parentSize = React.useContext(DialogSizeContext);
return (
-
- {...rest}>{children}
-
+
+
+ {...rest}>{children}
+
+
);
}
@@ -167,12 +188,15 @@ const Backdrop = React.forwardRef(function
ref,
) {
const size = React.useContext(DialogSizeContext);
+ const isStacked = useIsStacked();
return (
(function
);
});
+/**
+ * Warns when a dialog opened inside another dialog is not a `prompt`.
+ *
+ * `panel` and `card` are root-level surfaces: they host what opens over them and are never the
+ * thing that opens. A `panel` inside a dialog renders at a size that assumes it owns the viewport,
+ * over a surface it was meant to replace.
+ *
+ * One rule stated on the child covers every case — panel-in-panel, card-in-panel — without having
+ * to enumerate which sizes may host what.
+ */
+function useNestedSizeWarning(isNestedInDialog: boolean, size: DialogSize) {
+ React.useEffect(() => {
+ if (process.env.NODE_ENV === 'production' || !isNestedInDialog || size === 'prompt') {
+ return;
+ }
+ console.warn(
+ `[clerk] a Dialog opened inside another Dialog should be size="prompt", but this one is size="${size}". ` +
+ 'Only prompts are meant to open over another dialog; the rest are root-level surfaces.',
+ );
+ }, [isNestedInDialog, size]);
+}
+
/** The dialog surface: `role="dialog"`, focus-trapped, and the element that paints. */
const Popup = React.forwardRef(function DialogPopup(
{ className, style, ...rest },
ref,
) {
const size = React.useContext(DialogSizeContext);
+ // The headless flag, not `useIsStacked` — the rule is about opening a dialog inside ANY dialog,
+ // which is broader than the prompt-on-prompt case the stacking styles cover.
+ const { isStacked: isNestedInDialog } = useDialogContext();
// Observed through state rather than a plain ref, because the warning has to re-run when the
// node arrives and a ref mutation does not re-render.
const [node, setNode] = React.useState(null);
useAccessibleNameWarning(node, 'Dialog');
+ useNestedSizeWarning(isNestedInDialog, size);
const mergedRef = React.useCallback(
(element: HTMLDivElement | null) => {