-
Notifications
You must be signed in to change notification settings - Fork 466
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
Open
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| --- | ||
| --- |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
132 changes: 132 additions & 0 deletions
132
packages/swingset/src/stories/alert-dialog.component.mdx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| ## 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} | ||
| /> |
136 changes: 136 additions & 0 deletions
136
packages/swingset/src/stories/alert-dialog.component.stories.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
36 changes: 36 additions & 0 deletions
36
packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
| * | ||
| * 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'], | ||
| }, | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
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?