Skip to content

feat(ui): add Base UI-style composition APIs to the Dialog - #9419

Merged
maxyinger merged 5 commits into
mainfrom
max/dialog-composition-apis
Aug 12, 2026
Merged

feat(ui): add Base UI-style composition APIs to the Dialog#9419
maxyinger merged 5 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 rather than alongside that PR's styling work.

Detached triggers. Dialog.createHandle() returns a handle; pass the same one to a
Dialog.Trigger and a Dialog.Root and the trigger drives the dialog from anywhere in the tree,
with no JSX nesting. The handle also carries imperative open() / close() / isOpen for opens
with no trigger element at all. A root with no handle creates a private one, so nested and
detached triggers share a single registration and open/close path.

Multiple triggers and payloads. Each trigger can carry an id and a payload, and the root's
children can be a function of the active trigger's payload — one dialog, per-trigger content.
onOpenChange gains a details argument naming the trigger behind each change, and triggerId on
the root drives the attribution in controlled mode.

Custom focus management. initialFocus and finalFocus on Dialog.Popup take true, false,
a ref, or a function of the interaction type behind the open/close.

Notable internals: trigger activation moves off floating-ui's useClick, since a detached trigger
cannot collect reference props from context. Every open and close — trigger, dismissal, programmatic
— now funnels through one path so the onOpenChange details and the finalFocus resolution each
happen exactly once, synchronously, before focus restoration can run. The trigger registry is
subscribed to imperatively rather than via useSyncExternalStore, so a trigger with an inline-object
payload cannot 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:

@changeset-bot

changeset-bot Bot commented Aug 12, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 11fe160

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

This PR includes changesets to release 0 packages

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

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

@vercel

vercel Bot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
clerk-js-sandbox Ready Ready Preview Aug 12, 2026 11:04pm
swingset Ready Ready Preview Aug 12, 2026 11:04pm

Request Review

@pkg-pr-new

pkg-pr-new Bot commented Aug 12, 2026

Copy link
Copy Markdown

Open in StackBlitz

@clerk/astro

npm i https://pkg.pr.new/@clerk/astro@9419

@clerk/backend

npm i https://pkg.pr.new/@clerk/backend@9419

@clerk/chrome-extension

npm i https://pkg.pr.new/@clerk/chrome-extension@9419

@clerk/clerk-js

npm i https://pkg.pr.new/@clerk/clerk-js@9419

@clerk/electron

npm i https://pkg.pr.new/@clerk/electron@9419

@clerk/electron-passkeys

npm i https://pkg.pr.new/@clerk/electron-passkeys@9419

@clerk/eslint-plugin

npm i https://pkg.pr.new/@clerk/eslint-plugin@9419

@clerk/expo

npm i https://pkg.pr.new/@clerk/expo@9419

@clerk/expo-google-signin

npm i https://pkg.pr.new/@clerk/expo-google-signin@9419

@clerk/expo-passkeys

npm i https://pkg.pr.new/@clerk/expo-passkeys@9419

@clerk/express

npm i https://pkg.pr.new/@clerk/express@9419

@clerk/fastify

npm i https://pkg.pr.new/@clerk/fastify@9419

@clerk/hono

npm i https://pkg.pr.new/@clerk/hono@9419

@clerk/localizations

npm i https://pkg.pr.new/@clerk/localizations@9419

@clerk/nextjs

npm i https://pkg.pr.new/@clerk/nextjs@9419

@clerk/nuxt

npm i https://pkg.pr.new/@clerk/nuxt@9419

@clerk/react

npm i https://pkg.pr.new/@clerk/react@9419

@clerk/react-router

npm i https://pkg.pr.new/@clerk/react-router@9419

@clerk/shared

npm i https://pkg.pr.new/@clerk/shared@9419

@clerk/tanstack-react-start

npm i https://pkg.pr.new/@clerk/tanstack-react-start@9419

@clerk/testing

npm i https://pkg.pr.new/@clerk/testing@9419

@clerk/ui

npm i https://pkg.pr.new/@clerk/ui@9419

@clerk/upgrade

npm i https://pkg.pr.new/@clerk/upgrade@9419

@clerk/vue

npm i https://pkg.pr.new/@clerk/vue@9419

commit: 11fe160

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

API Changes Report

Generated by Break Check on 2026-08-12T23:05:39.817Z

Summary

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

No API Changes Detected

All packages have stable APIs with no detected changes.


Report generated by Break Check

Last ran on 11fe160.

maxyinger added a commit that referenced this pull request Aug 12, 2026
`Dialog.Root` does not take a `handle` — that arrives with the composition APIs
in #9419, not here.

`data-size` lands on the Viewport as well as the Popup: `themeProps` emits
`data-<axis>` for every variant it is handed, and `size` is passed to both.
@maxyinger
maxyinger force-pushed the max/dialog-composition-apis branch from 8e7129c to 9b86dcd Compare August 12, 2026 20:59
maxyinger added a commit that referenced this pull request Aug 12, 2026
`Dialog.Root` does not take a `handle` — that arrives with the composition APIs
in #9419, not here.

`data-size` lands on the Viewport as well as the Popup: `themeProps` emits
`data-<axis>` for every variant it is handed, and `size` is passed to both.
@maxyinger
maxyinger force-pushed the max/dialog-composition-apis branch from 9b86dcd to 0385d4c Compare August 12, 2026 21:04
@maxyinger
maxyinger force-pushed the max/dialog-composition-apis branch from 0385d4c to 4a1e4b1 Compare August 12, 2026 21:17
Base automatically changed from max/dialog-updates-stylex to main August 12, 2026 21:28
@maxyinger
maxyinger force-pushed the max/dialog-composition-apis branch from 4a1e4b1 to df51920 Compare August 12, 2026 21:28
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: d4c77837-91ec-470a-902b-f70a9da88511

📥 Commits

Reviewing files that changed from the base of the PR and between 9cee303 and 11fe160.

📒 Files selected for processing (9)
  • packages/headless/src/hooks/use-return-focus.test.ts
  • packages/headless/src/hooks/use-return-focus.ts
  • packages/headless/src/primitives/dialog/README.md
  • packages/headless/src/primitives/dialog/dialog-close.tsx
  • packages/headless/src/primitives/dialog/dialog-context.ts
  • packages/headless/src/primitives/dialog/dialog-handle.ts
  • packages/headless/src/primitives/dialog/dialog-root.tsx
  • packages/headless/src/primitives/dialog/dialog-trigger.tsx
  • packages/headless/src/primitives/dialog/dialog.test.tsx
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go (manual)
  • clerk/dashboard (manual)
  • clerk/accounts (manual)
  • clerk/backoffice (manual)
  • clerk/clerk (manual)
  • clerk/clerk-docs (manual)
  • clerk/cloudflare-workers (manual)
  • clerk/clerk-ios (auto-detected)
  • clerk/cli (auto-detected)
  • clerk/clerk-android (auto-detected)
🚧 Files skipped from review as they are similar to previous changes (7)
  • packages/headless/src/primitives/dialog/dialog-context.ts
  • packages/headless/src/primitives/dialog/dialog-handle.ts
  • packages/headless/src/primitives/dialog/dialog.test.tsx
  • packages/headless/src/primitives/dialog/README.md
  • packages/headless/src/primitives/dialog/dialog-trigger.tsx
  • packages/headless/src/primitives/dialog/dialog-root.tsx
  • packages/headless/src/hooks/use-return-focus.ts

📝 Walkthrough

Walkthrough

The dialog API adds detached trigger handles, typed trigger payloads, trigger attribution in open-change details, and configurable initial and final focus targets. Headless dialog state now uses a trigger registry and centralized open-change handling. Mosaic Dialog propagates the new types and handle API. Tests, documentation, stories, and a Changesets entry were updated.

Estimated code review effort: 4 (Complex) | ~60 minutes

Mergeability Score: 🟡 Moderate · up to 11fe1

The new Dialog composition APIs change trigger attribution, payloads, and focus restoration; a controlled close can leave function-based final focus unresolved, potentially stranding keyboard focus. The release entry and API reference also need follow-up, so merge should wait for the focus issue and release/documentation readiness to be addressed or explicitly accepted.

Possibly related PRs

Suggested reviewers: alexcarpenter

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.87% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding Base UI-style composition APIs to the Dialog.
Description check ✅ Passed The description directly explains the new Dialog APIs, implementation scope, tests, builds, and documentation status.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/swingset/src/stories/dialog.component.mdx (1)

19-29: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the complete Mosaic Dialog composition API.

Lines 19-29 omit handle and triggerId. The children type also omits the active trigger payload. Lines 186-191 omit triggerId from Dialog.Root and id from Dialog.Trigger.

Add these entries so consumers can configure detached triggers and controlled attribution from this API reference.

As per coding guidelines, “Update documentation for API changes.”

Also applies to: 184-192

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/swingset/src/stories/dialog.component.mdx` around lines 19 - 29,
Update the Dialog composition API documentation in the PropTable and the
Dialog.Root/Dialog.Trigger sections. Add entries for handle and triggerId,
include the active trigger payload in the children callback type, and document
triggerId on Dialog.Root and id on Dialog.Trigger, preserving the existing
formatting and defaults.

Source: Coding guidelines

🧹 Nitpick comments (1)
packages/headless/src/primitives/dialog/dialog-root.tsx (1)

89-95: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Condense the rationale comment blocks.

The coding guidelines limit warranted comments to one terse line. These three blocks span 4 to 7 lines each. Keep the non-obvious reason and drop the narrative.

Example for Line 145-151: state only that the reference must be the active trigger and that the subscription is imperative to avoid a re-register/re-render loop.

As per coding guidelines: "Keep code comments minimal. Add comments only when critical to explain why a non-obvious change was made; never restate code behavior, and keep warranted comments to one terse line rather than a verbose multi-line block."

Also applies to: 145-151, 162-165

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/headless/src/primitives/dialog/dialog-root.tsx` around lines 89 -
95, Condense the rationale comments near pendingDetailsRef, the active-trigger
subscription, and the open/close funnel to one terse line each. Preserve only
the non-obvious reasons: retain the reference to the active trigger, keep the
subscription imperative to avoid re-register/re-render loops, and note any
necessary synchronous detail/focus ordering; remove narrative descriptions of
routine behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.changeset/spicy-clocks-argue.md:
- Around line 1-2: Add a non-empty changeset release entry in the empty front
matter of spicy-clocks-argue.md, assigning the appropriate version bump for
`@clerk/ui` and briefly describing the public Dialog API additions, including
handles, payloads, trigger attribution, and focus-target APIs.

In `@packages/headless/src/primitives/dialog/dialog-popup.tsx`:
- Around line 78-107: Update the function-valued finalFocus handling around
resolvedFinalFocusRef and finalFocusResolverRef to track whether resolution has
run, defaulting resolvedReturnFocus to returnFocusRef until it has. Reset that
resolution state whenever the dialog opens so controlled closes restore trigger
focus, while preserving a resolved null when the callback explicitly returns
false.

In `@packages/headless/src/primitives/dialog/dialog-trigger.tsx`:
- Around line 56-63: The registration effect in DialogTrigger must not
unregister and re-register when only payload changes. Keep the existing
store.registerTrigger subscription stable for the element and triggerId, and
update the registered payload through the store’s existing registration/update
mechanism without emitting transient unregister notifications or changing the
root reference to null.

In `@packages/headless/src/primitives/dialog/dialog.test.tsx`:
- Around line 641-650: Update Dialog.Close’s close path to pass the triggering
event’s nativeEvent into setOpen so programmatic pointer closes resolve
finalFocus with the native event type. In the test around the function-form
close case, rename the test to reflect native-event forwarding and change the
expected finalFocus argument from an empty string to 'mouse'.

In `@packages/swingset/src/stories/dialog.component.stories.tsx`:
- Around line 518-522: Add accessible labeling to both Input controls in the
dialog story: associate a visible form label or provide an aria-label
identifying the first field as Name and the feedbackRef field as Feedback, while
preserving their existing placeholders.

---

Outside diff comments:
In `@packages/swingset/src/stories/dialog.component.mdx`:
- Around line 19-29: Update the Dialog composition API documentation in the
PropTable and the Dialog.Root/Dialog.Trigger sections. Add entries for handle
and triggerId, include the active trigger payload in the children callback type,
and document triggerId on Dialog.Root and id on Dialog.Trigger, preserving the
existing formatting and defaults.

---

Nitpick comments:
In `@packages/headless/src/primitives/dialog/dialog-root.tsx`:
- Around line 89-95: Condense the rationale comments near pendingDetailsRef, the
active-trigger subscription, and the open/close funnel to one terse line each.
Preserve only the non-obvious reasons: retain the reference to the active
trigger, keep the subscription imperative to avoid re-register/re-render loops,
and note any necessary synchronous detail/focus ordering; remove narrative
descriptions of routine behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

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

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f4703a4-ee88-4a51-b948-a3d2f067f0d1

📥 Commits

Reviewing files that changed from the base of the PR and between d7727d9 and df51920.

📒 Files selected for processing (19)
  • .changeset/spicy-clocks-argue.md
  • packages/headless/src/primitives/dialog/README.md
  • 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.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/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
🔗 Linked repositories identified

CodeRabbit considers these linked repositories for cross-repo context during reviews:

  • clerk/clerk_go (manual)
  • clerk/dashboard (manual)
  • clerk/accounts (manual)
  • clerk/backoffice (manual)
  • clerk/clerk (manual)
  • clerk/clerk-docs (manual)
  • clerk/cloudflare-workers (manual)
  • clerk/clerk-ios (auto-detected)
  • clerk/cli (auto-detected)
  • clerk/clerk-android (auto-detected)

Comment thread .changeset/spicy-clocks-argue.md
Comment thread packages/headless/src/primitives/dialog/dialog-popup.tsx Outdated
Comment thread packages/headless/src/primitives/dialog/dialog-trigger.tsx Outdated
Comment thread packages/headless/src/primitives/dialog/dialog.test.tsx Outdated
Comment thread packages/swingset/src/stories/dialog.component.stories.tsx
maxyinger and others added 5 commits August 12, 2026 17:01
`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 replaces the popup shadow with one three-layer shadow shared by both
schemes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s openchange

Drops `finalFocusResolverRef` from the dialog context. The popup now subscribes
to floating-ui's `openchange` itself, which fires synchronously before the state
commit, so the root no longer has to reach into the popup on every close.

That lets programmatic `setOpen` route through `floatingContext.onOpenChange`
like trigger activations and dismissals already did, which removes
`applyOpenChange` and the ref-repointing dance that kept its closures fresh.
Before, a programmatic close bypassed floating-ui's emit entirely, so no
listener saw it.

The resolved target is now materialised lazily, when the focus manager reads the
ref at restore time, rather than eagerly at close. Ordering between this and
`useReturnFocus` therefore stops mattering: the pointer-close downgrade has
landed on the default by the time it is read.

Also extracts `useInitialFocus` / `useFinalFocus` from the popup body, and drops
`getRegistryVersion`, which nothing consumed.
…e bus

Dialog's programmatic closes route through `floatingContext.onOpenChange`, so
they do reach this listener — they just carry no event. Menu and Popover are
the case the note described: their closes go through the consumer's own state
setter and never reach the bus at all.
`useReturnFocus` now requires a floating-ui `reason` alongside the event before
it drops the trigger. A reason marks a close its interaction hooks drove — an
outside press, a trigger press — which is the case that should leave focus where
the pointer put it. A `Dialog.Close` press is not that: focus is inside the
dialog and about to be destroyed, so it belongs back on the trigger.

`Dialog.Close` forwards its click through `setOpen`, which now takes an optional
event, so `finalFocus` can still resolve the interaction type behind the close
while the default keeps the trigger. Programmatic closes carry neither event nor
reason and resolve to the empty type, as before.

Trigger registrations hand out a `getPayload()` getter rather than a payload
snapshot, so an inline-literal `payload` no longer re-registers the trigger on
every render — which is what the root's imperative subscription was working
around. That also lets the `exhaustive-deps` disables in the root go: re-running
`setRoot` is an idempotent swap.
@maxyinger
maxyinger force-pushed the max/dialog-composition-apis branch from 9cee303 to 11fe160 Compare August 12, 2026 23:01
@maxyinger
maxyinger merged commit 3349410 into main Aug 12, 2026
52 checks passed
@maxyinger
maxyinger deleted the max/dialog-composition-apis branch August 12, 2026 23:12
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.

2 participants