From f926749822cd9e95f2f0f0e9e77b881344b637a1 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 21:45:34 -0600 Subject: [PATCH 1/3] feat(ui): add Mosaic AlertDialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Dialog that interrupts to ask for a decision and waits for one. Base UI's shape: the same parts as Dialog, with the three props that would let it stop being an alert dialog removed rather than defaulted — `role` is `alertdialog`, `closedBy` is `closerequest`, `size` is `prompt`. Everything else is Dialog's own component rather than a wrapper around it, so there is one implementation of each and no way for the two to drift. No `CloseButton` part, for the same reason an outside press cannot dismiss it: a corner X is a way out without answering. `AlertDialog.Actions` is the one addition — the response row, which is anatomy here in a way a dialog's footer is not. A grid rather than a flex row, because the phone layout is a property on the container instead of something every button has to be told: `grid-auto-columns` is `1fr` under the sheet band, so the buttons split the row and span it, and `auto` above it, where the tracks size to their labels and sit at the inline end. Full-width beats a right-aligned pair floating against one edge of a screen-wide sheet. The cancel goes first, which makes it the first tabbable element and therefore what the alert opens focused on — the least destructive choice, with no `initialFocus` plumbing, and with the keyboard order agreeing with the screen. Title and Description are both required, and both warn in development when missing: an alert dialog's description is announced with its name at the moment it interrupts, so without one the user is choosing between "Cancel" and "Delete" with nothing saying what is being deleted. The existing name warning skipped any role but `dialog`, which would have made it silently inert here, and it now names the component it is complaining about instead of always saying "Dialog". --- .changeset/mosaic-alert-dialog.md | 2 + .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/registry.ts | 12 + .../src/stories/alert-dialog.component.mdx | 131 +++++++ .../alert-dialog.component.stories.tsx | 136 +++++++ .../alert-dialog/alert-dialog.styles.ts | 38 ++ .../alert-dialog/alert-dialog.test.tsx | 335 ++++++++++++++++++ .../components/alert-dialog/alert-dialog.tsx | 230 ++++++++++++ .../mosaic/components/alert-dialog/index.ts | 13 + .../src/mosaic/components/dialog/dialog.tsx | 10 +- .../hooks/useAccessibleDescriptionWarning.ts | 47 +++ .../mosaic/hooks/useAccessibleNameWarning.ts | 7 +- packages/ui/src/mosaic/styles/index.ts | 13 + 13 files changed, 972 insertions(+), 3 deletions(-) create mode 100644 .changeset/mosaic-alert-dialog.md create mode 100644 packages/swingset/src/stories/alert-dialog.component.mdx create mode 100644 packages/swingset/src/stories/alert-dialog.component.stories.tsx create mode 100644 packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts create mode 100644 packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx create mode 100644 packages/ui/src/mosaic/components/alert-dialog/alert-dialog.tsx create mode 100644 packages/ui/src/mosaic/components/alert-dialog/index.ts create mode 100644 packages/ui/src/mosaic/hooks/useAccessibleDescriptionWarning.ts 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 8ac7bc81d51..782eb619801 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -21,6 +21,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 daa6b2e8f23..fffccccfd7c 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, @@ -108,6 +113,12 @@ const sectionModule: StoryModule = { }; const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +const alertDialogComponentModule: StoryModule = { + meta: alertDialogComponentMeta, + Default: AlertDialogDefault, + DiscardChanges: AlertDialogDiscardChanges, +}; + const cardComponentModule: StoryModule = { meta: cardComponentMeta, Default: CardDefault, Centered: CardCentered }; const avatarModule: StoryModule = { @@ -215,6 +226,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..d6605d8f66c --- /dev/null +++ b/packages/swingset/src/stories/alert-dialog.component.mdx @@ -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 + + + +## 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 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 + + 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..a19841dce8f --- /dev/null +++ b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts @@ -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'], + }, +}); 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. +