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
5 changes: 5 additions & 0 deletions .changeset/dialog-stack-motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/ui': patch
---

Mosaic `Dialog` now distinguishes a stack — successive `prompt` dialogs, such as a confirmation over the form it is confirming — from a dialog opened over a `panel` or `card`. A stacked prompt paints no backdrop of its own, so the page no longer darkens further with every level; the prompt beneath it dims and recedes instead. Dialogs opened over a `panel` or `card` are unchanged. Opening a `panel` or `card` inside another dialog now warns in development.
44 changes: 40 additions & 4 deletions packages/swingset/src/stories/dialog.component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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:

<Story
name='StackedPrompts'
storyModule={DialogStories}
/>

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:

Expand Down
154 changes: 139 additions & 15 deletions packages/swingset/src/stories/dialog.component.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,54 +107,110 @@ 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,
description,
placeholder,
confirmLabel = 'Continue',
confirmColor,
confirmDiscard = false,
}: {
trigger: (props: RenderProps) => React.ReactElement;
title: string;
description: string;
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 (
<Dialog
trigger={trigger}
closedBy='closerequest'
open={open}
onOpenChange={next => {
// 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 }) => (
<>
<Dialog.CloseButton />
<Dialog.Title render={<Heading size='sm' />}>{title}</Dialog.Title>
<Dialog.Description render={<Text />}>{description}</Dialog.Description>
<Input placeholder={placeholder} />
<Dialog.CloseButton />
<Dialog.Title render={<Heading size='sm' />}>{title}</Dialog.Title>
<Dialog.Description render={<Text />}>{description}</Dialog.Description>
<Input
placeholder={placeholder}
value={value}
onChange={event => setValue(event.target.value)}
/>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<Dialog.Close render={<Button variant='outline' />}>Cancel</Dialog.Close>
<Button
color={confirmColor}
onClick={dismiss}
>
{confirmLabel}
</Button>
</div>
{confirmDiscard ? (
<Dialog
open={discardOpen}
onOpenChange={setDiscardOpen}
closedBy='closerequest'
>
<Dialog.Title render={<Heading size='sm' />}>Discard changes?</Dialog.Title>
<Dialog.Description render={<Text />}>
You have not finished adding this address. It will not be saved.
</Dialog.Description>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<Button
variant='outline'
onClick={close}
onClick={() => setDiscardOpen(false)}
>
Cancel
Keep editing
</Button>
<Button
color={confirmColor}
onClick={close}
color='negative'
onClick={() => {
setDiscardOpen(false);
dismiss();
}}
>
{confirmLabel}
Discard
</Button>
</div>
</>
)}
</Dialog>
) : null}
</Dialog>
);
}

/** 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 (
<Dialog
Expand All @@ -173,6 +229,7 @@ export function Nested() {
title='Add email address'
description="We'll send a verification code to this address."
placeholder='you@example.com'
confirmDiscard
/>
</div>
<Item.Group>
Expand Down Expand Up @@ -252,6 +309,73 @@ const SESSIONS = Array.from({ length: 40 }, (_, index) => ({
when: SESSION_TIMES[index % SESSION_TIMES.length],
}));

const editProfileTrigger = (props: RenderProps) => <Button {...props}>Edit profile</Button>;

const discardTrigger = (props: RenderProps) => (
<Button
variant='outline'
{...props}
>
Cancel
</Button>
);

/**
* 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 (
<Dialog
trigger={editProfileTrigger}
closedBy='closerequest'
>
{({ close }) => (
<>
<Dialog.CloseButton />
<Dialog.Title render={<Heading size='sm' />}>Update profile</Dialog.Title>
<Dialog.Description render={<Text />}>Change the name people see on your account.</Dialog.Description>
<Input
defaultValue='Ada Lovelace'
placeholder='Your name'
/>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<Dialog
trigger={discardTrigger}
closedBy='closerequest'
>
{({ close: closeConfirmation }) => (
<>
<Dialog.Title render={<Heading size='sm' />}>Discard changes?</Dialog.Title>
<Dialog.Description render={<Text />}>Your edits will be lost.</Dialog.Description>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<Button
variant='outline'
onClick={closeConfirmation}
>
Keep editing
</Button>
<Button
color='negative'
onClick={() => {
closeConfirmation();
close();
}}
>
Discard
</Button>
</div>
</>
)}
</Dialog>
<Button onClick={close}>Save</Button>
</div>
</>
)}
</Dialog>
);
}

/** The panel clips rather than scrolling, so the scroll region is composed inside it. */
export function PanelSidebar() {
return (
Expand Down
Loading
Loading