diff --git a/.changeset/mosaic-dialog-role-inline.md b/.changeset/mosaic-dialog-role-inline.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-dialog-role-inline.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index f3a069df1ee..a85d843fe89 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -192,9 +192,14 @@ When `root` is provided, the dialog is portaled into that container instead of ` ### `Dialog.Viewport` -| Prop | Type | Default | Description | -| ------------ | --------- | ------- | ------------------------------- | -| `lockScroll` | `boolean` | `true` | Prevents body scroll while open | +| Prop | Type | Default | Description | +| ------------ | --------- | ------- | ---------------------------------------------------------------------------- | +| `lockScroll` | `boolean` | `true` | Prevents body scroll while open | +| `overlay` | `boolean` | `true` | Wraps the viewport in a fixed overlay. `false` renders it in flow, unlocked. | + +`overlay={false}` is for a dialog presented inline in its host rather than over the page — an +account panel mounted in a page slot. Pair it with `modal={false}` and `closedBy='none'` on the +root, and `initialFocus={false}` on the popup so mounting does not steal focus. ### `Dialog.Trigger` @@ -213,6 +218,10 @@ When `root` is provided, the dialog is portaled into that container instead of ` `DialogFocusTarget` is `boolean | RefObject | (interactionType) => boolean | void | HTMLElement | null`. +The popup's children are held at their last committed frame while it exits (`Freeze`), so state +that resets on close — a machine returning to its initial state — does not flash through the +fade. The popup element itself stays live for `data-closed` / `data-ending-style`. + ### `Dialog.Backdrop`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close` No additional props beyond standard HTML attributes and the `render` prop. diff --git a/packages/headless/src/primitives/dialog/dialog-context.ts b/packages/headless/src/primitives/dialog/dialog-context.ts index a20561a2e64..309b83e45c4 100644 --- a/packages/headless/src/primitives/dialog/dialog-context.ts +++ b/packages/headless/src/primitives/dialog/dialog-context.ts @@ -3,6 +3,7 @@ import { createContext, useContext } from 'react'; import type { TransitionProps } from '../../hooks/use-transition'; import type { DialogHandle } from './dialog-handle'; +import type { DialogRole } from './dialog-root'; export interface DialogContextValue { open: boolean; @@ -21,6 +22,8 @@ export interface DialogContextValue { */ store: DialogHandle; modal: boolean; + /** The popup's ARIA role, as the root was told. Lets a styled layer branch on alert-dialog behaviour. */ + role: DialogRole; /** * Whether this dialog opened from inside another floating element, so a stacked overlay can * style itself differently from the one beneath it — chiefly so backdrops don't composite into diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index 587062ecf2b..77fb76d710a 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -3,7 +3,7 @@ import { type FloatingContext, FloatingFocusManager } from '@floating-ui/react'; import React from 'react'; -import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { type ComponentProps, type DefaultProps, Freeze, mergeProps, useRender } from '../../utils'; import { type InteractionType, interactionTypeFromEvent } from '../../utils/interaction-modality'; import { useDialogContext } from './dialog-context'; @@ -128,7 +128,7 @@ export interface DialogPopupProps extends ComponentProps<'div'> { /** The dialog content container. Manages focus trapping via `FloatingFocusManager` and wires ARIA attributes from `Dialog.Title` and `Dialog.Description`. */ export const DialogPopup = React.forwardRef(function DialogPopup(props, ref) { - const { render, initialFocus, finalFocus, ...otherProps } = props; + const { render, initialFocus, finalFocus, children, ...otherProps } = props; const { open, popupRef, @@ -164,6 +164,11 @@ export const DialogPopup = React.forwardRef(fu ...(stackedChildCount > 0 ? { 'data-stack-base': '' } : {}), ...getFloatingProps(), ...transitionProps, + // The popup outlives `open` by the length of its exit animation, and whatever closed it has + // usually reset the state behind it — a machine returning to `idle`, a form clearing. The + // contents hold their last frame on the way out instead of snapping back under the fade. The + // popup element itself stays live, so `data-closed` / `data-ending-style` still land. + children: {children}, }; const element = useRender({ diff --git a/packages/headless/src/primitives/dialog/dialog-root.tsx b/packages/headless/src/primitives/dialog/dialog-root.tsx index 8a6c2abac72..0d2f3c1a01e 100644 --- a/packages/headless/src/primitives/dialog/dialog-root.tsx +++ b/packages/headless/src/primitives/dialog/dialog-root.tsx @@ -238,6 +238,7 @@ function DialogInner(props: DialogProps & { isNested: boolean returnFocusRef, store, modal, + role: ariaRole, isNested, isStacked: nesting.isStacked, stackedChildCount: nesting.stackedChildCount, @@ -255,6 +256,7 @@ function DialogInner(props: DialogProps & { isNested: boolean returnFocusRef, store, modal, + ariaRole, isNested, nesting.isStacked, nesting.stackedChildCount, diff --git a/packages/headless/src/primitives/dialog/dialog-viewport.tsx b/packages/headless/src/primitives/dialog/dialog-viewport.tsx index 419babc7d4c..8401a2c7977 100644 --- a/packages/headless/src/primitives/dialog/dialog-viewport.tsx +++ b/packages/headless/src/primitives/dialog/dialog-viewport.tsx @@ -10,6 +10,11 @@ import { useDialogContext } from './dialog-context'; export interface DialogViewportProps extends ComponentProps<'div'> { /** When true, locks body scroll while the dialog is open. Default: true */ lockScroll?: boolean; + /** + * When false, renders the viewport in flow — no fixed overlay, no scroll lock — for a dialog + * presented inline in its host rather than over the page. Default: true + */ + overlay?: boolean; } /** @@ -22,14 +27,14 @@ export interface DialogViewportProps extends ComponentProps<'div'> { */ export const DialogViewport = React.forwardRef( function DialogViewport(props, ref) { - const { render, lockScroll = true, ...otherProps } = props; + const { render, lockScroll = true, overlay = true, ...otherProps } = props; const { open, mounted, isNested, transitionProps, modal } = useDialogContext(); const state = { open, nested: isNested }; const defaultProps = { ...transitionProps, - style: modal ? undefined : { pointerEvents: 'auto' as const }, + style: overlay && !modal ? { pointerEvents: 'auto' as const } : undefined, } satisfies DefaultProps<'div'>; const element = useRender({ @@ -49,6 +54,10 @@ export const DialogViewport = React.forwardRef cleanup()); @@ -782,6 +783,95 @@ describe('Dialog', () => { expect(screen.getByRole('alertdialog')).toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); + + // A styled layer branches on the role — pinning a size, demanding a description — and the + // parts are where it branches, so the role has to reach them through the context. + it('publishes the role on the context', () => { + const seen: string[] = []; + function Probe() { + seen.push(useDialogContext().role); + return null; + } + render( + + + , + ); + + expect(seen).toContain('alertdialog'); + }); + }); + + describe('viewport overlay', () => { + it('renders in flow without a fixed overlay or a scroll lock when overlay is false', () => { + render( + + + Body + + , + ); + + const viewport = screen.getByTestId('dialog-viewport'); + expect(viewport.parentElement).toBe(document.body.firstElementChild); + expect(viewport.parentElement?.style.position).not.toBe('fixed'); + expect(document.body.style.overflow).toBe(''); + }); + }); + + describe('exit', () => { + // A machine driving the dialog resets to its initial state on close, in the same commit that + // starts the exit. Without holding the frame, the dialog would repaint that reset state and + // fade out showing the wrong thing. + it('holds the contents at their last frame while the popup exits', () => { + const original = (Element.prototype as { getAnimations?: unknown }).getAnimations; + (Element.prototype as { getAnimations?: unknown }).getAnimations = () => [ + { finished: new Promise(() => {}) }, + ]; + try { + function Fixture({ open, label }: { open: boolean; label: string }) { + return ( + + + {label} + + + ); + } + const { rerender } = render( + , + ); + + rerender( + , + ); + + const popup = screen.getByRole('dialog', { hidden: true }); + expect(popup).toHaveAttribute('data-closed', ''); + expect(popup).toHaveTextContent('Confirming'); + } finally { + if (original) { + (Element.prototype as { getAnimations?: unknown }).getAnimations = original; + } else { + delete (Element.prototype as { getAnimations?: unknown }).getAnimations; + } + } + }); }); describe('stacking', () => { diff --git a/packages/headless/src/primitives/drawer/drawer-context.ts b/packages/headless/src/primitives/drawer/drawer-context.ts index 30cec1dfafc..49798c47eea 100644 --- a/packages/headless/src/primitives/drawer/drawer-context.ts +++ b/packages/headless/src/primitives/drawer/drawer-context.ts @@ -35,7 +35,10 @@ export interface NestedDrawerCallbacks { // `nestedOpenCount` / `onNested`, which is a different question from the dialog's: `isStacked` // asks whether a DIALOG sits above, and a drawer's stacked-child styling has nothing to read it // from. Inheriting them would oblige every drawer root to publish two values no drawer part uses. -export interface DrawerContextValue extends Omit { +export interface DrawerContextValue extends Omit< + DialogContextValue, + 'isStacked' | 'stackedChildCount' | 'store' | 'role' +> { getReferenceProps: UseInteractionsReturn['getReferenceProps']; backdropRef: React.RefObject; drag: DrawerDrag; diff --git a/packages/swingset/next.config.mjs b/packages/swingset/next.config.mjs index ce720c0da34..a9c62bc3fb7 100644 --- a/packages/swingset/next.config.mjs +++ b/packages/swingset/next.config.mjs @@ -1,9 +1,10 @@ -import stylexPlugin from '@stylexjs/unplugin/webpack'; import createMDX from '@next/mdx'; -import remarkGfm from 'remark-gfm'; -import rehypeRaw from 'rehype-raw'; +import stylexPlugin from '@stylexjs/unplugin/webpack'; import { resolve } from 'path'; +import rehypeRaw from 'rehype-raw'; +import remarkGfm from 'remark-gfm'; import { fileURLToPath } from 'url'; + import { mosaicLightningCssTargets } from '../ui/stylex-lightningcss.config.mjs'; const __dirname = fileURLToPath(new URL('.', import.meta.url)); @@ -82,6 +83,15 @@ const nextConfig = { }), ); + // Dev-only: StyleX's runtime injector drops named `@container` rules after the first per + // query — see the loader for the bug. Scoped to that one module. + if (isDev) { + config.module.rules.push({ + test: /[\\/]@stylexjs[\\/]stylex[\\/]lib[\\/](es|cjs)[\\/]inject\.(mjs|js)$/, + use: [{ loader: resolve(__dirname, 'src/lib/loaders/stylex-inject-named-container.cjs') }], + }); + } + config.resolve.alias['@clerk/ui/mosaic'] = resolve(__dirname, '../ui/src/mosaic'); // Consume @clerk/headless primitives from source (no dist build needed), mirroring Mosaic. // `/hooks` and `/utils` live outside `primitives/`, so alias them first (more specific wins). diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 260229f0569..3c7a94f7b9b 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -55,7 +55,6 @@ 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')), 'icon-frame': dynamic(() => import('../stories/icon-frame.mdx')), diff --git a/packages/swingset/src/lib/loaders/stylex-inject-named-container.cjs b/packages/swingset/src/lib/loaders/stylex-inject-named-container.cjs new file mode 100644 index 00000000000..e045a6a4d26 --- /dev/null +++ b/packages/swingset/src/lib/loaders/stylex-inject-named-container.cjs @@ -0,0 +1,27 @@ +/** + * Corrects a regex in StyleX's dev runtime injector so named container queries survive. + * + * `@stylexjs/stylex@0.19.0`'s `getSeenRuleKey` recognises `@container (…)` but not + * `@container name (…)`. A named query falls through to the plain-selector branch, whose key + * is the text before the first `{` — the at-rule prelude — so every rule under the same named + * query shares one key and all but the first are dropped as duplicates. The injected default + * (carrying the `:not(#\#)` bumps) then beats the container rule in the extracted sheet, and + * the query silently never applies. Dev only: production uses the extracted CSS. + * + * Applied at bundle time to the one module rather than as a package patch, so it stays inside + * this private dev tool. Delete once upstream's `conditionalRulePattern` accepts a name. + */ +const BROKEN = String.raw`/^@(media|supports|container)\s*\([^)]+\)\s*{/`; +const FIXED = String.raw`/^@(media|supports|container)\b[^{]*{/`; + +module.exports = function stylexInjectNamedContainer(source) { + if (!source.includes(BROKEN)) { + this.emitWarning( + new Error( + `stylex-inject-named-container: pattern not found in ${this.resourcePath}; StyleX may have fixed it — remove this loader.`, + ), + ); + return source; + } + return source.replace(BROKEN, FIXED); +}; diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 53bc5ebeef7..4b08de77663 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,10 +1,5 @@ // 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, @@ -241,12 +236,6 @@ 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 }; const avatarModule: StoryModule = { @@ -519,7 +508,6 @@ export const registry: StoryModule[] = [ inputModule, itemModule, dialogComponentModule, - alertDialogComponentModule, headingModule, iconModule, iconFrameModule, diff --git a/packages/swingset/src/stories/alert-dialog.component.mdx b/packages/swingset/src/stories/alert-dialog.component.mdx deleted file mode 100644 index 5817be0c292..00000000000 --- a/packages/swingset/src/stories/alert-dialog.component.mdx +++ /dev/null @@ -1,189 +0,0 @@ -import * as AlertDialogStories from './alert-dialog.component.stories'; - -# AlertDialog - -The Mosaic `AlertDialog` is 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`. - -## Example - - - -### Confirming a discard - - - -## 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) above. - -### 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. - -### Confirming a close - -A dialog holding unsaved work should ask before discarding it. That is three pieces: a handle, a -hook that guards the close, and the confirmation itself. - -```tsx -import { AlertDialog, createConfirmHandle, useConfirmedClose } from '@clerk/ui/mosaic/components/alert-dialog'; - -const confirm = React.useMemo(() => createConfirmHandle(), []); - -const onOpenChange = useConfirmedClose({ - handle: confirm, - when: () => value !== '', - onOpenChange: setOpen, - confirm: { - title: 'Discard changes?', - description: 'You have not finished adding this address.', - actionLabel: 'Discard', - cancelLabel: 'Keep editing', - destructive: true, - }, -}); - - - {/* … */} - - -``` - -**Render `AlertDialog.Confirm` inside the dialog it guards** — anywhere in its children. That is -what puts the two in one floating tree, and escape ordering, the stacking styles and the refcounted -scroll lock all read that tree. A confirmation mounted app-globally would be a sibling of the dialog -rather than a child of it, and all three would break. - -**The guarded dialog must be controlled.** A veto is the absence of a commit, and an uncontrolled -dialog has already committed by the time `onOpenChange` runs. - -**What it covers is every close the dialog owns**: Escape, an outside press where `closedBy` allows -one, `Dialog.CloseButton`, `Dialog.Close`, and the `close` the `Dialog` wrapper hands its children. -A button wired to your own `setOpen(false)` never reaches the dialog, so it bypasses the question -silently — route those through `Dialog.Close`. - -`when()` is evaluated at each close request, so a close that no longer needs guarding (the form has -just been submitted, the field cleared) passes straight through. - -#### Asking without a close - -`show()` is the same confirmation, awaited directly — for a decision that is not about closing: - -```tsx -if (await confirm.show({ title: 'Delete this key?', description: 'Applications using it stop working.' })) { - await deleteKey(); -} -``` - -It resolves `true` for the action and `false` for cancel or any dismissal. Calling it while a -confirmation is already showing returns the in-flight promise rather than opening a second one, so -repeated close requests ask once. - -**`AlertDialog.Confirm` must be mounted when `show()` is called** — it is the thing that opens, and -a `show()` with nothing mounted to answer it never resolves. Since the confirmation lives inside the -dialog it guards, that means asking from inside that dialog, while it is open. A confirmation that -unmounts with a question in flight answers `false` rather than leaving the `await` hanging. - -## Parts - -| Part | Slot | Description | -| ------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| `AlertDialog.Root` | — | State provider; owns open/close, `modal`, `handle`. `role` is `alertdialog`, `closedBy` is `closerequest`, `size` is `prompt`. | -| `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. | -| `AlertDialog.Confirm` | `dialog-popup` | A whole confirmation rendered from a `show()` call. See [Confirming a close](#confirming-a-close). | - -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. diff --git a/packages/swingset/src/stories/alert-dialog.component.stories.tsx b/packages/swingset/src/stories/alert-dialog.component.stories.tsx deleted file mode 100644 index 70287bb3f2d..00000000000 --- a/packages/swingset/src/stories/alert-dialog.component.stories.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import type { RenderProps } from '@clerk/headless/utils'; -import { AlertDialog, createConfirmHandle, useConfirmedClose } 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', -}; - -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) => ; - -// `useConfirmedClose` wraps the dialog's own `onOpenChange`, so every close it owns — Escape, the -// corner X, `Dialog.Close` — is guarded by one hook and a veto is just the absence of a commit. -// `AlertDialog.Confirm` renders INSIDE the dialog it guards so the two share a floating tree, which -// escape ordering, the stacking styles and the refcounted scroll lock all depend on. `finalFocus` is -// optional but wanted here: a confirmation raised by a close request has no trigger to return to. -export function DiscardChanges() { - const confirm = React.useMemo(() => createConfirmHandle(), []); - const [open, setOpen] = React.useState(false); - const [value, setValue] = React.useState(''); - const inputRef = React.useRef(null); - // Adding is the one close that must not be questioned. A ref rather than clearing `value`, - // because `when` runs before React has re-rendered and would still read the old state. - const bypassGuardRef = React.useRef(false); - - const onOpenChange = useConfirmedClose({ - handle: confirm, - when: () => !bypassGuardRef.current && value.trim() !== '', - onOpenChange: next => { - setOpen(next); - if (!next) { - bypassGuardRef.current = false; - setValue(''); - } - }, - confirm: { - title: 'Discard changes?', - description: 'You have not finished adding this address. It will not be saved.', - actionLabel: 'Discard', - cancelLabel: 'Keep editing', - destructive: true, - }, - }); - - return ( - - {({ close }) => ( - <> - - }>Add email address - }> - You will need to verify this address before it can be used. - - setValue(event.target.value)} - /> -
- }>Cancel - -
- - - - )} -
- ); -} diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 0128da7fd06..aaaf023a069 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -2,10 +2,9 @@ import * as DialogStories from './dialog.component.stories'; # Dialog -The Mosaic `Dialog` — the styled component built on the `@clerk/headless` dialog primitive and -themed with StyleX. It flattens the required nesting (Root, Portal, Backdrop, Viewport, Popup) into -a single component, hands `children` a `close` callback through a render prop, and inherits the -primitive's focus trapping, scroll lock, and ARIA wiring. +The Mosaic `Dialog` — a modal surface built on the `@clerk/headless` dialog primitive. Three parts: +`Dialog.Root` owns the state, `Dialog.Trigger` opens it, `Dialog.Popup` is the surface. Focus +trapping, scroll lock and ARIA wiring come from the primitive. ## Playground @@ -19,8 +18,8 @@ primitive's focus trapping, scroll lock, and ARIA wiring. ) => ReactElement' }, - { name: 'children', type: 'ReactNode | ((ctx: { close: () => void }) => ReactNode)' }, + { name: 'role', type: "'dialog' | 'alertdialog'", default: "'dialog'" }, + { name: 'inline', type: 'boolean', default: 'false' }, { name: 'open', type: 'boolean' }, { name: 'defaultOpen', type: 'boolean', default: 'false' }, { name: 'onOpenChange', type: '(open: boolean, details: DialogOpenChangeDetails) => void' }, @@ -29,185 +28,248 @@ primitive's focus trapping, scroll lock, and ARIA wiring. ]} /> +`size` belongs to `Dialog.Popup`; the rest belong to `Dialog.Root`. + ## Usage ```tsx import { Button } from '@clerk/ui/mosaic/components/button'; import { Dialog } from '@clerk/ui/mosaic/components/dialog'; - }> - {({ close }) => ( - <> - Confirm action - Are you sure you want to proceed? - - - )} - + + }>Open dialog + + + Confirm action + Are you sure you want to proceed? + }>Cancel + +; ``` -The `trigger` render prop receives the interaction props (ARIA attributes, click handler) and -spreads them onto whatever element opens the dialog. It is optional — omit it for a dialog driven -entirely by `open`, opened from a menu item, a route, or a state machine. - -`children` can also be a plain `ReactNode` when no programmatic close is needed — the dialog can -always be dismissed via Escape or the backdrop: - -```tsx - }> - Info - Nothing to confirm here. - -``` +`Dialog.Trigger` is optional — omit it for a dialog driven by `open` alone. ### Controlled ```tsx const [open, setOpen] = useState(false); - } > - {({ close }) => ( - <> - Confirm - - - )} - + … +; ``` +Every close the dialog owns — Escape, outside press, `Dialog.Close`, `Dialog.CloseButton`, +`handle.close()` — goes through `onOpenChange`, so a controlled consumer can veto one by not +committing. Your own `setOpen(false)` skips that; use `Dialog.Close` when a close might need vetoing. + ### Size -`size` names the surface, not a t-shirt step: +| Value | Width | For | +| -------- | ---------------------------------------- | ----------------------------------------------------- | +| `prompt` | `23.75rem`, height from content | One question or one field (default) | +| `card` | `25rem`, height from content | Sign-in / sign-up | +| `panel` | Width from its surface, fills the height | Account profile and settings — a surface you navigate | -| Value | Size | For | -| -------- | -------------------------------------------- | ---------------------------------------------------------------------- | -| `prompt` | `max-width: 23.75rem`, height from content | Asking one thing: a confirmation, or a single-field form (the default) | -| `card` | `max-width: 25rem`, height from content | The sign-in / sign-up surface | -| `panel` | `max-width: 94rem`, fills the viewport inset | The account-profile and settings surface, which you navigate | +### `card` and `panel` bring their own surface -`prompt` and `card` set a max width and let their content decide the height. `panel` fixes both -axes: its content navigates in place — a settings surface switching sections — so a content-driven -height would resize the window on every section change. +Only `prompt` paints itself. A `card` holds a `Card`, and a `panel` holds a `ProfilePage` (or +`UserPageView`): the dialog positions and animates the popup, and the surface inside paints it. +Both surfaces read `DialogContext` and are self-contained — `Card.Title` and the page's label name +the dialog, and `Card.Header` and `ProfilePage.Root` carry the dismiss — so `Dialog.CloseButton` +and `Dialog.Title` are only for a `prompt`. -`size` lives on `Dialog.Root`, not `Dialog.Popup`, because the backdrop reads it too. + -### A card brings its own surface +A `panel` composes the same way, with the user page in place of the card. The page names the +dialog, carries the dismiss, scrolls its own content column, and collapses its own sidebar — the +full example is under [A panel](#a-panel): -`prompt` and `panel` paint themselves. **`card` does not** — it contributes width and motion only, -and the background, shadow, radius and padding come from `Card`. Compose it by rendering the popup -**as** the card rather than nesting one inside the other: +```tsx +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Dialog } from '@clerk/ui/mosaic/components/dialog'; +import { UserPageView } from '@clerk/ui/mosaic/user-profile/user-page.view'; + + + }>Manage account + + + +; +``` + +### Alert dialogs + +`role='alertdialog'` on the root makes a dialog that interrupts to ask for a decision — a +destructive confirmation, a "discard changes?". -Rendering it as the card matters for more than tidiness. One element then both paints and animates, -which is what keeps the popup's corner-radius correction landing on the corners you can see — -`transform: scale()` scales a rendered radius along with everything else, so the popup divides the -radius by the same factor to cancel it. Nested, the popup would scale a transparent box while the -`Card` inside took that scale on its own painted corners with no correction. +What follows from the role: + +- Announced as an interruption. +- No outside-press dismissal. `closedBy` narrows to `closerequest` (default) or `none`; Escape still + cancels. +- Always a `prompt`. `size` is ignored (warns in dev). +- A `Title` **and** a `Description` are required; both warn in dev when missing. +- `Dialog.CloseButton` warns: the cancel in `Dialog.Actions` is the way out. + +`Dialog.Actions` is the button row for any prompt, not only alerts — it splits the width evenly. +Render the cancel first so it takes focus on open. The action usually starts +work, so close from your own state when it resolves rather than with `Dialog.Close`. Pass +`finalFocus` when there is no trigger to return focus to. + +### Confirming a discard + +Guard a dialog's close behind a confirmation with a handle, a hook, and `Dialog.Confirm`: -The trade is that `size="card"` with no `Card` inside renders an unpainted box. + -### The inset +```tsx +import { createConfirmHandle, Dialog, useConfirmedClose } from '@clerk/ui/mosaic/components/dialog'; + +const confirm = React.useMemo(() => createConfirmHandle(), []); + +const onOpenChange = useConfirmedClose({ + handle: confirm, + when: () => value !== '', + onOpenChange: setOpen, + confirm: { title: 'Discard changes?', description: '…', actionLabel: 'Discard', destructive: true }, +}); + + + + … + + +; +``` -The gap between a dialog and the edge of the screen is a fixed inset that steps up at two -breakpoints, rather than a percentage of the viewport: +- Render `Dialog.Confirm` **inside** the popup it guards, so the two share a floating tree. +- The guarded dialog must be controlled — a veto is the absence of a commit. +- `confirm.show(options)` asks the same question directly and resolves `true`/`false`. It needs the + `Dialog.Confirm` mounted, and returns the in-flight promise if one is already showing. -| Viewport | Top & bottom | Sides | -| ------------- | ---------------- | ------------- | -| `< 48rem` | `1.25rem` (20px) | `1rem` (16px) | -| `48rem–90rem` | `2rem` (32px) | `2rem` (32px) | -| `>= 90rem` | `3rem` (48px) | `3rem` (48px) | +### Inline -Square except on a phone, where the sides come in and the top and bottom stay put. The horizontal -inset is the expensive one at that width — it comes out of a content box only around 380px wide, so -a pixel there costs line length in a way the same pixel costs nothing vertically. The vertical edges -are doing the opposite job: holding the surface off the browser's own chrome, which is closer on a -phone than on any desktop. +`inline` on the root renders the dialog in its host instead of over the page: no portal, scrim, +scroll lock or focus trap, and nothing dismisses it. For the account panel mounted in a page slot. -It lives on `Dialog.Viewport`'s padding, so a popup gets it for free by being `width: 100%` inside -it — no width arithmetic of its own. + + +- `open`, `modal`, `closedBy` are implied; `onOpenChange` is never called. +- No initial focus on mount. `Dialog.CloseButton` renders nothing; `Card.Header` carries no dismiss. +- Fills the host edge to edge. How the surface looks there is the surface's call — the page + paints the same frame modal or inline. +- Dialogs opened from inside it are normal modal dialogs over the page. -### On a phone, a prompt is a sheet +### Responsive behaviour -Below `48rem`, a `prompt` pins to the bottom of the viewport and slides up instead of scaling out -of its trigger. It keeps an inset on all four sides and all four corners rounded — a floating -sheet, not a tray welded to the edge — and its width cap lifts so it spans whatever the inset -leaves. `card` and `panel` are unchanged at every width. Resize the preview under -[Playground](#playground) below `48rem` to see it. +The gap to the screen edge is a fixed inset, not a percentage: -The sheet fades over the full length of its slide, while the backdrop keeps its own faster timing — -the scrim answers the tap first, then the sheet arrives into an already-dimmed page. Under -`prefers-reduced-motion: reduce` the sheet holds flat and only the fade runs. +| Container | Top & bottom | Sides | +| ------------- | ------------ | ------ | +| `< 48rem` | `1.25rem` | `1rem` | +| `48rem–90rem` | `2rem` | `2rem` | +| `>= 90rem` | `3rem` | `3rem` | -A sheet arriving over another dialog takes the shorter desktop fade instead. The long one earns -itself against the page, where it gives the travel somewhere to resolve into; over an opaque -surface it just shows the dialog underneath through the one arriving, and the two read as one muddy -surface. The slide is unchanged, and carries the arrival on its own. +The bands are **container queries** against the dialog's viewport element (named `cl-dialog`), not +media queries. For a modal dialog that is the window, so nothing differs; for an `inline` dialog the +bands follow the host's width. `ProfilePage` does the same against its own width (`cl-profile-page`), +which is what collapses its sidebar inside a narrow host. -Drag-to-dismiss is deliberately absent — `Drawer` owns the drag engine, and a second one should not -grow inside `Dialog`. +Below `48rem` a `prompt` becomes a bottom sheet: it pins to the bottom, slides up, and lifts its +width cap. `card` and `panel` are unchanged. Under `prefers-reduced-motion: reduce` only the fade +runs. ### Close button -`Dialog.CloseButton` is the corner X — a ghost circular `Button` holding the close glyph, anchored -to the popup's top-inline-end corner. Being absolutely positioned, it never joins the popup's -column layout, so you can render it anywhere among the children without the rest moving. +`Dialog.CloseButton` is the styled corner X for a `prompt`, absolutely positioned so it can sit +anywhere in the children. Pass `aria-label` to localise it. A `card` or `panel` surface carries its +own. `Dialog.Close` is the unstyled alternative for a footer "Cancel". + +Focus opens on the first tabbable element, so a `CloseButton` rendered first takes it. Point +`initialFocus` on `Dialog.Popup` at the field that should have it instead. + +### Dismissal + +`closedBy` mirrors the native `` attribute: + +| Value | Escape | Outside press | Programmatic | Use for | +| -------------- | ------ | ------------- | ------------ | ------------------------------------------- | +| `any` | ✅ | ✅ | ✅ | Read-and-dismiss content (default) | +| `closerequest` | ✅ | ❌ | ✅ | Anything holding input; alert dialogs | +| `none` | ❌ | ❌ | ✅ | Flows the user must complete or acknowledge | ```tsx - }> - - Add email address - + ``` -It carries an English `Close` label by default; pass `aria-label` to override it. +### Keyboard -`Dialog.Close` stays available and unstyled — that is what a "Cancel" button in a footer wants. -`Dialog.CloseButton` is the styled corner affordance. +| Key | Does | +| ------------------ | ----------------------------------------------------------------- | +| Enter (in a field) | Submits the prompt's form — the primary action | +| Escape | Cancels, per `closedBy` | +| Tab | Moves through the popup in visual order: field → Cancel → confirm | -> **Where you put it decides what the dialog opens focused on.** Focus goes to the first tabbable -> element, so a `Dialog.CloseButton` rendered before the form makes "dismiss" the initial focus. -> Point `initialFocus` on `Dialog.Popup` at the field that should take it instead — see -> [Custom focus management](#custom-focus-management). +Tab follows the layout (WCAG 2.4.3), so a prompt with a field should be a form with the primary +action as its submit — that is how Enter reaches it: -### Dismissal +```tsx +
+ + + }>Cancel + + +
+``` + +### Exit animations -`closedBy` chooses which gestures dismiss the dialog, mirroring the native `` -attribute: `any` (Escape and outside press, the default), `closerequest` (Escape only), or `none` -(neither — the dialog closes only programmatically). Reach for `closerequest` on a dialog holding -user input, so a stray backdrop click cannot discard it. +The popup's contents hold their last frame while it fades out, so state that resets on close (a +machine returning to idle) does not flash through the exit. ## Parts -| Part | Slot | Description | -| -------------------- | --------------------- | ---------------------------------------------------------------------------- | -| `Dialog.Root` | — | State provider; owns `size`, open/close, `modal`, `closedBy`, `handle`. | -| `Dialog.Trigger` | — | Opens the dialog; accepts `render`, and `handle` + `payload` when detached. | -| `Dialog.Portal` | — | Portals the overlay out of the tree. | -| `Dialog.Backdrop` | `dialog-backdrop` | The scrim behind the dialog. | -| `Dialog.Viewport` | `dialog-viewport` | Centering container; owns the scroll lock. | -| `Dialog.Popup` | `dialog-popup` | The surface (`role="dialog"`, focus-trapped); `initialFocus` / `finalFocus`. | -| `Dialog.Title` | — | Heading; wired to the popup's `aria-labelledby`. | -| `Dialog.Description` | — | Description; wired to the popup's `aria-describedby`. | -| `Dialog.Close` | — | Dismisses the dialog; unstyled, accepts a `render` prop. | -| `Dialog.CloseButton` | `dialog-close-button` | The styled corner X. | - -`Dialog.Title` and `Dialog.Description` are unstyled passthroughs from the headless layer — render -them through your own typography (`Heading`, `Text`) via `render`. +| Part | Slot | Description | +| -------------------- | --------------------- | ------------------------------------------------------------------- | +| `Dialog.Root` | — | State: open/close, `role`, `inline`, `modal`, `closedBy`, `handle`. | +| `Dialog.Trigger` | — | Opens the dialog; `render`, and `handle` + `payload` when detached. | +| `Dialog.Popup` | `dialog-popup` | The surface; `size`, `initialFocus`, `finalFocus`. | +| `Dialog.Title` | — | Wired to `aria-labelledby`. Unstyled — render through `Heading`. | +| `Dialog.Description` | — | Wired to `aria-describedby`. Unstyled — render through `Text`. | +| `Dialog.Close` | — | Unstyled dismiss; accepts `render`. | +| `Dialog.CloseButton` | `dialog-close-button` | The styled corner X. | +| `Dialog.Actions` | `dialog-actions` | An alert dialog's response row. Cancel first. | +| `Dialog.Confirm` | `dialog-popup` | A confirmation rendered from `confirm.show()`. | + +`Dialog.Popup` also renders the scrim (`dialog-backdrop`), the viewport (`dialog-viewport`, the +`cl-dialog` container) and the centering track inside it (`dialog-track`, which carries the inset). ## Styling -The Mosaic dialog is themed with **StyleX**. Each styled part carries a stable `.cl-` class -(the slots in the table above) alongside the StyleX atoms. Consumers never target the hashed atomic -classes — override by targeting the `.cl-*` slot from a CSS layer that wins over -`@clerk/ui/styles.css`: +Override by targeting the `.cl-*` slot from a layer that wins over `@clerk/ui/styles.css`: ```css @import '@clerk/ui/styles.css' layer(components); @@ -219,209 +281,71 @@ classes — override by targeting the `.cl-*` slot from a CSS layer that wins ov } ``` -State attributes from the headless layer are available for CSS targeting: - -| Attribute | Applies To | Description | -| --------------------- | ---------------------------------- | ------------------------------------------- | -| `data-open` | Trigger, Backdrop, Viewport, Popup | Present when the dialog is open | -| `data-closed` | Trigger, Backdrop, Viewport, Popup | Present when closed (during exit) | -| `data-starting-style` | Backdrop, Viewport, Popup | Present on the entering frame | -| `data-ending-style` | Backdrop, Viewport, Popup | Present during the exit animation | -| `data-size` | Viewport, Popup | Resolved size (`prompt` / `card` / `panel`) | -| `data-nested` | Backdrop, Viewport, Popup | Present when opened inside another overlay | - -### Motion - -Every size fades. `prompt` and `card` scale as well; `panel` does not. +State attributes: -Opacity and scale are driven off `data-starting-style` / `data-ending-style`, with the exit shorter -than the entrance. The popup scales from its own centre. Under -`prefers-reduced-motion: reduce` only `transform` drops out — the fade still runs, since the -vestibular concern is the movement. - -### On-screen keyboards - -iOS shrinks the visual viewport when the keyboard opens but leaves layout alone, so a -`position: fixed` overlay would end up behind the keyboard. `Dialog.Viewport` measures the -difference and adds it to its own bottom padding, which gives each size the right behaviour: - -| Size | Alignment | With the keyboard open | -| -------- | --------------------- | ------------------------------------------------------------------- | -| `prompt` | `align-self: end` | rises to sit on top of the keyboard | -| `card` | centred | re-centres in the space left — moves up, height still from content | -| `panel` | `align-self: stretch` | shrinks, which is right for the one size with its own scroll region | - -A card taller than the remaining space aligns to its top rather than losing its head. Pinch-zoom — -which also shrinks the visual viewport — is excluded. +| Attribute | Applies to | Description | +| ------------------------------------------- | ---------------------------------- | ------------------------------------- | +| `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state (`closed` during the exit) | +| `data-starting-style` / `data-ending-style` | Backdrop, Viewport, Popup | Entering frame / exit animation | +| `data-size` | Viewport, Track, Popup | Resolved size | +| `data-inline` | Viewport, Track, Popup | Root is `inline` | +| `data-nested` | Backdrop, Viewport, Popup | Opened inside another overlay | +| `data-stacked` / `data-stack-base` | Popup | On top of / beneath another dialog | ### Nested dialogs and stacks -Two different relationships, which look different on purpose. - -A **nested** dialog is one opened over a `panel` or a `card` — a new surface over a page-like one. -It paints its own scrim, lighter than the base so the two composite to the intended darkness rather -than doubling it. Nothing else changes. - -A **stack** is successive `prompt`s: the confirmation over the form it is confirming. The same -conversation, one step further in. A stacked prompt paints **no** scrim — one backdrop serves the -whole stack, so how dark the page goes never depends on how deep the stack is. Depth comes from the -prompt beneath instead: its contents dim toward its own background, and it recedes, scaling down -slightly and lifting, with its radius divided by the same factor so the corners render unchanged. +A dialog opened inside another is one of two relationships, decided by the size beneath it: -Whichever it is, the thing that opens is always a `prompt`. `panel` and `card` are root-level -surfaces — they host, they are never hosted — and a dialog opened inside another one warns in -development if it is any other size. +| | Nested | Stack | +| ------------------- | -------------------------- | ---------------------------------- | +| Opens over | a `panel` or `card` | a `prompt` | +| Its own scrim | Yes, lighter than the base | None — one scrim serves the stack | +| The surface beneath | Unchanged | Dims and recedes | +| Attributes | `data-nested` | `data-stacked` / `data-stack-base` | -Under `prefers-reduced-motion: reduce` the recede still happens, it just arrives in a single frame -with nothing interpolating — the setting asks for no animation, not for no distinction. +A `panel` never opens inside another dialog — it warns in dev. A `prompt` or a `card` (a confirmation holding a `Card`) is what opens over one. --- ## Examples -### Scrolling a panel +### A panel -A `panel` is a fixed-height surface that does not scroll itself — putting the scroll on the popup -would take everything anchored to it, starting with `Dialog.CloseButton`, along for the ride. So -the popup clips, and the scroll region is composed inside it out of the `ScrollArea` atoms. +The account profile as a `panel`: the page inside paints the frame, names the dialog, scrolls its +own content column, collapses its own sidebar, and carries the dismiss. The dialog only positions it. - -A `panel` carries **no padding of its own** — its regions reach the popup's edges, which lets a -scroll region sit flush so its scrollbar and edge fade land on the true edge, and lets a sidebar -run the full height. Padding goes on the content inside each region. (`prompt` still pads itself; -`card` takes its padding from the `Card` that supplies its surface.) - -`scrollAreaRoot` is the positioned ancestor and `scrollAreaViewport()` is the element that actually -scrolls — both are style objects, not components, so they add no DOM of their own: - -```tsx -import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; - - }> - - }>Settings - -
- - -
-
-
-
-
-
-
; -``` - -The sidebar is dropped below `48rem` — a fixed rail beside a scrolling column has nowhere to go on -a phone — which is why the title sits in its own header rather than in the rail: the dialog's -accessible name has to survive the rail disappearing. - -`min-height: 0` on the row is load-bearing — a flex child's default `min-height: auto` refuses to -shrink below its content, so without it the row grows past the panel and the scroll never engages. - -### Scrolling a tall card - -The other half of the scroll story. A `panel` scrolls **inside**, because it is a fixed-height -window you navigate within. A `prompt` and a `card` take their height from their content and have -no obvious region to scroll, so they scroll **outside**: the popup keeps its natural height and the -whole dialog moves within the viewport, inset and all. - - -Nothing is opted into — it follows from the size. The viewport is `min-height: 100%` for the two -content-height surfaces and a pinned `height: 100%` for `panel`. That one property is the whole -mechanism: pinned, the viewport cannot grow, so an over-tall popup spills past its padding box and -runs flush into the bottom of the screen with none of the inset it has everywhere else. Allowed to -grow, the padding travels with the content, while short dialogs still fill the overlay so centring -has something to centre against. - -`place-items: safe center` is the other half. Centring an item taller than its box overflows it -equally in both directions, putting the top half above the scroll origin where it cannot be -reached; `safe` falls back to start alignment in exactly that case. - -> One surface is excluded: a `prompt` below `48rem` is a bottom sheet, and the rule that stops its -> off-screen slide painting a scrollbar also stops it scrolling. A prompt asks one thing, so it -> should not reach that height — reach for `card` if a tall surface is needed on a phone. - -### Nested dialogs - -The account-profile shape: a `panel` holding the settings surface, with `prompt` dialogs opened -from triggers inside it. Open the panel, then add an email address — the panel stays put behind the -prompt. +Add an email address, type into the field and try to close it: the prompt opens over the panel +(nested, with its own lighter scrim), and a confirmation stacks on the prompt (no second scrim; the +prompt recedes). The same stack on its own: -This is the nested case, not a stack: the prompt paints its own scrim over the panel, and the panel -neither dims nor recedes. +Nest by rendering a `Dialog.Root` inside another popup's children. Dismissal reaches only the top +dialog, scroll stays locked until the last one closes, and focus returns down the stack. -Type into **Add email address** and then try to close it — Escape, the corner X, or Cancel — and a -confirmation stacks on top instead, making the panel → prompt → prompt case reachable. The veto is -a controlled `open` whose `onOpenChange` declines to commit; every close request routes through it, -so one check covers all of them. +### Scrolling a tall card -Stack a prompt on a prompt and the relationship changes — the shape a close confirmation -takes: +A `prompt` or `card` taller than the screen scrolls the whole dialog inside the viewport, inset and +all. Nothing to opt into. (A phone-band `prompt` sheet is clipped instead — use `card` for a tall +surface on a phone.) -Nest by rendering a `Dialog` inside another one's children. Nothing else is required — the inner -dialog finds the outer through Floating UI's tree and wires up its own stacking: - -```tsx - } -> - }>Account - - } - > - {({ close }) => ( - <> - }>Add email address - - - - )} - - -``` - -What you get without asking for it: - -| | | -| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| **Dismissal reaches the top only** | Escape and backdrop presses close the inner dialog and leave the panel open; they reach the panel only once it is gone. | -| **Scroll stays locked** | The body stays locked until the _last_ dialog closes, not the first. | -| **Focus returns down the stack** | Closing the inner dialog returns focus to its trigger inside the panel, which is still mounted and focus-trapped. | -| **Scrims don't compound** | The inner backdrop is lighter, so two levels stay a step from the page rather than an opaque wall. | - -Give the inner dialog `closedBy='closerequest'` whenever it holds input, so a stray click on its -backdrop cannot discard what was typed. - ### Detached triggers -A trigger does not have to be nested inside its root. `Dialog.createHandle()` returns a handle; -pass the same handle to both `Dialog.Trigger` and `Dialog.Root`, and the trigger drives the -dialog from anywhere in the tree. The handle also has imperative `open()` / `close()` / `isOpen` -members for opens with no trigger element at all — calls made while no root is mounted are -ignored. +`Dialog.createHandle()` links a `Dialog.Trigger` to a `Dialog.Root` anywhere in the tree. The +handle also has `open()` / `close()` / `isOpen` for opens with no trigger element. ()`. +Several triggers can share one dialog through a handle. Give each an `id` and a `payload`, and make +the root's children a function of `{ payload }`. Type it with `Dialog.createHandle()`. -Everything keyed to "the trigger" follows the one that was actually used: focus returns to it on -close. In controlled mode, drive the attribution yourself with -`triggerId` on `Dialog.Root` — `onOpenChange`'s second argument names the trigger behind each -change, and setting `triggerId` alongside a programmatic `open` behaves exactly as if that -trigger had been clicked. - ### Custom focus management -`initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves when the dialog -opens and closes. Each accepts `true` (the default behavior), `false` (do not move focus), a -ref, or a function of the interaction type behind the change -(`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty when programmatic). +`initialFocus` and `finalFocus` on `Dialog.Popup` take `true`, `false`, a ref, or a function of the +interaction type (`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`). - -This is the answer to the close-button caveat under [Close button](#close-button): when a corner -X would otherwise take the dialog's initial focus, point `initialFocus` at the field that should -have it. diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index fb107e05ac0..ebebe39f18c 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -2,18 +2,17 @@ import type { RenderProps } from '@clerk/headless/utils'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Card } from '@clerk/ui/mosaic/components/card'; import type { DialogSize } from '@clerk/ui/mosaic/components/dialog'; -import { Dialog } from '@clerk/ui/mosaic/components/dialog'; +import { createConfirmHandle, Dialog, useConfirmedClose } from '@clerk/ui/mosaic/components/dialog'; import { Heading } from '@clerk/ui/mosaic/components/heading'; -import { Icon } from '@clerk/ui/mosaic/components/icon'; import { Input } from '@clerk/ui/mosaic/components/input'; -import { Item } from '@clerk/ui/mosaic/components/item'; -import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; import { Text } from '@clerk/ui/mosaic/components/text'; -import * as stylex from '@stylexjs/stylex'; +import { UserPageView } from '@clerk/ui/mosaic/user-profile/user-page.view'; import React from 'react'; import type { StoryMeta } from '@/lib/types'; +import { useUserPageFixture } from './fixtures/user-page'; + // 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 './dialog.component.stories?raw'; @@ -37,419 +36,362 @@ const dialogTrigger = (props: RenderProps) => +); + +/** + * `role='alertdialog'` is the whole difference: it announces as an interruption, an outside press + * cannot dismiss it, and it is always a `prompt`. `Dialog.Actions` holds the answer, cancel first. + */ +export function Alert() { + const [open, setOpen] = React.useState(false); + return ( + - {({ close }) => ( - <> - - Confirm action - Are you sure you want to proceed? This action cannot be undone. + + + }>Delete Acme Inc? + }> + The organization and everything in it will be permanently removed. This cannot be undone. + + + }>Cancel + {/* Not a `Dialog.Close`: the action is where the work happens, so the caller closes + once it resolves rather than the button closing on press. */} - - )} -
+ + + ); } -const accountTrigger = (props: RenderProps) => ; +const addEmailTrigger = (props: RenderProps) => ; -const addTrigger = (label: string) => (props: RenderProps) => ( - -); +// `useConfirmedClose` wraps the dialog's own `onOpenChange`, so every close it owns — Escape, the +// corner X, `Dialog.Close` — is guarded by one hook and a veto is just the absence of a commit. +// `Dialog.Confirm` renders INSIDE the dialog it guards so the two share a floating tree, which +// escape ordering, the stacking styles and the refcounted scroll lock all depend on. `finalFocus` is +// optional but wanted here: a confirmation raised by a close request has no trigger to return to. +export function DiscardChanges() { + const confirm = React.useMemo(() => createConfirmHandle(), []); + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + const inputRef = React.useRef(null); + + const onOpenChange = useConfirmedClose({ + handle: confirm, + when: () => value.trim() !== '', + onOpenChange: next => { + setOpen(next); + if (!next) { + setValue(''); + } + }, + confirm: { + title: 'Discard changes?', + description: 'You have not finished adding this address. It will not be saved.', + actionLabel: 'Discard', + cancelLabel: 'Keep editing', + destructive: true, + }, + }); -const addEmailTrigger = addTrigger('Add email address'); -const addPhoneTrigger = addTrigger('Add phone number'); -const deleteAccountTrigger = (props: RenderProps) => ( - -); + return ( + + + + + }>Add email address + }> + You will need to verify this address before it can be used. + + {/* A form, so Enter in the field is the primary action; Tab stays in visual order. Adding + is the one close that must not be questioned, so it goes straight to `setOpen`, past + the guard. */} +
{ + event.preventDefault(); + setValue(''); + setOpen(false); + }} + > + setValue(event.target.value)} + /> + + }>Cancel + + +
+ + +
+
+ ); +} -// A `panel` has no padding of its own, so a body of ordinary content supplies it. -const panelBody = { - display: 'flex', - flex: 1, - flexDirection: 'column', - gap: '0.75rem', - minHeight: 0, - overflowY: 'auto', - padding: '1.5rem', -} as const; - -const sectionHeader = { - alignItems: 'center', - display: 'flex', - gap: '1rem', - justifyContent: 'space-between', -} as const; +const accountTrigger = (props: RenderProps) => ; /** - * A `prompt` dialog opened from inside the `panel` — the shape the account profile uses. - * - * With `confirmDiscard`, closing it while the field holds anything opens a confirmation stacked on - * top rather than closing: `panel -> prompt -> prompt`, and the veto is nothing more than a - * controlled `open` whose `onOpenChange` declines to commit. Hand-rolled here on purpose, to show - * that a veto needs no machinery; `AlertDialog`'s `useConfirmedClose` is the same thing packaged, - * and its page has the composed version. + * The "add email address" prompt the account panel opens, driven by `open` rather than a trigger. + * Closing it with a value typed asks first — `panel -> prompt -> prompt`. */ -function AddValueDialog({ - trigger, - title, - description, - placeholder, - confirmLabel = 'Continue', - confirmColor, - confirmDiscard = false, +function AddEmailDialog({ + open, + onOpenChange, + onAdd, }: { - trigger: (props: RenderProps) => React.ReactElement; - title: string; - description: string; - placeholder: string; - confirmLabel?: string; - confirmColor?: 'negative'; - confirmDiscard?: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onAdd: (value: string) => void; }) { - const [open, setOpen] = React.useState(false); - const [discardOpen, setDiscardOpen] = React.useState(false); + const confirm = React.useMemo(() => createConfirmHandle(), []); const [value, setValue] = React.useState(''); - - const dismiss = () => { - setValue(''); - setOpen(false); - }; + const inputRef = React.useRef(null); + + const guardedOpenChange = useConfirmedClose({ + handle: confirm, + when: () => value.trim() !== '', + onOpenChange: next => { + onOpenChange(next); + if (!next) { + setValue(''); + } + }, + confirm: { + title: 'Discard changes?', + description: 'You have not finished adding this address. It will not be saved.', + actionLabel: 'Discard', + cancelLabel: 'Keep editing', + destructive: true, + }, + }); return ( - { - // The veto. Every close request lands here — Escape, the corner X, `Dialog.Close` — so - // declining to commit covers all of them at once. A footer button wired to a bare - // `setOpen(false)` would go around it, which is the argument for `Dialog.Close`. - if (!next && confirmDiscard && value.trim() !== '') { - setDiscardOpen(true); - return; - } - if (!next) { - setValue(''); - } - setOpen(next); - }} + onOpenChange={guardedOpenChange} > - - }>{title} - }>{description} - setValue(event.target.value)} - /> -
- }>Cancel - -
- {confirmDiscard ? ( - + + }>Add email address + }>A verification code will be sent to this address. + {/* Straight to the parent's setter on submit: adding is the one close that must not be + questioned. A form, so Enter in the field adds. */} +
{ + event.preventDefault(); + onAdd(value.trim()); + setValue(''); + onOpenChange(false); + }} > - }>Discard changes? - }> - You have not finished adding this address. It will not be saved. - -
- - -
-
- ) : null} -
+ setValue(event.target.value)} + /> + + }>Cancel + + + + + + ); } -/** A `panel` account surface with `prompt` dialogs opened from inside it. */ +/** + * The real user page inside a `panel` dialog. The dialog positions it and the page paints + * itself — the same composition as a `Card` inside a `card` dialog — so the page names the + * dialog, scrolls its own content column, collapses its own sidebar, and carries the dismiss. + * Adding an email opens a prompt over the panel; the danger zone's delete confirmation is the + * page's own. + */ export function Nested() { + const [addEmailOpen, setAddEmailOpen] = React.useState(false); + const { activePanel, setActivePanel, panels, addEmail } = useUserPageFixture({ + onAddEmail: () => setAddEmailOpen(true), + }); + return ( + + + + + + + + ); +} + +/** + * The same page presented `inline`: it is the page's content rather than a surface over it, so + * there is no trigger, portal, scrim, scroll lock or focus trap, and nothing dismisses it. The + * prompts it opens are still modal over the whole page. + * + * The host is resizable. The page's compact layout is a container query against the page itself, + * and the dialog's inset is one against its viewport, so dragging the host below `48rem` + * collapses the sidebar without the browser window moving. + */ +export function Inline() { + const [addEmailOpen, setAddEmailOpen] = React.useState(false); + const { activePanel, setActivePanel, panels, addEmail } = useUserPageFixture({ + onAddEmail: () => setAddEmailOpen(true), + }); return ( - - -
- }>Account - }>Manage the addresses people can reach you at. - -
- Email addresses - + + -
- - - - ada@example.com - Primary - - - - - ada.lovelace@work.example.com - - - - -
- Phone numbers - -
- - - - +1 (555) 010-1842 - - - - -
- -
-
-
+ + + ); } -const settingsTrigger = (props: RenderProps) => ; - -const NAV_SECTIONS = ['Profile', 'Security', 'Sessions', 'Connected accounts', 'Billing']; - -// Long enough to overflow the panel even on a large display, or the scroll example shows nothing. -const SESSION_DEVICES = [ - 'MacBook Pro', - 'iPhone 15', - 'Windows PC', - 'iPad Air', - 'Pixel 8', - 'Linux Workstation', - 'MacBook Air', - 'Steam Deck', -]; -const SESSION_PLACES = [ - 'Denver, CO · Chrome', - 'Boulder, CO · Edge', - 'Fort Collins, CO · Firefox', - 'Seattle, WA · Chrome', - 'Remote · Safari', -]; -const SESSION_TIMES = ['Active now', '2 hours ago', 'Yesterday', '3 days ago', 'Last week', 'Last month']; - -const SESSIONS = Array.from({ length: 40 }, (_, index) => ({ - id: index, - device: SESSION_DEVICES[index % SESSION_DEVICES.length], - where: SESSION_PLACES[index % SESSION_PLACES.length], - when: SESSION_TIMES[index % SESSION_TIMES.length], -})); - const editProfileTrigger = (props: RenderProps) => ; -const discardTrigger = (props: RenderProps) => ( - -); - /** - * A prompt stacked on a prompt — the shape a close confirmation takes. The second prompt paints - * no scrim of its own; the one beneath it recedes instead. + * A prompt stacked on a prompt — the shape a close confirmation takes. Edit the name and press + * Cancel: the second prompt paints no scrim of its own, and the one beneath it recedes instead. */ export function StackedPrompts() { + const confirm = React.useMemo(() => createConfirmHandle(), []); + const [open, setOpen] = React.useState(false); + const [savedName, setSavedName] = React.useState('Ada Lovelace'); + const [name, setName] = React.useState(savedName); + const nameRef = React.useRef(null); + + const onOpenChange = useConfirmedClose({ + handle: confirm, + when: () => name !== savedName, + onOpenChange: next => { + setOpen(next); + if (!next) { + setName(savedName); + } + }, + confirm: { + title: 'Discard changes?', + description: 'Your edits will be lost.', + actionLabel: 'Discard', + cancelLabel: 'Keep editing', + destructive: true, + }, + }); + return ( - - {({ close }) => ( - <> - - }>Update profile - }>Change the name people see on your account. + + + + }>Update profile + }>Change the name people see on your account. + {/* Saving goes straight to `setOpen`, past the guard. A form, so Enter in the field saves. */} +
{ + event.preventDefault(); + setSavedName(name); + setOpen(false); + }} + > setName(event.target.value)} /> -
- - {({ close: closeConfirmation }) => ( - <> - }>Discard changes? - }>Your edits will be lost. -
- - -
- - )} -
- -
- - )} -
- ); -} - -/** The panel clips rather than scrolling, so the scroll region is composed inside it. */ -export function PanelSidebar() { - return ( - - - - {/* Its own header, so the accessible name survives the nav being hidden on a phone. */} -
- }>Settings -
- -
- {/* The rail has nowhere to go on a phone; `md` is 48rem, the dialog's own mobile band. */} - - - {/* Flush with the popup edge, so the scrollbar and edge fade land on the true edge. */} -
-
-
- - {SESSIONS.map(session => ( - - - {session.device} - - {session.where} · {session.when} - - - - - - - ))} - -
-
-
-
-
+ + }>Cancel + + + + + + ); } @@ -467,16 +409,11 @@ export function DetachedTrigger() { render={props => } /> - - - - - - }>Notifications - }>You are all caught up. Good job! - - - + + + }>Notifications + }>You are all caught up. Good job! + ); @@ -514,57 +451,49 @@ export function MultipleTriggers() { {({ payload }) => ( - - - - - - }>{payload?.name} - }> - {payload ? `${payload.role} of this organization.` : null} - - - - + + + }>{payload?.name} + }> + {payload ? `${payload.role} of this organization.` : null} + + )} ); } -/** `size='card'` paints nothing itself — the popup renders AS a `Card`, which supplies the surface. */ +/** `size='card'` paints nothing itself — the `Card` inside supplies the surface. */ export function CardSurface() { return ( - + } /> - - - - }> - - Sign in - Continue to your account. - - - - - - ( - - )} - /> - - - - - + + + + Sign in + Continue to your account. + + + + + + ( + + )} + /> + + + + ); } @@ -580,44 +509,41 @@ const TERMS_CLAUSES = Array.from({ length: 12 }, (_, index) => ({ /** A tall `card` outgrows the screen, so the whole dialog scrolls inside the viewport. */ export function OutsideScroll() { return ( - + } /> - - - - }> - - }>Terms of service - }> - Nothing here scrolls on its own — the card grows past the screen and the viewport takes the scroll. - - - -
- {TERMS_CLAUSES.map(clause => ( -
- {clause.heading} - {clause.body} -
- ))} -
-
- - ( - - )} - /> - - -
-
-
+ + + + Terms of service + + Nothing here scrolls on its own — the card grows past the screen and the viewport takes the scroll. + + + +
+ {TERMS_CLAUSES.map(clause => ( +
+ {clause.heading} + {clause.body} +
+ ))} +
+
+ + ( + + )} + /> + + +
+
); } @@ -628,23 +554,18 @@ export function CustomFocus() { return ( } /> - - - - - - }>Feedback - }> - The feedback field takes focus on open — past the close button and the name field. - - - - - - + + + }>Feedback + }> + The feedback field takes focus on open — past the close button and the name field. + + + + ); } diff --git a/packages/swingset/src/stories/fixtures/user-page.ts b/packages/swingset/src/stories/fixtures/user-page.ts new file mode 100644 index 00000000000..eb1a0c93d4e --- /dev/null +++ b/packages/swingset/src/stories/fixtures/user-page.ts @@ -0,0 +1,127 @@ +import type { UserPageViewProps } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import type { UserProfileEmail, UserProfilePhone } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; +import type { + UserProfileDevice, + UserProfileMfaMethod, + UserProfilePasskey, +} from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; +import type { UserProfilePanelId } from '@clerk/ui/mosaic/user-profile/user-profile-sidebar'; +import { useState } from 'react'; + +export interface UserPageFixtureOptions { + /** Replaces the default "append an address" behaviour, e.g. to open a real prompt. */ + onAddEmail?: () => void; +} + +/** + * The account and security panels of the user page, backed by local state so the actions on them + * do something. For stories that need a realistic profile surface without being about it. + */ +export function useUserPageFixture({ onAddEmail }: UserPageFixtureOptions = {}) { + const [activePanel, setActivePanel] = useState('account'); + const [emails, setEmails] = useState([ + { id: 'email_1', value: 'preston@clerk.dev', isDefault: true, isVerified: true }, + { id: 'email_2', value: 'preston.booth@gmail.com', isVerified: true }, + ]); + const [phones, setPhones] = useState([ + { id: 'phone_1', value: '+1 801-888-8181', isDefault: true, isVerified: true }, + ]); + const [passkeys, setPasskeys] = useState([ + { + id: 'passkey', + name: 'MacBook Pro', + createdAtLabel: 'Created today at 10:12 PM', + lastUsedAtLabel: 'Last used 1h ago', + }, + ]); + const [mfaMethods, setMfaMethods] = useState([ + { id: 'sms', type: 'sms', description: '+1 801-888-8181' }, + { id: 'backup', type: 'backup-codes' }, + ]); + const [devices, setDevices] = useState([ + { + id: 'current', + name: 'Safari on macOS', + description: 'Salt Lake City, UT, United States', + type: 'desktop', + isCurrent: true, + }, + { + id: 'mobile', + name: 'Safari on iOS', + description: 'Last seen 2 weeks ago · Orem, UT, United States', + type: 'mobile', + }, + { + id: 'desktop', + name: 'Clerk App on macOS', + description: 'Last seen May 14th, 2026 · San Francisco, CA, United States', + type: 'desktop', + }, + ]); + + const addEmail = (value: string) => + setEmails(current => [...current, { id: `email_${Date.now()}`, value, isVerified: false }]); + + const panels: UserPageViewProps['panels'] = { + account: { + allowMultipleAccounts: true, + imageUrl: 'https://avatars.githubusercontent.com/u/51144033?v=4', + name: 'Preston Booth', + username: 'prestonxyz', + emails, + phones, + onAddEmail: onAddEmail ?? (() => addEmail(`preston+${emails.length}@clerk.dev`)), + onAddPhone: () => + setPhones(current => [ + ...current, + { + id: `phone_${Date.now()}`, + value: `+1 801-555-${String(current.length + 1).padStart(4, '0')}`, + isVerified: true, + }, + ]), + onDeleteAccount: () => Promise.resolve(), + onEditProfilePicture: () => undefined, + onManageEmail: () => undefined, + onManagePhone: () => undefined, + onNameChange: () => undefined, + onRemoveEmail: id => setEmails(current => current.filter(email => email.id !== id)), + onRemovePhone: id => setPhones(current => current.filter(phone => phone.id !== id)), + onSetPrimaryEmail: id => setEmails(current => current.map(email => ({ ...email, isDefault: email.id === id }))), + onSetPrimaryPhone: id => setPhones(current => current.map(phone => ({ ...phone, isDefault: phone.id === id }))), + onUsernameChange: () => undefined, + onVerifyEmail: id => + setEmails(current => current.map(email => (email.id === id ? { ...email, isVerified: true } : email))), + onVerifyPhone: id => + setPhones(current => current.map(phone => (phone.id === id ? { ...phone, isVerified: true } : phone))), + }, + security: { + hasPassword: true, + passkeys, + mfaMethods, + devices, + onAddMfaMethod: type => + setMfaMethods(current => [ + ...current, + { id: `${type}-${Date.now()}`, type, description: type === 'sms' ? '+1 801-555-0100' : undefined }, + ]), + onAddPasskey: () => + setPasskeys(current => [ + ...current, + { id: `passkey-${Date.now()}`, name: `Passkey ${current.length + 1}`, createdAtLabel: 'Created just now' }, + ]), + onChangePassword: () => undefined, + onDeleteAccount: () => Promise.resolve(), + onManageDevice: () => undefined, + onManagePasskey: () => undefined, + onRegenerateBackupCodes: () => undefined, + onRemoveMfaMethod: id => setMfaMethods(current => current.filter(method => method.id !== id)), + onRemovePasskey: id => setPasskeys(current => current.filter(passkey => passkey.id !== id)), + onSignOutAllOtherDevices: () => setDevices(current => current.filter(device => device.isCurrent)), + onSignOutDevice: id => setDevices(current => current.filter(device => device.id !== id)), + }, + }; + + return { activePanel, setActivePanel, panels, addEmail, devices }; +} diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx index b2902669272..fa0804dc8f5 100644 --- a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx +++ b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx @@ -3,12 +3,10 @@ import { useEffect, useId, useState } from 'react'; import { Button, SubmitButton } from '../../components/button'; import { Card } from '../../components/card'; -import type { DialogProps } from '../../components/dialog'; +import type { DialogTriggerProps } from '../../components/dialog'; import { Dialog } from '../../components/dialog'; import { Field } from '../../components/field'; -import { Heading } from '../../components/heading'; import { Input } from '../../components/input'; -import { Text } from '../../components/text'; export interface DestructiveProps { /** Whether the dialog is open */ @@ -16,7 +14,7 @@ export interface DestructiveProps { /** Callback when open state changes */ onOpenChange: (open: boolean) => void; /** Element that opens the dialog */ - trigger?: DialogProps['trigger']; + trigger?: DialogTriggerProps['render']; /** Dialog heading */ title: string; /** What the action destroys */ @@ -99,73 +97,64 @@ export function Destructive({ return ( {trigger ? : null} - - - - - } - > - - - }>{title} - }>{description} - - -
- - {fieldLabel} - setTypedValue(event.target.value)} - /> - {errorMessage ? {errorMessage} : null} - -
-
- - - {cancelLabel} - - } - /> - - {actionLabel} - - -
-
-
+ + + + {title} + {description} + + +
+ + {fieldLabel} + setTypedValue(event.target.value)} + /> + {errorMessage ? {errorMessage} : null} + +
+
+ + + {cancelLabel} + + } + /> + + {actionLabel} + + +
+
); } 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 deleted file mode 100644 index cf7b1f3ba25..00000000000 --- a/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts +++ /dev/null @@ -1,18 +0,0 @@ -import * as stylex from '@stylexjs/stylex'; - -import { space } from '../../tokens.stylex'; - -export const styles = stylex.create({ - // Grid, not flex: an even split needs `flex: 1` on each CHILD, and StyleX has no child selector - // to set it from the container. Keep DOM order visual order — the cancel is first so it is the - // first tabbable element, which is what opens it focused without any `initialFocus` plumbing. - 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.tsx b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.tsx deleted file mode 100644 index a799bf18506..00000000000 --- a/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import type { DialogFocusTarget } from '@clerk/headless/dialog'; -import { useRender } from '@clerk/headless/utils'; -import * as stylex from '@stylexjs/stylex'; -import type { ReactNode } from 'react'; -import React from 'react'; - -import { useAccessibleDescriptionWarning } from '../../hooks/useAccessibleDescriptionWarning'; -import type { MosaicComponentProps } from '../../props'; -import { mergeStyleProps, themeProps } from '../../props'; -import { reset } from '../../utils/reset.styles'; -import { Button } from '../button'; -import type { - DialogBackdropProps, - DialogCloseProps, - DialogDescriptionProps, - DialogPopupProps, - DialogRootProps, - DialogTitleProps, - DialogTriggerProps, - DialogViewportProps, -} from '../dialog'; -import { Dialog } from '../dialog'; -// Deep import: the part-name context and the content resolver are how one Mosaic component wraps -// another and are deliberately absent from `../dialog`'s public surface. -import { DialogContent, DialogPartNameContext } from '../dialog/dialog'; -import { Heading } from '../heading'; -import { Text } from '../text'; -import { styles } from './alert-dialog.styles'; -import { type ConfirmHandle, createConfirmHandle } from './confirm-handle'; - -/** - * An alert dialog is a `Dialog` with three decisions already made, so the props that would make - * them are not offered: - * - * - `role` is `alertdialog`, which is the whole point — assistive technology announces it as an - * interruption rather than as a surface the user navigated to; - * - `closedBy` is `closerequest`, so an outside press cannot dismiss it. A dialog asking a - * question it needs an answer to must not be answerable by clicking next to it. Escape still - * closes, which is not negotiable either: it is the keyboard's equivalent of the cancel button, - * and the cancel button is always present here; - * - `size` is `prompt`, the size that means "asks one thing and returns". - */ -export type AlertDialogRootProps = Omit, 'closedBy' | 'role' | 'size'>; - -export type AlertDialogTriggerProps = DialogTriggerProps; -export type AlertDialogBackdropProps = DialogBackdropProps; -export type AlertDialogViewportProps = DialogViewportProps; -export type AlertDialogPopupProps = Omit; -export type AlertDialogTitleProps = DialogTitleProps; -export type AlertDialogDescriptionProps = DialogDescriptionProps; -export type AlertDialogCloseProps = DialogCloseProps; -export type AlertDialogActionsProps = MosaicComponentProps<'div'>; - -/** Owns the open state, and pins the three props that make a dialog an alert dialog. */ -function Root({ children, ...rest }: AlertDialogRootProps) { - return ( - - {...rest} - role='alertdialog' - closedBy='closerequest' - size='prompt' - > - {children} -
- ); -} - -/** - * The alert surface. Identical to `Dialog.Popup` — same styles, same focus trap, same stacking — - * plus the description check, which is a requirement here rather than a nicety. - * - * No `Dialog.CloseButton` counterpart, and that omission is the design: a corner X is a way out - * without answering, and an alert dialog has no such path. The cancel button is the way out. - */ -const Popup = React.forwardRef(function AlertDialogPopup(props, ref) { - // Observed through state rather than a plain ref, for the same reason `Dialog.Popup` does it: - // the warning has to re-run when the node arrives, and a ref mutation does not re-render. - const [node, setNode] = React.useState(null); - useAccessibleDescriptionWarning(node, 'AlertDialog'); - - const mergedRef = React.useCallback( - (element: HTMLDivElement | null) => { - setNode(element); - if (typeof ref === 'function') { - ref(element); - } else if (ref) { - ref.current = element; - } - }, - [ref], - ); - - // Scoped to the popup rather than to the whole root: this is the only place the name is read, - // and a plain `Dialog` nested inside an alert would otherwise inherit it and have its own - // warnings name `AlertDialog` parts that do not exist at that call site. - return ( - - {/* After the spread on purpose: `mergeProps` lets consumer props win, so a `role` passed - here would otherwise downgrade the alert back to a plain dialog. */} - - - ); -}); - -/** - * The row holding the answer. Render the cancel first — see `alert-dialog.styles.ts` for why that - * ordering is what focuses it on open. - */ -const Actions = React.forwardRef(function AlertDialogActions( - { render, className, style, ...rest }, - ref, -) { - return useRender({ - defaultTagName: 'div', - render, - ref, - props: { - ...mergeStyleProps( - themeProps('alert-dialog-actions'), - stylex.props(reset.base, styles.actions), - className, - style, - ), - ...rest, - }, - }); -}); - -export interface AlertDialogProps - extends - Pick, - /** - * Focus, forwarded to the popup. `finalFocus` earns its place on the wrapper rather than only - * on the part: an alert is usually raised by something that happened rather than by a trigger, - * and with no trigger there is nothing for focus to return to when it closes. Answering - * "keep editing" should put the caret back in the field the question was about. - */ - Pick { - /** - * Renders the button that opens the alert. Omit for alerts driven entirely by `open` — the - * common case, since an alert is usually raised by something that already happened rather than - * by a button that exists to raise it. - */ - trigger?: MosaicComponentProps<'button'>['render']; - children: ReactNode | ((ctx: { close: () => void }) => ReactNode); -} - -/** - * 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 simply read and dismiss is a `Dialog`. - * - * Composed from the same parts, so everything true of `Dialog` is true here — the surface, the - * motion, the stacking over another dialog, the scroll lock. What differs is what it announces - * itself as, that an outside press does not dismiss it, and that it carries a `Title`, a - * `Description`, and an `Actions` row rather than arbitrary content. Both are checked in - * development; neither is enforceable in the type system, since parts arrive as children. - * - * Drop to the compound parts (`AlertDialog.Root` and friends) for layouts this wrapper does not - * cover. - * - * @example - * } - * > - * Delete this key? - * Applications using it will stop working immediately. - * - * }>Cancel - * - * - * - */ -export function AlertDialog({ - trigger, - children, - open, - defaultOpen, - onOpenChange, - modal, - initialFocus, - finalFocus, -}: AlertDialogProps) { - return ( - - {trigger ? : null} - - - - - {children} - - - - - ); -} - -export interface AlertDialogConfirmProps { - /** Shared with the `show()` call, or with `useConfirmedClose`, that raises this confirmation. */ - handle: ConfirmHandle; - /** - * Where focus goes when the confirmation closes. Worth passing: the confirmation has no trigger, - * so by default there is nothing for focus to return to. Point it at the field the question was - * about and declining puts the caret back in it. - */ - finalFocus?: DialogFocusTarget; -} - -/** - * The dialog half of {@link createConfirmHandle} — an alert dialog rendered from whatever the - * `show()` call asked, and closed by answering it. - * - * Render it INSIDE the dialog it guards (anywhere in its children; outside its `Portal` is fine). - * That is what puts the two in one floating tree, which is what escape ordering, the stacking - * styles and the refcounted scroll lock all read. - */ -function Confirm({ handle, finalFocus }: AlertDialogConfirmProps) { - // A question can only be answered while the thing that asks it is on screen. Going away with one - // in flight would leave the promise unresolved forever, and `show()` short-circuits on an - // in-flight question — so the handle would never open a confirmation again, and a guarded dialog - // whose closes route through one could no longer be closed at all. - React.useEffect(() => () => handle.settle(false), [handle]); - - return ( - { - // Every close that is not the action lands here — cancel, Escape, a programmatic close — - // and they all mean no. The action settles `true` BEFORE closing, and `settle` is a no-op - // once the question is answered, so this cannot overwrite it. - if (!open) { - handle.settle(false); - } - }} - > - {({ payload }) => - payload ? ( - - - - - }>{payload.title} - }>{payload.description} - - }>{payload.cancelLabel ?? 'Cancel'} - - - - - - ) : null - } - - ); -} - -/** - * Compound parts. The ones an alert dialog does not change are `Dialog`'s own — same components, - * not wrappers around them, so there is one implementation of each and no way for the two to - * drift. - */ -AlertDialog.Root = Root; -AlertDialog.Trigger = Dialog.Trigger; -/** Creates a handle linking detached `AlertDialog.Trigger`s to an `AlertDialog.Root` anywhere in the tree. */ -AlertDialog.createHandle = Dialog.createHandle; -AlertDialog.Portal = Dialog.Portal; -AlertDialog.Backdrop = Dialog.Backdrop; -AlertDialog.Viewport = Dialog.Viewport; -AlertDialog.Popup = Popup; -AlertDialog.Title = Dialog.Title; -AlertDialog.Description = Dialog.Description; -AlertDialog.Close = Dialog.Close; -AlertDialog.Actions = Actions; -AlertDialog.Confirm = Confirm; -/** Creates the handle pairing an awaitable `show()` with an ``. */ -AlertDialog.createConfirmHandle = createConfirmHandle; diff --git a/packages/ui/src/mosaic/components/alert-dialog/index.ts b/packages/ui/src/mosaic/components/alert-dialog/index.ts deleted file mode 100644 index 8acec07aa7e..00000000000 --- a/packages/ui/src/mosaic/components/alert-dialog/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { AlertDialog } from './alert-dialog'; -export { createConfirmHandle } from './confirm-handle'; -export type { ConfirmHandle, ConfirmOptions } from './confirm-handle'; -export { useConfirmedClose } from './use-confirmed-close'; -export type { UseConfirmedCloseOptions } from './use-confirmed-close'; -export type { - AlertDialogActionsProps, - AlertDialogBackdropProps, - AlertDialogCloseProps, - AlertDialogConfirmProps, - AlertDialogDescriptionProps, - AlertDialogPopupProps, - AlertDialogProps, - AlertDialogRootProps, - AlertDialogTitleProps, - AlertDialogTriggerProps, - AlertDialogViewportProps, -} from './alert-dialog'; diff --git a/packages/ui/src/mosaic/components/card/card.test.tsx b/packages/ui/src/mosaic/components/card/card.test.tsx index 92751bac5e2..9bad722b9da 100644 --- a/packages/ui/src/mosaic/components/card/card.test.tsx +++ b/packages/ui/src/mosaic/components/card/card.test.tsx @@ -174,14 +174,16 @@ describe('Mosaic Card', () => { it('names and describes the dialog it is rendered inside', () => { render( - - - - Review terms - Accept before you continue. - - - , + + + + + Review terms + Accept before you continue. + + + + , ); const popup = screen.getByRole('dialog'); @@ -200,13 +202,9 @@ describe('Mosaic Card', () => { Terms Open - - - - Review terms - - - + + Review terms +
, ); @@ -221,22 +219,24 @@ describe('Mosaic Card', () => { // id that displaced it would silently leave the dialog unnamed. it('keeps the dialog id over an explicit one, and stays named', () => { render( - - - - Review terms - - - Read them before you continue. - - - , + + + + + Review terms + + + Read them before you continue. + + + + , ); const dialog = screen.getByRole('dialog'); @@ -267,13 +267,15 @@ describe('Mosaic Card', () => { it('carries the dialog dismiss button in the header', async () => { const user = userEvent.setup(); render( - - - - Review terms - - - , + + + + + Review terms + + + + , ); const close = screen.getByRole('button', { name: 'Close' }); @@ -285,6 +287,23 @@ describe('Mosaic Card', () => { await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); + it('carries no dismiss button in an inline dialog, which nothing closes', () => { + render( + + + + + Account + + + + , + ); + + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + expect(screen.getByRole('dialog')).toHaveAccessibleName('Account'); + }); + it('carries no dismiss button in a header outside a dialog', () => { render( diff --git a/packages/ui/src/mosaic/components/card/card.tsx b/packages/ui/src/mosaic/components/card/card.tsx index 968d99b5b84..6083bca0718 100644 --- a/packages/ui/src/mosaic/components/card/card.tsx +++ b/packages/ui/src/mosaic/components/card/card.tsx @@ -115,8 +115,9 @@ const Header = React.forwardRef>(fun children: ( <> {/* First in the DOM, so it is the first tabbable element and takes the dialog's opening - focus — the same reason `Dialog.CloseButton` is a part rather than a popup flag. */} - {dialog ? : null} + focus — the same reason `Dialog.CloseButton` is a part rather than a popup flag. + Not for an inline dialog, which nothing closes. */} + {dialog && !dialog.inline ? : null}
{children}
diff --git a/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/alert-dialog.test.tsx similarity index 55% rename from packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx rename to packages/ui/src/mosaic/components/dialog/alert-dialog.test.tsx index e0a9ead5e4b..ebc93f4df4a 100644 --- a/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/alert-dialog.test.tsx @@ -3,8 +3,8 @@ 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'; +import type { DialogRootProps } from './dialog'; +import { Dialog } from './dialog'; afterEach(() => cleanup()); @@ -14,23 +14,27 @@ const settle = () => await new Promise(resolve => setTimeout(resolve, 0)); }); -function Confirm({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) { +function Confirm({ onOpenChange, ...rest }: Partial = {}) { return ( - - Discard changes? - This address has not been saved. - - Keep editing - - - + + Discard changes? + This address has not been saved. + + Keep editing + + + + ); } -describe('Mosaic AlertDialog', () => { +describe('role="alertdialog"', () => { it('renders as an alertdialog, named and described by its parts', () => { render(); @@ -40,16 +44,15 @@ describe('Mosaic AlertDialog', () => { it('keeps the alertdialog role when a consumer passes one to the popup', () => { render( - - - - {/* `role` is omitted from AlertDialogPopupProps; the cast is how a JS consumer gets here. */} - )}> - Discard changes? - This address has not been saved. - - - , + + + Discard changes? + This address has not been saved. + + , ); expect(screen.getByRole('alertdialog', { name: 'Discard changes?' })).toBeInTheDocument(); @@ -62,31 +65,38 @@ describe('Mosaic AlertDialog', () => { 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(); + expect(document.querySelector('.cl-dialog-actions')).toBeInTheDocument(); }); - it('is always the prompt size', () => { - render(); + it('is always the prompt size, and warns when asked for another', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + Discard changes? + This address has not been saved. + + , + ); expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'prompt'); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('size="panel"')); + warn.mockRestore(); }); it('opens from a trigger', async () => { const user = userEvent.setup(); render( - ( - - )} - > - Delete this key? - Applications using it stop working. - , + + Delete + + Delete this key? + Applications using it stop working. + + , ); expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); @@ -104,7 +114,7 @@ describe('Mosaic AlertDialog', () => { await waitFor(() => expect(screen.getByRole('button', { name: 'Keep editing' })).toHaveFocus()); }); - it('closes on AlertDialog.Close, reporting it through onOpenChange', async () => { + it('closes on Dialog.Close, reporting it through onOpenChange', async () => { const user = userEvent.setup(); const onOpenChange = vi.fn(); render(); @@ -114,40 +124,8 @@ describe('Mosaic AlertDialog', () => { 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', () => { @@ -163,17 +141,19 @@ describe('focus', () => { ref={inputRef} aria-label='Email address' /> - - Discard changes? - This address has not been saved. - - Keep editing - - + + Discard changes? + This address has not been saved. + + Keep editing + + + ); } @@ -185,6 +165,8 @@ describe('focus', () => { }); }); +// 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. describe('dismissal', () => { it('does not close on an outside press', async () => { const user = userEvent.setup(); @@ -204,13 +186,23 @@ describe('dismissal', () => { expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); }); + it('keeps Escape out too under closedBy="none"', async () => { + const user = userEvent.setup(); + render(); + + await user.keyboard('{Escape}'); + + expect(screen.getByRole('alertdialog')).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) { @@ -218,12 +210,14 @@ describe('dismissal', () => { } }} > - Discard changes? - This address has not been saved. - - Keep editing - - + + Discard changes? + This address has not been saved. + + Keep editing + + + ); } render(); @@ -239,32 +233,58 @@ describe('dev warnings', () => { it('warns when the alert dialog has no description', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( - - Discard changes? - , + + + Discard changes? + + , ); await settle(); expect(warn).toHaveBeenCalledWith(expect.stringContaining('no description')); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); + 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 () => { + it('does not ask a plain dialog for a description', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( - - This address has not been saved. - , + + + Notifications + + , + ); + + await settle(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + // The name warning skipped any role but `dialog` before alert dialogs existed, which would have + // made it silently inert for every one of them. + it('warns when it has no accessible name', 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('')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); warn.mockRestore(); }); @@ -279,40 +299,50 @@ describe('dev warnings', () => { }); }); -describe('AlertDialog.Actions', () => { +describe('Dialog.Actions', () => { it('merges consumer className and style', () => { render( - - Discard changes? - This address has not been saved. - - Keep editing - - , + + + 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('cl-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. -