-
Notifications
You must be signed in to change notification settings - Fork 467
feat(ui): add Mosaic AlertDialog #9433
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| 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. | ||
|
Comment on lines
+5
to
+11
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Reduce the introduction to one short paragraph. The introduction uses two long paragraphs. Keep one short present-tense introduction. Move behavior details to the relevant later sections. As per coding guidelines: “Keep each documentation introduction to one short present-tense paragraph.” 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| ## 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 one declaration cover | ||
| both cases without the buttons knowing anything. Every button takes an equal share of the row, so a | ||
| single action fills it and two split it in half, at every width — the convention for a `prompt` | ||
| generally, not a rule about alert dialogs. Nothing about it is media-scoped, so a third button | ||
| divides the same row into thirds rather than finding an edge case. | ||
|
|
||
| --- | ||
|
|
||
| ## Examples | ||
|
|
||
| ### Confirming a discard | ||
|
|
||
| <Story | ||
| name='DiscardChanges' | ||
| storyModule={AlertDialogStories} | ||
| /> | ||
|
Comment on lines
+125
to
+132
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Keep the compound-page top-level section order.
As per coding guidelines: “Compound Components pages must use the exact section order: 🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| 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. | ||
| */ | ||
|
Comment on lines
+61
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Keep implementation comments concise. The rendered story source and style definition contain verbose implementation commentary. Remove comments that repeat surrounding guidance, or reduce each to one terse line explaining only a non-obvious reason. 📍 Affects 2 files
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
| 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,36 @@ | ||
| 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. | ||
| * | ||
| * One layout at every width, because that is the convention for a `prompt` generally rather than | ||
| * a rule about alert dialogs: its buttons span the surface, one full width or two at even halves. | ||
| * A right-aligned pair sized to its labels was tried first and is what the designs do not do. | ||
| * | ||
| * A GRID rather than a flex row, and that is what makes both cases the same declaration. Filling | ||
| * the row needs `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` with `grid-auto-columns: 1fr` puts it on the container | ||
| * instead: every button takes an equal share of the row, so one fills it and two split it, with | ||
| * no branch and nothing for a third to break. | ||
|
Comment on lines
+6
to
+20
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. fuggin opus. All these styles are more comments than style. I kinda want to strip them out, but also maybe they're useful context for future agents editing 🤷 any thoughts?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I wonder if this being apart of an |
||
| * | ||
| * DOM order is the visual order: 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['3'], | ||
| display: 'grid', | ||
| gridAutoColumns: '1fr', | ||
| gridAutoFlow: 'column', | ||
| // 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'], | ||
| }, | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Add a release entry for the public
@clerk/uifeature.This PR adds the public Mosaic
AlertDialogAPI. The empty Changeset will not record the required release or changelog entry. Add an@clerk/uiminor entry with a user-facing summary.Proposed Changeset
As per coding guidelines: “Use Changesets for version management and changelogs.” Based on learnings: empty Changesets apply only to documentation-only or non-published changes.
📝 Committable suggestion
🤖 Prompt for AI Agents
Sources: Coding guidelines, Learnings