-
Notifications
You must be signed in to change notification settings - Fork 465
feat(ui): rebuild the Mosaic Dialog on StyleX and add composition APIs #9388
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| --- | ||
| '@clerk/headless': patch | ||
| '@clerk/ui': patch | ||
| --- | ||
|
|
||
| Move the Mosaic `Dialog` onto StyleX, joining the other migrated components, and rework its sizing, motion and mobile behaviour. | ||
|
|
||
| **Styling.** The dialog's rules now ship in `@clerk/ui/styles.css`. Style it by targeting the `.cl-dialog-backdrop` / `.cl-dialog-viewport` / `.cl-dialog-popup` slot classes from a CSS layer of your own, or per-part with `className` and `style`, in place of the previous `sx` prop. | ||
|
|
||
| **Sizes.** `size` replaces `md` / `lg` with three named surfaces, and moves from `Dialog.Popup` to `Dialog.Root` because the backdrop reads it too. `prompt` (the default, `23.75rem`) asks one thing — a confirmation or a single-field form. `card` (`25rem`) is the sign-in / sign-up surface. `panel` fills the viewport minus its inset, up to `94rem` wide, so a settings surface does not resize as you navigate between its sections. | ||
|
|
||
| **The inset.** The gap between a dialog and the edge of the screen is now a fixed inset that steps up at two breakpoints — `1rem`, `2rem` at `48rem`, `3rem` at `90rem` — rather than a percentage of the viewport. A percentage margin is asymmetric between the axes and the asymmetry tracks the viewport's aspect ratio, so the surround never read as an even frame. | ||
|
|
||
| **Panels compose.** A `panel` clips rather than scrolling, and carries no padding of its own. Build the scroll region inside it with `scrollAreaRoot` / `scrollAreaViewport()`. That keeps anything anchored to the popup's corner from scrolling away, lets a scroll region sit flush with the dialog's edge, and makes a fixed-sidebar layout a plain flex row. `prompt` and `card` still pad themselves. | ||
|
|
||
| **On a phone.** Below `48rem` a `prompt` pins to the bottom of the viewport and slides up as a sheet, keeping the inset on all four sides. `card` and `panel` are unchanged at every width. When an on-screen keyboard opens, `Dialog.Viewport` measures how much of the viewport it covers and pads for it, so a sheet rises to sit on top of the keyboard, a card re-centres in the space that is left without being squashed, and a panel shrinks. | ||
|
|
||
| **Motion.** `prompt` and `card` scale out of the element that opened them — the dialog measures its trigger on open and exposes the result as `--cl-dialog-origin`, which the popup uses as its `transform-origin`; a dialog with no trigger falls back to a centred scale. Corner radius no longer distorts during the scale. `panel` has no enter or exit animation, since the absolute travel of a scale is a proportion of the element's own size. Under `prefers-reduced-motion: reduce` the movement drops and the fade remains. | ||
|
|
||
| This also fixes the enter/exit transition, which was keyed to a `data-cl-starting-style` attribute the headless layer does not emit — dialogs previously appeared with no animation at all. | ||
|
|
||
| **New `Dialog.CloseButton`.** The corner dismiss affordance: a ghost circular button holding the close glyph, anchored to the popup's top-inline-end corner. `Dialog.Close` is unchanged and stays unstyled, for footer "Cancel" buttons. Note that a close button rendered before a form becomes the dialog's initial focus. | ||
|
|
||
| **Stacked dialogs.** A dialog opened from inside another one carries `data-nested` and paints a lighter scrim, so backdrops no longer compound into an opaque wall as the stack grows; the nested value is solved against the base so two levels composite to a `0.68` dim. | ||
|
|
||
| **Browser chrome.** While a dialog is open, the mobile browser's own chrome is tinted to match the scrim — both `theme-color` and the `<body>` background, the latter being what paints the overscroll gutter and the area behind the address bar. On by default and needing no integration: the colour is derived from the backdrop rather than shipped, the meta is prepended rather than mutated so removing it restores the app's own, and it is refcounted across stacked dialogs. Opt out with `syncBrowserChrome={false}`. | ||
|
|
||
| **`Button` gains an `xstyle` prop** for composing StyleX styles into its own, last so they win. Styles passed through `className` sit outside the button's `stylex.props` call and cannot be deduped, so the button's media-guarded rules — which compile to a doubled class — silently outrank them; positioning a button absolutely via `className` was ignored under a coarse pointer. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| --- | ||
| '@clerk/headless': patch | ||
| '@clerk/ui': patch | ||
| --- | ||
|
|
||
| Add Base UI–style composition APIs to the Dialog, in both the headless primitive and the Mosaic component. | ||
|
|
||
| **Detached triggers.** `Dialog.createHandle()` returns a handle; pass the same handle to a `Dialog.Trigger` and a `Dialog.Root`, and the trigger drives the dialog from anywhere in the tree — no JSX nesting required. The handle also has imperative `open()` / `close()` / `isOpen` members; calls made while no root is mounted are ignored. | ||
|
|
||
| **Multiple triggers and payloads.** Several triggers can share one dialog. Each can carry an `id` and a `payload`, and the root's children can be a function receiving `{ payload }` from the active trigger, so one dialog renders per-trigger content. Type the payload through the handle: `Dialog.createHandle<Payload>()`. Everything keyed to "the trigger" now follows the one actually used: the dialog scales out of it and returns focus to it on close. In controlled mode, `triggerId` on `Dialog.Root` names the active trigger, and `onOpenChange` gains a second `details` argument (`{ trigger, triggerId, event }`) reporting the trigger behind each change — existing single-argument callbacks are unaffected. Setting `triggerId` alongside a programmatic `open` attributes the open to that trigger, which also gives controlled, trigger-less dialogs the origin-aware open animation. | ||
|
|
||
| **Custom focus management.** `initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves on open and close. Each accepts `true` (the default behaviour), `false` (do not move focus), a ref, or a function of the interaction type behind the change (`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty when programmatic) returning any of those. Defaults are unchanged: first tabbable on open; the trigger on close, except after a pointer-driven dismissal, where focus stays where the pointer put it. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,80 @@ const [open, setOpen] = useState(false); | |
| <Dialog.Root modal={false}>{/* Focus is not trapped, page remains interactive */}</Dialog.Root> | ||
| ``` | ||
|
|
||
| ### Detached triggers | ||
|
|
||
| A trigger does not have to be nested inside its root. `Dialog.createHandle()` returns a handle; | ||
| pass the same handle to both, and the trigger drives the root from anywhere in the tree. The | ||
| handle also has imperative `open()` / `close()` / `isOpen` members; calls made while no root is | ||
| mounted are ignored. | ||
|
|
||
| ```tsx | ||
| const feedbackDialog = Dialog.createHandle(); | ||
|
|
||
| <Dialog.Trigger handle={feedbackDialog}>Give feedback</Dialog.Trigger>; | ||
|
|
||
| <Dialog.Root handle={feedbackDialog}>{/* ... */}</Dialog.Root>; | ||
| ``` | ||
|
|
||
| ### Multiple triggers and payloads | ||
|
|
||
| Each trigger can carry an `id` and a `payload`. The root's children can be a function receiving | ||
| the active trigger's payload, so one dialog renders per-trigger content. Type the payload through | ||
| the handle: `Dialog.createHandle<Payload>()`. | ||
|
|
||
| ```tsx | ||
| const detail = Dialog.createHandle<{ name: string }>(); | ||
|
|
||
| <Dialog.Trigger handle={detail} id='a' payload={{ name: 'Alice' }}>Alice</Dialog.Trigger> | ||
| <Dialog.Trigger handle={detail} id='b' payload={{ name: 'Bob' }}>Bob</Dialog.Trigger> | ||
|
|
||
| <Dialog.Root handle={detail}> | ||
| {({ payload }) => <Dialog.Popup>{payload?.name}</Dialog.Popup>} | ||
| </Dialog.Root> | ||
| ``` | ||
|
|
||
| In controlled mode, track which trigger is active with `triggerId` — `onOpenChange`'s second | ||
| argument reports the trigger behind each change: | ||
|
|
||
| ```tsx | ||
| const [open, setOpen] = useState(false); | ||
| const [triggerId, setTriggerId] = useState<string | null>(null); | ||
|
|
||
| <Dialog.Root | ||
| open={open} | ||
| triggerId={triggerId} | ||
| onOpenChange={(next, details) => { | ||
| setOpen(next); | ||
| setTriggerId(details.triggerId); | ||
| }} | ||
| > | ||
| {/* ... */} | ||
| </Dialog.Root>; | ||
| ``` | ||
|
|
||
| Setting `triggerId` alongside a programmatic `open` also attributes the open to that trigger — | ||
| the dialog scales out of it (`--cl-dialog-origin`) and returns focus to it on close, exactly as | ||
| if it had been clicked. | ||
|
|
||
| ### Custom focus management | ||
|
|
||
| `initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves on open and close. | ||
| Each accepts `true` (the default behaviour), `false` (do not move focus), a ref, or a function of | ||
| the interaction type behind the open/close (`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty | ||
| for programmatic) returning any of those: | ||
|
Comment on lines
+105
to
+108
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
ast-grep outline packages/headless/src/primitives/dialog --items all --match 'DialogFocusTarget|initialFocus|finalFocus'
rg -n -C 4 '\b(DialogFocusTarget|initialFocus|finalFocus)\b' packages/headless/src/primitives/dialog -g '*.ts' -g '*.tsx'Repository: clerk/javascript Length of output: 22452 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- README focus documentation ---'
cat -n packages/headless/src/primitives/dialog/README.md | sed -n '95,115p;190,210p'
printf '%s\n' '--- exported focus type and implementation ---'
cat -n packages/headless/src/primitives/dialog/dialog-popup.tsx | sed -n '10,32p;60,86p;86,116p'
cat -n packages/headless/src/primitives/dialog/index.ts | sed -n '1,25p'
printf '%s\n' '--- focus-related README references ---'
rg -n -C 3 'RefObject|callback|function|initialFocus|finalFocus|focus target' packages/headless/src/primitives/dialog/README.mdRepository: clerk/javascript Length of output: 10340 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
import re
source = Path("packages/headless/src/primitives/dialog/dialog-popup.tsx").read_text()
readme = Path("packages/headless/src/primitives/dialog/README.md").read_text()
type_match = re.search(
r"export type DialogFocusTarget\s*=\s*(.*?);",
source,
re.S,
)
assert type_match, "DialogFocusTarget declaration not found"
type_text = " ".join(type_match.group(1).split())
callback_match = re.search(r"\(\(interactionType: InteractionType\) => ([^)]+)\)", type_text)
assert callback_match, "DialogFocusTarget callback return type not found"
callback_return = callback_match.group(1)
claim_match = re.search(
r"Each accepts .*?a ref, or a function .*?returning any of those:",
readme,
re.S,
)
assert claim_match, "README focus-target claim not found"
print("DialogFocusTarget:", type_text)
print("Callback return type:", callback_return)
print("Callback return includes RefObject:", "RefObject" in callback_return)
print("README says callback returns the ref option:", "returning any of those" in claim_match.group(0))
PYRepository: clerk/javascript Length of output: 449 Correct the focus callback documentation.
🤖 Prompt for AI AgentsSource: Coding guidelines |
||
|
|
||
| ```tsx | ||
| <Dialog.Popup | ||
| initialFocus={interactionType => (interactionType === 'keyboard' ? firstFieldRef.current : false)} | ||
| finalFocus={finalFocusRef} | ||
| > | ||
| {/* ... */} | ||
| </Dialog.Popup> | ||
| ``` | ||
|
|
||
| The defaults stay what they were: first tabbable element on open; on close, the trigger — unless | ||
| the close was pointer-driven, where focus is left where the pointer put it (see `useReturnFocus`). | ||
|
|
||
| ## Parts | ||
|
|
||
| | Part | Default Element | Description | | ||
|
|
@@ -63,13 +137,16 @@ const [open, setOpen] = useState(false); | |
|
|
||
| ### `Dialog.Root` | ||
|
|
||
| | Prop | Type | Default | Description | | ||
| | -------------- | ----------------------------------- | ------- | --------------------------------------- | | ||
| | `open` | `boolean` | — | Controlled open state | | ||
| | `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | | ||
| | `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | | ||
| | `modal` | `boolean` | `true` | Traps focus and blocks page interaction | | ||
| | `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog | | ||
| | Prop | Type | Default | Description | | ||
| | -------------- | ----------------------------------------------------------- | ------- | --------------------------------------------------------------------- | | ||
| | `open` | `boolean` | — | Controlled open state | | ||
| | `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | | ||
| | `onOpenChange` | `(open: boolean, details: DialogOpenChangeDetails) => void` | — | Called when open state changes; `details` names the trigger behind it | | ||
| | `modal` | `boolean` | `true` | Traps focus and blocks page interaction | | ||
| | `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog | | ||
| | `handle` | `DialogHandle` | — | Connects detached triggers (see `Dialog.createHandle()`) | | ||
| | `triggerId` | `string \| null` | — | Controls which trigger the open is attributed to | | ||
| | `children` | `ReactNode \| ({ payload }) => ReactNode` | — | Content, or a render function of the active trigger's `payload` | | ||
|
|
||
| #### `closedBy` | ||
|
|
||
|
|
@@ -107,7 +184,24 @@ When `root` is provided, the dialog is portaled into that container instead of ` | |
| | ------------ | --------- | ------- | ------------------------------- | | ||
| | `lockScroll` | `boolean` | `true` | Prevents body scroll while open | | ||
|
|
||
| ### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close` | ||
| ### `Dialog.Trigger` | ||
|
|
||
| | Prop | Type | Default | Description | | ||
| | --------- | -------------- | ------- | -------------------------------------------------------- | | ||
| | `handle` | `DialogHandle` | — | Drives a root elsewhere in the tree (detached trigger) | | ||
| | `id` | `string` | auto | Names this trigger for the root's `triggerId` | | ||
| | `payload` | `Payload` | — | Delivered to the root's children render function on open | | ||
|
|
||
| ### `Dialog.Popup` | ||
|
|
||
| | Prop | Type | Default | Description | | ||
| | -------------- | ------------------- | ------- | --------------------------------------- | | ||
| | `initialFocus` | `DialogFocusTarget` | `true` | Where focus moves when the dialog opens | | ||
| | `finalFocus` | `DialogFocusTarget` | `true` | Where focus returns when it closes | | ||
|
|
||
| `DialogFocusTarget` is `boolean | RefObject | (interactionType) => boolean | void | HTMLElement | null`. | ||
|
|
||
| ### `Dialog.Backdrop`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close` | ||
|
|
||
| No additional props beyond standard HTML attributes and the `render` prop. | ||
|
|
||
|
|
@@ -120,17 +214,23 @@ No additional props beyond standard HTML attributes and the `render` prop. | |
|
|
||
| ## Data Attributes | ||
|
|
||
| | Attribute | Applies To | Description | | ||
| | --------------------------- | ---------------------------------- | ----------- | | ||
| | `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state | | ||
| | Attribute | Applies To | Description | | ||
| | --------------------------- | ---------------------------------- | ------------------------------------------- | | ||
| | `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state | | ||
| | `data-nested` | Backdrop, Viewport, Popup | Opened from inside another floating element | | ||
|
|
||
| `data-nested` is what a stacked overlay styles itself from — chiefly so backdrops don't composite | ||
| into an ever-darker scrim as the stack grows. It reflects any floating ancestor, not strictly a | ||
| dialog one: the `FloatingTree` a Menu or Popover establishes counts too. | ||
|
|
||
| The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above. | ||
|
|
||
| ## Important Notes | ||
|
|
||
| - **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock. | ||
| - **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup. | ||
| - **Nested dialogs are supported.** The `FloatingTree` pattern handles nesting automatically. | ||
| - **Nested dialogs are supported**, and covered by tests. The `FloatingTree` pattern handles it: `useDismiss` blocks both Escape and outside-press on a parent while any child is open, and `FloatingOverlay`'s scroll lock is refcounted, so the body stays locked until the last dialog closes. | ||
| - **`Dialog.Popup` gets a `--cl-dialog-origin` custom property** when the dialog was opened from a `Dialog.Trigger` — the trigger's centre, in the popup's own coordinate space. Use it as `transform-origin` to scale the dialog out of whatever opened it. It is left unset for a dialog with no trigger, so a `var(--cl-dialog-origin, center)` fallback centres the scale. | ||
| - **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning. | ||
|
|
||
| ## Authoring rule for new primitives | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Preserve the removed Dialog APIs in this patch release.
This changeset states that consumers must replace
sxand migrate existingsizeusage. That breaks consumers that remain on@clerk/ui@1after a patch upgrade.Keep deprecated compatibility paths for these APIs in a non-major release. Otherwise, publish this as a major release with a migration path.
As per coding guidelines, “Maintain backward compatibility in
packages/clerk-jsandpackages/uiwith SDK versions already in the wild.”🤖 Prompt for AI Agents
Sources: Coding guidelines, Linked repositories