Max/dialog composition apis - #9420
Conversation
Adds `closedBy: 'any' | 'closerequest' | 'none'` to the headless Dialog root, driving `escapeKey` and `outsidePress` on `useDismiss`. Defaults to `any`, so existing callers are unaffected. The five Mosaic dialogs now use `closerequest`, which stops a stray backdrop click from discarding the type-to-confirm input in `Destructive` or closing a dialog mid-request. `trigger` on the Mosaic `Dialog` becomes optional, so the machine-driven dialogs no longer render a button they don't use. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moves the dialog off the Emotion slot-recipe engine onto StyleX, leaving `tabs` as the last component on the old path, and reworks its sizing, motion and mobile behaviour on top of that. `size` becomes three named surfaces — `prompt`, `card`, `panel` — and moves to `Dialog.Root`, since the backdrop reads it too. The gap to the screen edge is a fixed inset at three breakpoints rather than a percentage, which is what makes the surround an even frame. A `panel` clips and carries no padding, so its scroll region is composed inside it from the ScrollArea atoms; that keeps the close button anchored and makes a sidebar a plain flex row. Below 48rem a `prompt` becomes a bottom sheet, and `Dialog.Viewport` measures the on-screen keyboard so the sheet rises above it while a card re-centres and a panel shrinks. The chrome of a mobile browser is tinted to match the scrim, derived from the backdrop rather than shipped as a colour, refcounted across stacked dialogs and reverting exactly. Adds `Dialog.CloseButton`, `data-nested` for stacked scrims, and `--cl-dialog-origin` so a dialog scales out of whatever opened it. Also fixes a transition that never ran: it was keyed to a `data-cl-starting-style` attribute the headless layer does not emit, so dialogs appeared with no animation at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the trigger-origin open animation in favour of a plain centre scale, deleting the headless `useDialogOrigin` hook and `--cl-dialog-origin` with it. Motion. Add a `--cl-ease-enter` token — a decelerate curve that lands on target rather than carrying ~2% past it like `--cl-ease-default` — and take it for every dialog entrance. The backdrop fades a step faster than the popup, so the scrim answers the gesture and the surface arrives into an already-dimmed page. The mobile sheet now fades over the full length of its slide instead of holding opaque. Enter scale 0.98 -> 0.94. Fix a `prompt` that never scaled at all. Its transform was a single media-scoped rule with no resting declaration, leaving the transition with `transform: none` as its other endpoint; it now mirrors `card` exactly. Surface. Match the popup shadow to Menu's, which restores a visible hairline on dark surfaces. A `card` no longer paints itself: it takes its surface from a `Card` rendered AS the popup, so one element both paints and animates and the radius counter-scale keeps landing on the corners you can see. Scrolling. A dialog taller than the screen now scrolls, and how follows from its size rather than a prop. `panel` keeps a pinned viewport and scrolls inside; `prompt` and `card` grow with their content, so the dialog moves within the viewport and keeps its inset at both ends. `Dialog.Viewport` gains `data-size`. Spacing. `prompt` padding to 1rem; phone-band inset to 1.25rem block, 1rem inline. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Draw a real border on the popup under `forced-colors: active`. The mode discards `box-shadow` and the scrim alike, so the dialog otherwise floats edgeless over the page with nothing to separate it. A border rather than an outline, since the popup clears its outline deliberately — `FloatingFocusManager` focuses it when it holds no tabbable content — and the two would collide. Wrap long unbroken strings on the popup, matching `Popover`. A dialog holds prose it did not author, and an email address or an API key would otherwise push past the size's width clamp. Warn in development when a dialog has no accessible name, extracting `Popover`'s check into a shared `useAccessibleNameWarning` and pointing both at it. The check now RESOLVES `aria-labelledby` rather than testing for its presence: `Dialog` emits the attribute unconditionally, so with no `Dialog.Title` it references an id that is not in the document, which a presence check waves through while naming the dialog no better than having no attribute at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Dialog.createHandle()` returns a handle passed to both a `Dialog.Trigger`
and a `Dialog.Root`, so a trigger drives a dialog it is not nested under.
The handle also exposes imperative `open()` / `close()` / `isOpen`.
Several triggers can share one dialog, each carrying an `id` and a
`payload`, with the root's children as a function of `{ payload }` so one
dialog renders per-trigger content. Everything keyed to "the trigger" now
follows the one actually used — the dialog scales out of it and returns
focus to it — and `triggerId` names the active trigger in controlled mode,
which also gives controlled, trigger-less dialogs the origin-aware open.
`initialFocus` and `finalFocus` on `Dialog.Popup` take `true`, `false`, a
ref, or a function of the interaction type behind the change. Defaults are
unchanged: first tabbable on open, the trigger on close, except after a
pointer-driven dismissal.
Also retunes the dialog for dark mode. The scrim veils rather than darkens
there — light grey at low alpha over a dark page, against black over a
light one — so the two schemes are unrelated colours rather than one
colour at two opacities. The nested scrim stays solved rather than picked,
landing two levels on the same proportional deepening as light. The popup
shadow becomes one three-layer shadow shared by both schemes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: f563af4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository YAML (base), Organization UI (inherited) Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (38)
Disabled knowledge base sources:
📝 WalkthroughWalkthroughThe pull request rebuilds Mosaic Dialog on top of new headless Dialog primitives. It adds detached and multiple triggers, typed payloads, dismissal policies, detailed callbacks, and configurable focus targets. The Mosaic implementation adds StyleX surfaces, nested-dialog behavior, responsive sizing, motion, keyboard insets, browser-chrome synchronization, close buttons, and accessibility diagnostics. Tests, stories, documentation, consumer configurations, exports, tokens, and changesets are updated. Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested reviewers: ✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
Comment |
API Changes Report
Summary
🔴 Breaking changes index (3)Every breaking change, up front. Full diffs are in the package sections below.
@clerk/nextjsCurrent version: 7.7.4 🔴 Breaking Changes (3)Changed:
|
Description
Adds Base UI-style composition APIs to the headless
Dialog. Split out of #9388 so the runtime changes are reviewed on their own.Dialog.createHandle()returns a handle; pass it to aDialog.Triggerand aDialog.Rootand the trigger drives the dialog from anywhere in the tree, with no JSX nesting.open()/close()/isOpenfor opens with no trigger element. Calls made while no root is mounted are ignored.idand apayload; the root's children can be a function of the active trigger's payload, so one dialog renders per-trigger content.onOpenChangegains a details argument naming the trigger behind each change, andtriggerIdon the root drives attribution in controlled mode. Focus returns to whichever trigger was actually used.initialFocusandfinalFocusonDialog.Popuptaketrue,false, a ref, or a function of the interaction type behind the open/close.handlecreates a private one, so nested and detached triggers share a single registration and open/close path.Internals worth a look on review:
useClick— a detached trigger can't collect reference props from context.onOpenChangedetails and thefinalFocusresolution each happen exactly once, synchronously, before focus restoration runs.useSyncExternalStore, so a trigger with an inline-objectpayloadcan't loop with the root re-registering it.Checklist
pnpm testruns as expected.pnpm buildruns as expected.Type of change