Skip to content

Max/dialog composition apis - #9420

Closed
maxyinger wants to merge 6 commits into
mainfrom
max/dialog-composition-apis
Closed

Max/dialog composition apis#9420
maxyinger wants to merge 6 commits into
mainfrom
max/dialog-composition-apis

Conversation

@maxyinger

Copy link
Copy Markdown
Collaborator

Description

Adds Base UI-style composition APIs to the headless Dialog. Split out of #9388 so the runtime changes are reviewed on their own.

  • Detached triggersDialog.createHandle() returns a handle; pass it to a Dialog.Trigger and a Dialog.Root and the trigger drives the dialog from anywhere in the tree, with no JSX nesting.
  • Imperative control — the same handle carries open() / close() / isOpen for opens with no trigger element. Calls made while no root is mounted are ignored.
  • Multiple triggers — each trigger takes an id and a payload; the root's children can be a function of the active trigger's payload, so one dialog renders per-trigger content.
  • Trigger attributiononOpenChange gains a details argument naming the trigger behind each change, and triggerId on the root drives attribution in controlled mode. Focus returns to whichever trigger was actually used.
  • Custom focusinitialFocus and finalFocus on Dialog.Popup take true, false, a ref, or a function of the interaction type behind the open/close.
  • A root with no handle creates a private one, so nested and detached triggers share a single registration and open/close path.

Internals worth a look on review:

  • Trigger activation moves off floating-ui's useClick — a detached trigger can't collect reference props from context.
  • Every open and close (trigger, dismissal, programmatic) funnels through one path, so the onOpenChange details and the finalFocus resolution each happen exactly once, synchronously, before focus restoration runs.
  • The trigger registry is subscribed to imperatively rather than via useSyncExternalStore, so a trigger with an inline-object payload can't loop with the root re-registering it.

Checklist

  • pnpm test runs as expected.
  • pnpm build runs as expected.
  • (If applicable) JSDoc comments have been added or updated for any package exports
  • (If applicable) Documentation has been updated

Type of change

  • 🐛 Bug fix
  • 🌟 New feature
  • 🔨 Breaking change
  • 📖 Refactoring / dependency upgrade / documentation
  • other:

maxyinger and others added 6 commits August 7, 2026 15:16
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-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f563af4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@clerk/ui Patch
@clerk/headless Patch
@clerk/chrome-extension Patch
@clerk/swingset Patch

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

@github-actions github-actions Bot added the ui label Aug 12, 2026
@maxyinger maxyinger closed this Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository YAML (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 36688779-e45d-44ad-9ad5-2320a2b713b5

📥 Commits

Reviewing files that changed from the base of the PR and between f9c97ef and f563af4.

📒 Files selected for processing (38)
  • .changeset/lucky-donuts-invite.md
  • .changeset/olive-doors-tell.md
  • .changeset/spicy-clocks-argue.md
  • packages/headless/src/primitives/dialog/README.md
  • packages/headless/src/primitives/dialog/dialog-backdrop.tsx
  • packages/headless/src/primitives/dialog/dialog-context.ts
  • packages/headless/src/primitives/dialog/dialog-handle.ts
  • packages/headless/src/primitives/dialog/dialog-popup.tsx
  • packages/headless/src/primitives/dialog/dialog-root.tsx
  • packages/headless/src/primitives/dialog/dialog-trigger.tsx
  • packages/headless/src/primitives/dialog/dialog-viewport.tsx
  • packages/headless/src/primitives/dialog/dialog.test.tsx
  • packages/headless/src/primitives/dialog/index.ts
  • packages/headless/src/primitives/dialog/parts.ts
  • packages/headless/src/primitives/drawer/drawer-context.ts
  • packages/headless/src/utils/interaction-modality.ts
  • packages/swingset/src/stories/dialog.component.mdx
  • packages/swingset/src/stories/dialog.component.stories.tsx
  • packages/swingset/src/stories/dialog.mdx
  • packages/swingset/src/stories/dialog.stories.tsx
  • packages/ui/src/mosaic/block/destructive.tsx
  • packages/ui/src/mosaic/components/button/button.tsx
  • packages/ui/src/mosaic/components/dialog.tsx
  • packages/ui/src/mosaic/components/dialog/browser-chrome.ts
  • packages/ui/src/mosaic/components/dialog/dialog.styles.ts
  • packages/ui/src/mosaic/components/dialog/dialog.test.tsx
  • packages/ui/src/mosaic/components/dialog/dialog.tsx
  • packages/ui/src/mosaic/components/dialog/index.ts
  • packages/ui/src/mosaic/components/dialog/keyboard-inset.ts
  • packages/ui/src/mosaic/components/popover/popover.tsx
  • packages/ui/src/mosaic/hooks/useAccessibleNameWarning.ts
  • packages/ui/src/mosaic/organization/organization-profile-domains-section-add-verify.view.tsx
  • packages/ui/src/mosaic/organization/organization-profile-domains-section-enrollment.view.tsx
  • packages/ui/src/mosaic/organization/organization-profile-domains-section-remove.view.tsx
  • packages/ui/src/mosaic/organization/organization-profile-profile-section.view.tsx
  • packages/ui/src/mosaic/primitives/dialog.tsx
  • packages/ui/src/mosaic/styles/index.ts
  • packages/ui/src/mosaic/tokens.stylex.ts

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


📝 Walkthrough

Walkthrough

The 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: alexcarpenter

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-08-12T19:16:25.571Z

Summary

Metric Count
Packages analyzed 19
Packages with changes 1
🔴 Breaking changes 3
🟡 Non-breaking changes 0
🟢 Additions 0

Warning
3 breaking change(s) detected - Major version bump required

🤖 This report was reviewed by claude-sonnet-4-6.

🔴 Breaking changes index (3)

Every breaking change, up front. Full diffs are in the package sections below.

Package Subpath Change
@clerk/nextjs . Protect
@clerk/nextjs . SignedIn
@clerk/nextjs . SignedOut

@clerk/nextjs

Current version: 7.7.4
Recommended bump: MAJOR → 8.0.0

🔴 Breaking Changes (3)

Changed: Protect

- export declare function Protect(_props: RemovedControlComponentProps): never;

Static analyzer: Removed function Protect

🤖 AI review (confirmed) (95%): The Protect function was previously exported and consumers may import and use it; removing it breaks any such import.

Migration: Remove any usage of Protect from @clerk/nextjs or migrate to the equivalent component from the appropriate Clerk package.

Changed: SignedIn

- export declare function SignedIn(_props: RemovedControlComponentProps): never;

Static analyzer: Removed function SignedIn

🤖 AI review (confirmed) (95%): The SignedIn function was previously exported and consumers may import and use it; removing it breaks any such import.

Migration: Remove any usage of SignedIn from @clerk/nextjs or migrate to the equivalent component from the appropriate Clerk package.

Changed: SignedOut

- export declare function SignedOut(_props: RemovedControlComponentProps): never;

Static analyzer: Removed function SignedOut

🤖 AI review (confirmed) (95%): The SignedOut function was previously exported and consumers may import and use it; removing it breaks any such import.

Migration: Remove any usage of SignedOut from @clerk/nextjs or migrate to the equivalent component from the appropriate Clerk package.


Report generated by Break Check

Last ran on f563af4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant