Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-alert-dialog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
1 change: 1 addition & 0 deletions packages/swingset/src/components/DocsViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const docModules: Record<string, Record<string, React.ComponentType>> = {
input: dynamic(() => import('../stories/input.mdx')),
item: dynamic(() => import('../stories/item.mdx')),
dialog: dynamic(() => import('../stories/dialog.component.mdx')),
'alert-dialog': dynamic(() => import('../stories/alert-dialog.component.mdx')),
heading: dynamic(() => import('../stories/heading.mdx')),
icon: dynamic(() => import('../stories/icon.mdx')),
menu: dynamic(() => import('../stories/menu.component.mdx')),
Expand Down
12 changes: 12 additions & 0 deletions packages/swingset/src/lib/registry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
// Import stories explicitly to control order and avoid type casting through unknown.
import { meta as accordionMeta } from '../stories/accordion.stories';
import {
Default as AlertDialogDefault,
DiscardChanges as AlertDialogDiscardChanges,
meta as alertDialogComponentMeta,
} from '../stories/alert-dialog.component.stories';
import { meta as autocompleteMeta } from '../stories/autocomplete.stories';
import {
Fallback as AvatarFallbackStory,
Expand Down Expand Up @@ -195,6 +200,12 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes,

const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault };

const alertDialogComponentModule: StoryModule = {
meta: alertDialogComponentMeta,
Default: AlertDialogDefault,
DiscardChanges: AlertDialogDiscardChanges,
};

const popoverComponentModule: StoryModule = {
meta: popoverComponentMeta,
Default: PopoverComponentDefault,
Expand Down Expand Up @@ -293,6 +304,7 @@ export const registry: StoryModule[] = [
inputModule,
itemModule,
dialogComponentModule,
alertDialogComponentModule,
headingModule,
iconModule,
menuComponentModule,
Expand Down
131 changes: 131 additions & 0 deletions packages/swingset/src/stories/alert-dialog.component.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import * as AlertDialogStories from './alert-dialog.component.stories';

# AlertDialog

The Mosaic `AlertDialog` — a `Dialog` that interrupts to ask for a decision, and waits for one.
Reach for it when continuing depends on the answer: confirming something destructive, or warning
that leaving loses work. Anything the user can read and dismiss is a `Dialog`.

It is composed from the same parts as `Dialog`, so the surface, the motion, the stacking and the
scroll lock are all shared. What differs is fixed rather than configurable: it announces itself as
`role="alertdialog"`, an outside press cannot dismiss it, and it is always the `prompt` size.

## Example

<Story
name='Default'
storyModule={AlertDialogStories}
/>

## Usage

```tsx
import { AlertDialog } from '@clerk/ui/mosaic/components/alert-dialog';
import { Button } from '@clerk/ui/mosaic/components/button';

<AlertDialog trigger={props => <Button {...props} color='negative'>Delete</Button>}>
{({ close }) => (
<>
<AlertDialog.Title>Delete Acme Inc?</AlertDialog.Title>
<AlertDialog.Description>This cannot be undone.</AlertDialog.Description>
<AlertDialog.Actions>
<AlertDialog.Close render={<Button variant='outline' />}>Cancel</AlertDialog.Close>
<Button color='negative' onClick={close}>Delete organization</Button>
</AlertDialog.Actions>
</>
)}
</AlertDialog>
```

`trigger` is optional, and usually absent — an alert is normally raised by something that already
happened rather than by a button that exists to raise it. Drive those with `open` and
`onOpenChange`.

### A Title and a Description are both required

An alert dialog is announced as an interruption, and its description is announced with its name at
that moment — so a title and two buttons leave the user choosing between "Cancel" and "Delete" with
nothing saying what is being deleted. Both are checked in development and warn when missing; neither
can be required in the type system, since parts arrive as children.

### The cancel comes first

Render the cancel as the first child of `AlertDialog.Actions`. It is the least destructive choice,
and being first makes it the first tabbable element — which is what the dialog opens focused on, with
no `initialFocus` needed. It is also the visual order in both layouts, so the keyboard order and the
screen agree.

### The action does not close by itself

`AlertDialog.Close` dismisses on press, which is what the cancel wants. The action usually starts
work, so close it when that work resolves rather than on the press — the render-prop `close` above,
or your own controlled state. That leaves room for a pending state on the button.

### Returning focus

`finalFocus` (and `initialFocus`) are accepted on the wrapper as well as on `AlertDialog.Popup`.
Pass one whenever the alert has no trigger: focus returns to the trigger by default, and an alert
raised by something that happened has none, so answering it would otherwise drop the user on the
body. A confirmation guarding a form wants the caret back in the field it asked about — see
[Confirming a discard](#confirming-a-discard) below.

### Dismissal

There is no `closedBy` prop. An outside press never dismisses an alert dialog: a question that needs
an answer must not be answerable by clicking next to it. Escape still closes — it is the keyboard's
equivalent of the cancel button, which is always present here. There is no `CloseButton` part for
the same reason: a corner X is a way out without answering.

Every close request — Escape or `AlertDialog.Close` — routes through `onOpenChange`, so a controlled
consumer can decline one by not committing the state.

## Parts

| Part | Slot | Description |
| ------------------------- | ---------------------- | -------------------------------------------------------------------------------------------- |
| `AlertDialog.Root` | — | State provider; owns open/close, `modal`, `handle`. `role`, `closedBy` and `size` are fixed. |
| `AlertDialog.Trigger` | — | Opens the alert; accepts `render`, and `handle` + `payload` when detached. |
| `AlertDialog.Portal` | — | Portals the overlay out of the tree. |
| `AlertDialog.Backdrop` | `dialog-backdrop` | The scrim behind the alert. |
| `AlertDialog.Viewport` | `dialog-viewport` | Centering container; owns the scroll lock. |
| `AlertDialog.Popup` | `dialog-popup` | The surface (`role="alertdialog"`, focus-trapped); `initialFocus` / `finalFocus`. |
| `AlertDialog.Title` | — | Heading; wired to the popup's `aria-labelledby`. Required. |
| `AlertDialog.Description` | — | Description; wired to the popup's `aria-describedby`. Required. |
| `AlertDialog.Close` | — | Dismisses the alert; unstyled, accepts a `render` prop. |
| `AlertDialog.Actions` | `alert-dialog-actions` | The response row. Cancel first. |

Every part except `Popup` and `Actions` is `Dialog`'s own component, not a wrapper around it — one
implementation, so the two cannot drift. `Title` and `Description` are unstyled passthroughs from the
headless layer; render them through your own typography (`Heading`, `Text`) via `render`.

## Styling

The alert dialog carries the same `.cl-dialog-*` slots as `Dialog`, and is themed the same way — see
the [Dialog](/components/dialog) page for the surface, the motion, the inset, and the state
attributes, all of which apply unchanged. Only the response row is its own:

```css
@import '@clerk/ui/styles.css' layer(components);

@layer overrides {
.cl-alert-dialog-actions {
margin-block-start: 1.5rem;
}
}
```

`AlertDialog.Actions` is a grid rather than a flex row, which is what lets the row change shape
without the buttons knowing. From `48rem` up the tracks size to their labels and sit at the inline
end. Below it — where a `prompt` is a bottom sheet spanning the screen — the tracks split the row
evenly, so the buttons are full width rather than a pair floating against one edge.

---

## Examples

### Confirming a discard

<Story
name='DiscardChanges'
storyModule={AlertDialogStories}
/>
136 changes: 136 additions & 0 deletions packages/swingset/src/stories/alert-dialog.component.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/** @jsxImportSource @emotion/react */
import type { RenderProps } from '@clerk/headless/utils';
import { AlertDialog } from '@clerk/ui/mosaic/components/alert-dialog';
import { Button } from '@clerk/ui/mosaic/components/button';
import { Dialog } from '@clerk/ui/mosaic/components/dialog';
import { Heading } from '@clerk/ui/mosaic/components/heading';
import { Input } from '@clerk/ui/mosaic/components/input';
import { Text } from '@clerk/ui/mosaic/components/text';
import React from 'react';

import type { StoryMeta } from '@/lib/types';

// Exposes this file's own source (via the `?raw` webpack rule) so each `<Story>` example
// renders a code footer with its function's source. See `StoryModule.__source`.
export { default as __source } from './alert-dialog.component.stories?raw';

export const meta: StoryMeta = {
group: 'Components',
title: 'AlertDialog',
source: 'packages/ui/src/mosaic/components/alert-dialog/alert-dialog.tsx',
styleEngine: 'stylex',
};

const deleteTrigger = (props: RenderProps) => (
<Button
{...props}
color='negative'
>
Delete organization
</Button>
);

export function Default() {
return (
<AlertDialog trigger={deleteTrigger}>
{({ close }) => (
<>
<AlertDialog.Title render={<Heading size='sm' />}>Delete Acme Inc?</AlertDialog.Title>
<AlertDialog.Description render={<Text />}>
The organization and everything in it will be permanently removed. This cannot be undone.
</AlertDialog.Description>
<AlertDialog.Actions>
<AlertDialog.Close render={<Button variant='outline' />}>Cancel</AlertDialog.Close>
{/* Not an `AlertDialog.Close`: the action is where the work happens, so the caller
closes once it resolves rather than the button closing on press. */}
<Button
color='negative'
onClick={close}
>
Delete organization
</Button>
</AlertDialog.Actions>
</>
)}
</AlertDialog>
);
}

const addEmailTrigger = (props: RenderProps) => <Button {...props}>Add email address</Button>;

/**
* The case the stack was built for: a form prompt raising a confirmation over itself rather than
* discarding what was typed.
*
* The veto is a controlled `open` whose `onOpenChange` declines to commit — every close request
* lands there, so Escape, the corner X and `Dialog.Close` are all covered by the one branch. The
* `AlertDialog` is rendered inside the dialog it guards, which is what puts the two in the same
* floating tree: escape ordering, the stacking styles and the refcounted scroll lock all depend
* on it.
*/
export function DiscardChanges() {
const [open, setOpen] = React.useState(false);
const [confirmOpen, setConfirmOpen] = React.useState(false);
const [value, setValue] = React.useState('');
const inputRef = React.useRef<HTMLInputElement>(null);

const discard = () => {
setValue('');
setConfirmOpen(false);
setOpen(false);
};

return (
<Dialog
trigger={addEmailTrigger}
closedBy='closerequest'
open={open}
onOpenChange={next => {
if (!next && value.trim() !== '') {
setConfirmOpen(true);
return;
}
setOpen(next);
}}
>
<Dialog.CloseButton />
<Dialog.Title render={<Heading size='sm' />}>Add email address</Dialog.Title>
<Dialog.Description render={<Text />}>
You will need to verify this address before it can be used.
</Dialog.Description>
<Input
ref={inputRef}
placeholder='name@example.com'
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 onClick={discard}>Add</Button>
</div>

{/* `finalFocus` puts the caret back in the field. Without it there is nowhere to return to —
this alert is raised by the veto rather than by a trigger — so keeping editing would
leave focus on the body, at the top of the page rather than where the work was. */}
<AlertDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
finalFocus={inputRef}
>
<AlertDialog.Title render={<Heading size='sm' />}>Discard changes?</AlertDialog.Title>
<AlertDialog.Description render={<Text />}>
You have not finished adding this address. It will not be saved.
</AlertDialog.Description>
<AlertDialog.Actions>
<AlertDialog.Close render={<Button variant='outline' />}>Keep editing</AlertDialog.Close>
<Button
color='negative'
onClick={discard}
>
Discard
</Button>
</AlertDialog.Actions>
</AlertDialog>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import * as stylex from '@stylexjs/stylex';

import { space } from '../../tokens.stylex';

export const styles = stylex.create({
/**
* The response row. An alert dialog exists to be answered, so its buttons are anatomy rather
* than content — the one part `Dialog` deliberately does not ship, because a dialog's footer is
* whatever the consumer composes and an alert dialog's is always the same two choices.
*
* A GRID rather than a flex row, and that is what buys the phone layout without touching the
* buttons. Full-width buttons need `flex: 1` on each CHILD, which a parent cannot set — StyleX
* has no child selector, and reaching into the children would mean every call site remembering
* to pass something. `grid-auto-flow: column` + `grid-auto-columns` moves the same decision onto
* the container: `1fr` gives every button an equal share of the row, `auto` sizes each to its
* label. One property, two layouts.
*
* Under the phone band the row therefore splits evenly and spans the sheet; from 48rem up the
* tracks shrink to their labels and `justify-content: end` puts them at the inline end. The
* buttons never sit hard against one edge on a phone, where the row is the width of the screen
* and a right-aligned pair reads as floating.
*
* DOM order is the visual order in both: the cancel comes first, which is also what makes it the
* first tabbable element and therefore what opens focused — the least destructive choice, with
* no `initialFocus` plumbing. Keep it first; reversing the row visually would leave the keyboard
* order disagreeing with the screen.
*/
actions: {
gap: space['2'],
display: 'grid',
gridAutoColumns: { default: '1fr', '@media (min-width: 48rem)': 'auto' },
gridAutoFlow: 'column',
justifyContent: { default: null, '@media (min-width: 48rem)': 'end' },
// On top of the popup's own `gap`, so the response separates from the question it answers
// rather than reading as a third paragraph.
marginBlockStart: space['2'],
},
});
Loading
Loading