From 01bd5de479e6217a01c9481e6d0de4320c93f03a Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 14:52:49 -0600 Subject: [PATCH 01/22] feat(ui,headless): collapse AlertDialog into Dialog, simplify to Root/Trigger/Popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `role='alertdialog'` on `Dialog.Root` is now what makes an alert dialog: it pins `closedBy` to `closerequest`/`none` at the type level and the size to `prompt`, and brings `Dialog.Actions`, `Dialog.Confirm`, `createConfirmHandle` and `useConfirmedClose` into the dialog folder. The `AlertDialog` component and the flat `` wrapper are gone. `Dialog.Popup` renders the portal, scrim and viewport itself, so the public parts are Root, Trigger and Popup plus the content parts; `size` moves to the popup. `inline` on the root presents a dialog in its host — no portal, scrim, scroll lock or focus trap, and nothing dismisses it — for the account panel mounted in a page slot. Dialogs opened from inside it still portal over the page and take the base scrim. Every width band in the dialog styles is now a `@container cl-dialog` query against the viewport element rather than a media query, so an inline dialog's inset and phone-band treatment follow its host's width. Over the page the viewport is the window, so nothing changes there. Headless: the dialog context exposes `role`, `Dialog.Viewport` takes `overlay={false}`, and `Dialog.Popup` holds its children with `Freeze` while it exits so state that resets on close does not flash through the fade. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .changeset/mosaic-dialog-role-inline.md | 2 + .../headless/src/primitives/dialog/README.md | 15 +- .../src/primitives/dialog/dialog-context.ts | 3 + .../src/primitives/dialog/dialog-popup.tsx | 9 +- .../src/primitives/dialog/dialog-root.tsx | 2 + .../src/primitives/dialog/dialog-viewport.tsx | 13 +- .../src/primitives/dialog/dialog.test.tsx | 90 ++ .../src/primitives/drawer/drawer-context.ts | 5 +- .../swingset/src/components/DocsViewer.tsx | 1 - packages/swingset/src/lib/registry.ts | 12 - .../src/stories/alert-dialog.component.mdx | 189 ---- .../alert-dialog.component.stories.tsx | 132 --- .../swingset/src/stories/dialog.component.mdx | 334 ++++++-- .../src/stories/dialog.component.stories.tsx | 811 ++++++++++-------- .../mosaic/blocks/destructive/destructive.tsx | 115 ++- .../alert-dialog/alert-dialog.styles.ts | 18 - .../components/alert-dialog/alert-dialog.tsx | 300 ------- .../mosaic/components/alert-dialog/index.ts | 18 - .../src/mosaic/components/card/card.test.tsx | 95 +- .../ui/src/mosaic/components/card/card.tsx | 5 +- .../alert-dialog.test.tsx | 294 ++++--- .../confirm-handle.ts | 12 +- .../{alert-dialog => dialog}/confirm.test.tsx | 121 +-- .../mosaic/components/dialog/dialog.styles.ts | 133 ++- .../mosaic/components/dialog/dialog.test.tsx | 588 +++++++------ .../src/mosaic/components/dialog/dialog.tsx | 498 +++++++---- .../ui/src/mosaic/components/dialog/index.ts | 17 +- .../use-confirmed-close.ts | 17 +- packages/ui/src/mosaic/styles/index.ts | 27 +- 29 files changed, 2003 insertions(+), 1873 deletions(-) create mode 100644 .changeset/mosaic-dialog-role-inline.md delete mode 100644 packages/swingset/src/stories/alert-dialog.component.mdx delete mode 100644 packages/swingset/src/stories/alert-dialog.component.stories.tsx delete mode 100644 packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts delete mode 100644 packages/ui/src/mosaic/components/alert-dialog/alert-dialog.tsx delete mode 100644 packages/ui/src/mosaic/components/alert-dialog/index.ts rename packages/ui/src/mosaic/components/{alert-dialog => dialog}/alert-dialog.test.tsx (55%) rename packages/ui/src/mosaic/components/{alert-dialog => dialog}/confirm-handle.ts (84%) rename packages/ui/src/mosaic/components/{alert-dialog => dialog}/confirm.test.tsx (85%) rename packages/ui/src/mosaic/components/{alert-dialog => dialog}/use-confirmed-close.ts (85%) 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/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/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..24155053eb2 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -3,9 +3,10 @@ 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. +themed with StyleX. Three parts make a dialog: `Dialog.Root` owns the state, `Dialog.Trigger` opens +it, and `Dialog.Popup` is the surface — and renders the portal, the scrim and the centering viewport +around itself, so those are not parts you compose. It inherits the primitive's focus trapping, +scroll lock, and ARIA wiring. ## Playground @@ -19,8 +20,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,56 +30,51 @@ primitive's focus trapping, scroll lock, and ARIA wiring. ]} /> +`size` is a `Dialog.Popup` prop; everything else in the table belongs 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 +`Dialog.Trigger` renders a `}> - Info - Nothing to confirm here. -
-``` - ### Controlled ```tsx const [open, setOpen] = useState(false); - } > - {({ close }) => ( - <> - Confirm - - - )} - + }>Open + + Confirm + + +; ``` +Every close the dialog owns — Escape, an outside press, `Dialog.Close`, `Dialog.CloseButton`, +`handle.close()` — routes through `onOpenChange`, so a controlled consumer can decline one by not +committing the state. A button wired to your own `setOpen(false)` never reaches the dialog and +bypasses that; route it through `Dialog.Close` if the close might need vetoing. + ### Size `size` names the surface, not a t-shirt step: @@ -93,8 +89,6 @@ const [open, setOpen] = useState(false); axes: its content navigates in place — a settings surface switching sections — so a content-driven height would resize the window on every section change. -`size` lives on `Dialog.Root`, not `Dialog.Popup`, because the backdrop reads it too. - ### A card brings its own surface `prompt` and `panel` paint themselves. **`card` does not** — it contributes width and motion only, @@ -114,12 +108,142 @@ radius by the same factor to cancel it. Nested, the popup would scale a transpar The trade is that `size="card"` with no `Card` inside renders an unpainted box. -### The inset +### Alert dialogs + +`role='alertdialog'` on the root makes 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 plain dialog. + + + +Three things follow from the role, and none of them is a prop: + +- It is announced as an interruption rather than as a surface the user navigated to. +- An outside press never dismisses it: a question that needs an answer must not be answerable by + clicking next to it. `closedBy` narrows to `closerequest` (the default) or `none`. Escape still + closes — it is the keyboard's equivalent of the cancel button, which is always present. +- It is always a `prompt`; `size` on the popup is ignored, and warns in development. + +**A Title and a Description are both required.** An alert dialog's description is announced with +its name at the moment it interrupts — 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; +neither can be required in the type system, since parts arrive as children. + +**The cancel comes first.** Render it as the first child of `Dialog.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, so the keyboard order +and the screen agree. + +**The action does not close by itself.** `Dialog.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 — controlled `open`, as above. That leaves room for a pending state on the button. + +**There is no corner X.** `Dialog.CloseButton` warns in development inside an alert dialog: a +corner X is a way out without answering, and the cancel action is the way out. + +**Pass `finalFocus`** 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. + +### Confirming a discard + +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 { 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: 'You have not finished adding this address.', + actionLabel: 'Discard', + cancelLabel: 'Keep editing', + destructive: true, + }, +}); + + + + {/* … */} + + +; +``` + +**Render `Dialog.Confirm` inside the popup it guards.** 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. + +`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. + +**`Dialog.Confirm` must be mounted when `show()` is called** — it is the thing that opens. Since the +confirmation lives inside the dialog it guards, that means asking from inside that dialog, while it +is open. A `show()` with nothing mounted resolves `false` and warns; a confirmation that unmounts +with a question in flight answers `false` rather than leaving the `await` hanging. + +### Inline + +`inline` on the root presents the dialog in its host rather than over the page: no portal, no +scrim, no scroll lock, no focus trap, and nothing dismisses it — it is open for as long as it is +mounted. It is for a surface that is the page's content, such as the account panel mounted in a +layout slot. + + + +`open`, `modal` and `closedBy` are implied and ignored, and `onOpenChange` is never called. The +popup takes no initial focus, since it mounts with the page rather than in answer to a gesture. +`Dialog.CloseButton` renders nothing inside it (and warns), and a `Card.Header` carries no dismiss. + +The surface keeps its ring, radius and shadow — it reads as a card sitting on the page — and fills +its host edge to edge, with none of the inset a modal dialog keeps from the screen. Its height +follows the host's when the host has one; otherwise it takes its height from its content. + +A dialog opened from inside an inline one presents normally: portalled, scrimmed, modal over the +whole page. It paints the base scrim rather than the lighter nested one, since the inline surface +beneath it has no scrim of its own to composite with. + +### The inset, and what it is measured against 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: -| Viewport | Top & bottom | Sides | +| Container | Top & bottom | Sides | | ------------- | ---------------- | ------------- | | `< 48rem` | `1.25rem` (20px) | `1rem` (16px) | | `48rem–90rem` | `2rem` (32px) | `2rem` (32px) | @@ -131,8 +255,18 @@ a pixel there costs line length in a way the same pixel costs nothing vertically are doing the opposite job: holding the surface off the browser's own chrome, which is closer on a phone than on any desktop. -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. +It lives on the viewport's padding, so a popup gets it for free by being `width: 100%` inside it — +no width arithmetic of its own. + +**The bands are container queries, not media queries.** The viewport element is an inline-size +container named `cl-dialog`, and every width-dependent rule in the dialog — the inset ladder, the +phone-band sheet, the width caps — queries it. Over the page the viewport is `position: fixed; +inset: 0`, so its width is the window's and nothing differs from a media query. The difference shows +when the viewport is smaller than the window: an `inline` dialog fills its host, and its bands then +follow the host's width. Content inside a dialog can query the same name for its own layout — +[Scrolling a panel](#scrolling-a-panel) hides its rail with `@3xl/cl-dialog:flex`. + +`prefers-reduced-motion` and `forced-colors` stay media queries: they are preferences, not sizes. ### On a phone, a prompt is a sheet @@ -161,10 +295,10 @@ to the popup's top-inline-end corner. Being absolutely positioned, it never join column layout, so you can render it anywhere among the children without the rest moving. ```tsx - }> + Add email address - + ``` It carries an English `Close` label by default; pass `aria-label` to override it. @@ -184,20 +318,31 @@ attribute: `any` (Escape and outside press, the default), `closerequest` (Escape (neither — the dialog closes only programmatically). Reach for `closerequest` on a dialog holding user input, so a stray backdrop click cannot discard it. +### Exit + +The popup's contents are held at their last committed frame while it exits. A state machine that +drives a dialog resets to its initial state on close, in the same commit that starts the fade — +without the hold, the dialog would repaint that reset state and fade out showing the wrong thing. +Nothing to opt into; the popup element itself stays live so `data-closed` and `data-ending-style` +still land. + ## 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. | +| Part | Slot | Description | +| -------------------- | --------------------- | ------------------------------------------------------------------------------------------------------- | +| `Dialog.Root` | — | State provider; owns open/close, `role`, `inline`, `modal`, `closedBy`, `handle`. | +| `Dialog.Trigger` | — | Opens the dialog; accepts `render`, and `handle` + `payload` when detached. | +| `Dialog.Popup` | `dialog-popup` | The surface (`role="dialog"` or `"alertdialog"`, focus-trapped); `size`, `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.Actions` | `dialog-actions` | An alert dialog's response row. Cancel first. | +| `Dialog.Confirm` | `dialog-popup` | A whole confirmation rendered from a `show()` call. See [Confirming a discard](#confirming-a-discard). | + +`Dialog.Popup` also renders two styled elements that are not parts: the scrim (`dialog-backdrop`) +and the centering viewport (`dialog-viewport`), both of which carry `data-size` and, when the root +is inline, `data-inline`. `Dialog.Title` and `Dialog.Description` are unstyled passthroughs from the headless layer — render them through your own typography (`Heading`, `Text`) via `render`. @@ -216,9 +361,17 @@ classes — override by targeting the `.cl-*` slot from a CSS layer that wins ov .cl-dialog-popup[data-size='panel'] { max-width: 60rem; } + + .cl-dialog-actions { + margin-block-start: 1.5rem; + } } ``` +`Dialog.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. + State attributes from the headless layer are available for CSS targeting: | Attribute | Applies To | Description | @@ -228,6 +381,7 @@ State attributes from the headless layer are available for CSS targeting: | `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-inline` | Viewport, Popup | Present when the root is `inline` | | `data-nested` | Backdrop, Viewport, Popup | Present when opened inside another overlay | ### Motion @@ -242,8 +396,8 @@ 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: +`position: fixed` overlay would end up behind the keyboard. The 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 | | -------- | --------------------- | ------------------------------------------------------------------- | @@ -252,7 +406,8 @@ difference and adds it to its own bottom padding, which gives each size the righ | `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. +which also shrinks the visual viewport — is excluded. An `inline` dialog does none of this: it is +not fixed, so the page's own layout handles the keyboard. ### Nested dialogs and stacks @@ -301,25 +456,28 @@ scrolls — both are style objects, not components, so they add no DOM of their ```tsx import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; - }> - - }>Settings + + }>Open settings + + + }>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. +The sidebar is dropped below `48rem` of the dialog's own container — 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. @@ -378,29 +536,25 @@ takes: storyModule={DialogStories} /> -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: +Nest by rendering a `Dialog.Root` inside another popup'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 }) => ( - <> + + }>Open account + + }>Account + + + }>Add email address + }>Add email address - - - )} - - + }>Cancel + + + + ``` What you get without asking for it: diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index fb107e05ac0..65c881be08a 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -2,7 +2,7 @@ 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'; @@ -37,24 +37,133 @@ 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 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. +// `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); + // 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 ( + + + + + }>Add email address + }> + You will need to verify this address before it can be used. + + setValue(event.target.value)} + /> +
+ }>Cancel + - - )} - +
+ + +
+
); } @@ -74,7 +183,7 @@ const addTrigger = (label: string) => (props: RenderProps) => ( ); -const addEmailTrigger = addTrigger('Add email address'); +const addEmailRowTrigger = addTrigger('Add email address'); const addPhoneTrigger = addTrigger('Add phone number'); const deleteAccountTrigger = (props: RenderProps) => ( + + + + }>{title} + }>{description} + setValue(event.target.value)} + /> +
+ }>Cancel + +
+ {confirmDiscard ? ( + + + }>Discard changes? + }> + You have not finished adding this address. It will not be saved. + + + }>Keep editing + + + + + ) : null} +
+ + ); +} + +/** The account surface, shared by the modal `panel` and the inline one below. */ +function AccountPanelBody() { + return ( +
+ }>Account + }>Manage the addresses people can reach you at. + +
+ Email addresses +
- {confirmDiscard ? ( - - }>Discard changes? - }> - You have not finished adding this address. It will not be saved. - -
- - -
-
- ) : null} - + + + + ada@example.com + Primary + + + + + ada.lovelace@work.example.com + + + + +
+ Phone numbers + +
+ + + + +1 (555) 010-1842 + + + + +
+ +
+
); } /** A `panel` account surface with `prompt` dialogs opened from inside it. */ export function Nested() { return ( - + + + + + + + ); +} + +/** + * The same panel presented `inline`: it is the page's content rather than a surface over it, so + * there is no 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 dialog's width bands are container queries against its own viewport + * element, so dragging the host below `48rem` gives the panel its phone-band inset without the + * browser window moving — the same rule that makes a modal dialog respond to the window. + */ +export function Inline() { + 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 - - - - -
- -
-
-
+ + + + + + ); } @@ -324,132 +471,131 @@ const discardTrigger = (props: RenderProps) => ( * no scrim of its own; the one beneath it recedes instead. */ export function StackedPrompts() { + const [open, setOpen] = React.useState(false); + const [confirmationOpen, setConfirmationOpen] = React.useState(false); return ( - - {({ close }) => ( - <> - - }>Update profile - }>Change the name people see on your account. - -
- - {({ close: closeConfirmation }) => ( - <> - }>Discard changes? - }>Your edits will be lost. -
- - -
- - )} -
- -
- - )} -
+ + + + }>Update profile + }>Change the name people see on your account. + +
+ + + + }>Discard changes? + }>Your edits will be lost. + + }>Keep editing + + + + + +
+
+ ); } /** 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 -
+ {/* 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} - - - - - - - ))} - +
+ {/* The rail has nowhere to go on a phone. Queried against the dialog's own `cl-dialog` + container rather than the window, so it follows the surface it sits in — `@3xl` is + Tailwind's 48rem, the dialog's phone 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} + + + + + + + ))} + +
-
-
+ + ); } @@ -467,16 +613,11 @@ export function DetachedTrigger() { render={props => } /> - - - - - - }>Notifications - }>You are all caught up. Good job! - - - + + + }>Notifications + }>You are all caught up. Good job! + ); @@ -514,18 +655,13 @@ export function MultipleTriggers() { {({ payload }) => ( - - - - - - }>{payload?.name} - }> - {payload ? `${payload.role} of this organization.` : null} - - - - + + + }>{payload?.name} + }> + {payload ? `${payload.role} of this organization.` : null} + + )} @@ -535,36 +671,34 @@ export function MultipleTriggers() { /** `size='card'` paints nothing itself — the popup renders AS a `Card`, which supplies the surface. */ export function CardSurface() { return ( - + } /> - - - - }> - - Sign in - Continue to your account. - - - - - - ( - - )} - /> - - - - - + } + > + + Sign in + Continue to your account. + + + + + + ( + + )} + /> + + + ); } @@ -580,44 +714,42 @@ 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 +760,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/ui/src/mosaic/blocks/destructive/destructive.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx index b2902669272..cd4f1cdc82e 100644 --- a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx +++ b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx @@ -3,7 +3,7 @@ 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'; @@ -16,7 +16,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 +99,68 @@ export function Destructive({ return ( {trigger ? : null} - - - - - } + + } + > + + + }>{title} + }>{description} + + +
- - - }>{title} - }>{description} - - - - - {fieldLabel} - setTypedValue(event.target.value)} - /> - {errorMessage ? {errorMessage} : null} - - - - - - {cancelLabel} - - } + + {fieldLabel} + setTypedValue(event.target.value)} /> - {errorMessage} : null} + + +
+ + - {actionLabel} - - -
-
-
+ {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. -
}> - Keep editing - - , + + + Discard changes? + This address has not been saved. +
}> + Keep editing + + + , ); - expect(document.querySelector('footer.cl-alert-dialog-actions')).toBeInTheDocument(); + expect(document.querySelector('footer.cl-dialog-actions')).toBeInTheDocument(); }); }); @@ -323,22 +353,18 @@ describe('stacked on another dialog', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const user = userEvent.setup(); render( - - Add email address - ( - - )} - > - Discard changes? - This address has not been saved. - - , + + + Add email address + + Discard + + Discard changes? + This address has not been saved. + + + + , ); await user.click(screen.getByRole('button', { name: 'Discard' })); diff --git a/packages/ui/src/mosaic/components/alert-dialog/confirm-handle.ts b/packages/ui/src/mosaic/components/dialog/confirm-handle.ts similarity index 84% rename from packages/ui/src/mosaic/components/alert-dialog/confirm-handle.ts rename to packages/ui/src/mosaic/components/dialog/confirm-handle.ts index f90ad853e14..84b33ce0e96 100644 --- a/packages/ui/src/mosaic/components/alert-dialog/confirm-handle.ts +++ b/packages/ui/src/mosaic/components/dialog/confirm-handle.ts @@ -1,7 +1,7 @@ import { Dialog as Primitive, type DialogHandle } from '@clerk/headless/dialog'; import type { ReactNode } from 'react'; -/** What a confirmation asks. Delivered to `` as the dialog's payload. */ +/** What a confirmation asks. Delivered to `` as the dialog's payload. */ export interface ConfirmOptions { title: ReactNode; description: ReactNode; @@ -14,7 +14,7 @@ export interface ConfirmOptions { } /** - * Links a `show()` call to the `` that answers it. Create with + * Links a `show()` call to the `` that answers it. Create with * {@link createConfirmHandle}; `show` is the whole public surface. */ export interface ConfirmHandle { @@ -27,14 +27,14 @@ export interface ConfirmHandle { * confirmations, one per keypress. The options of the later call are ignored, since the * question on screen is already the one being answered. * - * The `` must be MOUNTED when this is called. It is what opens, and a + * The `` must be MOUNTED when this is called. It is what opens, and a * `dialog.open()` with no root attached is a no-op. Since the confirmation belongs inside the * dialog it guards, that means asking only from inside that dialog while it is open. Calling it * with nothing mounted resolves `false` and warns in development; a confirmation that unmounts * with a question in flight answers `false` too, rather than hanging. */ show(options: ConfirmOptions): Promise; - /** The dialog handle `` mounts against. @internal */ + /** The dialog handle `` mounts against. @internal */ readonly dialog: DialogHandle; /** * Resolves the in-flight promise, if any. Idempotent per question: the second call for the same @@ -55,7 +55,7 @@ export interface ConfirmHandle { * } * ``` * - * The dialog itself is still rendered as JSX — `` — and + * The dialog itself is still rendered as JSX — `` — and * where it is rendered matters: it belongs inside the dialog it guards, so the two are in the same * floating tree and escape ordering, the stacking styles and the refcounted scroll lock all apply. * A confirmation mounted app-globally would be a sibling of the dialog rather than a child of it, @@ -80,7 +80,7 @@ export function createConfirmHandle(): ConfirmHandle { if (!dialog.hasRoot) { if (process.env.NODE_ENV !== 'production') { console.warn( - '[clerk] `confirm.show()` was called with no `` mounted against this handle, so there is nothing to open. It resolved `false`. Render `` inside the dialog it guards, and ask only while that dialog is open.', + '[clerk] `confirm.show()` was called with no `` mounted against this handle, so there is nothing to open. It resolved `false`. Render `` inside the dialog it guards, and ask only while that dialog is open.', ); } return Promise.resolve(false); diff --git a/packages/ui/src/mosaic/components/alert-dialog/confirm.test.tsx b/packages/ui/src/mosaic/components/dialog/confirm.test.tsx similarity index 85% rename from packages/ui/src/mosaic/components/alert-dialog/confirm.test.tsx rename to packages/ui/src/mosaic/components/dialog/confirm.test.tsx index db72b63fee0..d8a0ad1f918 100644 --- a/packages/ui/src/mosaic/components/alert-dialog/confirm.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/confirm.test.tsx @@ -3,9 +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 { createConfirmHandle } from './confirm-handle'; +import { Dialog } from './dialog'; import { useConfirmedClose } from './use-confirmed-close'; afterEach(() => cleanup()); @@ -44,24 +43,26 @@ function GuardedForm({ onClosed }: { onClosed?: () => void } = {}) { }); return ( - - Add email address - setValue(event.target.value)} - /> - Cancel - - + + Add email address + setValue(event.target.value)} + /> + Cancel + + + ); } @@ -202,20 +203,22 @@ describe('createConfirmHandle', () => { function Harness() { return ( - - Host - - - + + + Host + + + + ); } render(); @@ -235,18 +238,20 @@ describe('createConfirmHandle', () => { const answers: boolean[] = []; render( - - Host - - - , + + + Host + + + + , ); await user.click(screen.getByRole('button', { name: 'Ask' })); @@ -258,10 +263,12 @@ describe('createConfirmHandle', () => { it('returns the in-flight promise rather than opening a second confirmation', async () => { const handle = createConfirmHandle(); render( - - Host - - , + + + Host + + + , ); let first!: Promise; @@ -283,10 +290,12 @@ describe('createConfirmHandle', () => { function Harness({ mounted }: { mounted: boolean }) { return ( - - Host - {mounted ? : null} - + + + Host + {mounted ? : null} + + ); } const { rerender } = render(); @@ -310,14 +319,16 @@ describe('createConfirmHandle', () => { const handle = createConfirmHandle(); await expect(handle.show({ title: 'Sure?', description: 'No going back.' })).resolves.toBe(false); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); // Not poisoned: the unanswerable request is not retained, so mounting one still works. render( - - Host - - , + + + Host + + + , ); await act(async () => { void handle.show({ title: 'Again?', description: 'Still no going back.' }); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 27c51dc0fc8..453174203e5 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -7,12 +7,34 @@ import { colorVars, durationVars, easingVars, radiusVars, space } from '../../to // StyleX requires a referenced constant to be declared before the `create()` call that reads it. const STACK_VEIL_OPACITY = 0.4; +// The scrim over the bare page. A black wash over `transparent` rather than a percentage of a +// neutral token: it composites over whatever the host app renders, so the same value reads +// consistently on any page. +const BASE_SCRIM = 'color-mix(in oklab, oklch(0 0 0) 40%, transparent)'; + +/** + * The width bands, queried against the VIEWPORT ELEMENT rather than the window — it is a + * `container-type: inline-size` named `cl-dialog`, and every `@container` below reads it. + * + * Over the page the viewport is `position: fixed; inset: 0`, so its width IS the window's and the + * bands resolve exactly as media queries would. The difference shows when the viewport is smaller + * than the window: an `inline` dialog fills its host, and its inset and phone-band treatment then + * follow the host's width, not the browser's. Content inside a dialog may query the same name for + * its own layout. + * + * The two bands are deliberately NON-OVERLAPPING. Overlapping `min-width` bands would leave the + * winner to source order, which `@stylexjs/sort-keys` reorders on autofix — and its string sort + * would put a future `100rem` band BEFORE `48rem`, silently inverting the ladder. + * + * `prefers-reduced-motion` and `forced-colors` stay `@media`: they are preferences, not sizes. + */ +const PHONE = '@container cl-dialog (max-width: 47.99rem)'; +const ABOVE_PHONE = '@container cl-dialog (min-width: 48rem)'; +const DESK = '@container cl-dialog (min-width: 48rem) and (max-width: 89.99rem)'; +const WIDE = '@container cl-dialog (min-width: 90rem)'; + export const styles = stylex.create({ - // The scrim. A black wash over `transparent` rather than a percentage of a neutral - // token: it composites over whatever the host app renders, so the same value reads - // consistently on any page. - // - // Black in both schemes. A grey veil was tried for dark mode — lightening a dark page rather + // The scrim. Black in both schemes. A grey veil was tried for dark mode — lightening a dark page rather // than darkening it — and it read as haze over the page rather than as a surface lifting off it. // // A dialog opened over a `panel` or a `card` paints its OWN scrim, lighter than the base because @@ -24,12 +46,21 @@ export const styles = stylex.create({ backdrop: { inset: 0, backgroundColor: { - default: 'color-mix(in oklab, oklch(0 0 0) 40%, transparent)', + default: BASE_SCRIM, ':where([data-nested])': 'color-mix(in oklab, oklch(0 0 0) 46.67%, transparent)', }, position: 'fixed', }, + /** + * A dialog opened from inside an INLINE dialog is nested, but the surface it opens over paints + * no scrim for the nested value to composite with — so it takes the base one. Rides the same + * `stylex.props` call as `backdrop`, so this `backgroundColor` replaces that one outright. + */ + backdropOverInline: { + backgroundColor: BASE_SCRIM, + }, + /** * A prompt stacked on a prompt paints NO scrim — one serves the whole stack. * @@ -54,6 +85,10 @@ export const styles = stylex.create({ // the scroll lock. Whether this box is a fixed height or grows with its content is the whole // outside-scroll question, and it differs per size — see `viewportSizes` below. // + // Also the query container every band reads — see `PHONE` and friends above. `inline-size` + // rather than `size`: block-size containment would stop the box growing with its content, + // which is exactly what the outside-scroll sizes need it to do, and no band queries height. + // // The gap between a dialog and the edge of the screen is a FIXED INSET, not a percentage. // A percentage margin is asymmetric between the axes and the asymmetry tracks the viewport's // aspect ratio: at 90vw/90dvh a 1920x1080 screen leaves 96px at the sides and 54px top and @@ -72,26 +107,25 @@ export const styles = stylex.create({ // costs nothing vertically. The vertical edges are doing the opposite job: separating the surface // from the browser's own chrome, which is closer on a phone than on any desktop. // - // The two queries are deliberately NON-OVERLAPPING. Overlapping `min-width` bands would leave - // the winner to source order, which `@stylexjs/sort-keys` reorders on autofix — and its string - // sort would put a future `100rem` band BEFORE `48rem`, silently inverting the ladder. viewport: { '--_cl-dialog-inset': { + [DESK]: space['8'], + [WIDE]: space['12'], default: space['5'], - '@media (min-width: 48rem) and (max-width: 89.99rem)': space['8'], - '@media (min-width: 90rem)': space['12'], }, padding: 'var(--_cl-dialog-inset)', // Narrower sides under the phone band only. A longhand beside the `padding` shorthand above is // safe in either order — StyleX ranks a longhand higher regardless — which is the same reason // `paddingBlockEnd` below works. Above the phone band this resolves back to the ladder, so // there is exactly one place to retune each band. - paddingInline: { default: space['4'], '@media (min-width: 48rem)': 'var(--_cl-dialog-inset)' }, + paddingInline: { [ABOVE_PHONE]: 'var(--_cl-dialog-inset)', default: space['4'] }, // `safe center` rather than plain `center`, and it is what makes an over-tall popup reachable. // Centring an item TALLER than its box overflows it equally in both directions, leaving the // top half above the scroll origin and unreachable; `safe` falls back to start alignment in // exactly that case, so the popup overflows downward only and scrolls from its top. placeItems: 'safe center', + containerName: 'cl-dialog', + containerType: 'inline-size', display: 'grid', // The keyboard's share of the viewport, added to the inset on the bottom edge only. A longhand // beside the `padding` shorthand above is deliberate — StyleX ranks a longhand higher @@ -101,6 +135,20 @@ export const styles = stylex.create({ width: '100%', }, + /** + * An inline dialog fills its host edge to edge: the inset is the gap between a surface and the + * screen, and a surface that IS the page's content has no screen edge to hold off. The + * surface keeps its own ring, radius and shadow — it reads as a card sitting on the page — + * which is one cell on the popup to change if it should ever sit flush instead. + * + * Only the var and the one longhand that departs from it need restating: `padding` and + * `paddingBlockEnd` both derive from the var, and the keyboard inset is never published inline. + */ + viewportInline: { + '--_cl-dialog-inset': '0px', + paddingInline: 0, + }, + // The dialog surface. Unlike `Popover`, this one paints, because a `prompt` and a `panel` take // raw content rather than a `Card` and the surface has to come from somewhere. `sizes.card` // nulls the painting properties back out — see the note there. @@ -184,8 +232,8 @@ export const styles = stylex.create({ // one gesture, and the phone band runs the transform at `slow`. Pinning the veil at `base` // there finishes the dim 100ms before the surface stops moving, in both directions. transitionDuration: { + [PHONE]: durationVars['--cl-duration-slow'], default: durationVars['--cl-duration-base'], - '@media (max-width: 47.99rem)': durationVars['--cl-duration-slow'], }, transitionProperty: 'opacity', transitionTimingFunction: easingVars['--cl-ease-enter'], @@ -211,6 +259,20 @@ export const styles = stylex.create({ position: 'absolute', zIndex: 1, }, + + // The response row of an alert dialog. 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'], + }, }); /** @@ -280,7 +342,7 @@ export const viewportSizes = stylex.create({ // scrolled, because the same rule that contains the slide also contains the overflow. A prompt // asks one thing, so it should not reach that height; a tall surface on a phone wants `card`, // which does not translate and therefore is not clipped here. - overflow: { default: null, '@media (max-width: 47.99rem)': 'clip' }, + overflow: { [PHONE]: 'clip', default: null }, minHeight: '100%', }, card: { minHeight: '100%' }, @@ -321,8 +383,8 @@ export const sizes = stylex.create({ // otherwise binds on larger phones — a 428px screen has 396px of content box against a 380px // cap — leaving the sheet inset further at the sides than at the bottom, which is exactly the // uneven frame the fixed inset exists to avoid. - alignSelf: { default: null, '@media (max-width: 47.99rem)': 'end' }, - maxWidth: { default: '23.75rem', '@media (max-width: 47.99rem)': 'none' }, + alignSelf: { [PHONE]: 'end', default: null }, + maxWidth: { [PHONE]: 'none', default: '23.75rem' }, }, // The one size that does NOT paint itself. A `card` is the sign-in / sign-up surface, which is // a `Card` — so the surface comes from `Card`'s own `elevations.overlay` rather than from here, @@ -524,16 +586,16 @@ export const popupMotion = stylex.create({ */ prompt: { borderRadius: { + [PHONE]: { + default: popupRadius, + ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`, + ':where([data-starting-style], [data-ending-style])': popupRadius, + }, default: popupRadius, // The recede is the one scale that survives the phone band, so unlike the entrance its // radius correction is NOT pinned flat there — see `transform` below. ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`, ':where([data-starting-style], [data-ending-style])': `calc(${popupRadius} / ${ENTER_SCALE})`, - '@media (max-width: 47.99rem)': { - default: popupRadius, - ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`, - ':where([data-starting-style], [data-ending-style])': popupRadius, - }, // Both entrance branches resolve to the same value, so their order relative to each other // cannot matter: there is no scale to counteract in either case. The recede is the // exception — it still applies under `reduce`, just without a duration — so its correction @@ -575,6 +637,11 @@ export const popupMotion = stylex.create({ * free to reorder them. */ transform: { + [PHONE]: { + default: 'scale(1)', + ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, default: 'scale(1)', /** * The recede: what a prompt does while another prompt is stacked on it. There is no second @@ -594,11 +661,6 @@ export const popupMotion = stylex.create({ */ ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE})`, - '@media (max-width: 47.99rem)': { - default: 'scale(1)', - ':where([data-stack-base])': `scale(${STACK_SCALE}) translateY(${STACK_LIFT})`, - ':where([data-starting-style], [data-ending-style])': 'scale(1)', - }, // The recede is NOT dropped here, unlike the entrance scale. `reduce` asks for no // ANIMATION, not for no distinction: `transitionProperty` below narrows to `opacity` in // this mode, so the recede lands in one frame with nothing interpolating. Dropping it @@ -637,14 +699,14 @@ export const popupMotion = stylex.create({ // plain `data-stacked` one, which would otherwise hand a stacked sheet the four-value entrance // list on its way out and slow its exit slide. transitionDuration: { - default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`, - ':where([data-ending-style])': durationVars['--cl-duration-fast'], - '@media (max-width: 47.99rem)': { + [PHONE]: { default: `${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`, ':where([data-ending-style])': durationVars['--cl-duration-base'], ':where([data-stacked])': `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`, ':where([data-stacked][data-ending-style])': durationVars['--cl-duration-base'], }, + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`, + ':where([data-ending-style])': durationVars['--cl-duration-fast'], }, transitionProperty: { default: 'opacity, transform, border-radius, translate', @@ -678,14 +740,19 @@ export const popupMotion = stylex.create({ * Giving the slide its own property removes the contest entirely, and omitting the `default` * leaves nothing for it to lose to: at rest `translate` is simply unset. The * `no-preference` guard then makes reduced motion a no-op for free — no branch matches, so - * the sheet holds flat and only the scrim fades. + * the sheet holds flat and only the scrim fades. It nests inside the band rather than being + * written as one combined query, because a container query and a media query cannot share + * an `and`. */ translate: { - default: null, - '@media (max-width: 47.99rem) and (prefers-reduced-motion: no-preference)': { + [PHONE]: { default: null, - ':where([data-starting-style], [data-ending-style])': '0 100%', + '@media (prefers-reduced-motion: no-preference)': { + default: null, + ':where([data-starting-style], [data-ending-style])': '0 100%', + }, }, + default: null, }, }, diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index 8ff9af9dedb..28f9b0e9eb9 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -17,22 +17,23 @@ const settle = () => await new Promise(resolve => setTimeout(resolve, 0)); }); +const nativeTrigger = (label: string) => (props: MosaicComponentProps<'button'>) => ( + +); + describe('Mosaic Dialog', () => { it('renders the trigger and opens the dialog on click', async () => { const user = userEvent.setup(); render( - ( - - )} - > - Body - , + + + Body + , ); expect(screen.queryByText('Body')).not.toBeInTheDocument(); @@ -42,94 +43,85 @@ describe('Mosaic Dialog', () => { expect(screen.getByText('Body')).toBeInTheDocument(); }); - it('renders no trigger when one is not supplied', () => { + it('renders the whole floating tree from the popup: backdrop, viewport and popup carry the slots', () => { render( - {}} - > - Body - , + + Body + , ); - expect(screen.getByText('Body')).toBeInTheDocument(); - // Not `queryByRole('button')` — floating-ui's focus guards are `role="button"`. - expect(document.querySelector('[aria-haspopup="dialog"]')).not.toBeInTheDocument(); - }); - - it('carries the mosaic slot classes on the backdrop, viewport and popup', () => { - render(Body); - expect(document.querySelector('.cl-dialog-backdrop')).toBeInTheDocument(); expect(document.querySelector('.cl-dialog-viewport')).toBeInTheDocument(); expect(document.querySelector('.cl-dialog-popup')).toBeInTheDocument(); + // Portalled: the tree lands in the body, not where the root sits. + expect(document.querySelector('.cl-dialog-viewport')?.closest('[data-testid="host"]')).toBeNull(); }); it('defaults the popup to the prompt size and reflects it as data-size', () => { - render(Body); + render( + + Body + , + ); expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'prompt'); }); - it('reflects an explicit size as data-size', () => { + it('reflects an explicit size as data-size on the popup and the viewport', () => { render( - - Body - , + + Body + , ); expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'panel'); + expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-size', 'panel'); }); it('merges consumer className and style onto the popup', () => { render( - - - - Body - - - + + Body + , ); - const popup = screen.getByText('Body'); + const popup = document.querySelector('.cl-dialog-popup'); expect(popup).toHaveClass('cl-dialog-popup', 'my-popup'); expect(popup).toHaveStyle({ marginTop: '8px' }); }); - it('hands children a close callback', async () => { + it('closes on Dialog.Close, reporting it through onOpenChange', async () => { const user = userEvent.setup(); + const onOpenChange = vi.fn(); render( - - {({ close }) => ( - - )} - , + + + Dismiss + + , ); await user.click(screen.getByRole('button', { name: 'Dismiss' })); + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); expect(screen.queryByRole('button', { name: 'Dismiss' })).not.toBeInTheDocument(); }); it('names the dialog from Dialog.Title', () => { render( - - Confirm action - , + + + Confirm action + + , ); expect(screen.getByRole('dialog', { name: 'Confirm action' })).toBeInTheDocument(); @@ -139,45 +131,34 @@ describe('Mosaic Dialog', () => { const ref = React.createRef(); render( - - - Body - - + Body , ); - expect(ref.current).toBe(screen.getByText('Body')); + expect(ref.current).toBe(document.querySelector('.cl-dialog-popup')); }); }); -// A `panel` dialog (account profile) opening a `card` dialog (add an email address) is a -// real shape, so the `FloatingTree` nesting the headless README claims is exercised here -// rather than assumed. Dismissal must reach the topmost dialog only, and the body must -// stay locked until the last one closes. +// A `panel` dialog (account profile) opening a `prompt` dialog (add an email address) is a real +// shape, so the `FloatingTree` nesting the headless README claims is exercised here rather than +// assumed. Dismissal must reach the topmost dialog only, and the body must stay locked until the +// last one closes. describe('nested Mosaic Dialogs', () => { - const addEmailTrigger = (props: MosaicComponentProps<'button'>) => ( - - ); - - function Nested() { + function Nested({ innerSize }: { innerSize?: DialogSize } = {}) { return ( - - Account -
Outer body
- - Add email address -
Inner body
-
-
+ + + Account +
Outer body
+ + + + Add email address +
Inner body
+
+
+
+
); } @@ -233,73 +214,97 @@ describe('nested Mosaic Dialogs', () => { await user.keyboard('{Escape}'); expect(document.body.style.overflow).toBe(''); }); -}); -describe('stacked backdrops', () => { - const addEmailTrigger = (props: MosaicComponentProps<'button'>) => ( - - ); + it('warns when a stacked dialog is not a prompt', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const user = userEvent.setup(); + render(); - function renderStack() { - return render( - - Account -
Outer body
- - Add email address -
Inner body
-
-
, - ); - } + await user.click(screen.getByRole('button', { name: 'Add email' })); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('size="card"')); + warn.mockRestore(); + }); + + it('does not warn for a stacked prompt, or for a root-level panel', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('stacked backdrops', () => { // The backdrop's two cases differ by a style rather than by an attribute, so the assertion is // that the same tree with only the hosting size changed produces different classes. Comparing // rather than matching a class: StyleX names are content hashes and would pin the value. - async function innerBackdropClass(hostSize: DialogSize) { + async function innerBackdropClass(host: { size?: DialogSize; inline?: boolean }) { const user = userEvent.setup(); render( - - Host - - Add email address - - , + + Host + + + + Add email address + + + + , ); await user.click(screen.getByRole('button', { name: 'Add email' })); - const className = document.querySelectorAll('.cl-dialog-backdrop')[1].className; + // An inline host renders no backdrop of its own, so the inner one is the only one. + const backdrops = document.querySelectorAll('.cl-dialog-backdrop'); + const className = backdrops[backdrops.length - 1].className; cleanup(); return className; } it('drops the scrim for a prompt over a prompt, and keeps it for one over a panel', async () => { - const overPrompt = await innerBackdropClass('prompt'); - const overPanel = await innerBackdropClass('panel'); + const overPrompt = await innerBackdropClass({ size: 'prompt' }); + const overPanel = await innerBackdropClass({ size: 'panel' }); expect(overPrompt).not.toBe(overPanel); }); it('keeps a prompt over a card on the nested scrim, same as over a panel', async () => { - const overCard = await innerBackdropClass('card'); - const overPanel = await innerBackdropClass('panel'); + const overCard = await innerBackdropClass({ size: 'card' }); + const overPanel = await innerBackdropClass({ size: 'panel' }); expect(overCard).toBe(overPanel); }); + // The nested scrim is solved to composite over the host's own, and an inline host has none. + it('paints the base scrim, not the nested one, for a prompt over an inline panel', async () => { + const overInline = await innerBackdropClass({ size: 'panel', inline: true }); + const overPanel = await innerBackdropClass({ size: 'panel' }); + + expect(overInline).not.toBe(overPanel); + }); + it('marks the popup beneath as the stack base, so it can recede', async () => { const user = userEvent.setup(); - renderStack(); + render( + + + Account + + + + Add email address + + + + , + ); const outerPopup = document.querySelector('.cl-dialog-popup'); expect(outerPopup).not.toHaveAttribute('data-stack-base'); @@ -310,53 +315,18 @@ describe('stacked backdrops', () => { expect(popups[0]).toHaveAttribute('data-stack-base', ''); expect(popups[1]).not.toHaveAttribute('data-stack-base'); }); - - it('warns when a stacked dialog is not a prompt', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const user = userEvent.setup(); - render( - - Account -
Outer body
- - Add email address -
Inner body
-
-
, - ); - - await user.click(screen.getByRole('button', { name: 'Add email' })); - - expect(warn).toHaveBeenCalledWith(expect.stringContaining('size="card"')); - warn.mockRestore(); - }); - - it('does not warn for a stacked prompt, or for a root-level panel', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const user = userEvent.setup(); - renderStack(); - - await user.click(screen.getByRole('button', { name: 'Add email' })); - - expect(warn).not.toHaveBeenCalled(); - warn.mockRestore(); - }); }); describe('Dialog.CloseButton', () => { it('closes the dialog and carries a default accessible name', async () => { const user = userEvent.setup(); render( - - -
Body
-
, + + + +
Body
+
+
, ); const close = screen.getByRole('button', { name: 'Close' }); @@ -368,9 +338,11 @@ describe('Dialog.CloseButton', () => { it('takes an overridable label, ready for a localized string', () => { render( - - - , + + + + + , ); expect(screen.getByRole('button', { name: 'Fermer' })).toBeInTheDocument(); @@ -378,10 +350,12 @@ describe('Dialog.CloseButton', () => { it('is the first tabbable element when rendered first — see initialFocus', async () => { render( - - - - , + + + + + + , ); // Pinning the default: a corner X rendered before the form is what the dialog opens @@ -389,6 +363,25 @@ describe('Dialog.CloseButton', () => { // `FloatingFocusManager` moves focus in an effect, hence the wait. await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()); }); + + it('warns inside an alert dialog, where a corner X is a way out without answering', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + + Discard? + Unsaved. + + , + ); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('alert dialog')); + warn.mockRestore(); + }); }); describe('composition APIs', () => { @@ -458,33 +451,37 @@ describe('composition APIs', () => { }); }); +// A probe gives us atoms to look for without hard-coding a hash. StyleX dedupes by property +// within one `stylex.props` call, so a size atom should REPLACE a base one rather than sit +// alongside it — and a `null` should remove it outright. +const atomFor = (style: Parameters[0]) => + stylex + .props(style) + .className!.split(' ') + .filter(name => !name.includes('__')); + +const classesOf = (selector: string) => Array.from(document.querySelector(selector)!.classList); + +function renderSize(size: DialogSize, inline = false) { + return render( + + Body + , + ); +} + describe('popup padding', () => { - // Regression: `sizes[size]` has to actually override `styles.popup`'s padding. StyleX dedupes - // by property within one `stylex.props` call, so the size atom should REPLACE the base one - // rather than sit alongside it — and a `null` should remove it outright. A probe gives us the - // atoms to look for without hard-coding a hash. const probe = stylex.create({ zero: { padding: space['0'] }, four: { padding: space['4'] }, six: { padding: space['6'] }, }); - const atomFor = (style: Parameters[0]) => - stylex - .props(style) - .className!.split(' ') - .filter(name => !name.includes('__')); - - const classesOf = (selector: string) => Array.from(document.querySelector(selector)!.classList); const popupClassesFor = (size: DialogSize) => { - const { unmount } = render( - - Body - , - ); + const { unmount } = renderSize(size); const classes = classesOf('.cl-dialog-popup'); unmount(); return classes; @@ -526,22 +523,10 @@ describe('viewport scroll behaviour', () => { fixed: { height: '100%' }, grows: { minHeight: '100%' }, }); - const atomFor = (style: Parameters[0]) => - stylex - .props(style) - .className!.split(' ') - .filter(name => !name.includes('__')); const viewportClassesFor = (size: DialogSize) => { - const { unmount } = render( - - Body - , - ); - const classes = Array.from(document.querySelector('.cl-dialog-viewport')!.classList); + const { unmount } = renderSize(size); + const classes = classesOf('.cl-dialog-viewport'); unmount(); return classes; }; @@ -559,25 +544,159 @@ describe('viewport scroll behaviour', () => { expect(viewport).toEqual(expect.arrayContaining(atomFor(probe.fixed))); expect(viewport).not.toEqual(expect.arrayContaining(atomFor(probe.grows))); }); +}); + +describe('sizing container', () => { + // The width bands are container queries against the viewport, so the viewport has to BE a + // container — drop that and every band silently stops matching, at every width. + const probe = stylex.create({ + container: { containerName: 'cl-dialog', containerType: 'inline-size' }, + }); + + it('makes the viewport the named inline-size container the bands query', () => { + renderSize('prompt'); + + expect(classesOf('.cl-dialog-viewport')).toEqual(expect.arrayContaining(atomFor(probe.container))); + }); + + it('keeps the container inline, where the host width is what the bands should follow', () => { + renderSize('panel', true); + + expect(classesOf('.cl-dialog-viewport')).toEqual(expect.arrayContaining(atomFor(probe.container))); + }); +}); + +describe('inline presentation', () => { + function Inline({ onOpenChange }: { onOpenChange?: () => void } = {}) { + return ( +
+ + + Account + + + +
+ ); + } + + it('renders in place, open, with no portal, backdrop or scroll lock', () => { + render(); + + const popup = screen.getByRole('dialog', { name: 'Account' }); + expect(screen.getByTestId('host')).toContainElement(popup); + expect(document.querySelector('.cl-dialog-backdrop')).not.toBeInTheDocument(); + expect(document.body.style.overflow).toBe(''); + expect(popup).toHaveAttribute('data-inline', ''); + expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-inline', ''); + }); + + it('does not steal focus on mount', async () => { + render(); + + await settle(); + + expect(screen.getByRole('textbox', { name: 'Name' })).not.toHaveFocus(); + }); + + it('is not dismissed by Escape, and reports no close', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + + await user.click(screen.getByRole('textbox', { name: 'Name' })); + await user.keyboard('{Escape}'); + await user.tab(); + + expect(screen.getByRole('dialog', { name: 'Account' })).toBeInTheDocument(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); - it('exposes the size on the viewport for styling', () => { + it('does not trap focus: the page around it stays reachable', async () => { + const user = userEvent.setup(); render( - - Body - , + <> + + + , + ); + + await user.click(screen.getByRole('textbox', { name: 'Name' })); + await user.tab(); + + expect(screen.getByRole('button', { name: 'After' })).toHaveFocus(); + }); + + it('drops the inset so the surface fills its host', () => { + const probe = stylex.create({ flush: { paddingInline: 0 } }); + renderSize('panel', true); + + expect(classesOf('.cl-dialog-viewport')).toEqual(expect.arrayContaining(atomFor(probe.flush))); + }); + + it('renders no corner close button, and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + + Account + + , ); - expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-size', 'card'); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('inline')); + warn.mockRestore(); + }); + + // The shape the account profile takes when mounted in a page: the panel is the page, and the + // prompts it opens are modal over everything. + it('still portals and dismisses a dialog opened from inside it', async () => { + const user = userEvent.setup(); + render( +
+ + + Account + + + + Add email address + + + + +
, + ); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + + const prompt = screen.getByRole('dialog', { name: 'Add email address' }); + expect(screen.getByTestId('host')).not.toContainElement(prompt); + expect(document.querySelector('.cl-dialog-backdrop')).toBeInTheDocument(); + expect(document.body.style.overflow).toBe('hidden'); + expect(prompt).not.toHaveAttribute('data-inline'); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('dialog', { name: 'Add email address' })).not.toBeInTheDocument(); + expect(screen.getByRole('dialog', { name: 'Account' })).toBeInTheDocument(); + expect(document.body.style.overflow).toBe(''); }); }); describe('accessible name warning', () => { it('warns when the dialog has no accessible name', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - render(Body); + render( + + Body + , + ); await settle(); @@ -588,9 +707,11 @@ describe('accessible name warning', () => { it('does not warn when a Dialog.Title supplies the name', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( - - Confirm action - , + + + Confirm action + + , ); await settle(); @@ -605,11 +726,7 @@ describe('accessible name warning', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); render( - - - Body - - + Body , ); @@ -618,19 +735,4 @@ describe('accessible name warning', () => { expect(warn).not.toHaveBeenCalled(); warn.mockRestore(); }); - - it('still forwards the popup ref alongside the observing one', () => { - const ref = React.createRef(); - render( - - - - Body - - - , - ); - - expect(ref.current).toBe(screen.getByText('Body')); - }); }); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx index fd1f1aa13da..393386c3191 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -1,45 +1,30 @@ -import type { DialogFocusTarget, DialogHandle, DialogProps as HeadlessDialogProps } from '@clerk/headless/dialog'; +import type { + DialogClosedBy, + DialogFocusTarget, + DialogHandle, + DialogProps as HeadlessDialogProps, +} from '@clerk/headless/dialog'; import { Dialog as Primitive, useDialogContext as useHeadlessDialogContext } 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 { useAccessibleNameWarning } from '../../hooks/useAccessibleNameWarning'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { reset } from '../../utils/reset.styles'; import { Button } from '../button'; +import { Heading } from '../heading'; import { Icon } from '../icon'; +import { Text } from '../text'; +import { type ConfirmHandle, createConfirmHandle } from './confirm-handle'; import { backdropMotion, closeInsets, popupMotion, sizes, styles, viewportSizes } from './dialog.styles'; import { acquireKeyboardInset } from './keyboard-inset'; /** Width of the dialog surface, and for `panel` its height too. */ export type DialogSize = keyof typeof sizes; -export interface DialogRootProps extends HeadlessDialogProps { - /** Width, and for `panel` also height, of the dialog surface. @default 'prompt' */ - size?: DialogSize; -} - -/** - * `size` lives on the Root rather than on the Popup because the Backdrop needs it too — the - * two sizes animate differently, and a backdrop that outlives its popup gets cut off - * mid-fade. Popover puts `size` on its Popup because that part renders the whole floating - * tree; Dialog's parts are siblings, so the Root is the only place both can read. - */ -const DialogSizeContext = React.createContext('prompt'); - -/** - * The size of the dialog this one was opened from, which is what decides whether the two form a - * STACK — successive prompts — or a nested dialog over a `panel` or `card`. The two want opposite - * backdrops, so the distinction has to be reachable from the parts. - * - * Read from `DialogSizeContext` before a root overwrites it with its own size. Meaningless on its - * own, since a root-level dialog reads the context default: pair it with the headless `isStacked`, - * which is what reports that there is a dialog above at all. - */ -const DialogParentSizeContext = React.createContext('prompt'); - /** * The dialog surface a part is rendered inside, or `null` when there is none. * @@ -50,6 +35,10 @@ const DialogParentSizeContext = React.createContext('prompt'); * Distinct from the headless `DialogContext`, which `Dialog.Root` provides. `Root` also spans the * trigger, so a part reading that one reports a dialog while sitting outside the popup, and would * claim ids that belong to the surface. This is published by the popup, which is the real boundary. + * + * It is also how a dialog learns about the one it renders inside: `Dialog.Popup` reads it before + * publishing its own, and that is what decides whether two dialogs form a STACK — successive + * prompts — or a nested dialog over a `panel` or `card`. The two want opposite backdrops. */ export interface DialogContextValue { /** Id the popup points `aria-labelledby` at. The part that names the dialog takes it. */ @@ -58,24 +47,17 @@ export interface DialogContextValue { descriptionId: string; /** Width, and for `panel` also height, of the surface. */ size: DialogSize; + /** Whether the surface is presented in its host rather than over the page — see `Dialog.Root`. */ + inline: boolean; } export const DialogContext = React.createContext(null); /** - * The compound component the popup's dev warnings speak in. `AlertDialog` is composed from these - * same parts, so a message hardcoded to `Dialog` would name parts that do not exist at the call - * site it is complaining about. Not exported from the folder's `index.ts`: it is how one Mosaic - * component wraps another, not something a consumer sets. + * What the root decided about how its dialog is presented, for the parts it does not render + * itself. Only `Dialog.Popup` reads it; a consumer never sets it. */ -export const DialogPartNameContext = React.createContext('Dialog'); - -/** Whether this dialog is a prompt stacked on a prompt — see {@link DialogParentSizeContext}. */ -function useIsStacked() { - const { isStacked } = useHeadlessDialogContext(); - const parentSize = React.useContext(DialogParentSizeContext); - return isStacked && parentSize === 'prompt'; -} +const DialogPresentationContext = React.createContext<{ inline: boolean }>({ inline: false }); /** * The headless parts type their props (and the `render` callback's argument) against @@ -108,27 +90,92 @@ export interface DialogCloseButtonProps extends MosaicComponentProps<'button'> { */ 'aria-label'?: string; } -export type DialogBackdropProps = MosaicComponentProps<'div'>; -export interface DialogViewportProps extends MosaicComponentProps<'div'> { - /** When true, locks body scroll while the dialog is open. @default true */ - lockScroll?: boolean; -} -export type DialogPopupProps = MosaicComponentProps<'div'> & { - /** Where focus moves when the dialog opens. Default: the first tabbable element inside it. */ +export type DialogActionsProps = MosaicComponentProps<'div'>; + +export interface DialogPopupProps extends MosaicComponentProps<'div'> { + /** + * Width, and for `panel` also height, of the dialog surface. Ignored under + * `role="alertdialog"`, which is always a `prompt`. @default 'prompt' + */ + size?: DialogSize; + /** + * Where focus moves when the dialog opens. Default: the first tabbable element inside it — + * or nowhere, for an `inline` dialog, which mounts with the page rather than in answer to a + * gesture. + */ initialFocus?: DialogFocusTarget; - /** Where focus returns when the dialog closes. Default: the trigger. */ + /** Where focus returns when the dialog closes. Default: the trigger; nowhere for an `inline` dialog. */ finalFocus?: DialogFocusTarget; +} + +type DialogRootBaseProps = Omit, 'role' | 'closedBy'> & { + /** + * Presents the dialog in its host rather than over the page: no portal, no scrim, no scroll + * lock, no focus trap, and nothing dismisses it — it is open for as long as it is mounted. + * For a surface that is the page's content, such as an account panel mounted in a layout slot. + * + * Implies `open`, `modal={false}` and `closedBy='none'`; those props are ignored. A dialog + * opened from inside an inline one presents normally, over the page. + */ + inline?: boolean; }; -/** Owns the open state and the size both the backdrop and the popup read. */ -function Root({ size = 'prompt', children, ...rest }: DialogRootProps) { - const parentSize = React.useContext(DialogSizeContext); +/** + * `role` decides the dismissal policy and the size, so the props that would contradict it are + * narrowed away rather than checked at runtime: + * + * - `alertdialog` announces as an interruption rather than as a surface the user navigated to; + * - it cannot be dismissed by an outside press. A dialog asking a question it needs an answer to + * must not be answerable by clicking next to it. Escape still closes, which is the keyboard's + * equivalent of the cancel button that is always present; + * - it is always a `prompt`, the size that means "asks one thing and returns". + */ +export type DialogRootProps = DialogRootBaseProps & + ( + | { + /** The popup's ARIA role. @default 'dialog' */ + role?: 'dialog'; + /** Which gestures dismiss the dialog. @default 'any' */ + closedBy?: DialogClosedBy; + } + | { + role: 'alertdialog'; + /** An alert dialog never dismisses on an outside press. @default 'closerequest' */ + closedBy?: Exclude; + } + ); + +/** Owns the open state and the decisions — role, presentation — every part reads. */ +function Root({ + inline = false, + role = 'dialog', + closedBy, + open, + defaultOpen, + onOpenChange, + modal, + children, + ...rest +}: DialogRootProps) { + const presentation = React.useMemo(() => ({ inline }), [inline]); + const resolvedClosedBy = closedBy ?? (role === 'alertdialog' ? 'closerequest' : 'any'); return ( - - - {...rest}>{children} - - + + + {...rest} + role={role} + // An inline dialog is open for as long as it is mounted and closes for nothing, so a + // consumer's `onOpenChange` is withheld too: floating-ui asks a non-modal dialog to close + // when focus leaves it, and that request would otherwise reach the consumer as a close. + closedBy={inline ? 'none' : resolvedClosedBy} + modal={inline ? false : modal} + open={inline ? true : open} + defaultOpen={inline ? undefined : defaultOpen} + onOpenChange={inline ? undefined : onOpenChange} + > + {children} + + ); } @@ -176,6 +223,24 @@ const Description = React.forwardRef { + if (process.env.NODE_ENV === 'production' || !(isAlert || inline)) { + return; + } + console.warn( + isAlert + ? '[clerk] is rendered inside an alert dialog. A corner X is a way out without answering; the cancel action in is the way out.' + : '[clerk] is rendered inside an inline dialog, which nothing closes. It was not rendered.', + ); + }, [isAlert, inline]); +} + /** * The corner dismiss affordance: a ghost circular button holding the close glyph, anchored to * the popup's top-inline-end corner. @@ -189,7 +254,14 @@ const CloseButton = React.forwardRef( { 'aria-label': ariaLabel = 'Close', className, style, ...rest }, ref, ) { - const size = React.useContext(DialogSizeContext); + const surface = React.useContext(DialogContext); + const { role } = useHeadlessDialogContext(); + const size = surface?.size ?? 'prompt'; + const inline = surface?.inline ?? false; + useCloseButtonWarning(role === 'alertdialog', inline); + if (inline) { + return null; + } return ( ( ); }); -/** The scrim behind the dialog. Owns no scroll lock or positioning — that is `Dialog.Viewport`. */ -const Backdrop = React.forwardRef(function DialogBackdrop( - { className, style, ...rest }, - ref, -) { - const size = React.useContext(DialogSizeContext); - const isStacked = useIsStacked(); +/** + * The scrim behind the dialog. Owns no scroll lock or positioning — that is the viewport. + * Rendered by `Dialog.Popup`, which is also what decides the two things it varies on. + */ +function Backdrop({ size, stacked, overInline }: { size: DialogSize; stacked: boolean; overInline: boolean }) { return ( ); -}); +} /** - * Centering container for the popup. Locks body scroll while the dialog is open, and — because it - * is the element that owns the inset — publishes the on-screen keyboard's share of the viewport - * for its own bottom padding to consume. See `keyboard-inset.ts`. + * Centering container for the popup, and the container its sizes are queried against. Over the + * page it also locks body scroll and — because it is the element that owns the inset — publishes + * the on-screen keyboard's share of the viewport for its own bottom padding to consume. See + * `keyboard-inset.ts`. Inline, it is a plain box that fills its host. */ -const Viewport = React.forwardRef(function DialogViewport( - { className, style, ...rest }, - ref, -) { - const size = React.useContext(DialogSizeContext); - React.useEffect(() => acquireKeyboardInset(), []); +function Viewport({ size, inline, children }: { size: DialogSize; inline: boolean; children: React.ReactNode }) { + React.useEffect(() => (inline ? undefined : acquireKeyboardInset()), [inline]); return ( + > + {children} + ); -}); +} /** * Warns when a dialog opened inside another dialog is not a `prompt`. @@ -282,21 +351,50 @@ function useNestedSizeWarning(isNestedInDialog: boolean, size: DialogSize) { }, [isNestedInDialog, size]); } -/** The dialog surface: `role="dialog"`, focus-trapped, and the element that paints. */ +/** Warns when a size other than `prompt` is asked of an alert dialog, which ignores it. */ +function useAlertSizeWarning(isAlert: boolean, size: DialogSize | undefined) { + React.useEffect(() => { + if (process.env.NODE_ENV === 'production' || !isAlert || size === undefined || size === 'prompt') { + return; + } + console.warn( + `[clerk] is inside a role="alertdialog" root, which is always a prompt. The size was ignored.`, + ); + }, [isAlert, size]); +} + +/** + * The dialog surface: `role="dialog"` (or `alertdialog`, from the root), focus-trapped, and the + * element that paints — and the whole floating tree around it. Over the page that is a portal, + * a scrim, and a centering viewport; inline it is the viewport alone, in place. Neither is a + * part a consumer composes, so they stay out of the public API. + */ const Popup = React.forwardRef(function DialogPopup( - { className, style, ...rest }, + { size: sizeProp, initialFocus, finalFocus, className, style, ...rest }, ref, ) { - const size = React.useContext(DialogSizeContext); - // The headless flag, not `useIsStacked` — the rule is about opening a dialog inside ANY dialog, - // which is broader than the prompt-on-prompt case the stacking styles cover. - const { isStacked: isNestedInDialog, labelId, descriptionId } = useHeadlessDialogContext(); - const surface = React.useMemo(() => ({ labelId, descriptionId, size }), [labelId, descriptionId, size]); - // Observed through state rather than a plain ref, because the warning has to re-run when the + const { inline } = React.useContext(DialogPresentationContext); + // The dialog this one renders inside, read before this popup publishes its own. + const host = React.useContext(DialogContext); + // The headless flag, not the stack check below — the size rule is about opening a dialog inside + // ANY dialog, which is broader than the prompt-on-prompt case the stacking styles cover. + const { role, isStacked: isNestedInDialog, labelId, descriptionId } = useHeadlessDialogContext(); + const isAlert = role === 'alertdialog'; + const size: DialogSize = isAlert ? 'prompt' : (sizeProp ?? 'prompt'); + useAlertSizeWarning(isAlert, sizeProp); + useNestedSizeWarning(isNestedInDialog, size); + + const surface = React.useMemo( + () => ({ labelId, descriptionId, size, inline }), + [labelId, descriptionId, size, inline], + ); + // Observed through state rather than a plain ref, because the warnings have to re-run when the // node arrives and a ref mutation does not re-render. const [node, setNode] = React.useState(null); - useAccessibleNameWarning(node, React.useContext(DialogPartNameContext)); - useNestedSizeWarning(isNestedInDialog, size); + useAccessibleNameWarning(node, 'Dialog'); + // A name alone is enough for an ordinary dialog; an alert is announced as an interruption and + // its description is what says which decision is being asked for. + useAccessibleDescriptionWarning(isAlert ? node : null, 'Dialog'); const mergedRef = React.useCallback( (element: HTMLDivElement | null) => { @@ -310,95 +408,177 @@ const Popup = React.forwardRef(function Dialog [ref], ); - return ( + const popup = ( ); + + if (inline) { + return ( + + {popup} + + ); + } + + return ( + + + + {popup} + + + ); }); -export interface DialogProps extends Pick< - HeadlessDialogProps, - 'open' | 'defaultOpen' | 'onOpenChange' | 'modal' | 'closedBy' -> { +/** + * The row holding an alert dialog's answer. Render the cancel first — see `dialog.styles.ts` for + * why that ordering is what focuses it on open. + */ +const Actions = React.forwardRef(function DialogActions( + { render, className, style, ...rest }, + ref, +) { + return useRender({ + defaultTagName: 'div', + render, + ref, + props: { + ...mergeStyleProps(themeProps('dialog-actions'), stylex.props(reset.base, styles.actions), className, style), + ...rest, + }, + }); +}); + +export interface DialogConfirmProps { + /** Shared with the `show()` call, or with `useConfirmedClose`, that raises this confirmation. */ + handle: ConfirmHandle; /** - * Renders the button that opens the dialog. Omit for dialogs driven entirely by `open` — - * opened from a menu item, a route, or a state machine — where there is no trigger to render. + * 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. */ - trigger?: MosaicComponentProps<'button'>['render']; - children: ReactNode | ((ctx: { close: () => void }) => ReactNode); - /** Width, and for `panel` also height, of the dialog surface. @default 'prompt' */ - size?: DialogSize; + finalFocus?: DialogFocusTarget; } /** - * Resolves the render-prop form of `children`. Shared with `AlertDialog`, which offers the same - * `close` contract and would otherwise carry a second implementation of it. Not exported from the - * folder's `index.ts`, for the same reason as {@link DialogPartNameContext}. + * The dialog half of `createConfirmHandle` — an alert dialog rendered from whatever the `show()` + * call asked, and closed by answering it. * - * Routed through the primitive's close funnel, so a controlled consumer's `onOpenChange` sees this - * close the same as Escape does — and can decline it. + * Render it INSIDE the dialog it guards (anywhere in its popup). 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. */ -export function DialogContent({ children }: { children: DialogProps['children'] }) { - const { setOpen } = useHeadlessDialogContext(); - if (typeof children !== 'function') { - return <>{children}; - } - return <>{children({ close: () => setOpen(false) })}; -} +function Confirm({ handle, finalFocus }: DialogConfirmProps) { + // 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]); -/** - * Mosaic `Dialog` — a modal surface built on the `@clerk/headless` dialog primitive. - * Flattens the required nesting (Root, Portal, Backdrop, Viewport, Popup) into one - * component and hands `children` a `close` callback through a render prop. - * - * Each styled part spreads `themeProps` + `stylex.props` through `mergeStyleProps`, so - * it carries the public `.cl-` class and StyleX atoms while the headless part - * keeps its focus management, scroll lock, and ARIA wiring. Drop to the compound parts - * (`Dialog.Root` and friends) for layouts this wrapper does not cover. - */ -export function Dialog({ trigger, children, size, open, defaultOpen, onOpenChange, modal, closedBy }: DialogProps) { 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); + } + }} > - {trigger ? : null} - - - - - {children} + {({ payload }) => + payload ? ( + + }>{payload.title} + }>{payload.description} + + }>{payload.cancelLabel ?? 'Cancel'} + + - - + ) : null + } ); } -/** Compound parts for power-user / custom dialog layouts. */ -Dialog.Root = Root; -Dialog.Trigger = Trigger; -/** Creates a handle linking detached `Dialog.Trigger`s to a `Dialog.Root` anywhere in the tree. */ -Dialog.createHandle = Primitive.createHandle; -Dialog.Portal = Primitive.Portal; -Dialog.Backdrop = Backdrop; -Dialog.Viewport = Viewport; -Dialog.Popup = Popup; -Dialog.Title = Title; -Dialog.Description = Description; -Dialog.Close = Close; -Dialog.CloseButton = CloseButton; +/** + * Mosaic `Dialog` — a modal surface built on the `@clerk/headless` dialog primitive, composed + * via dot syntax: + * + * ```tsx + * + * }>Open + * + * + * + * + * + * + * ``` + * + * `Dialog.Popup` renders the portal, the scrim and the centering viewport itself, so those are + * not parts. `role='alertdialog'` on the root makes it an alert dialog — one that interrupts to + * ask for a decision and waits for one — with `Dialog.Actions` for the answer and + * `Dialog.Confirm` for a whole confirmation raised from a `show()` call. `inline` on the root + * presents it in its host instead of over the page. + * + * Each styled part spreads `themeProps` + `stylex.props` through `mergeStyleProps`, so it + * carries the public `.cl-` class and StyleX atoms while the headless part keeps its focus + * management, scroll lock, and ARIA wiring. + */ +export const Dialog = { + Root, + Trigger, + Popup, + Title, + Description, + Close, + CloseButton, + Actions, + Confirm, + /** Creates a handle linking detached `Dialog.Trigger`s to a `Dialog.Root` anywhere in the tree. */ + createHandle: Primitive.createHandle, + /** Creates the handle pairing an awaitable `show()` with a ``. */ + createConfirmHandle, +}; diff --git a/packages/ui/src/mosaic/components/dialog/index.ts b/packages/ui/src/mosaic/components/dialog/index.ts index 62ad5488198..e5f249f88e7 100644 --- a/packages/ui/src/mosaic/components/dialog/index.ts +++ b/packages/ui/src/mosaic/components/dialog/index.ts @@ -1,16 +1,25 @@ export { Dialog, DialogContext } from './dialog'; -export type { DialogFocusTarget, DialogHandle, DialogOpenChangeDetails } from '@clerk/headless/dialog'; +export { createConfirmHandle } from './confirm-handle'; +export { useConfirmedClose } from './use-confirmed-close'; +export type { ConfirmHandle, ConfirmOptions } from './confirm-handle'; +export type { UseConfirmedCloseOptions } from './use-confirmed-close'; export type { - DialogBackdropProps, + DialogClosedBy, + DialogFocusTarget, + DialogHandle, + DialogOpenChangeDetails, + DialogRole, +} from '@clerk/headless/dialog'; +export type { + DialogActionsProps, DialogCloseButtonProps, DialogCloseProps, + DialogConfirmProps, DialogContextValue, DialogDescriptionProps, DialogPopupProps, - DialogProps, DialogRootProps, DialogSize, DialogTitleProps, DialogTriggerProps, - DialogViewportProps, } from './dialog'; diff --git a/packages/ui/src/mosaic/components/alert-dialog/use-confirmed-close.ts b/packages/ui/src/mosaic/components/dialog/use-confirmed-close.ts similarity index 85% rename from packages/ui/src/mosaic/components/alert-dialog/use-confirmed-close.ts rename to packages/ui/src/mosaic/components/dialog/use-confirmed-close.ts index f29f718e595..c0310d7a5f9 100644 --- a/packages/ui/src/mosaic/components/alert-dialog/use-confirmed-close.ts +++ b/packages/ui/src/mosaic/components/dialog/use-confirmed-close.ts @@ -4,7 +4,7 @@ import React from 'react'; import type { ConfirmHandle, ConfirmOptions } from './confirm-handle'; export interface UseConfirmedCloseOptions { - /** The handle shared with the `` rendered inside the guarded dialog. */ + /** The handle shared with the `` rendered inside the guarded dialog. */ handle: ConfirmHandle; /** * Whether closing needs confirming, evaluated at the moment of each close request — typically @@ -31,10 +31,12 @@ const PROGRAMMATIC_DETAILS: DialogOpenChangeDetails = { trigger: null, triggerId * confirm: { title: 'Discard changes?', description: '…', actionLabel: 'Discard' }, * }); * - * - * … - * - * + * + * + * … + * + * + * * ``` * * **The dialog must be controlled.** A veto is the absence of a commit, and an uncontrolled dialog @@ -42,9 +44,8 @@ const PROGRAMMATIC_DETAILS: DialogOpenChangeDetails = { trigger: null, triggerId * decline. * * **What it covers is every close the dialog itself owns**: Escape, an outside press where - * `closedBy` allows one, `Dialog.CloseButton`, `Dialog.Close`, and the `close` the `Dialog` wrapper - * hands its children. All of them funnel through `onOpenChange`, so one branch here answers them - * all. What it cannot cover is a button wired to your own `setOpen(false)` — that never reaches the + * `closedBy` allows one, `Dialog.CloseButton`, `Dialog.Close`, and a `handle.close()`. All of them + * funnel through `onOpenChange`, so one branch here answers them all. What it cannot cover is a button wired to your own `setOpen(false)` — that never reaches the * dialog, so it bypasses the question silently. Route those through `Dialog.Close` instead. */ export function useConfirmedClose({ handle, when, onOpenChange, confirm }: UseConfirmedCloseOptions) { diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts index 12b27884da8..8baeb4ed16a 100644 --- a/packages/ui/src/mosaic/styles/index.ts +++ b/packages/ui/src/mosaic/styles/index.ts @@ -14,23 +14,6 @@ export type { ProfilePageSidebarProps, } from '../profile-page'; -export { AlertDialog, createConfirmHandle, useConfirmedClose } from '../components/alert-dialog'; -export type { - ConfirmHandle, - ConfirmOptions, - UseConfirmedCloseOptions, - AlertDialogActionsProps, - AlertDialogBackdropProps, - AlertDialogCloseProps, - AlertDialogConfirmProps, - AlertDialogDescriptionProps, - AlertDialogPopupProps, - AlertDialogProps, - AlertDialogRootProps, - AlertDialogTitleProps, - AlertDialogTriggerProps, - AlertDialogViewportProps, -} from '../components/alert-dialog'; export { Avatar } from '../components/avatar'; export type { AvatarProps, AvatarImageProps, AvatarFallbackProps, AvatarIconProps } from '../components/avatar'; export { Badge } from '../components/badge'; @@ -41,19 +24,21 @@ export { Button, SubmitButton } from '../components/button'; export type { ButtonProps, SpinDelayOptions, SubmitButtonProps } from '../components/button'; export { Card } from '../components/card'; export type { CardProps } from '../components/card'; -export { Dialog } from '../components/dialog'; +export { Dialog, createConfirmHandle, useConfirmedClose } from '../components/dialog'; export type { - DialogBackdropProps, + ConfirmHandle, + ConfirmOptions, + DialogActionsProps, DialogCloseButtonProps, DialogCloseProps, + DialogConfirmProps, DialogDescriptionProps, DialogPopupProps, - DialogProps, DialogRootProps, DialogSize, DialogTitleProps, DialogTriggerProps, - DialogViewportProps, + UseConfirmedCloseOptions, } from '../components/dialog'; export { Field } from '../components/field'; export type { FieldDescriptionProps, FieldErrorProps, FieldLabelProps, FieldRootProps } from '../components/field'; From b1a695fd1b73dec6e69f1da13552beca264e2234 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 15:16:24 -0600 Subject: [PATCH 02/22] docs(swingset): tighten the Dialog page Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 514 ++++-------------- 1 file changed, 111 insertions(+), 403 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 24155053eb2..8397c4cceb5 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -2,11 +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. Three parts make a dialog: `Dialog.Root` owns the state, `Dialog.Trigger` opens -it, and `Dialog.Popup` is the surface — and renders the portal, the scrim and the centering viewport -around itself, so those are not parts you compose. It 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 @@ -30,7 +28,7 @@ scroll lock, and ARIA wiring. ]} /> -`size` is a `Dialog.Popup` prop; everything else in the table belongs to `Dialog.Root`. +`size` belongs to `Dialog.Popup`; the rest belong to `Dialog.Root`. ## Usage @@ -49,9 +47,7 @@ import { Dialog } from '@clerk/ui/mosaic/components/dialog'; ; ``` -`Dialog.Trigger` renders a ` - + … ; ``` -Every close the dialog owns — Escape, an outside press, `Dialog.Close`, `Dialog.CloseButton`, -`handle.close()` — routes through `onOpenChange`, so a controlled consumer can decline one by not -committing the state. A button wired to your own `setOpen(false)` never reaches the dialog and -bypasses that; route it through `Dialog.Close` if the close might need vetoing. +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 | 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 | - -`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. +| 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` | `94rem`, fills the viewport | Account profile and settings — a surface you navigate | ### A card brings its own surface -`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: +`card` paints nothing itself; render the popup **as** a `Card` so one element paints and animates: -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. - -The trade is that `size="card"` with no `Card` inside renders an unpainted box. - ### Alert dialogs -`role='alertdialog'` on the root makes 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 plain dialog. +`role='alertdialog'` on the root makes a dialog that interrupts to ask for a decision — a +destructive confirmation, a "discard changes?". -Three things follow from the role, and none of them is a prop: - -- It is announced as an interruption rather than as a surface the user navigated to. -- An outside press never dismisses it: a question that needs an answer must not be answerable by - clicking next to it. `closedBy` narrows to `closerequest` (the default) or `none`. Escape still - closes — it is the keyboard's equivalent of the cancel button, which is always present. -- It is always a `prompt`; `size` on the popup is ignored, and warns in development. - -**A Title and a Description are both required.** An alert dialog's description is announced with -its name at the moment it interrupts — 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; -neither can be required in the type system, since parts arrive as children. - -**The cancel comes first.** Render it as the first child of `Dialog.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, so the keyboard order -and the screen agree. - -**The action does not close by itself.** `Dialog.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 — controlled `open`, as above. That leaves room for a pending state on the button. +What follows from the role: -**There is no corner X.** `Dialog.CloseButton` warns in development inside an alert dialog: a -corner X is a way out without answering, and the cancel action is the way out. +- 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. -**Pass `finalFocus`** 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. +Render the cancel first in `Dialog.Actions` 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 -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. +Guard a dialog's close behind a confirmation with a handle, a hook, and `Dialog.Confirm`: value !== '', onOpenChange: setOpen, - confirm: { - title: 'Discard changes?', - description: 'You have not finished adding this address.', - actionLabel: 'Discard', - cancelLabel: 'Keep editing', - destructive: true, - }, + confirm: { title: 'Discard changes?', description: '…', actionLabel: 'Discard', destructive: true }, }); - {/* … */} + … ; ``` -**Render `Dialog.Confirm` inside the popup it guards.** 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. - -`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. - -**`Dialog.Confirm` must be mounted when `show()` is called** — it is the thing that opens. Since the -confirmation lives inside the dialog it guards, that means asking from inside that dialog, while it -is open. A `show()` with nothing mounted resolves `false` and warns; a confirmation that unmounts -with a question in flight answers `false` rather than leaving the `await` hanging. +- 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. ### Inline -`inline` on the root presents the dialog in its host rather than over the page: no portal, no -scrim, no scroll lock, no focus trap, and nothing dismisses it — it is open for as long as it is -mounted. It is for a surface that is the page's content, such as the account panel mounted in a -layout slot. +`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. -`open`, `modal` and `closedBy` are implied and ignored, and `onOpenChange` is never called. The -popup takes no initial focus, since it mounts with the page rather than in answer to a gesture. -`Dialog.CloseButton` renders nothing inside it (and warns), and a `Card.Header` carries no dismiss. - -The surface keeps its ring, radius and shadow — it reads as a card sitting on the page — and fills -its host edge to edge, with none of the inset a modal dialog keeps from the screen. Its height -follows the host's when the host has one; otherwise it takes its height from its content. - -A dialog opened from inside an inline one presents normally: portalled, scrimmed, modal over the -whole page. It paints the base scrim rather than the lighter nested one, since the inline surface -beneath it has no scrim of its own to composite with. - -### The inset, and what it is measured against - -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: - -| Container | Top & bottom | Sides | -| ------------- | ---------------- | ------------- | -| `< 48rem` | `1.25rem` (20px) | `1rem` (16px) | -| `48rem–90rem` | `2rem` (32px) | `2rem` (32px) | -| `>= 90rem` | `3rem` (48px) | `3rem` (48px) | - -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. - -It lives on the viewport's padding, so a popup gets it for free by being `width: 100%` inside it — -no width arithmetic of its own. - -**The bands are container queries, not media queries.** The viewport element is an inline-size -container named `cl-dialog`, and every width-dependent rule in the dialog — the inset ladder, the -phone-band sheet, the width caps — queries it. Over the page the viewport is `position: fixed; -inset: 0`, so its width is the window's and nothing differs from a media query. The difference shows -when the viewport is smaller than the window: an `inline` dialog fills its host, and its bands then -follow the host's width. Content inside a dialog can query the same name for its own layout — -[Scrolling a panel](#scrolling-a-panel) hides its rail with `@3xl/cl-dialog:flex`. +- `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, keeping its ring, radius and shadow. +- Dialogs opened from inside it are normal modal dialogs over the page. -`prefers-reduced-motion` and `forced-colors` stay media queries: they are preferences, not sizes. +### Responsive behaviour -### On a phone, a prompt is a sheet +The gap to the screen edge is a fixed inset, not a percentage: -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. +| Container | Top & bottom | Sides | +| ------------- | ------------ | ------ | +| `< 48rem` | `1.25rem` | `1rem` | +| `48rem–90rem` | `2rem` | `2rem` | +| `>= 90rem` | `3rem` | `3rem` | -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. +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. Content inside a dialog can query the same container — the panel +sidebar example hides its rail with `@3xl/cl-dialog:flex`. -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. - -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. - -```tsx - - - Add email address - -``` - -It carries an English `Close` label by default; pass `aria-label` to override it. +`Dialog.CloseButton` is the styled corner X, absolutely positioned so it can sit anywhere in the +children. Pass `aria-label` to localise it. `Dialog.Close` is the unstyled alternative for a footer +"Cancel". -`Dialog.Close` stays available and unstyled — that is what a "Cancel" button in a footer wants. -`Dialog.CloseButton` is the styled corner affordance. - -> **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). +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` 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. +`closedBy`: `any` (Escape and outside press, default), `closerequest` (Escape only), `none` +(programmatic only). Use `closerequest` for anything holding input. ### Exit -The popup's contents are held at their last committed frame while it exits. A state machine that -drives a dialog resets to its initial state on close, in the same commit that starts the fade — -without the hold, the dialog would repaint that reset state and fade out showing the wrong thing. -Nothing to opt into; the popup element itself stays live so `data-closed` and `data-ending-style` -still land. +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 open/close, `role`, `inline`, `modal`, `closedBy`, `handle`. | -| `Dialog.Trigger` | — | Opens the dialog; accepts `render`, and `handle` + `payload` when detached. | -| `Dialog.Popup` | `dialog-popup` | The surface (`role="dialog"` or `"alertdialog"`, focus-trapped); `size`, `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.Actions` | `dialog-actions` | An alert dialog's response row. Cancel first. | -| `Dialog.Confirm` | `dialog-popup` | A whole confirmation rendered from a `show()` call. See [Confirming a discard](#confirming-a-discard). | - -`Dialog.Popup` also renders two styled elements that are not parts: the scrim (`dialog-backdrop`) -and the centering viewport (`dialog-viewport`), both of which carry `data-size` and, when the root -is inline, `data-inline`. - -`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`) and the centering viewport +(`dialog-viewport`). ## 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); @@ -361,74 +221,25 @@ classes — override by targeting the `.cl-*` slot from a CSS layer that wins ov .cl-dialog-popup[data-size='panel'] { max-width: 60rem; } - - .cl-dialog-actions { - margin-block-start: 1.5rem; - } } ``` -`Dialog.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. - -State attributes from the headless layer are available for CSS targeting: +State attributes: -| 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-inline` | Viewport, Popup | Present when the root is `inline` | -| `data-nested` | Backdrop, Viewport, Popup | Present when opened inside another overlay | - -### Motion - -Every size fades. `prompt` and `card` scale as well; `panel` does not. - -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. The 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. An `inline` dialog does none of this: it is -not fixed, so the page's own layout handles the keyboard. +| 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, Popup | Resolved size | +| `data-inline` | Viewport, 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. - -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. - -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 dialog over a `panel` or `card` is **nested**: it paints its own lighter scrim. A prompt over a +prompt is a **stack**: no second scrim, and the prompt beneath dims and recedes. Whichever it is, +the dialog that opens should be a `prompt` — anything else warns in dev. --- @@ -436,146 +247,57 @@ with nothing interpolating — the setting asks for no animation, not for no dis ### Scrolling 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. +A `panel` clips rather than scrolls; compose the scroll region inside it with the `ScrollArea` +atoms. The panel has no padding of its own — put it on the content. -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'; - - - }>Open settings - - - }>Settings - -
- - -
-
-
-
-
-
-
-
; +
+
+
+
+
``` -The sidebar is dropped below `48rem` of the dialog's own container — 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. +`min-height: 0` on the flex row is required, or the row grows past the panel and never scrolls. ### 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. +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.) -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. +A `panel` with `prompt` dialogs opened from inside it. Type into **Add email address** and try to +close it to see a confirmation stack on top. -This is the nested case, not a stack: the prompt paints its own scrim over the panel, and the panel -neither dims nor recedes. - -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. - -Stack a prompt on a prompt and the relationship changes — the shape a close confirmation -takes: - -Nest by rendering a `Dialog.Root` inside another popup's children. Nothing else is required — the -inner dialog finds the outer through Floating UI's tree and wires up its own stacking: - -```tsx - - }>Open account - - }>Account - - - }>Add email address - - }>Add email address - - }>Cancel - - - - -``` - -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. +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. ### 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. From b4cd8d0d093375737a7260c7c37fceb85e176bdb Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 15:31:21 -0600 Subject: [PATCH 03/22] docs(swingset): build the Dialog panel examples on the real user page Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 3 +- .../src/stories/dialog.component.stories.tsx | 334 ++++++------------ .../src/stories/fixtures/user-page.ts | 127 +++++++ 3 files changed, 236 insertions(+), 228 deletions(-) create mode 100644 packages/swingset/src/stories/fixtures/user-page.ts diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 8397c4cceb5..4b3f912b85d 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -102,7 +102,8 @@ What follows from the role: - 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. -Render the cancel first in `Dialog.Actions` so it takes focus on open. The action usually starts +`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. diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 65c881be08a..955a56a9ad0 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -4,16 +4,18 @@ import { Card } from '@clerk/ui/mosaic/components/card'; import type { DialogSize } 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 { UserPageView } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import { UserProfileSecurityPanelView } from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; import * as stylex from '@stylexjs/stylex'; 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'; @@ -146,7 +148,7 @@ export function DiscardChanges() { value={value} onChange={event => setValue(event.target.value)} /> -
+ }>Cancel -
+
; -const addTrigger = (label: string) => (props: RenderProps) => ( - -); - -const addEmailRowTrigger = addTrigger('Add email address'); -const addPhoneTrigger = addTrigger('Add phone number'); -const deleteAccountTrigger = (props: RenderProps) => ( - -); - -// 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; - /** - * 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; `useConfirmedClose` is the same thing packaged, and - * [Confirming a discard](#confirming-a-discard) has the composed version. + * The "add email address" prompt the account panel opens, driven by `open` rather than a trigger. + * Closing it while the field holds anything raises a confirmation stacked on top instead — + * `panel -> prompt -> prompt` — and the veto is a controlled `open` whose `onOpenChange` declines + * to commit. Hand-rolled to show a veto needs no machinery; `useConfirmedClose` is the same thing + * packaged, and [Confirming a discard](#confirming-a-discard) has the composed version. */ -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 [value, setValue] = React.useState(''); const dismiss = () => { setValue(''); - setOpen(false); + onOpenChange(false); }; return ( @@ -257,143 +203,120 @@ function AddValueDialog({ // 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() !== '') { + if (!next && value.trim() !== '') { setDiscardOpen(true); return; } if (!next) { setValue(''); } - setOpen(next); + onOpenChange(next); }} > - - }>{title} - }>{description} + }>Add email address + }>A verification code will be sent to this address. setValue(event.target.value)} /> -
+ }>Cancel -
- {confirmDiscard ? ( - - - }>Discard changes? - }> - You have not finished adding this address. It will not be saved. - - - }>Keep editing - - - - - ) : null} + + + + }>Discard changes? + }> + You have not finished adding this address. It will not be saved. + + + }>Keep editing + + + +
); } -/** The account surface, shared by the modal `panel` and the inline one below. */ -function AccountPanelBody() { +/** + * The real user page — sidebar plus the account and security panels — as the content of a `panel` + * dialog. The popup supplies the frame, so the page's own border is dropped; the popup clips, so + * the page scrolls inside it. Adding an email opens a `prompt` over the panel, and the danger + * zone's delete confirmation is the page's own. + */ +function AccountPage() { + 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 - - - - -
- + <> +
+
+ +
-
+ + ); } -/** A `panel` account surface with `prompt` dialogs opened from inside it. */ +/** The user page in a `panel`, with `prompt` dialogs opened from inside it. */ export function Nested() { return ( - + - + ); } /** - * The same panel presented `inline`: it is the page's content rather than a surface over it, so + * The same page presented `inline`: it is the page's content rather than a surface over it, so * there is no portal, scrim, scroll lock or focus trap, and nothing dismisses it. The prompts it * opens are still modal over the whole page. * @@ -407,17 +330,20 @@ export function Inline() { style={{ border: '1px dashed var(--cl-color-border)', borderRadius: '0.5rem', - height: '32rem', + height: '36rem', maxWidth: '100%', overflow: 'auto', padding: '1rem', resize: 'horizontal', - width: '40rem', + width: '52rem', }} > - - + +
@@ -426,34 +352,7 @@ export function Inline() { 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 NAV_SECTIONS = ['Account', 'Security', 'Billing', 'API keys']; const editProfileTrigger = (props: RenderProps) => ; @@ -488,7 +387,7 @@ export function StackedPrompts() { defaultValue='Ada Lovelace' placeholder='Your name' /> -
+ -
+ ); @@ -521,6 +420,7 @@ export function StackedPrompts() { /** The panel clips rather than scrolling, so the scroll region is composed inside it. */ export function PanelSidebar() { + const { panels } = useUserPageFixture(); return ( @@ -555,7 +455,7 @@ export function PanelSidebar() { fullWidth // `Button` centres its content; a nav row wants a leading label. style={{ justifyContent: 'flex-start' }} - aria-current={index === 2 ? 'page' : undefined} + aria-current={index === 1 ? 'page' : undefined} > {section} @@ -569,27 +469,7 @@ export function PanelSidebar() { >
- - {SESSIONS.map(session => ( - - - {session.device} - - {session.where} · {session.when} - - - - - - - ))} - +
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 }; +} From 1bc8ba23c1a5437dba675d340ab04561bf562e49 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 15:32:54 -0600 Subject: [PATCH 04/22] fix(ui): drop the duplicate close button from the Destructive dialog Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- packages/ui/src/mosaic/blocks/destructive/destructive.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx index cd4f1cdc82e..109a2cd46b3 100644 --- a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx +++ b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx @@ -113,7 +113,6 @@ export function Destructive({ /> } > - }>{title} }>{description} From 1f5ec5d47ad01f2608e2a1ad2099f66546fdec92 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 15:35:23 -0600 Subject: [PATCH 05/22] docs(swingset): table the Dialog dismissal policy Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- packages/swingset/src/stories/dialog.component.mdx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 4b3f912b85d..3044e32c2bf 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -186,8 +186,17 @@ Focus opens on the first tabbable element, so a `CloseButton` rendered first tak ### Dismissal -`closedBy`: `any` (Escape and outside press, default), `closerequest` (Escape only), `none` -(programmatic only). Use `closerequest` for anything holding input. +`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 + +``` ### Exit From 65cde34723da68c6df0b12b5384cc6e6c31d6f9b Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 15:36:09 -0600 Subject: [PATCH 06/22] docs(swingset): rename the Dialog exit section Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- packages/swingset/src/stories/dialog.component.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 3044e32c2bf..c27fca2a593 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -198,7 +198,7 @@ Focus opens on the first tabbable element, so a `CloseButton` rendered first tak ``` -### Exit +### Exit animations 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. From 20965ca610e7a12e486970a2477f78139f4aa3f8 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 15:37:15 -0600 Subject: [PATCH 07/22] docs(swingset): table the Dialog nesting relationships Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- packages/swingset/src/stories/dialog.component.mdx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index c27fca2a593..edc1dc1ab31 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -247,9 +247,16 @@ State attributes: ### Nested dialogs and stacks -A dialog over a `panel` or `card` is **nested**: it paints its own lighter scrim. A prompt over a -prompt is a **stack**: no second scrim, and the prompt beneath dims and recedes. Whichever it is, -the dialog that opens should be a `prompt` — anything else warns in dev. +A dialog opened inside another is one of two relationships, decided by the size beneath it: + +| | 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` | + +The dialog that opens should always be a `prompt`; anything else warns in dev. --- From 1328ef9d18e06218ae6e3f79f4f96a06b3e72b27 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 15:40:17 -0600 Subject: [PATCH 08/22] docs(swingset): use the real profile sidebar in the Dialog panel example Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 13 ++- .../src/stories/dialog.component.stories.tsx | 96 +++++++++---------- 2 files changed, 57 insertions(+), 52 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index edc1dc1ab31..5f34742244d 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -273,14 +273,19 @@ atoms. The panel has no padding of its own — put it on the content. /> ```tsx -
-
-
+ +
+ + +
+
+
-
+ ``` `min-height: 0` on the flex row is required, or the row grows past the panel and never scrolls. +The rail hides below `48rem` of the dialog's own `cl-dialog` container, not the window. ### Scrolling a tall card diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 955a56a9ad0..18ce242c876 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -1,3 +1,4 @@ +import { Tabs } from '@clerk/headless/tabs'; import type { RenderProps } from '@clerk/headless/utils'; import { Button } from '@clerk/ui/mosaic/components/button'; import { Card } from '@clerk/ui/mosaic/components/card'; @@ -7,8 +8,12 @@ import { Heading } from '@clerk/ui/mosaic/components/heading'; import { Input } from '@clerk/ui/mosaic/components/input'; import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; import { Text } from '@clerk/ui/mosaic/components/text'; +import { ProfilePage } from '@clerk/ui/mosaic/profile-page'; import { UserPageView } from '@clerk/ui/mosaic/user-profile/user-page.view'; +import { UserProfileProfilePanelView } from '@clerk/ui/mosaic/user-profile/user-profile-profile-panel.view'; import { UserProfileSecurityPanelView } from '@clerk/ui/mosaic/user-profile/user-profile-security-panel.view'; +import type { UserProfilePanelId } from '@clerk/ui/mosaic/user-profile/user-profile-sidebar'; +import { UserProfileSidebar } from '@clerk/ui/mosaic/user-profile/user-profile-sidebar'; import * as stylex from '@stylexjs/stylex'; import React from 'react'; @@ -352,8 +357,6 @@ export function Inline() { const settingsTrigger = (props: RenderProps) => ; -const NAV_SECTIONS = ['Account', 'Security', 'Billing', 'API keys']; - const editProfileTrigger = (props: RenderProps) => ; const discardTrigger = (props: RenderProps) => ( @@ -418,62 +421,59 @@ export function StackedPrompts() { ); } +const PANELS: readonly UserProfilePanelId[] = ['account', 'security']; + /** The panel clips rather than scrolling, so the scroll region is composed inside it. */ export function PanelSidebar() { - const { panels } = useUserPageFixture(); + const { activePanel, setActivePanel, panels } = useUserPageFixture(); return ( - + + {/* The sidebar's tabs and the panels share this context; `ProfilePage.Root` would supply + it too, but its grid gives the content column no height to scroll within. */} + setActivePanel(value as UserProfilePanelId)} + orientation='vertical' + > +
+ {/* The rail has nowhere to go on a phone. Queried against the dialog's own `cl-dialog` + container rather than the window, so it follows the surface it sits in — `@3xl` is + Tailwind's 48rem, the dialog's phone band. On a wrapper, because the sidebar's own + StyleX `display` outranks a Tailwind utility. */} +
+ +
- {/* 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. Queried against the dialog's own `cl-dialog` - container rather than the window, so it follows the surface it sits in — `@3xl` is - Tailwind's 48rem, the dialog's phone band. */} - - - {/* Flush with the popup edge, so the scrollbar and edge fade land on the true edge. */} -
-
-
- + {/* Flush with the popup edge, so the scrollbar and edge fade land on the true edge. */} +
+
+
+ + + + + + +
-
+ ); From 6311846725ac49276c11773695884aed1317bb7f Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 15:41:52 -0600 Subject: [PATCH 09/22] docs(swingset): return focus to the field after a discard confirmation Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../src/stories/dialog.component.stories.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 18ce242c876..fe415a560ab 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -194,6 +194,7 @@ function AddEmailDialog({ }) { const [discardOpen, setDiscardOpen] = React.useState(false); const [value, setValue] = React.useState(''); + const inputRef = React.useRef(null); const dismiss = () => { setValue(''); @@ -223,6 +224,7 @@ function AddEmailDialog({ }>Add email address }>A verification code will be sent to this address. - + {/* Raised by a close request rather than a trigger, so without `finalFocus` there is + nothing for focus to return to. */} + }>Discard changes? }> You have not finished adding this address. It will not be saved. @@ -375,6 +379,7 @@ const discardTrigger = (props: RenderProps) => ( export function StackedPrompts() { const [open, setOpen] = React.useState(false); const [confirmationOpen, setConfirmationOpen] = React.useState(false); + const nameRef = React.useRef(null); return ( }>Update profile }>Change the name people see on your account. @@ -397,7 +403,9 @@ export function StackedPrompts() { onOpenChange={setConfirmationOpen} > - + {/* "Keep editing" should put the caret back in the field, not on the Cancel that + raised the question. */} + }>Discard changes? }>Your edits will be lost. From 1106dff7b0aff11e68fbabec7c75cba203bcd216 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 15:44:18 -0600 Subject: [PATCH 10/22] docs(swingset): guard the stacked Dialog examples with useConfirmedClose Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../src/stories/dialog.component.stories.tsx | 146 +++++++----------- 1 file changed, 58 insertions(+), 88 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index fe415a560ab..5efe5dabbb2 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -178,10 +178,7 @@ const accountTrigger = (props: RenderProps) => - - {/* Raised by a close request rather than a trigger, so without `finalFocus` there is - nothing for focus to return to. */} - - }>Discard changes? - }> - You have not finished adding this address. It will not be saved. - - - }>Keep editing - - - - + ); @@ -363,28 +341,39 @@ const settingsTrigger = (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 [confirmationOpen, setConfirmationOpen] = React.useState(false); + const [name, setName] = React.useState('Ada Lovelace'); const nameRef = React.useRef(null); + + const onOpenChange = useConfirmedClose({ + handle: confirm, + when: () => name !== 'Ada Lovelace', + onOpenChange: next => { + setOpen(next); + if (!next) { + setName('Ada Lovelace'); + } + }, + confirm: { + title: 'Discard changes?', + description: 'Your edits will be lost.', + actionLabel: 'Discard', + cancelLabel: 'Keep editing', + destructive: true, + }, + }); + return ( @@ -393,37 +382,18 @@ export function StackedPrompts() { }>Change the name people see on your account. setName(event.target.value)} /> - - - {/* "Keep editing" should put the caret back in the field, not on the Cancel that - raised the question. */} - - }>Discard changes? - }>Your edits will be lost. - - }>Keep editing - - - - + }>Cancel + ); From c8164f2e12a80e61c1dcb28f2c26eb6465e387c5 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Tue, 1 Sep 2026 16:00:13 -0600 Subject: [PATCH 11/22] docs(swingset): make the Dialog prompt examples forms so Enter confirms Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 21 ++++ .../src/stories/dialog.component.stories.tsx | 112 ++++++++++-------- 2 files changed, 85 insertions(+), 48 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 5f34742244d..514d91e3cb4 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -198,6 +198,27 @@ Focus opens on the first tabbable element, so a `CloseButton` rendered first tak ``` +### Keyboard + +| 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 | + +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: + +```tsx +
+ + + }>Cancel + + +
+``` + ### Exit animations The popup's contents hold their last frame while it fades out, so state that resets on close (a diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 5efe5dabbb2..913ee71064d 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -147,23 +147,27 @@ export function DiscardChanges() { }> You will need to verify this address before it can be used. - setValue(event.target.value)} - /> - - }>Cancel - - + {/* A form, so Enter in the field is the primary action; Tab stays in visual order. */} +
{ + event.preventDefault(); + bypassGuardRef.current = true; + onOpenChange(false, { trigger: null, triggerId: null, event: undefined }); + }} + > + setValue(event.target.value)} + /> + + }>Cancel + + +
}>Add email address }>A verification code will be sent to this address. - setValue(event.target.value)} - /> - - }>Cancel - {/* Straight to the parent's setter: adding is the one close that must not be questioned. */} - - + {/* 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); + }} + > + setValue(event.target.value)} + /> + + }>Cancel + + +
}>Update profile }>Change the name people see on your account. - setName(event.target.value)} - /> - - }>Cancel - - + {/* Saving goes straight to `setOpen`, past the guard. A form, so Enter in the field saves. */} +
{ + event.preventDefault(); + setOpen(false); + }} + > + setName(event.target.value)} + /> + + }>Cancel + + +
Date: Wed, 2 Sep 2026 09:59:42 -0600 Subject: [PATCH 12/22] fix(ui): name card dialogs through Card.Title and Card.Description Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- packages/swingset/src/stories/dialog.component.stories.tsx | 6 +++--- packages/ui/src/mosaic/blocks/destructive/destructive.tsx | 6 ++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 913ee71064d..5897d84acb4 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -595,10 +595,10 @@ export function OutsideScroll() { render={} > - }>Terms of service - }> + Terms of service + Nothing here scrolls on its own — the card grows past the screen and the viewport takes the scroll. - +
diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx index 109a2cd46b3..e3c3d2af88e 100644 --- a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx +++ b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx @@ -6,9 +6,7 @@ import { Card } from '../../components/card'; 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 */ @@ -114,8 +112,8 @@ export function Destructive({ } > - }>{title} - }>{description} + {title} + {description}
Date: Wed, 2 Sep 2026 10:09:42 -0600 Subject: [PATCH 13/22] feat(ui): make the panel dialog a surface-owned size, and ProfilePage a scrolling container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `size='panel'` now paints nothing itself, like `card`: the popup contributes geometry and motion, and the surface rendered as the popup — `ProfilePage.Root`, or `UserPageView` — paints the frame. That is what lets the same composition serve a modal panel and an inline one; the dialog decides where the page sits and the page decides how it looks. `ProfilePage.Root` becomes the surface that composition needs: a `cl-profile-page` inline-size container whose compact layout is a container query rather than a media query, with the grid on an inner element so the query has something to reshape, a definite row so the content column can scroll inside it (built on the ScrollArea atoms), and no standalone minimum height inside a dialog. `UserPageView` takes `children` so a dialog's parts land inside the page. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 61 ++++---- .../src/stories/dialog.component.stories.tsx | 138 ++++-------------- .../mosaic/components/dialog/dialog.styles.ts | 68 ++++----- .../mosaic/components/dialog/dialog.test.tsx | 52 +++++-- packages/ui/src/mosaic/profile-page.styles.ts | 55 ++++++- packages/ui/src/mosaic/profile-page.tsx | 36 ++++- .../__tests__/user-page.view.test.tsx | 77 ++++++++++ .../mosaic/user-profile/user-page.view.tsx | 8 +- 8 files changed, 287 insertions(+), 208 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 514d91e3cb4..06a4d4d59e7 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -68,21 +68,31 @@ committing. Your own `setOpen(false)` skips that; use `Dialog.Close` when a clos ### Size -| 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` | `94rem`, fills the viewport | Account profile and settings — a surface you navigate | +| 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 | -### A card brings its own surface +### `card` and `panel` bring their own surface -`card` paints nothing itself; render the popup **as** a `Card` so one element paints and animates: +Only `prompt` paints itself. A `card` renders **as** a `Card`, and a `panel` **as** a `ProfilePage` +(or `UserPageView`): the dialog positions and animates the popup, the surface paints it, and the +dialog's own parts go in as children. Use `Card.Title` / `Card.Description` inside a card — they +name the dialog through `DialogContext`, and `Card.Header` carries the dismiss, so no +`Dialog.CloseButton` is needed there. +```tsx +}> + + +``` + ### Alert dialogs `role='alertdialog'` on the root makes a dialog that interrupts to ask for a decision — a @@ -153,7 +163,8 @@ scroll lock or focus trap, and nothing dismisses it. For the account panel mount - `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, keeping its ring, radius and shadow. +- 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. ### Responsive behaviour @@ -168,8 +179,8 @@ The gap to the screen edge is a fixed inset, not a percentage: 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. Content inside a dialog can query the same container — the panel -sidebar example hides its rail with `@3xl/cl-dialog:flex`. +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. 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 @@ -285,29 +296,14 @@ The dialog that opens should always be a `prompt`; anything else warns in dev. ### Scrolling a panel -A `panel` clips rather than scrolls; compose the scroll region inside it with the `ScrollArea` -atoms. The panel has no padding of its own — put it on the content. +A `panel` clips rather than scrolls, and its surface owns the scroll: `ProfilePage` scrolls its +content column and keeps the sidebar put. Nothing to compose — render the page as the popup. -```tsx - -
- - -
-
-
-
-
-``` - -`min-height: 0` on the flex row is required, or the row grows past the panel and never scrolls. -The rail hides below `48rem` of the dialog's own `cl-dialog` container, not the window. - ### Scrolling a tall card A `prompt` or `card` taller than the screen scrolls the whole dialog inside the viewport, inset and @@ -321,13 +317,8 @@ surface on a phone.) ### Nested dialogs -A `panel` with `prompt` dialogs opened from inside it. Type into **Add email address** and try to -close it to see a confirmation stack on top. - - +In the panel above, add an email address, type into the field and try to close it: the prompt +opens over the panel, and a confirmation stacks on the prompt. The same stack on its own: setAddEmailOpen(true), }); return ( - <> -
-
+ + {inline ? null : } + -
-
- - + } + > + {inline ? null : } + + + ); } /** The user page in a `panel`, with `prompt` dialogs opened from inside it. */ export function Nested() { - return ( - - - - - - - - ); + return ; } /** @@ -314,9 +297,9 @@ export function Nested() { * there is no 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 dialog's width bands are container queries against its own viewport - * element, so dragging the host below `48rem` gives the panel its phone-band inset without the - * browser window moving — the same rule that makes a modal dialog respond to the window. + * 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() { return ( @@ -332,20 +315,11 @@ export function Inline() { width: '52rem', }} > - - - - - +
); } -const settingsTrigger = (props: RenderProps) => ; - const editProfileTrigger = (props: RenderProps) => ; /** @@ -415,64 +389,6 @@ export function StackedPrompts() { ); } -const PANELS: readonly UserProfilePanelId[] = ['account', 'security']; - -/** The panel clips rather than scrolling, so the scroll region is composed inside it. */ -export function PanelSidebar() { - const { activePanel, setActivePanel, panels } = useUserPageFixture(); - return ( - - - - - {/* The sidebar's tabs and the panels share this context; `ProfilePage.Root` would supply - it too, but its grid gives the content column no height to scroll within. */} - setActivePanel(value as UserProfilePanelId)} - orientation='vertical' - > -
- {/* The rail has nowhere to go on a phone. Queried against the dialog's own `cl-dialog` - container rather than the window, so it follows the surface it sits in — `@3xl` is - Tailwind's 48rem, the dialog's phone band. On a wrapper, because the sidebar's own - StyleX `display` outranks a Tailwind utility. */} -
- -
- - {/* Flush with the popup edge, so the scrollbar and edge fade land on the true edge. */} -
-
-
- - - - - - -
-
-
-
-
-
-
- ); -} - /** * A handle at module scope: the trigger and the root only share it, not a JSX ancestor. * The same handle also has imperative `open()` / `close()` for opens with no trigger at all. diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 453174203e5..7dc9d1f4a22 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -299,11 +299,9 @@ export const closeInsets = stylex.create({ * `card` sets only `max-width`; the popup is `width: 100%` and its height is whatever the * content needs, which is right for a confirmation or a two-field form. * - * `panel` decides both axes. Its content NAVIGATES — a settings surface switches sections - * in place — and a content-driven height would resize the window on every section change, - * in both directions at once since the viewport centres it. `94rem` is 1504px at the - * default root size; both axes stay in `rem`/`dvh` so a consumer scaling type scales with - * them. `dvh` rather than `vh` for mobile browser chrome. + * `panel` fixes the height. Its content NAVIGATES — a settings surface switches sections in + * place — and a content-driven height would resize the window on every section change, in both + * directions at once since the viewport centres it. Its width is the surface's own. */ /** * How the viewport behaves when the popup is taller than the screen — the "inside scroll" vs @@ -413,47 +411,43 @@ export const sizes = stylex.create({ boxShadow: null, maxWidth: '25rem', }, + /** + * Like `card`, the panel does NOT paint itself. It is the account-profile and settings surface, + * which is a `ProfilePage` — so the frame comes from `ProfilePage.Root`'s own styles and the + * popup contributes geometry and motion only. Compose it by rendering the popup AS the page: + * + * }> + * + * That is also what makes `inline` a non-event for the surface: modal or in a page slot, the + * page paints itself the same way, and the dialog only decides where it sits. The `null`s + * remove the popup's own atoms outright — see the note on `card` — so `ProfilePage`'s apply + * unopposed. The width cap is the surface's too, since a page knows its own reading width. + * + * Consequence worth knowing: `size="panel"` with no surface inside renders an unpainted box. + */ panel: { - // No padding, unlike `card`. A panel's regions reach the popup's edges: a scroll region sits - // flush, so its scrollbar and edge fade land on the true edge rather than floating in a - // margin, and a sidebar can run the full height. Padding belongs to the children, which is - // the same trade `overflow: hidden` makes — the panel supplies the frame, the composition - // supplies the anatomy. - padding: space['0'], + padding: null, + borderColor: null, + borderRadius: null, + borderStyle: null, + borderWidth: null, + gap: null, // The panel does NOT scroll itself, and that is the whole design. A fixed-height surface // needs somewhere for overflow to go, but putting the scroll on the POPUP takes everything - // anchored to it along for the ride — the close button most obviously, and anything else a - // consumer positions against the corner. - // - // So the popup clips, and the scroll region is composed INSIDE it out of `scrollAreaRoot` / - // `scrollAreaViewport()`. That also buys the sidebar case for free: a fixed rail beside a - // scrolling column is just a flex row, where a Header/Body/Footer anatomy would have had to - // grow a second axis to express it. `overscroll-behavior` comes with the ScrollArea viewport, - // so it is not restated here. + // anchored to it along for the ride — the close button most obviously. So the popup clips, + // and the scroll region is the surface's own: `ProfilePage` scrolls its content column. // // `clip` rather than `hidden` for the same reason as the viewport: `hidden` would make the // panel a scroll container, and focusing anything inside it that sits outside its box would - // scroll the panel itself. The panel must never scroll — that is the composed region's job. + // scroll the panel itself. overflow: 'clip', // Fills the viewport's content box rather than computing a height from `dvh`. The grid row - // already stretches to the container (`place-items` sets `align-items`, not `align-content`, - // so the row keeps its default stretch), and that box is by definition "the viewport minus the - // inset on every side" — so `stretch` lands the panel's edges on exactly the same lines a - // bottom-anchored `prompt` sheet reaches with `align-self: end`. - // - // Deriving the height from `100dvh` let the two disagree: `dvh` is measured against the visual - // viewport while the grid box is 100% of the overlay, and wherever those differ — mobile - // browser chrome most obviously — the panel overhung the box and sat lower than the sheet. - // Stretching removes the arithmetic, and with it the class of bug. - // Fills the viewport's content box exactly, and clamps to it. Both follow from the row being - // definite (see `styles.viewport`) — without that a grid auto-row grows to its content, and - // `stretch` faithfully filled 2144px in an 800px viewport, so the composed scroll region never - // engaged. With it, the panel's edges land on the same lines a bottom-anchored sheet reaches - // and its overflow has somewhere to go. + // is definite (see `styles.viewport`), so `stretch` lands the panel's edges on exactly the + // lines a bottom-anchored `prompt` sheet reaches with `align-self: end`, and clamps to them. alignSelf: 'stretch', - // No `vw` term: the popup is `width: 100%` inside the viewport's padding, so the inset is - // already subtracted. This only caps how wide the panel may get — 1504px at the default root. - maxWidth: '94rem', + backgroundColor: null, + boxShadow: null, + maxWidth: null, }, }); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index 28f9b0e9eb9..d420cd3219f 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { MosaicComponentProps } from '../../props'; -import { space } from '../../tokens.stylex'; +import { colorVars, radiusVars, space } from '../../tokens.stylex'; import type { DialogSize } from './dialog'; import { Dialog } from './dialog'; @@ -494,22 +494,48 @@ describe('popup padding', () => { expect(prompt).not.toEqual(expect.arrayContaining(atomFor(probe.six))); }); - it('leaves a panel unpadded so its children can sit flush with the edge', () => { - const panel = popupClassesFor('panel'); + // A `card` takes its padding from the `Card` rendered as the popup, and a `panel` from the + // `ProfilePage`, so the popup must emit NO padding atom at all — a competing value would put + // two atoms for the same property on the element, and StyleX cannot dedupe across the two + // `stylex.props` calls involved. + it.each(['card', 'panel'] as const)('emits no padding at all for a %s, deferring to its surface', size => { + const classes = popupClassesFor(size); - expect(panel).toEqual(expect.arrayContaining(atomFor(probe.zero))); - expect(panel).not.toEqual(expect.arrayContaining(atomFor(probe.six))); + for (const value of [probe.zero, probe.four, probe.six]) { + expect(classes).not.toEqual(expect.arrayContaining(atomFor(value))); + } }); +}); - // A `card` takes its padding from the `Card` rendered as the popup, so the popup must emit NO - // padding atom at all — a competing value would put two atoms for the same property on the - // element, and StyleX cannot dedupe across the two `stylex.props` calls involved. - it('emits no padding at all for a card, deferring to the Card surface', () => { - const card = popupClassesFor('card'); +describe('popup surface', () => { + // `card` and `panel` are painted by what renders as the popup, so the popup itself must emit + // no paint of its own — the same cross-call dedupe problem as the padding above. StyleX names + // an atom from its property and value, so a probe with the popup's own values yields the very + // atoms `styles.popup` declares. + const probe = stylex.create({ + background: { backgroundColor: colorVars['--cl-color-card'] }, + radius: { borderRadius: radiusVars['--cl-radius-xl'] }, + }); - for (const value of [probe.zero, probe.four, probe.six]) { - expect(card).not.toEqual(expect.arrayContaining(atomFor(value))); - } + it('paints a prompt itself', () => { + renderSize('prompt'); + + expect(classesOf('.cl-dialog-popup')).toEqual(expect.arrayContaining(atomFor(probe.background))); + expect(classesOf('.cl-dialog-popup')).toEqual(expect.arrayContaining(atomFor(probe.radius))); + }); + + it.each(['card', 'panel'] as const)('emits no background for a %s, deferring to its surface', size => { + renderSize(size); + + expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.background))); + }); + + // A card keeps the popup's radius for the scale's counter-correction; a panel does not scale, + // so it has no reason to claim one over the page's own. + it('leaves the radius to the page for a panel', () => { + renderSize('panel'); + + expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.radius))); }); }); diff --git a/packages/ui/src/mosaic/profile-page.styles.ts b/packages/ui/src/mosaic/profile-page.styles.ts index bb2f060064e..50006250ee8 100644 --- a/packages/ui/src/mosaic/profile-page.styles.ts +++ b/packages/ui/src/mosaic/profile-page.styles.ts @@ -1,27 +1,65 @@ import * as stylex from '@stylexjs/stylex'; +import { scrollAreaRoot, scrollAreaViewport } from './components/scroll-area'; import { colorVars, fontWeightVars, radiusVars, space, targetVars, typeScaleVars } from './tokens.stylex'; -const profilePageCompact = '@media (max-width: 48rem)' as const; +/** + * The compact layout — sidebar on top, navigation as a row — queried against the page's OWN + * width rather than the window's. The root is the container (`cl-profile-page`), so the same + * page collapses in a narrow layout slot, an inline dialog, or a phone alike. A container cannot + * query itself, which is why the grid lives on an inner element: the root is the container, the + * layout inside it is what the query reshapes. + */ +const profilePageCompact = '@container cl-profile-page (max-width: 48rem)' as const; export const styles = stylex.create({ + /** + * The surface, and the query container. Paints the frame (border, radius, background) so the + * page looks the same standalone and as the popup of a `panel` dialog — that size paints + * nothing itself, and is composed by rendering the popup AS this root. + * + * A column flex so the layout below can take the remaining height: standalone that is the + * `minHeight`, in a dialog it is the popup's stretched height, and either way the content + * column scrolls inside it rather than growing past it. + */ root: { borderColor: colorVars['--cl-color-border'], borderRadius: radiusVars['--cl-radius-xl'], borderStyle: 'solid', borderWidth: '1px', - overflow: 'hidden', + // `clip` rather than `hidden`: the page must never become a scroll container itself, or + // focusing something in the content column would scroll the whole page instead of the column. + overflow: 'clip', backgroundColor: colorVars['--cl-color-card'], color: colorVars['--cl-color-card-foreground'], + containerName: 'cl-profile-page', + containerType: 'inline-size', + display: 'flex', + flexDirection: 'column', + maxWidth: '66rem', + minHeight: '37.5rem', + width: '100%', + }, + + /** Inside a dialog the popup decides the height, so the standalone floor would only overflow it. */ + rootInDialog: { + minHeight: null, + }, + + layout: { display: 'grid', + flexGrow: 1, gridTemplateColumns: { default: `calc(${space['40']} + ${space['15']}) minmax(0, 1fr)`, [profilePageCompact]: 'minmax(0, 1fr)', }, - gridTemplateRows: 'auto', - maxWidth: '66rem', - minHeight: '37.5rem', - width: '100%', + // A definite row is what lets the content column scroll instead of growing: an `auto` row + // sizes to its content and happily exceeds the container. + gridTemplateRows: { + default: 'minmax(0, 1fr)', + [profilePageCompact]: 'auto minmax(0, 1fr)', + }, + minHeight: 0, }, sidebar: { padding: space['4'], @@ -119,9 +157,14 @@ export const styles = stylex.create({ display: 'inline-flex', height: space['4'], }, + // The content column is the scroll region — composed from the `ScrollArea` atoms, so the + // scrollbar and edge fade land on the column's true edge and the padding scrolls with the content. main: { minWidth: 0 }, content: { paddingBlock: space['16'], paddingInline: space['16'], }, }); + +export const mainScroll = scrollAreaRoot; +export const contentScroll = scrollAreaViewport(); diff --git a/packages/ui/src/mosaic/profile-page.tsx b/packages/ui/src/mosaic/profile-page.tsx index 15b0f1e9ded..b20bd93f825 100644 --- a/packages/ui/src/mosaic/profile-page.tsx +++ b/packages/ui/src/mosaic/profile-page.tsx @@ -5,9 +5,10 @@ import * as stylex from '@stylexjs/stylex'; import React from 'react'; import { ClerkLogo } from './components/clerk-logo'; +import { DialogContext } from './components/dialog'; import { Icon } from './components/icon'; import type { IconName } from './icons/registry'; -import { styles } from './profile-page.styles'; +import { contentScroll, mainScroll, styles } from './profile-page.styles'; import type { MosaicComponentProps } from './props'; import { mergeStyleProps, themeProps } from './props'; import { focusOutline } from './utils/focus-outline.styles'; @@ -27,18 +28,33 @@ export interface ProfilePageRootProps extends Omit, children: React.ReactNode; } +/** + * The page: a surface holding a sidebar and a content column. Doubles as the popup of a `panel` + * dialog — `}>` — where the dialog + * positions it and this root paints it, the way a `card` dialog renders as a `Card`. + */ const ProfilePageRoot = React.forwardRef(function ProfilePageRoot( { value, onValueChange, orientation = 'vertical', activationMode, children, render, className, style, ...rest }, ref, ) { + const dialog = React.useContext(DialogContext); const element = useRender({ defaultTagName: 'div', render, ref, props: { - ...mergeStyleProps(themeProps('profile-page'), stylex.props(styles.root), className, style), + ...mergeStyleProps( + themeProps('profile-page'), + stylex.props(reset.base, styles.root, dialog !== null && styles.rootInDialog), + className, + style, + ), ...rest, - children, + children: ( +
+ {children} +
+ ), }, }); @@ -137,10 +153,20 @@ const ProfilePageContent = React.forwardRef +
{children}
), diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx index ae75a6a006d..117c27bcfe4 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx @@ -1,7 +1,9 @@ +import * as stylex from '@stylexjs/stylex'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { Dialog } from '../../components/dialog'; import { MosaicProvider } from '../../MosaicProvider'; import type { UserPageViewProps } from '../user-page.view'; import { UserPageView } from '../user-page.view'; @@ -123,4 +125,79 @@ describe('UserPageView', () => { expect(screen.queryByText('Secured by')).not.toBeInTheDocument(); }); + + // The compact layout is a container query against the page itself, so the page has to BE a + // container — drop that and it never collapses, at any width. + it('is the named container its compact layout queries', () => { + const probe = stylex.create({ container: { containerName: 'cl-profile-page', containerType: 'inline-size' } }); + const atoms = stylex + .props(probe.container) + .className!.split(' ') + .filter(name => !name.includes('__')); + const { container } = renderView(); + + expect(Array.from((container.firstChild as HTMLElement).classList)).toEqual(expect.arrayContaining(atoms)); + }); + + // The shape the account profile takes as a modal: the page IS the popup, and the dialog's own + // parts land inside it through `children`. + it('renders as the popup of a panel dialog, with the dialog parts inside it', () => { + render( + + + + } + > + + + + , + ); + + const popup = screen.getByRole('dialog', { name: 'Account' }); + expect(popup).toHaveClass('cl-profile-page', 'cl-dialog-popup'); + expect(popup).toContainElement(screen.getByRole('button', { name: 'Close' })); + expect(popup).toContainElement(screen.getByRole('tab', { name: 'Security' })); + }); + + it('drops its standalone minimum height inside a dialog, where the popup decides', () => { + const probe = stylex.create({ floor: { minHeight: '37.5rem' } }); + const atoms = stylex + .props(probe.floor) + .className!.split(' ') + .filter(name => !name.includes('__')); + + const standalone = renderView(); + expect(Array.from((standalone.container.firstChild as HTMLElement).classList)).toEqual( + expect.arrayContaining(atoms), + ); + standalone.unmount(); + + render( + + + + } + /> + + , + ); + expect(Array.from(screen.getByRole('dialog').classList)).not.toEqual(expect.arrayContaining(atoms)); + }); }); diff --git a/packages/ui/src/mosaic/user-profile/user-page.view.tsx b/packages/ui/src/mosaic/user-profile/user-page.view.tsx index 9d302d466db..b483355cc57 100644 --- a/packages/ui/src/mosaic/user-profile/user-page.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-page.view.tsx @@ -25,6 +25,11 @@ export interface UserPageViewProps extends Omit void; renderBranding?: boolean; + /** + * Rendered after the panels. When the page is the popup of a dialog, this is where the dialog's + * parts land — its `CloseButton`, and any prompt it opens. + */ + children?: React.ReactNode; } function getAvailablePanels(panels: UserPagePanels): UserProfilePanelId[] { @@ -62,7 +67,7 @@ function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPageP } export const UserPageView = React.forwardRef(function UserPageView( - { activePanel, panels, onPanelChange, renderBranding = true, render, className, style, ...rest }, + { activePanel, panels, onPanelChange, renderBranding = true, children, render, className, style, ...rest }, ref, ) { const availablePanels = getAvailablePanels(panels); @@ -101,6 +106,7 @@ export const UserPageView = React.forwardRef( ))} + {children} ); }); From 6f3201446fcbcf1d9af0a0cedbd87bd9c27749ef Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 2 Sep 2026 10:52:30 -0600 Subject: [PATCH 14/22] feat(ui): ProfilePage carries the dialog dismiss itself, like Card.Header Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 18 +++++----- .../src/stories/dialog.component.stories.tsx | 7 ++-- packages/ui/src/mosaic/profile-page.styles.ts | 13 +++++++ packages/ui/src/mosaic/profile-page.tsx | 21 +++++++---- .../__tests__/user-page.view.test.tsx | 35 +++++++++++++++---- 5 files changed, 68 insertions(+), 26 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 06a4d4d59e7..8774cddc71f 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -77,10 +77,10 @@ committing. Your own `setOpen(false)` skips that; use `Dialog.Close` when a clos ### `card` and `panel` bring their own surface Only `prompt` paints itself. A `card` renders **as** a `Card`, and a `panel` **as** a `ProfilePage` -(or `UserPageView`): the dialog positions and animates the popup, the surface paints it, and the -dialog's own parts go in as children. Use `Card.Title` / `Card.Description` inside a card — they -name the dialog through `DialogContext`, and `Card.Header` carries the dismiss, so no -`Dialog.CloseButton` is needed there. +(or `UserPageView`): the dialog positions and animates the popup, and the surface paints it. Both +surfaces read `DialogContext` and carry the dismiss themselves (`Card.Header`, `ProfilePage.Root`), +so `Dialog.CloseButton` is only for a `prompt`. Inside a card, `Card.Title` / `Card.Description` +name the dialog. ```tsx -}> - - +} /> ``` ### Alert dialogs @@ -188,9 +186,9 @@ runs. ### Close button -`Dialog.CloseButton` is the styled corner X, absolutely positioned so it can sit anywhere in the -children. Pass `aria-label` to localise it. `Dialog.Close` is the unstyled alternative for a footer -"Cancel". +`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. diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 0e70b7954db..9b05a2146fe 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -253,9 +253,9 @@ function AddEmailDialog({ /** * The real user page as the popup of a `panel` dialog. The dialog positions it and the page * paints itself — the same composition as a `card` rendering as a `Card` — so the page scrolls - * its own content column and collapses its own sidebar. The dialog's parts go in as children. - * Adding an email opens a `prompt` over the panel; the danger zone's delete confirmation is the - * page's own. + * its own content column, collapses its own sidebar, and carries the dismiss. Prompts it opens + * go in as children: adding an email opens one over the panel, and the danger zone's delete + * confirmation is the page's own. */ function AccountPage({ inline = false }: { inline?: boolean }) { const [addEmailOpen, setAddEmailOpen] = React.useState(false); @@ -276,7 +276,6 @@ function AccountPage({ inline = false }: { inline?: boolean }) { /> } > - {inline ? null : } , /** * The page: a surface holding a sidebar and a content column. Doubles as the popup of a `panel` * dialog — `}>` — where the dialog - * positions it and this root paints it, the way a `card` dialog renders as a `Card`. + * positions it and this root paints it, the way a `card` dialog renders as a `Card`. Like + * `Card.Header`, it then carries the dialog's dismiss itself, so the composition needs nothing + * passed in; standalone it renders no such thing. */ const ProfilePageRoot = React.forwardRef(function ProfilePageRoot( { value, onValueChange, orientation = 'vertical', activationMode, children, render, className, style, ...rest }, @@ -51,9 +53,15 @@ const ProfilePageRoot = React.forwardRef(f ), ...rest, children: ( -
- {children} -
+ <> + {/* First in the DOM, so it is the first tabbable element and takes the dialog's opening + focus — the same reason `Card.Header` renders its dismiss first. Never inline, which + nothing closes. */} + {dialog && !dialog.inline ? : null} +
+ {children} +
+ ), }, }); @@ -80,6 +88,7 @@ const ProfilePageSidebar = React.forwardRef { expect(Array.from((container.firstChild as HTMLElement).classList)).toEqual(expect.arrayContaining(atoms)); }); - // The shape the account profile takes as a modal: the page IS the popup, and the dialog's own - // parts land inside it through `children`. - it('renders as the popup of a panel dialog, with the dialog parts inside it', () => { + // The shape the account profile takes as a modal: the page IS the popup. + it('renders as the popup of a panel dialog, and carries its dismiss', () => { render( @@ -155,19 +154,43 @@ describe('UserPageView', () => { onPanelChange={vi.fn()} /> } - > - -
+ /> , ); const popup = screen.getByRole('dialog', { name: 'Account' }); expect(popup).toHaveClass('cl-profile-page', 'cl-dialog-popup'); + // The page carries the dismiss itself, the way `Card.Header` does — nothing is passed in. expect(popup).toContainElement(screen.getByRole('button', { name: 'Close' })); expect(popup).toContainElement(screen.getByRole('tab', { name: 'Security' })); }); + it('carries no dismiss standalone, or inline', () => { + const standalone = renderView(); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + standalone.unmount(); + + render( + + + + } + /> + + , + ); + expect(screen.queryByRole('button', { name: 'Close' })).not.toBeInTheDocument(); + }); + it('drops its standalone minimum height inside a dialog, where the popup decides', () => { const probe = stylex.create({ floor: { minHeight: '37.5rem' } }); const atoms = stylex From 0ac491a8f28ce98a52660eb1fbe8d858f1086b6f Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 2 Sep 2026 11:12:40 -0600 Subject: [PATCH 15/22] docs(swingset): fold the Dialog panel examples into one Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 8774cddc71f..973889b3a8b 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -292,40 +292,39 @@ The dialog that opens should always be a `prompt`; anything else warns in dev. ## Examples -### Scrolling a panel +### A panel -A `panel` clips rather than scrolls, and its surface owns the scroll: `ProfilePage` scrolls its -content column and keeps the sidebar put. Nothing to compose — render the page as the popup. +The account profile as a `panel`: the page is the popup, so it paints the frame, scrolls its own +content column, collapses its own sidebar, and carries the dismiss. The dialog only positions it. -### Scrolling a tall card - -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.) +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: -### Nested dialogs +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. -In the panel above, add an email address, type into the field and try to close it: the prompt -opens over the panel, and a confirmation stacks on the prompt. The same stack on its own: +### Scrolling a tall card + +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.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. - ### Detached triggers `Dialog.createHandle()` links a `Dialog.Trigger` to a `Dialog.Root` anywhere in the tree. The From c40677eb6fb7783aaa6e59117e60bb5d4d4a58f4 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 2 Sep 2026 11:15:23 -0600 Subject: [PATCH 16/22] docs(swingset): show the panel composition in each Dialog story Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../src/stories/dialog.component.stories.tsx | 39 +++++++++++++------ 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 9b05a2146fe..cf42a6c4296 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -257,14 +257,14 @@ function AddEmailDialog({ * go in as children: adding an email opens one over the panel, and the danger zone's delete * confirmation is the page's own. */ -function AccountPage({ inline = false }: { inline?: boolean }) { +export function Nested() { const [addEmailOpen, setAddEmailOpen] = React.useState(false); const { activePanel, setActivePanel, panels, addEmail } = useUserPageFixture({ onAddEmail: () => setAddEmailOpen(true), }); return ( - - {inline ? null : } + + ; -} - /** * The same page presented `inline`: it is the page's content rather than a surface over it, so - * there is no portal, scrim, scroll lock or focus trap, and nothing dismisses it. The prompts it - * opens are still modal over the whole page. + * 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 (
- + + + } + > + + +
); } From 27a42201869bc5195b9b3be692fb6e59c071d9b8 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 2 Sep 2026 11:24:12 -0600 Subject: [PATCH 17/22] feat(ui): compose dialog surfaces as children of the popup A Card or a ProfilePage now goes inside `Dialog.Popup` rather than being rendered as it. The popup is a transparent positioner sized for the surface, and the surface reads `DialogContext` to stay self-contained: `Card.Title` and the page's `label` name the dialog through a visually hidden heading on the popup's `labelId`, and `Card.Header` and `ProfilePage.Root` carry the dismiss. The page grows to fill the popup's height inside a dialog. The radius correction the popup applies during its scale no longer reaches a card's painted corners; the drift is under a pixel and accepted for the simpler composition. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 18 +- .../src/stories/dialog.component.stories.tsx | 160 ++++++++---------- .../mosaic/blocks/destructive/destructive.tsx | 107 ++++++------ .../mosaic/components/dialog/dialog.styles.ts | 34 ++-- packages/ui/src/mosaic/profile-page.styles.ts | 8 +- packages/ui/src/mosaic/profile-page.tsx | 30 +++- .../__tests__/user-page.view.test.tsx | 75 ++++---- .../mosaic/user-profile/user-page.view.tsx | 21 ++- 8 files changed, 234 insertions(+), 219 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 973889b3a8b..b00ddf63b9d 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -76,11 +76,11 @@ committing. Your own `setOpen(false)` skips that; use `Dialog.Close` when a clos ### `card` and `panel` bring their own surface -Only `prompt` paints itself. A `card` renders **as** a `Card`, and a `panel` **as** a `ProfilePage` -(or `UserPageView`): the dialog positions and animates the popup, and the surface paints it. Both -surfaces read `DialogContext` and carry the dismiss themselves (`Card.Header`, `ProfilePage.Root`), -so `Dialog.CloseButton` is only for a `prompt`. Inside a card, `Card.Title` / `Card.Description` -name the dialog. +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`. ```tsx -} /> + + + ``` ### Alert dialogs @@ -294,8 +296,8 @@ The dialog that opens should always be a `prompt`; anything else warns in dev. ### A panel -The account profile as a `panel`: the page is the popup, so it paints the frame, scrolls its own -content column, collapses its own sidebar, and carries the dismiss. The dialog only positions it. +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. - - } - > + + - - } - > + + } /> - } - > - - Sign in - Continue to your account. - - - - - - ( - - )} - /> - - + + + + Sign in + Continue to your account. + + + + + + ( + + )} + /> + + + ); @@ -522,39 +511,38 @@ 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} +
+ ))} +
+
+ + ( + + )} + /> + + +
); diff --git a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx index e3c3d2af88e..fa0804dc8f5 100644 --- a/packages/ui/src/mosaic/blocks/destructive/destructive.tsx +++ b/packages/ui/src/mosaic/blocks/destructive/destructive.tsx @@ -102,61 +102,58 @@ export function Destructive({ onOpenChange={onOpenChange} > {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/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 7dc9d1f4a22..437024229dd 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -384,18 +384,17 @@ export const sizes = stylex.create({ alignSelf: { [PHONE]: 'end', default: null }, maxWidth: { [PHONE]: 'none', default: '23.75rem' }, }, - // The one size that does NOT paint itself. A `card` is the sign-in / sign-up surface, which is - // a `Card` — so the surface comes from `Card`'s own `elevations.overlay` rather than from here, - // and the popup contributes only geometry and motion. Compose it by rendering the popup AS the - // card, not by nesting one inside the other: + // Does NOT paint itself. A `card` is the sign-in / sign-up surface, which is a `Card` — so the + // surface comes from `Card`'s own `elevations.overlay` rather than from here, and the popup + // contributes only geometry and motion. Compose it by rendering the card INSIDE the popup: // - // }> + // // - // One element then both paints and animates, which is what keeps the radius counter-scale in - // `popupMotion.card` landing on the corners you actually see. Nested, the popup would scale a - // transparent box while the `Card` inside it took the scale on its painted corners with no - // correction. `borderRadius` stays here for that reason; `Card` declares the same token, so the - // two agree at rest and the counter-scale wins during the transition on specificity. + // The card reads `DialogContext` from there: `Card.Title` names the dialog and `Card.Header` + // carries its dismiss, so the surface stays self-contained. The popup is then a transparent + // box that scales; the card's painted corners take that scale without the radius correction + // `popupMotion.card` applies to the popup itself — under a pixel for the length of the + // entrance, and accepted for the simpler composition. // // These are `null` rather than `transparent` / `none` / `0`. Within one `stylex.props` call a // later `null` REMOVES the earlier atom, so the popup emits no class for these properties at @@ -414,14 +413,15 @@ export const sizes = stylex.create({ /** * Like `card`, the panel does NOT paint itself. It is the account-profile and settings surface, * which is a `ProfilePage` — so the frame comes from `ProfilePage.Root`'s own styles and the - * popup contributes geometry and motion only. Compose it by rendering the popup AS the page: + * popup contributes geometry and motion only. Compose it by rendering the page INSIDE the popup: * - * }> + * * - * That is also what makes `inline` a non-event for the surface: modal or in a page slot, the - * page paints itself the same way, and the dialog only decides where it sits. The `null`s - * remove the popup's own atoms outright — see the note on `card` — so `ProfilePage`'s apply - * unopposed. The width cap is the surface's too, since a page knows its own reading width. + * The page reads `DialogContext` from there — it names the dialog, carries its dismiss, and + * fills the popup's height — and that is also what makes `inline` a non-event for the surface: + * modal or in a page slot, the page paints itself the same way, and the dialog only decides + * where it sits. The `null`s remove the popup's own atoms outright — see the note on `card`. + * The width cap matches the page's, the way `card` matches the `Card`. * * Consequence worth knowing: `size="panel"` with no surface inside renders an unpainted box. */ @@ -436,6 +436,8 @@ export const sizes = stylex.create({ // needs somewhere for overflow to go, but putting the scroll on the POPUP takes everything // anchored to it along for the ride — the close button most obviously. So the popup clips, // and the scroll region is the surface's own: `ProfilePage` scrolls its content column. + // Deliberately a flex column with no `align-items` override, so the surface inside stretches + // to the popup's width and grows to its height. // // `clip` rather than `hidden` for the same reason as the viewport: `hidden` would make the // panel a scroll container, and focusing anything inside it that sits outside its box would diff --git a/packages/ui/src/mosaic/profile-page.styles.ts b/packages/ui/src/mosaic/profile-page.styles.ts index 20c34db87d4..5b052541d60 100644 --- a/packages/ui/src/mosaic/profile-page.styles.ts +++ b/packages/ui/src/mosaic/profile-page.styles.ts @@ -43,9 +43,13 @@ export const styles = stylex.create({ width: '100%', }, - /** Inside a dialog the popup decides the height, so the standalone floor would only overflow it. */ + /** + * Inside a dialog the popup decides the height: the page grows to fill it (the popup is a + * column flex) and drops the standalone floor, which would only overflow it. + */ rootInDialog: { - minHeight: null, + flexGrow: 1, + minHeight: 0, }, /** diff --git a/packages/ui/src/mosaic/profile-page.tsx b/packages/ui/src/mosaic/profile-page.tsx index 593cc1d70c9..9a4a52c9e69 100644 --- a/packages/ui/src/mosaic/profile-page.tsx +++ b/packages/ui/src/mosaic/profile-page.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { ClerkLogo } from './components/clerk-logo'; import { Dialog, DialogContext } from './components/dialog'; import { Icon } from './components/icon'; +import { VisuallyHidden } from './components/visually-hidden'; import type { IconName } from './icons/registry'; import { contentScroll, mainScroll, styles } from './profile-page.styles'; import type { MosaicComponentProps } from './props'; @@ -21,6 +22,12 @@ export interface ProfilePageItem { } export interface ProfilePageRootProps extends Omit, 'children'> { + /** + * What the page is called. Inside a dialog it names the dialog, through a visually hidden + * heading carrying the popup's `labelId` — the counterpart of `Card.Title`, for a surface + * whose visible headings belong to its panels. + */ + label?: string; value: string; onValueChange?: (value: string) => void; orientation?: TabsProps['orientation']; @@ -29,14 +36,24 @@ export interface ProfilePageRootProps extends Omit, } /** - * The page: a surface holding a sidebar and a content column. Doubles as the popup of a `panel` - * dialog — `}>` — where the dialog - * positions it and this root paints it, the way a `card` dialog renders as a `Card`. Like - * `Card.Header`, it then carries the dialog's dismiss itself, so the composition needs nothing - * passed in; standalone it renders no such thing. + * The page: a surface holding a sidebar and a content column. Rendered inside a `panel` dialog's + * popup, it fills it and paints it — the dialog positions, the page paints, the way a `Card` does + * inside a `card` dialog. Like `Card`, it reads `DialogContext` to name the dialog and carry its + * dismiss, so the composition needs nothing passed in; standalone it renders neither. */ const ProfilePageRoot = React.forwardRef(function ProfilePageRoot( - { value, onValueChange, orientation = 'vertical', activationMode, children, render, className, style, ...rest }, + { + label, + value, + onValueChange, + orientation = 'vertical', + activationMode, + children, + render, + className, + style, + ...rest + }, ref, ) { const dialog = React.useContext(DialogContext); @@ -58,6 +75,7 @@ const ProfilePageRoot = React.forwardRef(f focus — the same reason `Card.Header` renders its dismiss first. Never inline, which nothing closes. */} {dialog && !dialog.inline ? : null} + {dialog && label ? }>{label} : null}
{children}
diff --git a/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx b/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx index 1c12fb362e6..1ebc2329ada 100644 --- a/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx +++ b/packages/ui/src/mosaic/user-profile/__tests__/user-page.view.test.tsx @@ -139,29 +139,26 @@ describe('UserPageView', () => { expect(Array.from((container.firstChild as HTMLElement).classList)).toEqual(expect.arrayContaining(atoms)); }); - // The shape the account profile takes as a modal: the page IS the popup. - it('renders as the popup of a panel dialog, and carries its dismiss', () => { + // The shape the account profile takes as a modal: the page inside the popup, self-contained. + it('names a panel dialog and carries its dismiss from inside the popup', () => { render( - - } - /> + + + , ); - const popup = screen.getByRole('dialog', { name: 'Account' }); - expect(popup).toHaveClass('cl-profile-page', 'cl-dialog-popup'); - // The page carries the dismiss itself, the way `Card.Header` does — nothing is passed in. + // Named from inside, the way `Card.Title` names a card dialog — nothing is passed in. + const popup = screen.getByRole('dialog', { name: 'User profile' }); + expect(popup).toContainElement(document.querySelector('.cl-profile-page')); + // And the dismiss comes from the page too, the way `Card.Header` carries a card's. expect(popup).toContainElement(screen.getByRole('button', { name: 'Close' })); expect(popup).toContainElement(screen.getByRole('tab', { name: 'Security' })); }); @@ -174,17 +171,13 @@ describe('UserPageView', () => { render( - - } - /> + + + , ); @@ -207,20 +200,24 @@ describe('UserPageView', () => { render( - - } - /> + + + , ); - expect(Array.from(screen.getByRole('dialog').classList)).not.toEqual(expect.arrayContaining(atoms)); + expect(Array.from(document.querySelector('.cl-profile-page')!.classList)).not.toEqual( + expect.arrayContaining(atoms), + ); + }); + + it('renders no heading for the dialog standalone', () => { + renderView(); + + expect(screen.queryByRole('heading', { name: 'User profile' })).not.toBeInTheDocument(); }); }); diff --git a/packages/ui/src/mosaic/user-profile/user-page.view.tsx b/packages/ui/src/mosaic/user-profile/user-page.view.tsx index b483355cc57..ea9e0755fc7 100644 --- a/packages/ui/src/mosaic/user-profile/user-page.view.tsx +++ b/packages/ui/src/mosaic/user-profile/user-page.view.tsx @@ -25,11 +25,8 @@ export interface UserPageViewProps extends Omit void; renderBranding?: boolean; - /** - * Rendered after the panels. When the page is the popup of a dialog, this is where the dialog's - * parts land — its `CloseButton`, and any prompt it opens. - */ - children?: React.ReactNode; + /** Names the page, and the dialog it is rendered in. Defaults to English; pass a localized string once one is available. */ + label?: string; } function getAvailablePanels(panels: UserPagePanels): UserProfilePanelId[] { @@ -67,7 +64,17 @@ function Panel({ panel, panels }: { panel: UserProfilePanelId; panels: UserPageP } export const UserPageView = React.forwardRef(function UserPageView( - { activePanel, panels, onPanelChange, renderBranding = true, children, render, className, style, ...rest }, + { + activePanel, + panels, + onPanelChange, + renderBranding = true, + label = 'User profile', + render, + className, + style, + ...rest + }, ref, ) { const availablePanels = getAvailablePanels(panels); @@ -82,6 +89,7 @@ export const UserPageView = React.forwardRef( return ( ( ))} - {children} ); }); From 46f9b7cf68b811f57a02bb2d6e4d4368d9a6d83b Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 2 Sep 2026 11:28:41 -0600 Subject: [PATCH 18/22] refactor(ui): drop the dialog's border-radius counter-scale It only reached corners the popup paints, and with card and panel painted by the surface inside, that is prompt alone. Under a pixel for the length of the entrance; not worth a composition rule. The note on ENTER_SCALE says how it could return self-contained. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../mosaic/components/dialog/dialog.styles.ts | 95 ++++++------------- .../mosaic/components/dialog/dialog.test.tsx | 6 +- 2 files changed, 31 insertions(+), 70 deletions(-) diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 437024229dd..fee738f3368 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -221,7 +221,7 @@ export const styles = stylex.create({ width: '100%', '::after': { inset: 0, - // Follows the popup's own radius, counter-scale included. + // Follows the popup's own radius. borderRadius: 'inherit', backgroundColor: colorVars['--cl-color-card'], content: '""', @@ -392,9 +392,7 @@ export const sizes = stylex.create({ // // The card reads `DialogContext` from there: `Card.Title` names the dialog and `Card.Header` // carries its dismiss, so the surface stays self-contained. The popup is then a transparent - // box that scales; the card's painted corners take that scale without the radius correction - // `popupMotion.card` applies to the popup itself — under a pixel for the length of the - // entrance, and accepted for the simpler composition. + // box that scales, and the card's painted corners scale with it — see `ENTER_SCALE`. // // These are `null` rather than `transparent` / `none` / `0`. Within one `stylex.props` call a // later `null` REMOVES the earlier atom, so the popup emits no class for these properties at @@ -405,6 +403,7 @@ export const sizes = stylex.create({ // Consequence worth knowing: `size="card"` with no `Card` inside renders an unpainted box. card: { padding: null, + borderRadius: null, gap: null, backgroundColor: null, boxShadow: null, @@ -528,17 +527,6 @@ export const backdropMotion = stylex.create({ }, }); -// The entering/exiting scale, and the radius that survives it. `transform: scale()` scales the -// RENDERED border-radius along with everything else, so a popup at 0.94 draws its corners at 94% -// of their value and the roundness drifts over the transition. Dividing the radius by the same -// factor cancels it exactly: `r/s` drawn at scale `s` renders as `r`. -// -// One same-file const feeds both, so the correction cannot drift from the scale it corrects. At -// 0.94 it is worth about 0.77px on a 12px radius — well above the threshold it sat at when the -// scale was 0.98 (0.24px), so this is now load-bearing rather than merely principled. -// -// Only the endpoints are exact. Both properties interpolate on the same curve over the same -// duration, so the mid-transition error is second-order and stays well under a pixel. // The plain CSS `ease-out` — `cubic-bezier(0, 0, 0.58, 1)` — used ONLY for the sheet's slide out. // // `--cl-ease-exit` (In Quad) is right for a small delta: over ~11px its slow start is imperceptible @@ -552,16 +540,23 @@ export const backdropMotion = stylex.create({ // other in Mosaic. If a second large-travel exit appears, it should graduate to one. const SHEET_EXIT_EASE = 'ease-out'; +// The entering/exiting scale. `transform: scale()` scales the RENDERED border-radius along with +// everything else, so a surface at 0.94 draws its corners at 94% of their value for the length of +// the transition — about 0.77px on a 12px radius. An earlier version cancelled that by dividing +// the popup's radius by the same factor, which only reaches corners the POPUP paints: since a +// `card` and a `panel` are painted by the surface inside, the correction had stopped reaching the +// corners that matter and was dropped rather than pushed into every surface's API. If it comes +// back, it should come back self-contained — the popup publishing its current scale as a custom +// property a surface can read to counter its own radius — not as a composition rule. const ENTER_SCALE = 0.94; -// How far a prompt recedes while another prompt is stacked on it, and the radius that survives -// that scale — the same `r/s` correction `ENTER_SCALE` documents above, for the same reason. +// How far a prompt recedes while another prompt is stacked on it. // // Shallower than the entrance scale on purpose: the entrance is a surface arriving from nowhere, // while this is a surface that stays legible the whole time and only has to read as further back. -// The lift (`STACK_LIFT`, at the top of this file) is what separates it from the entrance rather -// than the depth of the scale — a surface that only shrinks reads as being pushed away, one that -// shrinks and rises reads as being layered over, which is the relationship this actually is. +// The lift (`STACK_LIFT`) is what separates it from the entrance rather than the depth of the +// scale — a surface that only shrinks reads as being pushed away, one that shrinks and rises reads +// as being layered over, which is the relationship this actually is. // // A single step rather than a `--cl-stack-index` formula: the headless layer counts DIRECT // children, so a third level would report the same 1 as the second and every level below the top @@ -570,8 +565,6 @@ const ENTER_SCALE = 0.94; const STACK_SCALE = 0.96; const STACK_LIFT = '-0.5rem'; -const popupRadius = radiusVars['--cl-radius-xl']; - export const popupMotion = stylex.create({ /** * A prompt scales from its centre — except under the phone band, where it slides up @@ -581,27 +574,6 @@ export const popupMotion = stylex.create({ * Each cell is therefore self-contained and reads straight against the design matrix. */ prompt: { - borderRadius: { - [PHONE]: { - default: popupRadius, - ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`, - ':where([data-starting-style], [data-ending-style])': popupRadius, - }, - default: popupRadius, - // The recede is the one scale that survives the phone band, so unlike the entrance its - // radius correction is NOT pinned flat there — see `transform` below. - ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`, - ':where([data-starting-style], [data-ending-style])': `calc(${popupRadius} / ${ENTER_SCALE})`, - // Both entrance branches resolve to the same value, so their order relative to each other - // cannot matter: there is no scale to counteract in either case. The recede is the - // exception — it still applies under `reduce`, just without a duration — so its correction - // has to come with it. - '@media (prefers-reduced-motion: reduce)': { - default: popupRadius, - ':where([data-stack-base])': `calc(${popupRadius} / ${STACK_SCALE})`, - ':where([data-starting-style], [data-ending-style])': popupRadius, - }, - }, // One fade at every width, including the sheet. An earlier version pinned the sheet at // opacity 1 on the theory that a pure slide reads more like a native sheet — compared // side by side it did not; the fade gives the travel somewhere to resolve into rather than @@ -676,9 +648,8 @@ export const popupMotion = stylex.create({ // The FIRST slot tracks the fourth on the phone branch rather than staying at `fast`: opacity // and the slide are one gesture there, and a fade that finishes while the surface is still // travelling reads as a flash rather than as an arrival. Above the phone band the fade keeps - // `fast` and lands with the scrim, since the scale it accompanies barely moves. The third slot - // is inert under the phone band (no scale, so no radius counter-scale) but still has to be - // filled — the list is positional. + // `fast` and lands with the scrim, since the scale it accompanies barely moves. The list is + // positional against `transitionProperty` below. // // EXCEPT for a sheet arriving over another dialog, which takes the desktop `fast` fade back. // The long fade earns itself on the first sheet, where it gives the travel somewhere to @@ -692,20 +663,20 @@ export const popupMotion = stylex.create({ // arriving over something opaque, and a panel is as opaque as a prompt. // // The combined exiting branch restates `base` because `@stylexjs/sort-keys` puts it after the - // plain `data-stacked` one, which would otherwise hand a stacked sheet the four-value entrance + // plain `data-stacked` one, which would otherwise hand a stacked sheet the three-value entrance // list on its way out and slow its exit slide. transitionDuration: { [PHONE]: { - default: `${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`, + default: `${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-slow']}`, ':where([data-ending-style])': durationVars['--cl-duration-base'], - ':where([data-stacked])': `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`, + ':where([data-stacked])': `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-slow']}`, ':where([data-stacked][data-ending-style])': durationVars['--cl-duration-base'], }, - default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`, + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`, ':where([data-ending-style])': durationVars['--cl-duration-fast'], }, transitionProperty: { - default: 'opacity, transform, border-radius, translate', + default: 'opacity, transform, translate', '@media (prefers-reduced-motion: reduce)': 'opacity', }, // Unchanged by the sheet: a translate is still something that moves, so it wants the arrival @@ -713,14 +684,14 @@ export const popupMotion = stylex.create({ // `--cl-ease-default` because a surface this size should land rather than settle — Swift Out's // ~2% overshoot reads as the sheet arriving past its inset and correcting. transitionTimingFunction: { - default: `linear, ${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-enter']}`, - // Positional against `transitionProperty`, so the fourth slot is `translate` — the sheet's + default: `linear, ${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-enter']}`, + // Positional against `transitionProperty`, so the third slot is `translate` — the sheet's // slide, and the only one that departs from `--cl-ease-exit`. Set on the PLAIN // `[data-ending-style]` branch rather than behind a media query on purpose: `translate` is // unset above the phone band, so the slot is inert there, and a media-scoped branch would // have to out-rank a plain sibling on the same property — the fight documented on // `translate` below. - ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}, ${easingVars['--cl-ease-exit']}, ${SHEET_EXIT_EASE}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}, ${SHEET_EXIT_EASE}`, }, /** * The sheet's slide rides the independent `translate` property, NOT `transform` — and it @@ -754,14 +725,6 @@ export const popupMotion = stylex.create({ /** The sign-in / sign-up surface. Stays centred and centre-scaled at every width. */ card: { - borderRadius: { - default: popupRadius, - ':where([data-starting-style], [data-ending-style])': `calc(${popupRadius} / ${ENTER_SCALE})`, - '@media (prefers-reduced-motion: reduce)': { - default: popupRadius, - ':where([data-starting-style], [data-ending-style])': popupRadius, - }, - }, opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0, @@ -775,16 +738,16 @@ export const popupMotion = stylex.create({ }, }, transitionDuration: { - default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`, + default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}`, ':where([data-ending-style])': durationVars['--cl-duration-fast'], }, transitionProperty: { - default: 'opacity, transform, border-radius', + default: 'opacity, transform', '@media (prefers-reduced-motion: reduce)': 'opacity', }, transitionTimingFunction: { - default: `linear, ${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-enter']}`, - ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}, ${easingVars['--cl-ease-exit']}`, + default: `linear, ${easingVars['--cl-ease-enter']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}`, }, }, diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index d420cd3219f..7c62b2a50ab 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -530,10 +530,8 @@ describe('popup surface', () => { expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.background))); }); - // A card keeps the popup's radius for the scale's counter-correction; a panel does not scale, - // so it has no reason to claim one over the page's own. - it('leaves the radius to the page for a panel', () => { - renderSize('panel'); + it.each(['card', 'panel'] as const)('leaves the radius to the surface for a %s', size => { + renderSize(size); expect(classesOf('.cl-dialog-popup')).not.toEqual(expect.arrayContaining(atomFor(probe.radius))); }); From f15dc041e2d6067c3f21531021116379e10fccab Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 2 Sep 2026 11:29:53 -0600 Subject: [PATCH 19/22] docs(swingset): complete the Dialog panel composition snippet Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index b00ddf63b9d..4041f04e341 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -87,10 +87,25 @@ and `Dialog.Title` are only for a `prompt`. storyModule={DialogStories} /> +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): + ```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 From 104b71ab6497c40c244cba24ddb95d7007ed45c5 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 2 Sep 2026 12:14:15 -0600 Subject: [PATCH 20/22] fix(ui): query the dialog's width bands from inside the container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An element is never its own query container, so the inset ladder, the phone side inset and the prompt's phone-band clip — all declared on the viewport that names the `cl-dialog` container — never matched on a top-level dialog. The viewport is now the container and the sizing box only; the padded centering grid moves to a `dialog-track` element inside it, which is where every banded rule lives. Also from review: the portal test now has a real host boundary, the inline user-page test asserts the dialog is on screen, the stacked-prompts example guards against the last saved name rather than a literal, and the discard-changes example closes past the guard through its own setter. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01CvGwnCtiDZnTQ6NtCSVQeC --- .../swingset/src/stories/dialog.component.mdx | 8 +- .../src/stories/dialog.component.stories.tsx | 22 +-- .../mosaic/components/dialog/dialog.styles.ts | 137 ++++++++++-------- .../mosaic/components/dialog/dialog.test.tsx | 28 +++- .../src/mosaic/components/dialog/dialog.tsx | 23 ++- .../__tests__/user-page.view.test.tsx | 2 + 6 files changed, 135 insertions(+), 85 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 4041f04e341..f823c6b5de7 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -264,8 +264,8 @@ machine returning to idle) does not flash through the exit. | `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`) and the centering viewport -(`dialog-viewport`). +`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 @@ -287,8 +287,8 @@ State attributes: | ------------------------------------------- | ---------------------------------- | ------------------------------------- | | `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, Popup | Resolved size | -| `data-inline` | Viewport, Popup | Root is `inline` | +| `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 | diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 29e73b06ef8..ebebe39f18c 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -103,17 +103,13 @@ export function DiscardChanges() { 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() !== '', + when: () => value.trim() !== '', onOpenChange: next => { setOpen(next); if (!next) { - bypassGuardRef.current = false; setValue(''); } }, @@ -139,13 +135,15 @@ export function DiscardChanges() { }> 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. */} + {/* 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(); - bypassGuardRef.current = true; - onOpenChange(false, { trigger: null, triggerId: null, event: undefined }); + setValue(''); + setOpen(false); }} >