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 ( { + // The veto. Every close request lands here — Escape, the corner X, `Dialog.Close` — so + // declining to commit covers all of them at once. A footer button wired to a bare + // `setOpen(false)` would go around it, which is the argument for `Dialog.Close`. + if (!next && confirmDiscard && value.trim() !== '') { + setDiscardOpen(true); + return; + } + if (!next) { + setValue(''); + } + setOpen(next); + }} > - {({ close }) => ( - <> - - }>{title} - }>{description} - + + }>{title} + }>{description} + setValue(event.target.value)} + /> +
+ }>Cancel + +
+ {confirmDiscard ? ( + + }>Discard changes? + }> + You have not finished adding this address. It will not be saved. +
- - )} +
+ ) : null}
); } -/** 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) => {