diff --git a/.changeset/mosaic-alert-dialog.md b/.changeset/mosaic-alert-dialog.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-alert-dialog.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3895b47ccfe..347a60ca40a 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -38,6 +38,7 @@ const docModules: Record> = { 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')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 9baf653c30b..48326de4769 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -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, @@ -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, @@ -293,6 +304,7 @@ export const registry: StoryModule[] = [ inputModule, itemModule, dialogComponentModule, + alertDialogComponentModule, headingModule, iconModule, menuComponentModule, diff --git a/packages/swingset/src/stories/alert-dialog.component.mdx b/packages/swingset/src/stories/alert-dialog.component.mdx new file mode 100644 index 00000000000..b1cbfe2c4cb --- /dev/null +++ b/packages/swingset/src/stories/alert-dialog.component.mdx @@ -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 + + + +## Usage + +```tsx +import { AlertDialog } from '@clerk/ui/mosaic/components/alert-dialog'; +import { Button } from '@clerk/ui/mosaic/components/button'; + + }> + {({ close }) => ( + <> + Delete Acme Inc? + This cannot be undone. + + }>Cancel + + + + )} + +``` + +`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 + + diff --git a/packages/swingset/src/stories/alert-dialog.component.stories.tsx b/packages/swingset/src/stories/alert-dialog.component.stories.tsx new file mode 100644 index 00000000000..d50b499f0d6 --- /dev/null +++ b/packages/swingset/src/stories/alert-dialog.component.stories.tsx @@ -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 `` 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) => ( + +); + +export function Default() { + return ( + + {({ close }) => ( + <> + }>Delete Acme Inc? + }> + The organization and everything in it will be permanently removed. This cannot be undone. + + + }>Cancel + {/* 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. */} + + + + )} + + ); +} + +const addEmailTrigger = (props: RenderProps) => ; + +/** + * 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(null); + + const discard = () => { + setValue(''); + setConfirmOpen(false); + setOpen(false); + }; + + return ( + { + if (!next && value.trim() !== '') { + setConfirmOpen(true); + return; + } + setOpen(next); + }} + > + + }>Add email address + }> + You will need to verify this address before it can be used. + + setValue(event.target.value)} + /> +
+ }>Cancel + +
+ + {/* `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. */} + + }>Discard changes? + }> + You have not finished adding this address. It will not be saved. + + + }>Keep editing + + + +
+ ); +} diff --git a/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts new file mode 100644 index 00000000000..7c56f170be2 --- /dev/null +++ b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts @@ -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'], + }, +}); diff --git a/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx new file mode 100644 index 00000000000..e3ecd2cfbe1 --- /dev/null +++ b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx @@ -0,0 +1,335 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Dialog } from '../dialog'; +import { AlertDialog } from './alert-dialog'; + +afterEach(() => cleanup()); + +// Both dev warnings defer by a task, so the assertions have to let one elapse. +const settle = () => + act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + +function Confirm({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) { + return ( + + Discard changes? + This address has not been saved. + + Keep editing + + + + ); +} + +describe('Mosaic AlertDialog', () => { + it('renders as an alertdialog, named and described by its parts', () => { + render(); + + const popup = screen.getByRole('alertdialog', { name: 'Discard changes?' }); + expect(popup).toHaveAccessibleDescription('This address has not been saved.'); + }); + + it('carries the dialog slot classes, so it inherits the surface and its motion', () => { + render(); + + expect(document.querySelector('.cl-dialog-backdrop')).toBeInTheDocument(); + expect(document.querySelector('.cl-dialog-viewport')).toBeInTheDocument(); + expect(document.querySelector('.cl-dialog-popup')).toBeInTheDocument(); + expect(document.querySelector('.cl-alert-dialog-actions')).toBeInTheDocument(); + }); + + it('is always the prompt size', () => { + render(); + + expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'prompt'); + }); + + it('opens from a trigger', async () => { + const user = userEvent.setup(); + render( + ( + + )} + > + Delete this key? + Applications using it stop working. + , + ); + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + it('opens focused on the cancel button, as the first element in the actions row', async () => { + render(); + + // `FloatingFocusManager` moves focus asynchronously after mount, so this waits rather than + // letting a single task elapse — under a loaded run the one task is not always enough. + await waitFor(() => expect(screen.getByRole('button', { name: 'Keep editing' })).toHaveFocus()); + }); + + it('closes on AlertDialog.Close, reporting it through onOpenChange', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Keep editing' })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('hands the render-prop form a close that routes through onOpenChange', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + {({ close }) => ( + <> + Discard changes? + This address has not been saved. + + + + + )} + , + ); + + await user.click(screen.getByRole('button', { name: 'Keep editing' })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); +}); + +// The dismissal policy is the behavioural half of what makes this an alert dialog: it cannot be +// answered by clicking next to it, but Escape — the keyboard's cancel — still works. +// An alert raised by a veto has no trigger, so without `finalFocus` there is nothing for focus to +// return to and answering the question drops the user on the body. +describe('focus', () => { + it('returns focus where finalFocus points when it closes', async () => { + const user = userEvent.setup(); + + function Guarded() { + const [confirmOpen, setConfirmOpen] = React.useState(true); + const inputRef = React.useRef(null); + return ( + <> + + + Discard changes? + This address has not been saved. + + Keep editing + + + + ); + } + render(); + + await user.click(screen.getByRole('button', { name: 'Keep editing' })); + + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Email address' })).toHaveFocus()); + }); +}); + +describe('dismissal', () => { + it('does not close on an outside press', async () => { + const user = userEvent.setup(); + render(); + + await user.click(document.querySelector('.cl-dialog-backdrop') as HTMLElement); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + render(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('lets a controlled consumer decline a close', async () => { + const user = userEvent.setup(); + + function Guarded() { + const [open, setOpen] = React.useState(true); + return ( + { + if (next) { + setOpen(true); + } + }} + > + Discard changes? + This address has not been saved. + + Keep editing + + + ); + } + render(); + + await user.keyboard('{Escape}'); + await user.click(screen.getByRole('button', { name: 'Keep editing' })); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); +}); + +describe('dev warnings', () => { + it('warns when the alert dialog has no description', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + Discard changes? + , + ); + + await settle(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no description')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); + warn.mockRestore(); + }); + + // The name warning skipped any role but `dialog` before this component existed, which would have + // made it silently inert for every alert dialog. + it('warns when it has no accessible name, and names the alert dialog parts', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + This address has not been saved. + , + ); + + await settle(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no accessible name')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); + warn.mockRestore(); + }); + + it('stays quiet when both are supplied', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + + await settle(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('AlertDialog.Actions', () => { + it('merges consumer className and style', () => { + render( + + Discard changes? + This address has not been saved. + + Keep editing + + , + ); + + const actions = screen.getByTestId('actions'); + expect(actions).toHaveClass('cl-alert-dialog-actions'); + expect(actions).toHaveClass('custom'); + expect(actions).toHaveStyle({ marginBlockStart: '2rem' }); + }); + + it('renders as another element through render', () => { + render( + + Discard changes? + This address has not been saved. +