From 5fabcc2fd5242444144a9af50ff27571f599d4f7 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Mon, 10 Aug 2026 18:08:37 -0600 Subject: [PATCH 1/9] feat(ui): rebuild the Mosaic Dialog on StyleX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/olive-doors-tell.md | 28 + .../headless/src/primitives/dialog/README.md | 14 +- .../src/primitives/dialog/dialog-backdrop.tsx | 5 +- .../src/primitives/dialog/dialog-context.ts | 10 + .../src/primitives/dialog/dialog-popup.tsx | 17 +- .../src/primitives/dialog/dialog-root.tsx | 18 +- .../src/primitives/dialog/dialog-viewport.tsx | 5 +- .../primitives/dialog/use-dialog-origin.ts | 55 ++ .../src/primitives/drawer/drawer-context.ts | 2 - .../swingset/src/stories/dialog.component.mdx | 370 ++++++++++++- .../src/stories/dialog.component.stories.tsx | 315 ++++++++++- .../src/mosaic/components/button/button.tsx | 14 + packages/ui/src/mosaic/components/dialog.tsx | 177 ------- .../components/dialog/browser-chrome.ts | 342 ++++++++++++ .../mosaic/components/dialog/dialog.styles.ts | 461 ++++++++++++++++ .../mosaic/components/dialog/dialog.test.tsx | 491 ++++++++++++++++++ .../src/mosaic/components/dialog/dialog.tsx | 358 +++++++++++++ .../ui/src/mosaic/components/dialog/index.ts | 14 + .../components/dialog/keyboard-inset.ts | 82 +++ packages/ui/src/mosaic/primitives/dialog.tsx | 28 - packages/ui/src/mosaic/styles/index.ts | 14 + packages/ui/src/mosaic/tokens.stylex.ts | 1 + 22 files changed, 2572 insertions(+), 249 deletions(-) create mode 100644 .changeset/olive-doors-tell.md create mode 100644 packages/headless/src/primitives/dialog/use-dialog-origin.ts delete mode 100644 packages/ui/src/mosaic/components/dialog.tsx create mode 100644 packages/ui/src/mosaic/components/dialog/browser-chrome.ts create mode 100644 packages/ui/src/mosaic/components/dialog/dialog.styles.ts create mode 100644 packages/ui/src/mosaic/components/dialog/dialog.test.tsx create mode 100644 packages/ui/src/mosaic/components/dialog/dialog.tsx create mode 100644 packages/ui/src/mosaic/components/dialog/index.ts create mode 100644 packages/ui/src/mosaic/components/dialog/keyboard-inset.ts delete mode 100644 packages/ui/src/mosaic/primitives/dialog.tsx diff --git a/.changeset/olive-doors-tell.md b/.changeset/olive-doors-tell.md new file mode 100644 index 00000000000..7e50597d7a6 --- /dev/null +++ b/.changeset/olive-doors-tell.md @@ -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 `` 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. diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index 3ae2a9f1f76..66ee695235d 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -120,9 +120,14 @@ 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. @@ -130,7 +135,8 @@ The headless parts are unstyled. Target a part with your own className (or `rend - **`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 diff --git a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx index 00aee9a2431..c934d4bfe6d 100644 --- a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx +++ b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx @@ -12,9 +12,9 @@ export type DialogBackdropProps = ComponentProps<'div'>; export const DialogBackdrop = React.forwardRef( function DialogBackdrop(props, ref) { const { render, ...otherProps } = props; - const { open, mounted, transitionProps } = useDialogContext(); + const { open, mounted, isNested, transitionProps } = useDialogContext(); - const state = { open }; + const state = { open, nested: isNested }; const defaultProps = { ...transitionProps, @@ -28,6 +28,7 @@ export const DialogBackdrop = React.forwardRef | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), + nested: (v: boolean): Record | null => (v ? { 'data-nested': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/dialog/dialog-context.ts b/packages/headless/src/primitives/dialog/dialog-context.ts index b730f698d36..21a57672b21 100644 --- a/packages/headless/src/primitives/dialog/dialog-context.ts +++ b/packages/headless/src/primitives/dialog/dialog-context.ts @@ -14,6 +14,16 @@ export interface DialogContextValue { /** Where focus goes when the dialog closes, or `null` to leave focus alone. */ returnFocusRef: React.MutableRefObject; modal: boolean; + /** + * 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 + * an ever-darker scrim as the stack grows. + * + * True for any floating ancestor, not strictly a dialog one: the `FloatingTree` a Menu or + * Popover establishes counts too. That is the honest reading of what is knowable here, and the + * cases coincide in practice. + */ + isNested: boolean; labelId: string; descriptionId: string; mounted: boolean; diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index 98a1d706987..d40b20a3d1a 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -5,6 +5,7 @@ import React from 'react'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; import { useDialogContext } from './dialog-context'; +import { useDialogOrigin } from './use-dialog-origin'; /** Props for {@link DialogPopup}. */ export type DialogPopupProps = ComponentProps<'div'>; @@ -13,11 +14,13 @@ export type DialogPopupProps = ComponentProps<'div'>; export const DialogPopup = React.forwardRef(function DialogPopup(props, ref) { const { render, ...otherProps } = props; const { + open, popupRef, refs, getFloatingProps, floatingContext, modal, + isNested, returnFocusRef, labelId, descriptionId, @@ -25,12 +28,24 @@ export const DialogPopup = React.forwardRef(fu transitionProps, } = useDialogContext(); + // Measured here rather than on the root: `Dialog.Portal` renders through `FloatingPortal`, + // which creates its container in a layout effect and renders nothing until it exists. A root + // effect keyed on `open` would therefore run one commit before the popup is in the DOM and + // never re-run. This component only renders once the portal is up, so its own layout effect + // is the first moment the popup can be measured. + useDialogOrigin(popupRef, floatingContext.elements.domReference, open); + const ownProps = { 'aria-labelledby': labelId, 'aria-describedby': descriptionId, } satisfies DefaultProps<'div'>; - const defaultProps = { ...ownProps, ...getFloatingProps(), ...transitionProps }; + const defaultProps = { + ...ownProps, + ...(isNested ? { 'data-nested': '' } : {}), + ...getFloatingProps(), + ...transitionProps, + }; const element = useRender({ defaultTagName: 'div', diff --git a/packages/headless/src/primitives/dialog/dialog-root.tsx b/packages/headless/src/primitives/dialog/dialog-root.tsx index 4b5ba8bdaa9..7fd6d7a8e20 100644 --- a/packages/headless/src/primitives/dialog/dialog-root.tsx +++ b/packages/headless/src/primitives/dialog/dialog-root.tsx @@ -42,9 +42,9 @@ export interface DialogProps { children: ReactNode; } -function DialogInner(props: DialogProps) { +function DialogInner(props: DialogProps & { isNested: boolean }) { const nodeId = useFloatingNodeId(); - const { modal = true, closedBy = 'any', children } = props; + const { modal = true, closedBy = 'any', isNested, children } = props; const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); @@ -87,6 +87,7 @@ function DialogInner(props: DialogProps) { popupRef, returnFocusRef, modal, + isNested, labelId, descriptionId, mounted, @@ -101,6 +102,7 @@ function DialogInner(props: DialogProps) { getFloatingProps, returnFocusRef, modal, + isNested, labelId, descriptionId, mounted, @@ -121,10 +123,18 @@ export function DialogRoot(props: DialogProps) { if (parentId === null) { return ( - + ); } - return ; + return ( + + ); } diff --git a/packages/headless/src/primitives/dialog/dialog-viewport.tsx b/packages/headless/src/primitives/dialog/dialog-viewport.tsx index 5fd3868c423..419babc7d4c 100644 --- a/packages/headless/src/primitives/dialog/dialog-viewport.tsx +++ b/packages/headless/src/primitives/dialog/dialog-viewport.tsx @@ -23,9 +23,9 @@ export interface DialogViewportProps extends ComponentProps<'div'> { export const DialogViewport = React.forwardRef( function DialogViewport(props, ref) { const { render, lockScroll = true, ...otherProps } = props; - const { open, mounted, transitionProps, modal } = useDialogContext(); + const { open, mounted, isNested, transitionProps, modal } = useDialogContext(); - const state = { open }; + const state = { open, nested: isNested }; const defaultProps = { ...transitionProps, @@ -40,6 +40,7 @@ export const DialogViewport = React.forwardRef | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), + nested: (v: boolean): Record | null => (v ? { 'data-nested': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/dialog/use-dialog-origin.ts b/packages/headless/src/primitives/dialog/use-dialog-origin.ts new file mode 100644 index 00000000000..16bdb51f47f --- /dev/null +++ b/packages/headless/src/primitives/dialog/use-dialog-origin.ts @@ -0,0 +1,55 @@ +'use client'; + +import { useLayoutEffect } from 'react'; + +/** The custom property the styled layer reads as the popup's `transform-origin`. */ +const ORIGIN_PROPERTY = '--cl-dialog-origin'; + +/** + * Points the popup's `transform-origin` at the element that opened it, so a dialog scales out + * of its trigger rather than out of its own middle. + * + * A dialog runs `useFloating` with no positioning middleware — it is centred by CSS — so the + * `cssVars` middleware that gives popovers `--cl-anchor-origin` has nothing to hook into. The + * rect is measured here instead, once per open. + * + * With no trigger (a dialog driven entirely by `open`, from a route or a state machine) the + * property is left unset and the styled layer's `var(--cl-dialog-origin, center)` fallback + * centres the scale — which is the right answer, since there is no origin the user is looking at. + */ +export function useDialogOrigin( + popupRef: React.RefObject, + trigger: Element | null, + open: boolean, +): void { + useLayoutEffect(() => { + const popup = popupRef.current; + if (!open || !popup || !trigger) { + return; + } + + const triggerRect = trigger.getBoundingClientRect(); + const popupRect = popup.getBoundingClientRect(); + + // `getBoundingClientRect` reports the SCALED box, and the entering frame is already at + // `scale(0.98)`. Its CENTRE is not affected, though — the property is still unset at this + // point, so that scale is about `center` — and `offsetWidth`/`offsetHeight` are the + // unscaled layout dimensions. Together they recover the untransformed box, which is what + // `transform-origin`'s coordinates are relative to. Measuring the scaled edges instead + // would offset the origin by half the scale delta on each axis. + const centerX = popupRect.left + popupRect.width / 2; + const centerY = popupRect.top + popupRect.height / 2; + const layoutLeft = centerX - popup.offsetWidth / 2; + const layoutTop = centerY - popup.offsetHeight / 2; + + const originX = triggerRect.left + triggerRect.width / 2 - layoutLeft; + const originY = triggerRect.top + triggerRect.height / 2 - layoutTop; + + popup.style.setProperty(ORIGIN_PROPERTY, `${originX}px ${originY}px`); + + // Runs in a layout effect, so this lands before paint on the frame that still carries + // `data-starting-style` — the frame pinned at `opacity: 0` with `transition: none`. Moving + // the origin repositions the scaled box, and that reflow is invisible for the same reason + // the popover's is: nothing is painted yet, and the transition arms a frame later. + }, [popupRef, trigger, open]); +} diff --git a/packages/headless/src/primitives/drawer/drawer-context.ts b/packages/headless/src/primitives/drawer/drawer-context.ts index b1e541ba058..d9342969639 100644 --- a/packages/headless/src/primitives/drawer/drawer-context.ts +++ b/packages/headless/src/primitives/drawer/drawer-context.ts @@ -44,8 +44,6 @@ export interface DrawerContextValue extends DialogContextValue { snapRestOffset: number | null; /** Callbacks a nested child `Drawer.Root` invokes on this (parent) drawer. */ onNested: NestedDrawerCallbacks; - /** True when this drawer is itself nested inside another drawer. */ - isNested: boolean; /** How many direct nested child drawers are currently open. */ nestedOpenCount: number; } diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index f94c16b1a3a..2edc495aa74 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -2,10 +2,10 @@ import * as DialogStories from './dialog.component.stories'; # Dialog -The Mosaic `Dialog` — an opinionated wrapper around the headless `@clerk/headless` dialog -primitives, composed with Mosaic slot recipes. It flattens the required nesting (Root, Portal, -Backdrop, Viewport, Popup) into a single component and exposes a `close` callback through a -render-prop children pattern. +The Mosaic `Dialog` — the styled Mosaic component composed from the `@clerk/headless` dialog +primitive and themed with StyleX. It flattens the required nesting (Root, Portal, Backdrop, +Viewport, Popup) into a single component and exposes a `close` callback through a render-prop +children pattern, while inheriting the primitive's focus trapping, scroll lock, and ARIA wiring. ## Playground @@ -25,6 +25,7 @@ render-prop children pattern. { name: 'defaultOpen', type: 'boolean', default: 'false' }, { name: 'onOpenChange', type: '(open: boolean) => void' }, { name: 'modal', type: 'boolean', default: 'true' }, + { name: 'closedBy', type: "'any' | 'closerequest' | 'none'", default: "'any'" }, ]} /> @@ -46,7 +47,8 @@ import { Dialog } from '@clerk/ui/mosaic/components/dialog'; ``` The `trigger` render prop receives the interaction props (ARIA attributes, click handler) from -Floating UI and should spread them onto whatever element opens the dialog. +Floating UI and should spread them onto whatever element opens the dialog. It is optional — omit it +for a dialog driven entirely by `open`, opened from a menu item, a route, or a state machine. `children` can also be a plain `ReactNode` when no programmatic close is needed — the dialog can always be dismissed via Escape or clicking the backdrop: @@ -77,31 +79,349 @@ const [open, setOpen] = useState(false); ``` -## Sub-parts +### Size -| Part | Slot | Description | -| -------------------- | -------------------- | ---------------------------------------------------- | -| `Dialog.Title` | `dialog-title` | Heading; wired to the popup's `aria-labelledby` | -| `Dialog.Description` | `dialog-description` | Description; wired to the popup's `aria-describedby` | +`size` names the surface rather than a t-shirt step, because these are different surfaces rather +than one surface at three scales — the names stay honest if they later diverge on padding, mobile +treatment, or footer. -## Styled slots +| 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 | -The Mosaic dialog exposes the following slots that can be styled via `appearance.elements`: +`prompt` is the default, because asking one thing is the common case. -| Slot | Component | Description | -| ----------------- | --------- | --------------------------------------------------- | -| `dialog-backdrop` | Backdrop | Themed overlay behind the dialog | -| `dialog-viewport` | Viewport | Fixed centering container; owns scroll lock | -| `dialog-popup` | Popup | The dialog surface (`role="dialog"`, focus-trapped) | +`prompt` and `card` set only a max width and let their content decide the height. `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. + +### The inset + +The gap between a dialog and the edge of the screen is a **fixed inset**, not a percentage, and it +steps up at two breakpoints: + +| Viewport | Inset | +| ------------- | ------------- | +| `< 48rem` | `1rem` (16px) | +| `48rem–90rem` | `2rem` (32px) | +| `>= 90rem` | `3rem` (48px) | + +A percentage margin is asymmetric between the axes, and the asymmetry tracks the viewport's aspect +ratio: at `90vw` / `90dvh` a 1920×1080 screen leaves 96px at the sides but 54px top and bottom, an +ultrawide closer to 172 against 72, and a phone inverts it — 20px at the sides against 42px. The +surround never reads as an even frame, and its character changes per device. One inset is even on +all four sides everywhere, and grows with available room rather than with aspect ratio. + +It lives on `Dialog.Viewport`'s padding, so a `prompt` or `card` gets it for free by being +`width: 100%` inside it. `panel` reads the same value back out of `--_cl-dialog-inset` for its +height. + +### On a phone, a prompt is a sheet + +Below `48rem` — the same band that sets the tightest inset — a `prompt` pins to the bottom of the +viewport and slides up instead of scaling out of its trigger. It keeps the inset on all four sides +and all four corners rounded, so it reads as a floating sheet rather than a tray welded to the +edge. Its width cap lifts at the same time, so the sheet spans whatever the inset leaves. + +`card` and `panel` are unchanged at every width: a `card` stays centred and origin-scaled, and a +`panel` already fills the inset. Resize the preview under [Playground](#playground) below `48rem` +to see it. + +**The gate is width, not `pointer: coarse`.** A coarse pointer describes the input device rather +than the screen — an iPad reports it in both orientations, so it would put a bottom sheet across a +1366px display, while a touchscreen laptop usually reports `fine` and would be missed. A bottom +sheet exists for thumb reach on a tall narrow screen, which is a width question. Width is also the +gate you can test by resizing a window. + +`Dialog.Viewport` clips under the phone band, so the sheet — which enters from a full height below +its resting place — never makes the overlay scrollable while it travels. Without that a scrollbar +appears for the duration of the animation. Tall content is unaffected: the viewport is +`height: auto`, so content taller than the screen grows it rather than overflowing it. + +**The sheet does not scale either.** `transform-origin` points at the trigger, which for a bottom +sheet sits outside its box, so a 2% scale about that point lifts the bottom edge a few pixels and +only releases it as the scale reaches 1 — the sheet appears to land above the inset and then +correct. The scale is scoped to widths at or above `48rem`. + +**The sheet does not fade.** It starts fully off the bottom edge, so a fade adds nothing at the +start and washes out the middle of the travel — the eye reads a panel materialising rather than one +arriving. Native sheets on both platforms slide fully opaque and let the scrim carry the "something +arrived" cue, which is what happens here too. + +The slide takes `--cl-duration-slow` in and `--cl-duration-base` out — longer than anything else +here, because it travels its own height rather than the ~6px a scale does. + +The backdrop is deliberately **not** synced to it. The scrim is the answer to the tap, so it keeps +its own faster timing (`--cl-duration-base` in) and lands first; the sheet then arrives into an +already-dimmed page. Making the dim wait for a surface that travels a whole screen height only +delays the feedback. + +Under `prefers-reduced-motion: reduce` the sheet holds flat and fades instead, since with the +transform pinned the fade is the only signal left. + +Drag-to-dismiss is deliberately absent. `Drawer` already owns a drag engine with snap points, and +growing a second one inside `Dialog` would duplicate it; if the sheet ever wants dragging, the +engine should be shared rather than rewritten. The panel scrolls its own +overflow. + +`size` lives on `Dialog.Root`, not on `Dialog.Popup`, because the backdrop reads it too — the two +sizes animate differently. + +### 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 on both sizes. It is inset `1rem` on a `card` and `1.125rem` on +a `panel`. 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.Close` stays available and stays 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. +> That is the argument for an `initialFocus` API, which does not exist yet — until it does, render +> the close button last if a field should take focus 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. + +## Parts + +| Part | Slot | Description | +| -------------------- | ----------------- | ------------------------------------------------------------- | +| `Dialog.Root` | — | State provider; owns `size`, open/close, `modal`, `closedBy`. | +| `Dialog.Trigger` | — | Opens the dialog; renders a `}> + + }>Settings + +
+ + +
+
+
+
+
+
+
; +``` + +The sidebar is dropped below `48rem` — a 14rem 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. + +Composition is what makes the sidebar case fall out for free: a fixed rail beside a scrolling +column is just a flex row. It also means the panel's anatomy is yours — there is no prescribed +Header/Body/Footer to work around, and anything you anchor to the popup's corner stays anchored. + +The scroll region brings its own `overscroll-behavior: contain`, a styled scrollbar, and the +mask-based "content continues" fade at its edges. + +### Nested dialogs + +The account-profile shape: a `panel` holding the settings surface, with `card` dialogs opened from +triggers inside it. Open the panel, then add an email address — the panel stays put behind the card. + + + +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: + +```tsx + } +> + }>Account + + } + > + {({ close }) => ( + <> + }>Add email address + + + + )} + + +``` + +What you get without asking for it: + +| | | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Dismissal reaches the top only** | Escape closes the card and leaves the panel open. A press on the card's backdrop does the same. Only once the card is gone does either gesture reach the panel. | +| **Scroll stays locked** | The body stays locked until the _last_ dialog closes, not the first. | +| **Focus returns down the stack** | Closing the card returns focus to the trigger inside the panel, which is still mounted and still focus-trapped. | +| **Scrims don't compound** | The card's backdrop is lighter, so two levels read as ~0.57 rather than 0.64. | + +The two sizes fall out of the structure: the panel is the surface being navigated, and each card is +one task within it. Give the inner dialog `closedBy='closerequest'` whenever it holds input, so a +stray click on its backdrop cannot discard what was typed. -| 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 | +Depth is not limited to two, but three is a signal the middle layer wants to be a step _inside_ the +card rather than another dialog on top of it. diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 051d810540c..74ca7ed790f 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -1,7 +1,16 @@ /** @jsxImportSource @emotion/react */ import type { RenderProps } from '@clerk/headless/utils'; import { Button } from '@clerk/ui/mosaic/components/button'; -import { Dialog, dialogRecipe } from '@clerk/ui/mosaic/components/dialog'; +import type { DialogSize } from '@clerk/ui/mosaic/components/dialog'; +import { Dialog } 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 * as stylex from '@stylexjs/stylex'; +import React from 'react'; import type { StoryMeta } from '@/lib/types'; @@ -12,14 +21,22 @@ export { default as __source } from './dialog.component.stories?raw'; export const meta: StoryMeta = { group: 'Components', title: 'Dialog', - source: 'packages/ui/src/mosaic/components/dialog.tsx', - styles: dialogRecipe, + source: 'packages/ui/src/mosaic/components/dialog/dialog.tsx', + styleEngine: 'stylex', + styles: { + _variants: { + size: { prompt: {}, card: {}, panel: {} }, + }, + _defaultVariants: { + size: 'prompt', + }, + }, }; const dialogTrigger = (props: RenderProps) => ; export function Default(args: Record) { - const { size } = args as { size?: 'md' | 'lg' }; + const { size } = args as { size?: DialogSize }; return ( ) { > {({ close }) => ( <> + Confirm action Are you sure you want to proceed? This action cannot be undone. ); } + +const accountTrigger = (props: RenderProps) => ; + +const addTrigger = (label: string) => (props: RenderProps) => ( + +); + +const addEmailTrigger = addTrigger('Add email address'); +const addPhoneTrigger = addTrigger('Add phone number'); +const deleteAccountTrigger = (props: RenderProps) => ( + +); + +// A `panel` has no padding of its own — its regions reach the popup's edges — so a body that is +// ordinary padded content supplies its own. See `PanelSidebar` for the case that motivates it. +const panelBody = { + display: 'flex', + flex: 1, + flexDirection: 'column', + gap: '0.75rem', + minHeight: 0, + overflowY: 'auto', + padding: '1.5rem', +} as const; + +// The triggers deliberately sit at three corners of the panel — two at the inline end, at +// different heights, and one at the start edge near the bottom. Each card scales out of the +// button that opened it, so spreading them apart is what makes that visible; stacked in a +// column they would all resolve to nearly the same origin. +const sectionHeader = { + alignItems: 'center', + display: 'flex', + gap: '1rem', + justifyContent: 'space-between', +} as const; + +/** A `card` dialog opened from inside the `panel` — the shape the account profile uses. */ +function AddValueDialog({ + trigger, + title, + description, + placeholder, + confirmLabel = 'Continue', + confirmColor, +}: { + trigger: (props: RenderProps) => React.ReactElement; + title: string; + description: string; + placeholder: string; + confirmLabel?: string; + confirmColor?: 'negative'; +}) { + return ( + + {({ close }) => ( + <> + + }>{title} + }>{description} + + {/* Hand-rolled, as every dialog's footer is today. A Header/Body/Footer split is planned. */} +
+ + +
+ + )} +
+ ); +} + +export function Nested() { + 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 + + + + + {/* Pinned to the bottom of the panel, at the opposite edge from the two above. */} +
+ +
+
+
+ ); +} + +const settingsTrigger = (props: RenderProps) => ; + +const NAV_SECTIONS = ['Profile', 'Security', 'Sessions', 'Connected accounts', 'Billing']; + +// Deliberately long — a 27" display shows a lot of a 90dvh panel, and the example is worthless +// if it doesn't actually overflow there. +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], +})); + +/** + * The panel clips rather than scrolling, so the scroll region is composed inside it. A flex row + * puts a fixed rail beside a scrolling column, and the close button — anchored to the popup — + * stays put while the right side moves. + */ +export function PanelSidebar() { + return ( + + + + {/* Its own header, so the accessible name survives the nav being hidden on a phone. */} +
+ }>Settings +
+ +
+ {/* + A 14rem rail beside a scrolling column has nowhere to go on a phone, so it is dropped + below Tailwind's `md` — which is 48rem, the same band the dialog's own layout switches on. + */} + + + {/* + The scroll region sits FLUSH with the popup — no padding between it and the dialog edge — + so its scrollbar and edge fade land on the true edge. Padding goes on the content inside + it instead. `scrollAreaRoot` is the positioned ancestor; `scrollAreaViewport()` scrolls. + */} +
+
+
+ + {SESSIONS.map(session => ( + + + {session.device} + + {session.where} · {session.when} + + + + + + + ))} + +
+
+
+
+
+ ); +} diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index 78187ee35fc..7c8d28cac40 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -22,6 +22,18 @@ export interface ButtonProps extends MosaicElementProps<'button'> { * effect on `variant='link'`, which is text rather than a control. */ touchTarget?: boolean; + /** + * StyleX styles composed into the button's own, last so they win. + * + * Needed rather than `className` whenever the override touches a property the button already + * declares conditionally. Atoms passed through `className` sit outside the button's + * `stylex.props` call, so StyleX cannot dedupe them and the winner falls to stylesheet order + * and specificity — and the button's media-guarded rules compile to a doubled class that + * outranks a plain one. `position` is the live example: `touchTarget` sets it under + * `@media (pointer: coarse)`, so a `className` trying to position the button absolutely is + * silently ignored on touch devices. + */ + xstyle?: stylex.StyleXStyles; /** * Keeps the button in the tab order while `disabled`, so focus is not dropped when a button * disables itself mid-interaction — while a form submits, say — and the user keeps their place @@ -100,6 +112,7 @@ export const Button = React.forwardRef(function fullWidth = false, touchTarget = true, disabled = false, + xstyle, focusableWhenDisabled = false, className, style, @@ -129,6 +142,7 @@ export const Button = React.forwardRef(function hasTouchTarget && isIconShape && styles.touchTargetIcon, fullWidth && styles.fullWidth, disabled && styles.disabled, + xstyle, ), className, style, diff --git a/packages/ui/src/mosaic/components/dialog.tsx b/packages/ui/src/mosaic/components/dialog.tsx deleted file mode 100644 index 5a4e5b3de80..00000000000 --- a/packages/ui/src/mosaic/components/dialog.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import type { DialogProps as HeadlessDialogProps } from '@clerk/headless/dialog'; -import { useDialogContext } from '@clerk/headless/dialog'; -import type { ReactNode } from 'react'; -import React from 'react'; - -import { Dialog as Primitive } from '../primitives/dialog'; -import type { MosaicComponentProps } from '../props'; -import type { RecipeVariantProps } from '../slot-recipe'; -import { defineSlotRecipe, useRecipe } from '../slot-recipe'; - -/** - * One multi-slot recipe owns every dialog part: slot identity (`data-cl-slot`), - * base styles, and the appearance cascade. Each exported part below reads its - * own slot from `useRecipe(dialogRecipe)` and spreads it onto the bridged - * headless primitive. The headless parts no longer emit `data-cl-slot` — slot - * identity is applied here, in the styled layer. - */ -export const dialogRecipe = defineSlotRecipe(theme => ({ - slots: { - backdrop: { slot: 'dialog-backdrop' }, - viewport: { slot: 'dialog-viewport' }, - popup: { slot: 'dialog-popup' }, - }, - base: { - backdrop: { - position: 'fixed', - inset: 0, - backgroundColor: 'color-mix(in oklab, #000, transparent 50%)', - transition: 'opacity 150ms', - '&[data-cl-starting-style], &[data-cl-ending-style]': { - opacity: 0, - }, - }, - viewport: { - display: 'grid', - placeItems: 'center', - width: '100%', - minHeight: '100%', - padding: theme.spacing(4), - }, - popup: { - backgroundColor: theme.color.primaryForeground, - color: theme.color.primary, - borderRadius: theme.rounded.lg, - padding: theme.spacing(6), - width: '100%', - boxShadow: '0 10px 30px rgba(0,0,0,0.18)', - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(3), - transition: 'transform 150ms ease-out, opacity 150ms ease-out', - '&[data-cl-starting-style], &[data-cl-ending-style]': { - opacity: 0, - transform: 'scale(0.98)', - }, - }, - }, - variants: { - size: { - md: { - popup: { minWidth: '20rem', maxWidth: '32rem' }, - }, - lg: { - popup: { minWidth: '28rem', maxWidth: '48rem' }, - }, - }, - }, - defaultVariants: { - size: 'md', - }, -})); - -type DialogVariantProps = RecipeVariantProps; - -const DialogVariantContext = React.createContext({}); - -declare module '../registry' { - interface MosaicSlotRegistry { - 'dialog-backdrop': true; - 'dialog-viewport': true; - 'dialog-popup': true; - } -} - -export type DialogBackdropProps = React.ComponentPropsWithoutRef; -export type DialogViewportProps = React.ComponentPropsWithoutRef; -export type DialogPopupProps = React.ComponentPropsWithoutRef; - -const Backdrop = React.forwardRef(function DialogBackdrop(props, ref) { - const { backdrop } = useRecipe(dialogRecipe); - return ( - - ); -}); - -const Viewport = React.forwardRef(function DialogViewport(props, ref) { - const { viewport } = useRecipe(dialogRecipe); - return ( - - ); -}); - -const Popup = React.forwardRef(function DialogPopup(props, ref) { - const variantProps = React.useContext(DialogVariantContext); - const { popup } = useRecipe(dialogRecipe, { variants: variantProps }); - return ( - - ); -}); - -interface DialogProps extends Pick< - HeadlessDialogProps, - 'open' | 'defaultOpen' | 'onOpenChange' | 'modal' | 'closedBy' -> { - /** - * 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. - */ - trigger?: MosaicComponentProps<'button'>['render']; - children: ReactNode | ((ctx: { close: () => void }) => ReactNode); - size?: DialogVariantProps['size']; -} - -function DialogContent({ children }: { children: DialogProps['children'] }) { - const { setOpen } = useDialogContext(); - if (typeof children !== 'function') { - return <>{children}; - } - return <>{children({ close: () => setOpen(false) })}; -} - -export function Dialog({ trigger, children, size, open, defaultOpen, onOpenChange, modal, closedBy }: DialogProps) { - return ( - - - {trigger ? : null} - - - - - {children} - - - - - - ); -} - -/** Compound parts for power-user / custom dialog layouts. */ -Dialog.Root = Primitive.Root; -Dialog.Trigger = Primitive.Trigger; -Dialog.Portal = Primitive.Portal; -Dialog.Backdrop = Backdrop; -Dialog.Viewport = Viewport; -Dialog.Popup = Popup; -Dialog.Title = Primitive.Title; -Dialog.Description = Primitive.Description; -Dialog.Close = Primitive.Close; diff --git a/packages/ui/src/mosaic/components/dialog/browser-chrome.ts b/packages/ui/src/mosaic/components/dialog/browser-chrome.ts new file mode 100644 index 00000000000..b4aaeca91cd --- /dev/null +++ b/packages/ui/src/mosaic/components/dialog/browser-chrome.ts @@ -0,0 +1,342 @@ +/** + * Tints the mobile browser's own chrome to match the dialog's scrim, so an open overlay reads as + * one continuous surface instead of a dimmed page inside undimmed browser furniture. + * + * Two surfaces have to move together: + * + * - `` tints the address bar and toolbar on iOS Safari and Chrome/Firefox + * for Android. + * - ``'s background propagates to the CANVAS (per CSS, when `` has none of its own), + * which is what paints everything OUTSIDE the layout viewport: the rubber-band overscroll gutter, + * the strip revealed as the address bar collapses, and the area behind the home indicator. A + * `position: fixed` scrim covers none of those, so without this the app's original colour shows + * through at the edges as an undimmed band. + * + * Nothing here is an opinion. The target colour is DERIVED — the backdrop's own computed + * background composited over whatever the page already had — so this ships no colour, and stays + * correct if a consumer retunes the scrim. It reverts exactly, and it is inert on platforms that + * ignore `theme-color`. + */ + +/** How the page looked before any dialog opened. Captured once, on the first open. */ +interface Snapshot { + /** The meta we inserted, so teardown removes exactly ours. */ + meta: HTMLMetaElement; + /** ``'s own inline background, restored verbatim (including "not set"). */ + bodyBackground: string; + /** The colour the chrome had before we touched it, and what we composite over. */ + base: string; +} + +/** One open dialog's contribution to the tint. */ +interface Layer { + /** The backdrop's computed background, composited over whatever is beneath it. */ + scrim: string; + /** + * The backdrop's LIVE computed style, not a snapshot of its timing. + * + * `getComputedStyle` returns a live object, and that matters twice. The backdrop's duration + * differs by direction — shorter leaving than arriving — so reading it at each use gets the + * right one for free. And it is `0s` on the entering frame, where the headless layer sets an + * inline `transition: none`; a value captured there would make every fade a snap. + */ + styles: CSSStyleDeclaration; +} + +/** Read at each use, never cached — see `Layer.styles`. */ +const layerDuration = (layer: Layer) => firstDuration(layer.styles.transitionDuration); +const layerEase = (layer: Layer) => makeEasing(layer.styles.transitionTimingFunction); + +let snapshot: Snapshot | null = null; +/** + * The open dialogs, outermost first. A STACK rather than a count, because the tint has to be + * reversible: closing a nested dialog must return the chrome to what the dialogs still open + * compose to, which a counter cannot reconstruct. Recomputing from the base every time also makes + * the result independent of the order things happened in. + */ +const layers: Layer[] = []; +let frame = 0; +/** + * The deferred teardown, so it can be CANCELLED if a dialog opens again before it fires. + * + * Without this, closing schedules a `finish` that removes the meta after the fade — and anything + * that re-opens inside that window (a second dialog, or React StrictMode's mount → cleanup → + * mount in dev) gets its tint torn out from under it a beat later. The symptom is a dialog that + * tints the chrome correctly and then reverts to the page's own colour while still open. + */ +let teardown = 0; + +/** + * A 1x1 scratch canvas, used as the colour engine. Created lazily and reused. + */ +let scratch: CanvasRenderingContext2D | null | undefined; +function context(): CanvasRenderingContext2D | null { + if (scratch === undefined) { + const canvas = document.createElement('canvas'); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + // Feature-detected rather than assumed: jsdom hands back a context object with none of the + // drawing methods on it, so a plain null check is not enough. Without a usable canvas the + // colour work is skipped entirely — the meta is still added and removed, so nothing else + // changes; only the tint is absent. + const usable = + typeof ctx?.clearRect === 'function' && + typeof ctx.fillRect === 'function' && + typeof ctx.getImageData === 'function'; + scratch = usable ? ctx : null; + } + return scratch; +} + +/** + * Resolves any CSS colour the browser can render to sRGB `[r, g, b]`, by painting it and reading + * the pixel back. + * + * Parsing the string ourselves is not an option, and the reason is worth stating: a computed + * colour is NOT necessarily `rgb()`. Our scrim serialises as `oklab(0 0 0 / 0.4)` and a light + * page's background as `oklab(1 0 0)` — where the three numbers are lightness and two opponent + * axes, not channels. Reading them positionally turns white into `rgb(1, 0, 0)`, i.e. black, which + * is exactly the bug this replaces. Canvas applies the real colour grammar and hands back sRGB. + * + * Returns `null` when the value is not a colour the canvas will take, which callers treat as "do + * nothing" — a colour we cannot resolve is not one to guess at. + */ +function readColor(input: string): [number, number, number] | null { + const ctx = context(); + if (!ctx || !input) { + return null; + } + // A sentinel that the input cannot coincidentally equal: if assignment is rejected, `fillStyle` + // keeps this value and we know the parse failed rather than silently painting the wrong colour. + ctx.fillStyle = '#010203'; + ctx.fillStyle = input; + if (ctx.fillStyle === '#010203' && input !== '#010203') { + return null; + } + ctx.clearRect(0, 0, 1, 1); + ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + return [r, g, b]; +} + +/** + * The scrim over the page, composited by the canvas rather than by hand — so the scrim's alpha, + * its colour space, and the blend are all the browser's own arithmetic. + */ +function composite(under: string, over: string): [number, number, number] | null { + const ctx = context(); + if (!ctx || !readColor(under) || !readColor(over)) { + return null; + } + ctx.clearRect(0, 0, 1, 1); + ctx.fillStyle = under; + ctx.fillRect(0, 0, 1, 1); + ctx.fillStyle = over; + ctx.fillRect(0, 0, 1, 1); + const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; + return [r, g, b]; +} + +const toCss = ([r, g, b]: [number, number, number]) => `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`; + +/** + * The colour the chrome already had. Prefers the app's own `theme-color` — honouring `media`, since + * an app may ship one per colour scheme and only the first MATCHING one applies — and falls back to + * the body's background, which is what a browser samples when no meta is present. + */ +function readBaseColor(): string { + const metas = document.head.querySelectorAll('meta[name="theme-color"]'); + for (const meta of metas) { + const media = meta.getAttribute('media'); + if (!media || window.matchMedia(media).matches) { + return meta.content; + } + } + return getComputedStyle(document.body).backgroundColor; +} + +/** + * A cubic-bézier sampler backed by a lookup table. + * + * Solving x→t exactly per frame is a Newton iteration on the main thread during the one animation + * the user is watching. Sampling the curve once into a table and interpolating between entries is a + * binary search instead, and at this resolution the error is far below a colour step. + */ +function makeEasing(spec: string): (t: number) => number { + const match = spec.match(/cubic-bezier\(([^)]+)\)/); + if (!match) { + return t => t; + } + const [x1, y1, x2, y2] = match[1].split(',').map(Number); + if ([x1, y1, x2, y2].some(Number.isNaN)) { + return t => t; + } + const axis = (p1: number, p2: number, t: number) => { + const u = 1 - t; + return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t; + }; + const SAMPLES = 32; + const table = Array.from({ length: SAMPLES + 1 }, (_, i) => axis(x1, x2, i / SAMPLES)); + return (x: number) => { + let lo = 0; + while (lo < SAMPLES && table[lo + 1] < x) { + lo++; + } + const span = table[lo + 1] - table[lo]; + const t = (lo + (span > 0 ? (x - table[lo]) / span : 0)) / SAMPLES; + return axis(y1, y2, t); + }; +} + +/** Seconds from the first entry of a computed `transition-duration` list. */ +const firstDuration = (value: string) => { + const first = value.split(',')[0].trim(); + const n = parseFloat(first); + return Number.isNaN(n) ? 0 : first.endsWith('ms') ? n : n * 1000; +}; + +function animate(from: string, to: string, durationMs: number, ease: (t: number) => number) { + cancelAnimationFrame(frame); + const apply = (value: string) => { + if (!snapshot) { + return; + } + snapshot.meta.content = value; + document.body.style.backgroundColor = value; + }; + const a = readColor(from); + const b = readColor(to); + if (!a || !b || durationMs <= 0) { + apply(to); + return; + } + const start = performance.now(); + const step = () => { + if (!snapshot) { + return; + } + const p = Math.min(1, (performance.now() - start) / durationMs); + const e = ease(p); + apply(toCss([a[0] + (b[0] - a[0]) * e, a[1] + (b[1] - a[1]) * e, a[2] + (b[2] - a[2]) * e])); + if (p < 1) { + frame = requestAnimationFrame(step); + } + }; + frame = requestAnimationFrame(step); +} + +/** + * Called by every mounted backdrop. Refcounted like floating-ui's scroll lock, so stacked dialogs + * compose: the first open captures and tints, each further open re-derives from the deeper scrim, + * and only the last close restores. + * + * @param backdrop - the element whose computed background and transition timing drive both the + * target colour and how long it takes to get there. Reading the timing from CSS rather than + * duplicating a constant means the chrome automatically follows the sheet's longer fade on mobile. + */ +/** + * The colour the chrome should show right now: the captured base with every open dialog's scrim + * composited over it in order, so two stacked dialogs land on the same value their two backdrops + * do. Recomputed from scratch on every change rather than accumulated, which is what makes + * closing one of them exactly reversible. + */ +function resolveTint(): string | null { + if (!snapshot) { + return null; + } + let colour = snapshot.base; + for (const layer of layers) { + const next = composite(colour, layer.scrim); + if (!next) { + return null; + } + colour = toCss(next); + } + return colour; +} + +export function acquireBrowserChrome(backdrop: HTMLElement): () => void { + if (typeof document === 'undefined') { + return () => {}; + } + + // Reclaim a teardown that has not fired yet: the snapshot it would have torn down is the one + // about to be reused. + window.clearTimeout(teardown); + teardown = 0; + + if (!snapshot) { + const base = readBaseColor(); + const meta = document.createElement('meta'); + meta.name = 'theme-color'; + meta.content = base; + // PREPENDED, never mutating the app's own. The UA uses the first `theme-color` in tree order + // whose media matches, so inserting ahead of theirs overrides it without touching it — and + // removing ours restores their value with no bookkeeping. That also sidesteps frameworks that + // manage the tag themselves (Next's `viewport.themeColor`), which can revert a mutation on + // any re-render. + document.head.prepend(meta); + snapshot = { meta, bodyBackground: document.body.style.backgroundColor, base }; + } + + const styles = getComputedStyle(backdrop); + const layer: Layer = { scrim: styles.backgroundColor, styles }; + layers.push(layer); + + const target = resolveTint(); + if (target) { + animate(snapshot.meta.content, target, layerDuration(layer), layerEase(layer)); + } + + // Idempotent: callers release on `data-ending-style` and again at unmount, and a second call + // must not re-run the fade or the teardown. + let released = false; + return () => { + if (released) { + return; + } + released = true; + const index = layers.indexOf(layer); + if (index >= 0) { + layers.splice(index, 1); + } + if (!snapshot) { + return; + } + + // Dialogs still open: return to what THEY compose to. The old code returned early here, which + // left a nested dialog's deeper tint on the chrome after it closed. + if (layers.length > 0) { + const remaining = resolveTint(); + if (remaining) { + animate(snapshot.meta.content, remaining, layerDuration(layer), layerEase(layer)); + } + return; + } + + const closing = snapshot; + const { meta, bodyBackground, base } = closing; + // Fade back before tearing down, so closing reads as the reverse of opening rather than as a + // flash. `snapshot` is cleared only once the colour has landed. + const finish = () => { + teardown = 0; + // Identity check as well as the cancel above: a re-open replaces `snapshot`, and this + // closure must not remove a meta that now belongs to a dialog which is still open. + if (snapshot !== closing) { + return; + } + cancelAnimationFrame(frame); + meta.remove(); + document.body.style.backgroundColor = bodyBackground; + snapshot = null; + }; + const durationMs = layerDuration(layer); + if (durationMs <= 0) { + finish(); + return; + } + animate(meta.content, base, durationMs, layerEase(layer)); + teardown = window.setTimeout(finish, durationMs); + }; +} diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts new file mode 100644 index 00000000000..1eb76130d03 --- /dev/null +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -0,0 +1,461 @@ +import * as stylex from '@stylexjs/stylex'; + +import { colorVars, durationVars, easingVars, radiusVars, space } from '../../tokens.stylex'; + +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. + // + // A stacked dialog paints its OWN scrim rather than deferring to the one beneath it, so each + // level reads as a step further from the page. It is lighter than the base because the two + // COMPOSITE: alpha over alpha is `1 − (1 − a)(1 − b)`, so the nested value is solved for the + // intended total rather than picked by eye — `1 − 0.32/0.6 = 0.4667` lands two levels on 0.68. + // Exact for a two-deep stack, which is the shape that exists; a third level would go darker + // still, and wants its own value rather than a third application of this one. + // `data-nested` comes from the headless layer. + backdrop: { + inset: 0, + backgroundColor: { + default: 'color-mix(in oklab, oklch(0 0 0) 40%, transparent)', + ':where([data-nested])': 'color-mix(in oklab, oklch(0 0 0) 46.67%, transparent)', + }, + position: 'fixed', + }, + + // Centering track inside the headless `FloatingOverlay`, which owns the fixed + // positioning and the scroll lock. `min-height: 100%` rather than a fixed height so + // a popup taller than the viewport scrolls the overlay instead of being clipped. + // + // 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 + // bottom, an ultrawide closer to 172 against 72, and a phone inverts it — 20px at the sides + // against 42px. The surround never reads as a frame and its character changes per device. One + // inset is even on all four sides everywhere, and steps up with available room rather than + // with aspect ratio. + // + // Published as a var because the panel's height derives from it (`sizes.panel`); custom + // properties inherit, so the popup reads it without plumbing. Widths need no such math — + // the popup is `width: 100%` inside this padding, so the inset is already subtracted. + // + // 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': { + default: space['4'], + '@media (min-width: 48rem) and (max-width: 89.99rem)': space['8'], + '@media (min-width: 90rem)': space['12'], + }, + padding: 'var(--_cl-dialog-inset)', + // Clips the sheet while it is outside the box. A `prompt` enters from `translate: 0 100%` — + // a full height BELOW its resting place, so under the phone band it starts off the bottom of + // the screen. The headless `FloatingOverlay` that wraps this is `overflow: auto`, so without + // clipping here it treats that as scrollable content and paints a scrollbar for the duration + // of the animation. + // + // `clip`, NOT `hidden`, and the difference is the whole point. `hidden` makes this a scroll + // CONTAINER — scrollable programmatically even though no scrollbar shows — and + // `FloatingFocusManager` focuses the popup the moment it mounts, at which point the browser + // scrolls it into view. On the entering frame the sheet is a full height BELOW the box, so + // that scroll jumps ~136px and drags the sheet part-way up the screen, then unwinds as the + // translate resolves: measured as `scrollTop` 0 -> 136 -> 50 -> 8 -> 0 across the animation. + // It reads as the sheet flying too far up and then snapping back, with the unwind adding + // extra bounces on top of the overshoot. `clip` never becomes scrollable, so there is nothing + // for focus to scroll. + // + // Safe for tall content, which is the thing this could plausibly break: this element is + // `height: auto`, so content taller than the viewport GROWS it rather than overflowing it — + // the overlay still scrolls, and nothing is clipped. Only a box moved outside its own bounds + // by a transform is affected, which is exactly the sheet and nothing else. Scoped to the + // phone band regardless, since that is the only place anything translates. + overflow: { default: null, '@media (max-width: 47.99rem)': 'clip' }, + // `safe center` rather than plain `center` is what makes the definite height below safe. + // Centring an item TALLER than its box overflows it equally in both directions, leaving the + // top half unreachable by scrolling; `safe` falls back to start alignment in exactly that + // case, so an over-tall card still scrolls from its top through the overlay. + placeItems: 'safe center', + display: 'grid', + // A definite container height is NOT enough on its own: an `auto` grid row still sizes to its + // content and happily exceeds the container, which is how a panel of rows measured 2208px + // inside a 1251px overlay. `minmax(0, 1fr)` pins the single row to the content box, so the row + // is what an item stretches to and what its overflow is measured against. + gridTemplateRows: 'minmax(0, 1fr)', + // 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 + // regardless of order, so this wins without depending on argument order. Falls back to `0px`, + // so it is inert until `acquireKeyboardInset` has something to report. + paddingBlockEnd: 'calc(var(--_cl-dialog-inset) + var(--_cl-keyboard-inset, 0px))', + // A DEFINITE height, taken from the overlay (`position: fixed; inset: 0`), which makes the + // single grid row definite too. That is what lets `sizes.panel` fill the content box with + // `align-self: stretch` alone — no `dvh` arithmetic, so nothing can disagree with the box a + // bottom-anchored sheet aligns to. They genuinely do diverge: on an emulated iPhone the + // overlay measures 1251px while `100dvh` reports 844. + height: '100%', + width: '100%', + }, + + // The dialog surface. Unlike `Popover`, this one paints: dialogs take raw content + // rather than a `Card`, so the surface has to come from somewhere. + popup: { + padding: space['6'], + borderRadius: radiusVars['--cl-radius-container'], + gap: space['3'], + // Cleared because `FloatingFocusManager` focuses the popup itself when it holds no + // tabbable content, which would otherwise draw a ring around the whole surface. + outline: 'none', + backgroundColor: colorVars['--cl-color-card'], + boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), + 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), + 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, + color: colorVars['--cl-color-card-foreground'], + display: 'flex', + flexDirection: 'column', + // The containing block for `Dialog.CloseButton`. + position: 'relative', + width: '100%', + }, + + /** + * Anchored to the popup's top-inline-end corner rather than placed in flow, so it never + * participates in the column's `gap` and a consumer can render it anywhere in the children + * without the layout moving. + * + * It stays put on a `panel` because the popup itself never scrolls — see `sizes.panel`. An + * absolutely positioned child of a scroll container scrolls away with the content, so the + * scroll region has to live in the panel's children, not on the popup. + */ + closeButton: { + position: 'absolute', + zIndex: 1, + }, +}); + +/** Distance from the popup's corner, per surface. */ +export const closeInsets = stylex.create({ + prompt: { insetBlockStart: space['4'], insetInlineEnd: space['4'] }, + card: { insetBlockStart: space['4'], insetInlineEnd: space['4'] }, + panel: { insetBlockStart: space['4.5'], insetInlineEnd: space['4.5'] }, +}); + +/** + * Named for what the surface IS rather than for a t-shirt step, because these are different + * surfaces rather than one surface at three scales — the names stay honest if they later diverge + * on padding, mobile treatment, or footer. + * + * `prompt` asks one thing and returns: a confirmation, or a single-field form like "add an email + * address". `card` is the sign-in / sign-up surface, and matches the width of the legacy card + * (`theme.sizes.$100`). `panel` is the account-profile and settings surface, which you navigate. + * + * `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. + */ +export const sizes = stylex.create({ + prompt: { + // Under the phone band, a prompt pins to the bottom of the viewport instead of centring. + // `align-self` on the grid item, not `align-items` on the viewport, because the viewport is + // shared: bottom-aligning there would drag `card` down with it, and `card` stays centred. + // + // The cap is lifted at the same time so the sheet spans the full width the inset leaves. It + // 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' }, + }, + card: { maxWidth: '25rem' }, + 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'], + // 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. + // + // `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. + 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. + 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', + }, +}); + +/** + * Enter/exit motion, keyed by size, because the two surfaces want opposite things. + * + * `card` scales out of whatever opened it. `panel` doesn't move at all — it is most of the + * viewport, and the larger a surface is the worse a scale reads on it: the absolute travel + * is `(1 − scale) ×` its own dimensions, so the same 2% that is a few pixels on a card is + * tens of pixels on a panel, and it arrives as a zoom rather than an emergence. + * + * Both maps are keyed by SIZE rather than by a shared "animated" cell. StyleX dedupes by + * PROPERTY across a `stylex.props` call, so a thin "mobile only" atom declaring `transform` would + * replace a shared cell's wholesale and take the desktop scale with it. Each cell is therefore + * self-contained and reads straight against the design matrix. + * + * The backdrop's timing is bound to the popup's rather than chosen independently. The + * headless transition watches the POPUP's animations to decide when to unmount, and the + * whole subtree goes at once — so a backdrop that outlives its popup gets cut off + * mid-fade. Panel is instant on both, or its scrim would be yanked away on close. + */ +export const backdropMotion = stylex.create({ + /** + * Deliberately NOT synced to the sheet's slide. An earlier version stretched this to match, on + * the theory that the room should darken as the sheet rises — but the scrim is the answer to the + * tap, and making it wait for a surface that travels its own height just delays the feedback. + * It lands first, and the sheet arrives into an already-dimmed page. + * + * Currently identical to `card` below. Kept as its own cell rather than shared because + * StyleX dedupes by property across a `stylex.props` call, so a per-size override cannot be + * layered on top of a shared cell — see `popupMotion`. + */ + prompt: { + opacity: { + default: 1, + ':where([data-starting-style], [data-ending-style])': 0, + }, + transitionDuration: { + default: durationVars['--cl-duration-base'], + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionProperty: 'opacity', + transitionTimingFunction: 'linear', + }, + + card: { + opacity: { + default: 1, + ':where([data-starting-style], [data-ending-style])': 0, + }, + // Longer arriving than leaving, and matched to the popup's scale so the dim and the + // surface land together. No reduced-motion gate — nothing here moves. + transitionDuration: { + default: durationVars['--cl-duration-base'], + ':where([data-ending-style])': durationVars['--cl-duration-fast'], + }, + transitionProperty: 'opacity', + transitionTimingFunction: 'linear', + }, + + panel: {}, +}); + +// 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.98 draws its corners at 98% +// 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. +// Honest about the magnitude here: at 0.98 this is a 0.24px difference on a 12px radius, which is +// invisible — it earns its place by holding at whatever scale the value is later tuned to, and by +// making the intent explicit rather than by what it fixes today. +// +// 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, at this delta, far below 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 ~6px its slow start is imperceptible +// and the acceleration reads as dismissal. Over a sheet's full height it reads as lag instead. But +// the obvious mirror, Out Quad `(0.25, 0.46, 0.45, 0.94)`, over-corrects: it covers 65% of the +// travel in the first 35% of the time, then spends the remaining two thirds on the last third, +// which is a slow crawl on something that has already visually left. `ease-out` is at 50% by the +// same point and spreads the rest far more evenly. +// +// Not a token: it exists because this one transition moves an order of magnitude further than any +// other in Mosaic. If a second large-travel exit appears, it should graduate to one. +const SHEET_EXIT_EASE = 'ease-out'; + +const ENTER_SCALE = 0.98; +const popupRadius = radiusVars['--cl-radius-container']; + +export const popupMotion = stylex.create({ + /** + * A prompt scales out of whatever opened it — except under the phone band, where it slides up + * from the bottom edge as a sheet. Written as its own cell rather than as an overlay on top of + * `card`: StyleX dedupes by PROPERTY across a `stylex.props` call, so a thin "mobile only" atom + * declaring `transform` would replace `card`'s wholesale and take the desktop scale with it. + * Each cell is therefore self-contained and reads straight against the design matrix. + */ + prompt: { + borderRadius: { + default: popupRadius, + ':where([data-starting-style], [data-ending-style])': `calc(${popupRadius} / ${ENTER_SCALE})`, + '@media (max-width: 47.99rem)': { + default: popupRadius, + ':where([data-starting-style], [data-ending-style])': popupRadius, + }, + // Both branches resolve to the same value, so their order relative to each other cannot + // matter: there is no scale to counteract in either case. + '@media (prefers-reduced-motion: reduce)': { + default: popupRadius, + ':where([data-starting-style], [data-ending-style])': popupRadius, + }, + }, + // The sheet does NOT fade, and that is what makes it read as a slide. It starts fully off + // the bottom edge, so a fade adds nothing at the start and washes out the middle of the + // travel — the eye reads a panel materialising rather than one arriving. Native sheets on + // both platforms slide fully opaque and let the scrim carry the "something arrived" cue. + // + // Held at 1 only when motion is allowed. Under reduce the transform is pinned flat, so the + // fade is the only signal left and has to survive; that branch is reached by falling through + // this one's `no-preference` guard. + opacity: { + default: 1, + ':where([data-starting-style], [data-ending-style])': 0, + '@media (max-width: 47.99rem) and (prefers-reduced-motion: no-preference)': { + default: 1, + ':where([data-starting-style], [data-ending-style])': 1, + }, + }, + /** + * Desktop only — a sheet does not scale, and the reason is positional rather than aesthetic. + * `transform-origin` is the trigger (see below), which for a bottom sheet sits well outside + * its box: measured at `184px -32px`, i.e. 32px ABOVE the popup's top edge. Scaling about a + * point outside the element moves every other point toward it, so at 0.98 the bottom edge + * lands `(176 + 32) × 0.02` ≈ 4px high and only releases as the scale reaches 1. That reads as + * the sheet arriving above the inset and then correcting — and it is NOT the overshoot, which + * belongs to the translate and resolves separately. + * + * Written as ONE rule with no unconditioned `default`, the same shape as `translate` below and + * for the same reason: a media-scoped branch that has to out-rank a plain sibling on the same + * property loses. With no sibling there is no contest — `transform` is simply unset at rest and + * under the phone band — and the `no-preference` guard makes reduced motion a no-op for free. + */ + transform: { + default: null, + '@media (min-width: 48rem) and (prefers-reduced-motion: no-preference)': { + default: null, + ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE})`, + }, + }, + transformOrigin: 'var(--cl-dialog-origin, center)', + // The sheet travels its OWN HEIGHT rather than the ~6px a scale does, so it runs longer than + // anything else here: `slow` in, `base` out, a 1.67:1 ratio in line with the rest of Mosaic. + // The dead-frame concern that caps long durations elsewhere does not apply — the delta is + // hundreds of pixels, so every frame moves far more than the visible threshold. Only the + // fourth slot is live on this branch: the sheet holds its opacity, and neither `transform` nor + // the radius counter-scale applies under the phone band. They still have to be filled, since + // the list is positional. + 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)': { + default: `${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`, + ':where([data-ending-style])': durationVars['--cl-duration-base'], + }, + }, + transitionProperty: { + default: 'opacity, transform, border-radius, translate', + '@media (prefers-reduced-motion: reduce)': 'opacity', + }, + // Unchanged by the sheet: a translate is still something that moves, so it wants the arrival + // curve in and the departure curve out exactly as the scale does. + transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}, ${easingVars['--cl-ease-default']}, ${easingVars['--cl-ease-default']}`, + // Positional against `transitionProperty`, so the fourth 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}`, + }, + /** + * The sheet's slide rides the independent `translate` property, NOT `transform` — and it + * declares exactly one rule, with no unconditioned `default`. + * + * Both halves of that are load-bearing. A `:where()` state branch nested inside an `@media` + * branch loses to the same property's unconditioned `default`, even at (0,3,0) against + * (0,1,0): verified in the browser, where `default + desktop-enter` correctly yields + * `scale(0.98)` while `default + mobile-enter` yields `scale(1)`. It is not specificity and + * not source order — both rules sit in `priority4` and the mobile one is emitted last. So a + * media-scoped state branch must never have to out-rank a plain sibling on the same property. + * + * 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. + */ + translate: { + default: null, + '@media (max-width: 47.99rem) and (prefers-reduced-motion: no-preference)': { + default: null, + ':where([data-starting-style], [data-ending-style])': '0 100%', + }, + }, + }, + + /** The sign-in / sign-up surface. Stays centred and origin-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, + }, + transform: { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE})`, + '@media (prefers-reduced-motion: reduce)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, + }, + transformOrigin: 'var(--cl-dialog-origin, center)', + transitionDuration: { + 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', + '@media (prefers-reduced-motion: reduce)': 'opacity', + }, + transitionTimingFunction: { + default: `linear, ${easingVars['--cl-ease-default']}, ${easingVars['--cl-ease-default']}`, + ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}, ${easingVars['--cl-ease-exit']}`, + }, + }, + + panel: {}, +}); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx new file mode 100644 index 00000000000..f5c12397a93 --- /dev/null +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -0,0 +1,491 @@ +import * as stylex from '@stylexjs/stylex'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, describe, expect, it } from 'vitest'; + +import type { MosaicComponentProps } from '../../props'; +import { space } from '../../tokens.stylex'; +import { Dialog } from './dialog'; + +afterEach(() => cleanup()); + +describe('Mosaic Dialog', () => { + it('renders the trigger and opens the dialog on click', async () => { + const user = userEvent.setup(); + render( + ( + + )} + > + Body + , + ); + + expect(screen.queryByText('Body')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Open' })); + + expect(screen.getByText('Body')).toBeInTheDocument(); + }); + + it('renders no trigger when one is not supplied', () => { + render( + {}} + > + 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(); + }); + + it('defaults the popup to the prompt size and reflects it as data-size', () => { + render(Body); + + expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'prompt'); + }); + + it('reflects an explicit size as data-size', () => { + render( + + Body + , + ); + + expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'panel'); + }); + + it('merges consumer className and style onto the popup', () => { + render( + + + + + Body + + + + , + ); + + const popup = screen.getByText('Body'); + expect(popup).toHaveClass('cl-dialog-popup', 'my-popup'); + expect(popup).toHaveStyle({ marginTop: '8px' }); + }); + + it('hands children a close callback', async () => { + const user = userEvent.setup(); + render( + + {({ close }) => ( + + )} + , + ); + + await user.click(screen.getByRole('button', { name: 'Dismiss' })); + + expect(screen.queryByRole('button', { name: 'Dismiss' })).not.toBeInTheDocument(); + }); + + it('names the dialog from Dialog.Title', () => { + render( + + Confirm action + , + ); + + expect(screen.getByRole('dialog', { name: 'Confirm action' })).toBeInTheDocument(); + }); + + it('forwards the ref to the popup element', () => { + const ref = React.createRef(); + render( + + + + Body + + + , + ); + + expect(ref.current).toBe(screen.getByText('Body')); + }); +}); + +// 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. +const addEmailTriggerShared = (props: MosaicComponentProps<'button'>) => ( + +); + +describe('nested Mosaic Dialogs', () => { + const addEmailTrigger = (props: MosaicComponentProps<'button'>) => ( + + ); + + function Nested() { + return ( + + Account +
Outer body
+ + Add email address +
Inner body
+
+
+ ); + } + + it('opens an inner dialog from inside an outer one', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + + expect(screen.getByText('Inner body')).toBeInTheDocument(); + expect(screen.getByText('Outer body')).toBeInTheDocument(); + }); + + it('closes only the inner dialog on Escape, then the outer', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + await user.keyboard('{Escape}'); + + expect(screen.queryByText('Inner body')).not.toBeInTheDocument(); + expect(screen.getByText('Outer body')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + expect(screen.queryByText('Outer body')).not.toBeInTheDocument(); + }); + + it('closes only the inner dialog when its backdrop is pressed', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + + const backdrops = document.querySelectorAll('.cl-dialog-backdrop'); + expect(backdrops).toHaveLength(2); + + await user.click(backdrops[1]); + + expect(screen.queryByText('Inner body')).not.toBeInTheDocument(); + expect(screen.getByText('Outer body')).toBeInTheDocument(); + }); + + it('keeps the body scroll-locked until the last dialog closes', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + expect(document.body.style.overflow).toBe('hidden'); + + await user.keyboard('{Escape}'); + expect(document.body.style.overflow).toBe('hidden'); + + await user.keyboard('{Escape}'); + expect(document.body.style.overflow).toBe(''); + }); +}); + +describe('stacked backdrops', () => { + const addEmailTrigger = (props: MosaicComponentProps<'button'>) => ( + + ); + + it('marks only the inner backdrop as nested, so the scrims do not compound', async () => { + const user = userEvent.setup(); + render( + +
Outer body
+ +
Inner body
+
+
, + ); + + expect(document.querySelector('.cl-dialog-backdrop')).not.toHaveAttribute('data-nested'); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + + const backdrops = document.querySelectorAll('.cl-dialog-backdrop'); + expect(backdrops[0]).not.toHaveAttribute('data-nested'); + expect(backdrops[1]).toHaveAttribute('data-nested', ''); + }); +}); + +describe('transform origin', () => { + it('points the popup at the trigger that opened it', async () => { + const user = userEvent.setup(); + render( + ( + + )} + > + Body + , + ); + + await user.click(screen.getByRole('button', { name: 'Open' })); + + // jsdom reports every rect as zero, so the computed offsets are not meaningful here — + // what this pins is that a trigger produces an origin at all, and the next test that a + // trigger-less dialog leaves the property alone so the `center` fallback applies. + const popup = document.querySelector('.cl-dialog-popup'); + expect(popup?.style.getPropertyValue('--cl-dialog-origin')).not.toBe(''); + }); + + it('leaves the origin unset on a trigger-less dialog, falling back to center', () => { + render(Body); + + const popup = document.querySelector('.cl-dialog-popup'); + expect(popup?.style.getPropertyValue('--cl-dialog-origin')).toBe(''); + }); +}); + +describe('Dialog.CloseButton', () => { + it('closes the dialog and carries a default accessible name', async () => { + const user = userEvent.setup(); + render( + + +
Body
+
, + ); + + const close = screen.getByRole('button', { name: 'Close' }); + expect(close).toHaveClass('cl-dialog-close-button'); + + await user.click(close); + expect(screen.queryByText('Body')).not.toBeInTheDocument(); + }); + + it('takes an overridable label, ready for a localized string', () => { + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'Fermer' })).toBeInTheDocument(); + }); + + it('is the first tabbable element when rendered first — see initialFocus', async () => { + render( + + + + , + ); + + // Pinning the consequence rather than endorsing it: with no `initialFocus` API, a corner X + // rendered before the form is what the dialog opens focused on. `FloatingFocusManager` moves + // focus in an effect, hence the wait. + await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()); + }); +}); + +describe('panel padding', () => { + // Regression: `sizes.panel` has to actually override `styles.popup`'s padding, so a panel's + // children own their own and a scroll region can sit flush with the popup's edge. StyleX + // dedupes by property within one `stylex.props` call, so the panel atom should REPLACE the + // base one rather than sit alongside it. A probe gives us the atom to look for without + // hard-coding a hash. + const probe = stylex.create({ + zero: { padding: space['0'] }, + 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); + + it('pads a card popup and leaves a panel popup unpadded', () => { + const { unmount } = render( + + Body + , + ); + const card = classesOf('.cl-dialog-popup'); + unmount(); + + render( + + Body + , + ); + const panel = classesOf('.cl-dialog-popup'); + + expect(card).toEqual(expect.arrayContaining(atomFor(probe.six))); + expect(panel).toEqual(expect.arrayContaining(atomFor(probe.zero))); + expect(panel).not.toEqual(expect.arrayContaining(atomFor(probe.six))); + }); +}); + +describe('browser chrome sync', () => { + const themeColor = () => document.head.querySelector('meta[name="theme-color"]'); + + afterEach(() => { + document.head.querySelectorAll('meta[name="theme-color"]').forEach(m => m.remove()); + document.body.style.backgroundColor = ''; + }); + + it('adds a theme-color meta while open and removes it on close', async () => { + const user = userEvent.setup(); + expect(themeColor()).toBeNull(); + + render( + + {({ close }) => ( + + )} + , + ); + // Acquired when the backdrop's transition arms — one frame after mount — not on the mount + // frame itself, where an inline `transition: none` would make the fade a snap. + await waitFor(() => expect(themeColor()).not.toBeNull()); + + await user.click(screen.getByRole('button', { name: 'Dismiss' })); + await waitFor(() => expect(themeColor()).toBeNull()); + }); + + it('prepends its meta so it wins over the app’s own, and leaves that one untouched', async () => { + const appMeta = document.createElement('meta'); + appMeta.name = 'theme-color'; + appMeta.content = 'rgb(10, 20, 30)'; + document.head.append(appMeta); + + render(Body); + await waitFor(() => expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(2)); + + const metas = document.head.querySelectorAll('meta[name="theme-color"]'); + // First in tree order is what the UA uses, so ours has to be first — and theirs unchanged. + expect(metas).toHaveLength(2); + expect(metas[0]).not.toBe(appMeta); + expect(appMeta.content).toBe('rgb(10, 20, 30)'); + }); + + it('opts out with syncBrowserChrome={false}', () => { + render( + + Body + , + ); + + expect(themeColor()).toBeNull(); + }); + + it('keeps the tint when a dialog re-opens before the previous teardown fires', async () => { + // Regression: closing schedules the meta's removal after the fade. React StrictMode's + // mount → cleanup → mount, or simply opening again quickly, used to let that deferred + // removal fire and strip the tint from a dialog that was still open. + const user = userEvent.setup(); + const { rerender } = render(Body); + await waitFor(() => expect(themeColor()).not.toBeNull()); + + rerender(Body); + rerender(Body); + + await new Promise(resolve => setTimeout(resolve, 250)); + expect(themeColor()).not.toBeNull(); + await user.keyboard('{Escape}'); + }); + + it('keeps one meta for stacked dialogs and removes it only with the last', async () => { + const user = userEvent.setup(); + render( + +
Outer
+ +
Inner
+
+
, + ); + + await user.click(screen.getByRole('button', { name: 'Add email' })); + expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1); + + await user.keyboard('{Escape}'); + expect(themeColor()).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx new file mode 100644 index 00000000000..06de91233b5 --- /dev/null +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -0,0 +1,358 @@ +import type { DialogProps as HeadlessDialogProps } from '@clerk/headless/dialog'; +import { Dialog as Primitive, useDialogContext } from '@clerk/headless/dialog'; +import * as stylex from '@stylexjs/stylex'; +import type { ReactNode } from 'react'; +import React from 'react'; + +import type { MosaicComponentProps } from '../../props'; +import { mergeStyleProps, themeProps } from '../../props'; +import { Button } from '../button'; +import { Icon } from '../icon'; +import { reset } from '../reset.styles'; +import { acquireBrowserChrome } from './browser-chrome'; +import { backdropMotion, closeInsets, popupMotion, sizes, styles } 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; + /** + * Tint the mobile browser's own chrome — the address bar, and the canvas behind the overscroll + * gutter — to match the dialog's scrim, so an open dialog reads as one continuous surface. + * + * On by default. It ships no colour of its own (the target is derived from the backdrop + * composited over whatever the page already had), reverts exactly on close, and is inert + * wherever `theme-color` is ignored. Pass `false` if the app drives `theme-color` itself. + * + * @default true + */ + syncBrowserChrome?: boolean; +} + +/** + * `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'); + +/** Whether the dialog tints the mobile browser's chrome to match its scrim. See `browser-chrome.ts`. */ +const DialogChromeContext = React.createContext(true); + +/** + * Drives the browser-chrome tint off the backdrop element itself, so both the colour and the timing + * come from the CSS rather than from constants duplicated in JS. + * + * Keyed on the NODE via state rather than a ref: the effect has to run once the backdrop is in the + * DOM and its computed style is readable, and a ref gives no signal when that happens. + */ +function useBrowserChrome(node: HTMLElement | null, enabled: boolean) { + React.useEffect(() => { + if (!enabled || !node) { + return; + } + + // Driven by the backdrop's own transition attributes rather than by mount and unmount, so the + // colour runs on exactly the same clock as the scrim in both directions. + // + // Both attributes matter, for different reasons. `data-ending-style` because the headless + // layer keeps the backdrop mounted until its exit animation finishes, so releasing at unmount + // starts the revert only once the scrim has already gone. And `data-starting-style` because + // that frame carries an inline `transition: none` — acquiring there reads a duration of `0s` + // and the fade becomes a snap. Waiting for both to be absent is precisely waiting for the + // scrim's transition to arm. + // + // Two-way, because an exit can be interrupted: re-opening mid-exit clears the attribute on the + // same element, and the tint has to come back without waiting for a remount. + let handle: (() => void) | null = null; + const sync = () => { + const transitioning = node.hasAttribute('data-starting-style') || node.hasAttribute('data-ending-style'); + if (transitioning && handle) { + handle(); + handle = null; + } else if (!transitioning && !handle) { + handle = acquireBrowserChrome(node); + } + }; + + const observer = new MutationObserver(sync); + observer.observe(node, { attributes: true, attributeFilter: ['data-starting-style', 'data-ending-style'] }); + sync(); + + return () => { + observer.disconnect(); + handle?.(); + }; + }, [node, enabled]); +} + +/** + * The headless parts type their props (and the `render` callback's argument) against + * the raw tag props, which carry the non-standard HTML `color` attribute typed + * `string`. Re-typing them through `MosaicComponentProps` drops it, so a `render` + * callback can spread straight into a Mosaic component whose own `color` is a narrow + * variant union. + */ +export type DialogTriggerProps = MosaicComponentProps<'button'>; +export type DialogCloseProps = MosaicComponentProps<'button'>; +/** `id` is owned by the primitive, which wires it to the popup's `aria-labelledby`. */ +export type DialogTitleProps = Omit, 'id'>; +/** `id` is owned by the primitive, which wires it to the popup's `aria-describedby`. */ +export type DialogDescriptionProps = Omit, 'id'>; +export interface DialogCloseButtonProps extends MosaicComponentProps<'button'> { + /** + * Names the button for assistive technology. Defaults to English; pass a localized string + * once one is available — no other change is needed when localization lands. + */ + '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'>; + +/** Owns the open state and the size both the backdrop and the popup read. */ +function Root({ size = 'prompt', syncBrowserChrome = true, children, ...rest }: DialogRootProps) { + return ( + + + {children} + + + ); +} + +/** Opens the dialog. Renders a `} /> + + + + }> + + + }>Sign in + }>Continue to your account. + + + + + + ( + + )} + /> + + + + + + + ); +} + +// Long enough to outgrow a laptop screen, or the example demonstrates nothing. +const TERMS_CLAUSES = Array.from({ length: 12 }, (_, index) => ({ + heading: `${index + 1}. ${['Acceptance', 'Your account', 'Acceptable use', 'Content', 'Payment', 'Termination'][index % 6]}`, + body: + 'You agree to use the service in accordance with these terms and with all applicable laws. ' + + 'We may update this document from time to time, and continued use after an update means you accept it.', +})); + +/** 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} +
+ ))} +
+
+ + ( + + )} + /> + + +
+
+
+
+ ); +} diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 1eb76130d03..9469784e155 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -23,9 +23,9 @@ export const styles = stylex.create({ position: 'fixed', }, - // Centering track inside the headless `FloatingOverlay`, which owns the fixed - // positioning and the scroll lock. `min-height: 100%` rather than a fixed height so - // a popup taller than the viewport scrolls the overlay instead of being clipped. + // Centering track inside the headless `FloatingOverlay`, which owns the fixed positioning and + // 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. // // 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 @@ -35,69 +35,48 @@ export const styles = stylex.create({ // inset is even on all four sides everywhere, and steps up with available room rather than // with aspect ratio. // - // Published as a var because the panel's height derives from it (`sizes.panel`); custom - // properties inherit, so the popup reads it without plumbing. Widths need no such math — - // the popup is `width: 100%` inside this padding, so the inset is already subtracted. + // Published as a var so the two edges can be driven from one ladder and so anything inside can + // read it without plumbing — custom properties inherit. The popup needs no width math of its + // own: it is `width: 100%` inside this padding, so the inset is already subtracted. + // + // Square everywhere except the phone band, where the sides come in to `1rem` and the block edges + // stay at `1.25rem`. On a phone the horizontal inset is the expensive one — it is subtracted from + // a content box only ~380px wide, so every pixel there costs line length in a way the same pixel + // 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': { - default: space['4'], + 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)', - // Clips the sheet while it is outside the box. A `prompt` enters from `translate: 0 100%` — - // a full height BELOW its resting place, so under the phone band it starts off the bottom of - // the screen. The headless `FloatingOverlay` that wraps this is `overflow: auto`, so without - // clipping here it treats that as scrollable content and paints a scrollbar for the duration - // of the animation. - // - // `clip`, NOT `hidden`, and the difference is the whole point. `hidden` makes this a scroll - // CONTAINER — scrollable programmatically even though no scrollbar shows — and - // `FloatingFocusManager` focuses the popup the moment it mounts, at which point the browser - // scrolls it into view. On the entering frame the sheet is a full height BELOW the box, so - // that scroll jumps ~136px and drags the sheet part-way up the screen, then unwinds as the - // translate resolves: measured as `scrollTop` 0 -> 136 -> 50 -> 8 -> 0 across the animation. - // It reads as the sheet flying too far up and then snapping back, with the unwind adding - // extra bounces on top of the overshoot. `clip` never becomes scrollable, so there is nothing - // for focus to scroll. - // - // Safe for tall content, which is the thing this could plausibly break: this element is - // `height: auto`, so content taller than the viewport GROWS it rather than overflowing it — - // the overlay still scrolls, and nothing is clipped. Only a box moved outside its own bounds - // by a transform is affected, which is exactly the sheet and nothing else. Scoped to the - // phone band regardless, since that is the only place anything translates. - overflow: { default: null, '@media (max-width: 47.99rem)': 'clip' }, - // `safe center` rather than plain `center` is what makes the definite height below safe. + // 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)' }, + // `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 unreachable by scrolling; `safe` falls back to start alignment in exactly that - // case, so an over-tall card still scrolls from its top through the overlay. + // 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', display: 'grid', - // A definite container height is NOT enough on its own: an `auto` grid row still sizes to its - // content and happily exceeds the container, which is how a panel of rows measured 2208px - // inside a 1251px overlay. `minmax(0, 1fr)` pins the single row to the content box, so the row - // is what an item stretches to and what its overflow is measured against. - gridTemplateRows: 'minmax(0, 1fr)', // 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 // regardless of order, so this wins without depending on argument order. Falls back to `0px`, // so it is inert until `acquireKeyboardInset` has something to report. paddingBlockEnd: 'calc(var(--_cl-dialog-inset) + var(--_cl-keyboard-inset, 0px))', - // A DEFINITE height, taken from the overlay (`position: fixed; inset: 0`), which makes the - // single grid row definite too. That is what lets `sizes.panel` fill the content box with - // `align-self: stretch` alone — no `dvh` arithmetic, so nothing can disagree with the box a - // bottom-anchored sheet aligns to. They genuinely do diverge: on an emulated iPhone the - // overlay measures 1251px while `100dvh` reports 844. - height: '100%', width: '100%', }, - // The dialog surface. Unlike `Popover`, this one paints: dialogs take raw content - // rather than a `Card`, so the surface has to come from somewhere. + // 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. popup: { padding: space['6'], borderRadius: radiusVars['--cl-radius-container'], @@ -106,6 +85,17 @@ export const styles = stylex.create({ // tabbable content, which would otherwise draw a ring around the whole surface. outline: 'none', backgroundColor: colorVars['--cl-color-card'], + // Matches `Menu`. The two schemes are different treatments, not one at two strengths: light + // gets the two drop layers and a dark hairline, dark drops them to `transparent` and separates + // with a light hairline instead — a shadow reads as depth against a light page and as nothing + // against a dark one. + // + // Branched per COLOUR via `light-dark()`, which is the only shape available: `light-dark()` + // resolves to a colour and cannot carry an offset or a blur, so the geometry has to be shared. + // `@media (prefers-color-scheme: dark)` is not the escape hatch it looks like — that tracks the + // OS preference while `light-dark()` tracks the `color-scheme` in scope, so an app forcing a + // scheme (swingset's own toggle does, via next-themes) would take its colours from one and its + // geometry from the other. boxShadow: `0 12px 12px -7px light-dark(oklch(0.2046 0 0 / 12%), transparent), 0 24px 24px -10px light-dark(oklch(0.2046 0 0 / 4%), transparent), 0 0 0 1px light-dark(oklch(0.2046 0 0 / 4%), oklch(1 0 0 / 10%))`, @@ -157,8 +147,72 @@ export const closeInsets = stylex.create({ * 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. */ +/** + * How the viewport behaves when the popup is taller than the screen — the "inside scroll" vs + * "outside scroll" split, decided by size rather than by a prop because it follows from what each + * surface already is. + * + * A `panel` is a fixed-height window you navigate inside, so it scrolls INSIDE: the viewport stays + * pinned to the overlay and the consumer composes a scroll region out of `scrollAreaRoot` / + * `scrollAreaViewport()`. A `prompt` and a `card` take their height from their content and have no + * obvious region to scroll, so they scroll OUTSIDE: the whole dialog moves within the overlay. + * + * The mechanism is one property. Pinned at `height: 100%` the viewport cannot grow, so an over-tall + * popup spills past its padding box — the scrollable overflow reaches the overlay, but the + * viewport's own `padding-block-end` stays behind at the fold, and the popup runs flush into the + * bottom edge with none of the inset that surrounds it everywhere else. `min-height: 100%` lets the + * box grow instead: the padding travels with the content, and short dialogs still fill the overlay + * so `place-items: center` has something to centre against. + */ +export const viewportSizes = stylex.create({ + prompt: { + // Clips the sheet while it is outside the box, and ONLY for the size that translates. A + // `prompt` enters from `translate: 0 100%` — a full height BELOW its resting place — and the + // `FloatingOverlay` wrapping this is `overflow: auto`, so without clipping it treats that as + // scrollable content and paints a scrollbar for the length of the animation. + // + // `clip`, NOT `hidden`, and the difference is the whole point. `hidden` makes this a scroll + // CONTAINER — scrollable programmatically even with no scrollbar — and `FloatingFocusManager` + // focuses the popup the moment it mounts, at which point the browser scrolls it into view. On + // the entering frame the sheet sits a full height below the box, so that scroll jumped ~136px + // and dragged the sheet part-way up the screen, then unwound as the translate resolved: + // measured as `scrollTop` 0 -> 136 -> 50 -> 8 -> 0. It read as the sheet flying too far up and + // snapping back, the unwind stacking extra bounces on the real overshoot. `clip` never becomes + // scrollable, so focus has nothing to scroll. + // + // The cost is real and accepted: a `prompt` taller than a phone screen is clipped rather than + // 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' }, + minHeight: '100%', + }, + card: { minHeight: '100%' }, + panel: { + // A definite container height is NOT enough on its own: an `auto` grid row still sizes to its + // content and happily exceeds the container, which is how a panel of rows measured 2208px + // inside a 1251px overlay. `minmax(0, 1fr)` pins the single row to the content box, so the row + // is what an item stretches to and what its overflow is measured against. + // + // Deliberately NOT applied to the scrolling sizes: it would clamp the row there too, which is + // exactly what has to stop happening for the popup to grow past the fold. + gridTemplateRows: 'minmax(0, 1fr)', + // A DEFINITE height, taken from the overlay (`position: fixed; inset: 0`), which makes the + // single grid row definite too. That is what lets `sizes.panel` fill the content box with + // `align-self: stretch` alone — no `dvh` arithmetic, so nothing can disagree with the box a + // bottom-anchored sheet aligns to. They genuinely do diverge: on an emulated iPhone the + // overlay measures 1251px while `100dvh` reports 844. + height: '100%', + }, +}); + export const sizes = stylex.create({ prompt: { + // Tighter than the popup's default 1.5rem. A prompt asks one thing, so its content box is + // small and a 1.5rem surround reads as a disproportionate frame around two lines of text. + // Overrides `styles.popup` by position — `sizes[size]` is spread after it in the same + // `stylex.props` call, so StyleX dedupes the property to this value. + padding: space['4'], // Under the phone band, a prompt pins to the bottom of the viewport instead of centring. // `align-self` on the grid item, not `align-items` on the viewport, because the viewport is // shared: bottom-aligning there would drag `card` down with it, and `card` stays centred. @@ -170,7 +224,33 @@ export const sizes = stylex.create({ alignSelf: { default: null, '@media (max-width: 47.99rem)': 'end' }, maxWidth: { default: '23.75rem', '@media (max-width: 47.99rem)': 'none' }, }, - card: { maxWidth: '25rem' }, + // 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: + // + // }> + // + // 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. + // + // 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 + // all — leaving `Card`'s to apply unopposed. Competing values would instead put two atoms on the + // element for the same property, and StyleX cannot dedupe across separate `stylex.props` calls, + // so the winner would fall to stylesheet order. + // + // Consequence worth knowing: `size="card"` with no `Card` inside renders an unpainted box. + card: { + padding: null, + gap: null, + backgroundColor: null, + boxShadow: null, + maxWidth: '25rem', + }, 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 @@ -218,7 +298,7 @@ export const sizes = stylex.create({ /** * Enter/exit motion, keyed by size, because the two surfaces want opposite things. * - * `card` scales out of whatever opened it. `panel` doesn't move at all — it is most of the + * `card` scales from its centre. `panel` doesn't move at all — it is most of the * viewport, and the larger a surface is the worse a scale reads on it: the absolute travel * is `(1 − scale) ×` its own dimensions, so the same 2% that is a few pixels on a card is * tens of pixels on a panel, and it arrives as a zoom rather than an emergence. @@ -250,7 +330,7 @@ export const backdropMotion = stylex.create({ ':where([data-starting-style], [data-ending-style])': 0, }, transitionDuration: { - default: durationVars['--cl-duration-base'], + default: durationVars['--cl-duration-fast'], ':where([data-ending-style])': durationVars['--cl-duration-fast'], }, transitionProperty: 'opacity', @@ -262,10 +342,12 @@ export const backdropMotion = stylex.create({ default: 1, ':where([data-starting-style], [data-ending-style])': 0, }, - // Longer arriving than leaving, and matched to the popup's scale so the dim and the - // surface land together. No reduced-motion gate — nothing here moves. + // One step below the popup's own entrance, so the dim lands first and the surface arrives + // into an already-darkened page rather than alongside the darkening. Symmetric in and out: + // the scrim is only ever the answer to the gesture, so there is nothing for a longer + // entrance to sell. No reduced-motion gate — nothing here moves. transitionDuration: { - default: durationVars['--cl-duration-base'], + default: durationVars['--cl-duration-fast'], ':where([data-ending-style])': durationVars['--cl-duration-fast'], }, transitionProperty: 'opacity', @@ -276,20 +358,19 @@ 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.98 draws its corners at 98% +// 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. -// Honest about the magnitude here: at 0.98 this is a 0.24px difference on a 12px radius, which is -// invisible — it earns its place by holding at whatever scale the value is later tuned to, and by -// making the intent explicit rather than by what it fixes today. +// 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, at this delta, far below a pixel. +// 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 ~6px its slow start is imperceptible +// `--cl-ease-exit` (In Quad) is right for a small delta: over ~11px its slow start is imperceptible // and the acceleration reads as dismissal. Over a sheet's full height it reads as lag instead. But // the obvious mirror, Out Quad `(0.25, 0.46, 0.45, 0.94)`, over-corrects: it covers 65% of the // travel in the first 35% of the time, then spends the remaining two thirds on the last third, @@ -300,12 +381,12 @@ 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'; -const ENTER_SCALE = 0.98; +const ENTER_SCALE = 0.94; const popupRadius = radiusVars['--cl-radius-container']; export const popupMotion = stylex.create({ /** - * A prompt scales out of whatever opened it — except under the phone band, where it slides up + * A prompt scales from its centre — except under the phone band, where it slides up * from the bottom edge as a sheet. Written as its own cell rather than as an overlay on top of * `card`: StyleX dedupes by PROPERTY across a `stylex.props` call, so a thin "mobile only" atom * declaring `transform` would replace `card`'s wholesale and take the desktop scale with it. @@ -326,56 +407,64 @@ export const popupMotion = stylex.create({ ':where([data-starting-style], [data-ending-style])': popupRadius, }, }, - // The sheet does NOT fade, and that is what makes it read as a slide. It starts fully off - // the bottom edge, so a fade adds nothing at the start and washes out the middle of the - // travel — the eye reads a panel materialising rather than one arriving. Native sheets on - // both platforms slide fully opaque and let the scrim carry the "something arrived" cue. - // - // Held at 1 only when motion is allowed. Under reduce the transform is pinned flat, so the - // fade is the only signal left and has to survive; that branch is reached by falling through - // this one's `no-preference` guard. + // 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 + // washing it out, provided it runs the length of the slide rather than finishing early. + // That is why the duration below is `slow` and not `fast`: a fade that lands while the + // surface is still moving reads as a flash, which is what the original objection was + // actually describing. opacity: { default: 1, ':where([data-starting-style], [data-ending-style])': 0, - '@media (max-width: 47.99rem) and (prefers-reduced-motion: no-preference)': { - default: 1, - ':where([data-starting-style], [data-ending-style])': 1, - }, }, /** - * Desktop only — a sheet does not scale, and the reason is positional rather than aesthetic. - * `transform-origin` is the trigger (see below), which for a bottom sheet sits well outside - * its box: measured at `184px -32px`, i.e. 32px ABOVE the popup's top edge. Scaling about a - * point outside the element moves every other point toward it, so at 0.98 the bottom edge - * lands `(176 + 32) × 0.02` ≈ 4px high and only releases as the scale reaches 1. That reads as - * the sheet arriving above the inset and then correcting — and it is NOT the overshoot, which - * belongs to the translate and resolves separately. + * Structurally identical to `card` below, and that is load-bearing rather than tidiness. + * + * This was once written as a single media-scoped rule with no unconditioned `default`, on the + * theory that leaving `transform` unset at rest removed any cascade contest. It emitted + * correctly — the rule is there, at HIGHER specificity than `card`'s — and yet no scale ever + * ran, while `card`'s did. The only difference between the two was the resting declaration, so + * the resting value is what a transition needs: an endpoint of `none` is not one the scale + * interpolates from here, whereas `scale(1)` is. + * + * Do NOT re-collapse this into the no-`default` shape. `translate` below keeps that shape and + * genuinely works, which makes the asymmetry easy to talk yourself back into. * - * Written as ONE rule with no unconditioned `default`, the same shape as `translate` below and - * for the same reason: a media-scoped branch that has to out-rank a plain sibling on the same - * property loses. With no sibling there is no contest — `transform` is simply unset at rest and - * under the phone band — and the `no-preference` guard makes reduced motion a no-op for free. + * The phone band then pins the scale flat, because a sheet slides rather than scales — stacking + * a shrink on top of a full-height travel makes the surface arrive slightly small and settle, + * which reads as a correction rather than as one movement. Both media branches resolve to + * `scale(1)`, so their order relative to each other cannot matter and `@stylexjs/sort-keys` is + * free to reorder them. */ transform: { - default: null, - '@media (min-width: 48rem) and (prefers-reduced-motion: no-preference)': { - default: null, - ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE})`, + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': `scale(${ENTER_SCALE})`, + '@media (max-width: 47.99rem)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', + }, + '@media (prefers-reduced-motion: reduce)': { + default: 'scale(1)', + ':where([data-starting-style], [data-ending-style])': 'scale(1)', }, }, - transformOrigin: 'var(--cl-dialog-origin, center)', - // The sheet travels its OWN HEIGHT rather than the ~6px a scale does, so it runs longer than + // The sheet travels its OWN HEIGHT rather than the ~11px a scale does, so it runs longer than // anything else here: `slow` in, `base` out, a 1.67:1 ratio in line with the rest of Mosaic. // The dead-frame concern that caps long durations elsewhere does not apply — the delta is - // hundreds of pixels, so every frame moves far more than the visible threshold. Only the - // fourth slot is live on this branch: the sheet holds its opacity, and neither `transform` nor - // the radius counter-scale applies under the phone band. They still have to be filled, since - // the list is positional. + // hundreds of pixels, so every frame moves far more than the visible threshold. + // + // 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. 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)': { - default: `${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-slow']}`, + 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'], }, }, @@ -384,9 +473,11 @@ export const popupMotion = stylex.create({ '@media (prefers-reduced-motion: reduce)': 'opacity', }, // Unchanged by the sheet: a translate is still something that moves, so it wants the arrival - // curve in and the departure curve out exactly as the scale does. + // curve in and the departure curve out exactly as the scale does. `--cl-ease-enter` rather than + // `--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-default']}, ${easingVars['--cl-ease-default']}, ${easingVars['--cl-ease-default']}`, + 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 // 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 @@ -420,7 +511,7 @@ export const popupMotion = stylex.create({ }, }, - /** The sign-in / sign-up surface. Stays centred and origin-scaled at every width. */ + /** The sign-in / sign-up surface. Stays centred and centre-scaled at every width. */ card: { borderRadius: { default: popupRadius, @@ -442,7 +533,6 @@ export const popupMotion = stylex.create({ ':where([data-starting-style], [data-ending-style])': 'scale(1)', }, }, - transformOrigin: 'var(--cl-dialog-origin, center)', transitionDuration: { default: `${durationVars['--cl-duration-fast']}, ${durationVars['--cl-duration-base']}, ${durationVars['--cl-duration-base']}`, ':where([data-ending-style])': durationVars['--cl-duration-fast'], @@ -452,7 +542,7 @@ export const popupMotion = stylex.create({ '@media (prefers-reduced-motion: reduce)': 'opacity', }, transitionTimingFunction: { - default: `linear, ${easingVars['--cl-ease-default']}, ${easingVars['--cl-ease-default']}`, + default: `linear, ${easingVars['--cl-ease-enter']}, ${easingVars['--cl-ease-enter']}`, ':where([data-ending-style])': `linear, ${easingVars['--cl-ease-exit']}, ${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 f5c12397a93..93b763b97a0 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { MosaicComponentProps } from '../../props'; import { space } from '../../tokens.stylex'; +import type { DialogSize } from './dialog'; import { Dialog } from './dialog'; afterEach(() => cleanup()); @@ -271,41 +272,6 @@ describe('stacked backdrops', () => { }); }); -describe('transform origin', () => { - it('points the popup at the trigger that opened it', async () => { - const user = userEvent.setup(); - render( - ( - - )} - > - Body - , - ); - - await user.click(screen.getByRole('button', { name: 'Open' })); - - // jsdom reports every rect as zero, so the computed offsets are not meaningful here — - // what this pins is that a trigger produces an origin at all, and the next test that a - // trigger-less dialog leaves the property alone so the `center` fallback applies. - const popup = document.querySelector('.cl-dialog-popup'); - expect(popup?.style.getPropertyValue('--cl-dialog-origin')).not.toBe(''); - }); - - it('leaves the origin unset on a trigger-less dialog, falling back to center', () => { - render(Body); - - const popup = document.querySelector('.cl-dialog-popup'); - expect(popup?.style.getPropertyValue('--cl-dialog-origin')).toBe(''); - }); -}); - describe('Dialog.CloseButton', () => { it('closes the dialog and carries a default accessible name', async () => { const user = userEvent.setup(); @@ -348,14 +314,14 @@ describe('Dialog.CloseButton', () => { }); }); -describe('panel padding', () => { - // Regression: `sizes.panel` has to actually override `styles.popup`'s padding, so a panel's - // children own their own and a scroll region can sit flush with the popup's edge. StyleX - // dedupes by property within one `stylex.props` call, so the panel atom should REPLACE the - // base one rather than sit alongside it. A probe gives us the atom to look for without - // hard-coding a hash. +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]) => @@ -366,31 +332,101 @@ describe('panel padding', () => { const classesOf = (selector: string) => Array.from(document.querySelector(selector)!.classList); - it('pads a card popup and leaves a panel popup unpadded', () => { + const popupClassesFor = (size: DialogSize) => { const { unmount } = render( Body , ); - const card = classesOf('.cl-dialog-popup'); + const classes = classesOf('.cl-dialog-popup'); unmount(); + return classes; + }; + + it('gives a prompt 1rem, overriding the popup default', () => { + const prompt = popupClassesFor('prompt'); + + expect(prompt).toEqual(expect.arrayContaining(atomFor(probe.four))); + 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'); + + expect(panel).toEqual(expect.arrayContaining(atomFor(probe.zero))); + expect(panel).not.toEqual(expect.arrayContaining(atomFor(probe.six))); + }); + + // 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'); + + for (const value of [probe.zero, probe.four, probe.six]) { + expect(card).not.toEqual(expect.arrayContaining(atomFor(value))); + } + }); +}); + +describe('viewport scroll behaviour', () => { + // The inside/outside scroll split. A pinned `height: 100%` cannot grow, so an over-tall popup + // spills past the viewport's padding box and loses the bottom inset; `min-height: 100%` lets the + // box grow with it. Which one applies follows from the size, so what this pins is that the + // viewport reads the size at all — a regression here is silent, since both values look right + // until the content is taller than the screen. + const probe = stylex.create({ + 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); + unmount(); + return classes; + }; + + it.each(['prompt', 'card'] as const)('lets the viewport grow for %s, so the inset survives', size => { + const viewport = viewportClassesFor(size); + + expect(viewport).toEqual(expect.arrayContaining(atomFor(probe.grows))); + expect(viewport).not.toEqual(expect.arrayContaining(atomFor(probe.fixed))); + }); + + it('pins the viewport for a panel, which scrolls inside instead', () => { + const viewport = viewportClassesFor('panel'); + + expect(viewport).toEqual(expect.arrayContaining(atomFor(probe.fixed))); + expect(viewport).not.toEqual(expect.arrayContaining(atomFor(probe.grows))); + }); + + it('exposes the size on the viewport for styling', () => { render( Body , ); - const panel = classesOf('.cl-dialog-popup'); - expect(card).toEqual(expect.arrayContaining(atomFor(probe.six))); - expect(panel).toEqual(expect.arrayContaining(atomFor(probe.zero))); - expect(panel).not.toEqual(expect.arrayContaining(atomFor(probe.six))); + expect(document.querySelector('.cl-dialog-viewport')).toHaveAttribute('data-size', 'card'); }); }); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx index 06de91233b5..41390355cde 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -10,7 +10,7 @@ import { Button } from '../button'; import { Icon } from '../icon'; import { reset } from '../reset.styles'; import { acquireBrowserChrome } from './browser-chrome'; -import { backdropMotion, closeInsets, popupMotion, sizes, styles } from './dialog.styles'; +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. */ @@ -247,11 +247,17 @@ const Viewport = React.forwardRef(function { className, style, ...rest }, ref, ) { + const size = React.useContext(DialogSizeContext); React.useEffect(() => acquireKeyboardInset(), []); return ( ); diff --git a/packages/ui/src/mosaic/tokens.stylex.ts b/packages/ui/src/mosaic/tokens.stylex.ts index e4c60baa8d1..a7de1823eba 100644 --- a/packages/ui/src/mosaic/tokens.stylex.ts +++ b/packages/ui/src/mosaic/tokens.stylex.ts @@ -313,9 +313,18 @@ export const durationVars = stylex.defineVars(durationDefaults); // distance over few frames, so a sharper curve (In Quart, In Circ) leaves half of them // below the threshold of visible change and reads as a stall followed by a lurch. // Pair it with a shorter duration than the matching entrance. +// +// `--cl-ease-enter` is the entrance curve for surfaces that should NOT settle: the same +// front-loaded deceleration, but landing exactly on target instead of carrying ~2% past it. +// Use it where the overshoot is read as a correction rather than as physicality — a large +// surface, or one whose arrival is already carried by a companion signal such as a scrim. +// `Dialog` takes it for that reason; a small element moving a short distance still wants +// `--cl-ease-default`, where the settle is the whole point. Same property rule applies: it +// belongs on things that MOVE, and opacity still takes `linear`. const easingDefaults = { '--cl-ease-default': 'cubic-bezier(0.175, 0.885, 0.32, 1.1)', + '--cl-ease-enter': 'cubic-bezier(0, 0, 0.2, 1)', '--cl-ease-exit': 'cubic-bezier(0.55, 0.085, 0.68, 0.53)', } as const; From 4b3664d4a13805a359cfd5dd0c678b1551bde34f Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 11:26:29 -0600 Subject: [PATCH 3/9] feat(ui): polish the Mosaic Dialog surface and warn on unnamed dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .changeset/olive-doors-tell.md | 4 +- .../mosaic/components/dialog/dialog.styles.ts | 20 +++++- .../mosaic/components/dialog/dialog.test.tsx | 71 ++++++++++++++++++- .../src/mosaic/components/dialog/dialog.tsx | 20 +++++- .../src/mosaic/components/popover/popover.tsx | 34 +-------- .../mosaic/hooks/useAccessibleNameWarning.ts | 52 ++++++++++++++ 6 files changed, 163 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/mosaic/hooks/useAccessibleNameWarning.ts diff --git a/.changeset/olive-doors-tell.md b/.changeset/olive-doors-tell.md index 0ed16fc7758..aaaf4d9a92a 100644 --- a/.changeset/olive-doors-tell.md +++ b/.changeset/olive-doors-tell.md @@ -13,7 +13,7 @@ Move the Mosaic `Dialog` onto StyleX, joining the other migrated components, and **The inset.** The gap between a dialog and the edge of the screen is now a fixed inset that steps up at two breakpoints — `1.25rem`, `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. Below `48rem` the sides come in to `1rem` while the top and bottom hold at `1.25rem`, since horizontal space is what a phone is short of. -**Scrolling.** A dialog taller than the screen now scrolls, and how it scrolls follows from its size rather than from a prop. A `panel` is a fixed-height window, so it scrolls *inside*: compose the region with `scrollAreaRoot` / `scrollAreaViewport()`. A `prompt` and a `card` take their height from their content, so they scroll *outside* — the popup keeps its natural height and the whole dialog moves within the viewport, keeping its inset at both ends rather than running flush into the bottom of the screen. `Dialog.Viewport` also now carries `data-size`. One exception: a `prompt` below `48rem` is a bottom sheet, and the rule that keeps its off-screen slide from painting a scrollbar also keeps it from scrolling — use `card` for a tall surface on a phone. +**Scrolling.** A dialog taller than the screen now scrolls, and how it scrolls follows from its size rather than from a prop. A `panel` is a fixed-height window, so it scrolls _inside_: compose the region with `scrollAreaRoot` / `scrollAreaViewport()`. A `prompt` and a `card` take their height from their content, so they scroll _outside_ — the popup keeps its natural height and the whole dialog moves within the viewport, keeping its inset at both ends rather than running flush into the bottom of the screen. `Dialog.Viewport` also now carries `data-size`. One exception: a `prompt` below `48rem` is a bottom sheet, and the rule that keeps its off-screen slide from painting a scrollbar also keeps it from scrolling — use `card` for a tall surface on a phone. **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. @@ -32,3 +32,5 @@ This also fixes the enter/exit transition, which was keyed to a `data-cl-startin **Browser chrome.** While a dialog is open, the mobile browser's own chrome is tinted to match the scrim — both `theme-color` and the `` 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. + +**Polish.** The dialog surface now draws a real border under `forced-colors: active`, where `box-shadow` is discarded and the popup would otherwise float edgeless over a scrim that is also discarded. Long unbroken strings — an email address, an org slug, an API key — wrap instead of pushing past the size's width clamp. And in development, a dialog with no accessible name warns: pass `aria-label`, or render a `Dialog.Title`. The same check now backs `Popover`, and it resolves `aria-labelledby` to a real element rather than trusting the attribute's presence, so a reference pointing at nothing is reported rather than waved through. diff --git a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 9469784e155..0639fcd71d3 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -79,7 +79,19 @@ export const styles = stylex.create({ // nulls the painting properties back out — see the note there. popup: { padding: space['6'], - borderRadius: radiusVars['--cl-radius-container'], + // Forced-colors mode discards `box-shadow` outright, and the ring above is the only thing + // separating the surface from the page — so in HCM the dialog would float edgeless over its + // own scrim (which is also discarded). A real border is the one edge the mode keeps. Set only + // inside the query so it costs nothing elsewhere, and `box-sizing: border-box` from `reset` + // means adding it moves nothing. + // + // Not an `outline`: the popup clears its outline on purpose (`FloatingFocusManager` focuses + // the popup itself when it holds no tabbable content), and reintroducing one here would put + // the edge and the focus ring on the same property. + borderColor: { default: null, '@media (forced-colors: active)': 'CanvasText' }, + borderRadius: radiusVars['--cl-radius-xl'], + borderStyle: { default: null, '@media (forced-colors: active)': 'solid' }, + borderWidth: { default: null, '@media (forced-colors: active)': '1px' }, gap: space['3'], // Cleared because `FloatingFocusManager` focuses the popup itself when it holds no // tabbable content, which would otherwise draw a ring around the whole surface. @@ -102,6 +114,10 @@ export const styles = stylex.create({ color: colorVars['--cl-color-card-foreground'], display: 'flex', flexDirection: 'column', + // A dialog holds prose it did not author — an email address, an org slug, an API key — and a + // long unbroken string would otherwise push past the size's width clamp. Same reasoning as + // `Popover`, which is narrower and hit it first. + overflowWrap: 'anywhere', // The containing block for `Dialog.CloseButton`. position: 'relative', width: '100%', @@ -382,7 +398,7 @@ export const backdropMotion = stylex.create({ const SHEET_EXIT_EASE = 'ease-out'; const ENTER_SCALE = 0.94; -const popupRadius = radiusVars['--cl-radius-container']; +const popupRadius = radiusVars['--cl-radius-xl']; export const popupMotion = stylex.create({ /** diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index 93b763b97a0..2b96c7c3000 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -1,8 +1,8 @@ import * as stylex from '@stylexjs/stylex'; -import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { MosaicComponentProps } from '../../props'; import { space } from '../../tokens.stylex'; @@ -11,6 +11,12 @@ import { Dialog } from './dialog'; afterEach(() => cleanup()); +// The accessible-name warning defers by a task, so the assertions have to let one elapse. +const settle = () => + act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + describe('Mosaic Dialog', () => { it('renders the trigger and opens the dialog on click', async () => { const user = userEvent.setup(); @@ -525,3 +531,64 @@ describe('browser chrome sync', () => { expect(themeColor()).not.toBeNull(); }); }); + +describe('accessible name warning', () => { + it('warns when the dialog has no accessible name', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(Body); + + await settle(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no accessible name')); + warn.mockRestore(); + }); + + it('does not warn when a Dialog.Title supplies the name', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + Confirm action + , + ); + + await settle(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); + + // The name can come from the consumer instead of from a Title, and an `aria-label` on the popup + // is the documented way to do that. + it('does not warn when aria-label supplies the name', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + + Body + + + , + ); + + await settle(); + + 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 41390355cde..e4106ea272c 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -4,6 +4,7 @@ import * as stylex from '@stylexjs/stylex'; import type { ReactNode } from 'react'; import React from 'react'; +import { useAccessibleNameWarning } from '../../hooks/useAccessibleNameWarning'; import type { MosaicComponentProps } from '../../props'; import { mergeStyleProps, themeProps } from '../../props'; import { Button } from '../button'; @@ -269,9 +270,26 @@ const Popup = React.forwardRef(function Dialog ref, ) { const size = React.useContext(DialogSizeContext); + // Observed through state rather than a plain ref, because the warning has to re-run when the + // node arrives and a ref mutation does not re-render. + const [node, setNode] = React.useState(null); + useAccessibleNameWarning(node, 'Dialog'); + + const mergedRef = React.useCallback( + (element: HTMLDivElement | null) => { + setNode(element); + if (typeof ref === 'function') { + ref(element); + } else if (ref) { + ref.current = element; + } + }, + [ref], + ); + return ( { - if (process.env.NODE_ENV === 'production' || !node) { - return; - } - // Deferred by a task, not checked inline: `Popover.Title` reports itself through an - // effect, so on the commit that mounts the positioner the label is legitimately not - // there yet. Checking immediately would warn on every popover that does have a Title. - const timer = setTimeout(() => { - if (!node.isConnected || node.getAttribute('role') !== 'dialog') { - return; - } - if (node.hasAttribute('aria-label') || node.hasAttribute('aria-labelledby')) { - return; - } - console.warn( - '[clerk] renders a dialog with no accessible name. Pass `aria-label`, or render a `` inside it.', - ); - }, 0); - - return () => clearTimeout(timer); - }, [node]); -} - function Positioner({ children, ...rest }: React.ComponentPropsWithoutRef) { const [node, setNode] = React.useState(null); - useAccessibleNameWarning(node); + useAccessibleNameWarning(node, 'Popover'); return ( { + if (process.env.NODE_ENV === 'production' || !node) { + return; + } + + const timer = setTimeout(() => { + if (!node.isConnected || node.getAttribute('role') !== 'dialog') { + return; + } + if (node.getAttribute('aria-label')?.trim()) { + return; + } + // RESOLVED, not merely present. `Dialog` emits `aria-labelledby` unconditionally, so with no + // `Dialog.Title` the attribute points at an id that is not in the document — which names the + // dialog exactly as poorly as having no attribute at all, and is what a presence check would + // wave through. `Popover` omits the attribute instead, so resolving covers both shapes. + const labelledBy = node.getAttribute('aria-labelledby'); + const named = labelledBy + ?.split(/\s+/) + .filter(Boolean) + .some(id => node.ownerDocument.getElementById(id)?.textContent?.trim()); + if (named) { + return; + } + console.warn( + `[clerk] <${component}.Popup> renders a dialog with no accessible name. Pass \`aria-label\`, or render a \`<${component}.Title>\` inside it.`, + ); + }, 0); + + return () => clearTimeout(timer); + }, [node, component]); +} From f17f4670abf37b8a958116da07df1768f216f39b Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 13:21:55 -0600 Subject: [PATCH 4/9] feat(ui): fade the panel dialog, and drop docs for the removed open origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `panel` fades in and out rather than appearing instantly. It still does not scale — the absolute travel of a scale is a proportion of the element's own size, so the 6% that reads as emergence on a card arrives as a zoom on a surface that is most of the viewport. The fade is load-bearing on the popup, not only decoration: the headless transition watches the POPUP's animations to decide when to unmount and takes the whole subtree at once, so with nothing running there the scrim was pulled before it could fade. That is what previously forced the backdrop to be instant too. Also removes documentation for `--cl-dialog-origin` and the trigger-origin open, which no longer exist, and corrects `Item.Title` to `Item.Label` in the dialog stories after that rename. --- .changeset/olive-doors-tell.md | 2 +- .../headless/src/primitives/dialog/README.md | 1 - .../swingset/src/stories/dialog.component.mdx | 228 ++++++------------ .../src/stories/dialog.component.stories.tsx | 14 +- .../mosaic/components/dialog/dialog.styles.ts | 41 +++- 5 files changed, 121 insertions(+), 165 deletions(-) diff --git a/.changeset/olive-doors-tell.md b/.changeset/olive-doors-tell.md index aaaf4d9a92a..b7f2ff36c61 100644 --- a/.changeset/olive-doors-tell.md +++ b/.changeset/olive-doors-tell.md @@ -19,7 +19,7 @@ Move the Mosaic `Dialog` onto StyleX, joining the other migrated components, and **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 from their own centre, and 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. +**Motion.** Every size fades in and out. `prompt` and `card` also scale from their own centre, and corner radius no longer distorts during the scale. `panel` fades without scaling, 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. **New `--cl-ease-enter` token.** An entrance curve that decelerates onto its target instead of carrying past it, for surfaces where `--cl-ease-default`'s overshoot reads as a correction rather than as physicality — a large surface, or one whose arrival is already announced by a scrim. `Dialog` uses it for every entrance; nothing else changes curve. Retarget it like any other Mosaic token. diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index 66ee695235d..9696c954016 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -136,7 +136,6 @@ The headless parts are unstyled. Target a part with your own className (or `rend - **`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**, 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 diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index f7c99fc0349..6fb5351cf00 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -2,10 +2,10 @@ import * as DialogStories from './dialog.component.stories'; # Dialog -The Mosaic `Dialog` — the styled Mosaic component composed from the `@clerk/headless` dialog -primitive and themed with StyleX. It flattens the required nesting (Root, Portal, Backdrop, -Viewport, Popup) into a single component and exposes a `close` callback through a render-prop -children pattern, while inheriting the primitive's focus trapping, scroll lock, and ARIA wiring. +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. ## Playground @@ -26,6 +26,7 @@ children pattern, while inheriting the primitive's focus trapping, scroll lock, { name: 'onOpenChange', type: '(open: boolean) => void' }, { name: 'modal', type: 'boolean', default: 'true' }, { name: 'closedBy', type: "'any' | 'closerequest' | 'none'", default: "'any'" }, + { name: 'syncBrowserChrome', type: 'boolean', default: 'true' }, ]} /> @@ -46,12 +47,12 @@ import { Dialog } from '@clerk/ui/mosaic/components/dialog'; ``` -The `trigger` render prop receives the interaction props (ARIA attributes, click handler) from -Floating UI and should spread them onto whatever element opens the dialog. It is optional — omit it -for a dialog driven entirely by `open`, opened from a menu item, a route, or a state machine. +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 +entirely by `open`, opened from a menu item, a route, or a state machine. -`children` can also be a plain `ReactNode` when no programmatic close is needed — the dialog -can always be dismissed via Escape or clicking the backdrop: +`children` can also be a plain `ReactNode` when no programmatic close is needed — the dialog can +always be dismissed via Escape or the backdrop: ```tsx }> @@ -81,9 +82,7 @@ const [open, setOpen] = useState(false); ### Size -`size` names the surface rather than a t-shirt step, because these are different surfaces rather -than one surface at three scales — the names stay honest if they later diverge on padding, mobile -treatment, or footer. +`size` names the surface, not a t-shirt step: | Value | Size | For | | -------- | -------------------------------------------- | ---------------------------------------------------------------------- | @@ -91,12 +90,11 @@ treatment, or footer. | `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` is the default, because asking one thing is the common case. +`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. -`prompt` and `card` set only a max width and let their content decide the height. `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. +`size` lives on `Dialog.Root`, not `Dialog.Popup`, because the backdrop reads it too. ### A card brings its own surface @@ -149,52 +147,14 @@ The sheet fades over the full length of its slide, while the backdrop keeps its 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 gate is width, not `pointer: coarse`.** A coarse pointer describes the input device rather -than the screen — an iPad reports it in both orientations, so it would put a bottom sheet across a -1366px display, while a touchscreen laptop usually reports `fine` and would be missed. A bottom -sheet exists for thumb reach on a tall narrow screen, which is a width question. Width is also the -gate you can test by resizing a window. - -`Dialog.Viewport` clips under the phone band, so the sheet — which enters from a full height below -its resting place — never makes the overlay scrollable while it travels. Without that a scrollbar -appears for the duration of the animation. Tall content is unaffected: the viewport is -`height: auto`, so content taller than the screen grows it rather than overflowing it. - -**The sheet does not scale either.** `transform-origin` points at the trigger, which for a bottom -sheet sits outside its box, so a 2% scale about that point lifts the bottom edge a few pixels and -only releases it as the scale reaches 1 — the sheet appears to land above the inset and then -correct. The scale is scoped to widths at or above `48rem`. - -**The sheet does not fade.** It starts fully off the bottom edge, so a fade adds nothing at the -start and washes out the middle of the travel — the eye reads a panel materialising rather than one -arriving. Native sheets on both platforms slide fully opaque and let the scrim carry the "something -arrived" cue, which is what happens here too. - -The slide takes `--cl-duration-slow` in and `--cl-duration-base` out — longer than anything else -here, because it travels its own height rather than the ~6px a scale does. - -The backdrop is deliberately **not** synced to it. The scrim is the answer to the tap, so it keeps -its own faster timing (`--cl-duration-base` in) and lands first; the sheet then arrives into an -already-dimmed page. Making the dim wait for a surface that travels a whole screen height only -delays the feedback. - -Under `prefers-reduced-motion: reduce` the sheet holds flat and fades instead, since with the -transform pinned the fade is the only signal left. - -Drag-to-dismiss is deliberately absent. `Drawer` already owns a drag engine with snap points, and -growing a second one inside `Dialog` would duplicate it; if the sheet ever wants dragging, the -engine should be shared rather than rewritten. The panel scrolls its own -overflow. - -`size` lives on `Dialog.Root`, not on `Dialog.Popup`, because the backdrop reads it too — the two -sizes animate differently. +Drag-to-dismiss is deliberately absent — `Drawer` owns the drag engine, and a second one should not +grow inside `Dialog`. ### 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 on both sizes. It is inset `1rem` on a `card` and `1.125rem` on -a `panel`. Being absolutely positioned, it never joins the popup's column layout, so you can render -it anywhere among the children without the rest moving. +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 }> @@ -205,13 +165,12 @@ it anywhere among the children without the rest moving. It carries an English `Close` label by default; pass `aria-label` to override it. -`Dialog.Close` stays available and stays unstyled — that is what a "Cancel" button in a footer -wants. `Dialog.CloseButton` is the styled corner affordance. +`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. -> That is the argument for an `initialFocus` API, which does not exist yet — until it does, render -> the close button last if a field should take focus instead. +> Render the close button last if a field should take focus instead. ### Dismissal @@ -222,17 +181,18 @@ user input, so a stray backdrop click cannot discard it. ## Parts -| Part | Slot | Description | -| -------------------- | ----------------- | ------------------------------------------------------------- | -| `Dialog.Root` | — | State provider; owns `size`, open/close, `modal`, `closedBy`. | -| `Dialog.Trigger` | — | Opens the dialog; renders a ` - )} - , - ); - // Acquired when the backdrop's transition arms — one frame after mount — not on the mount - // frame itself, where an inline `transition: none` would make the fade a snap. - await waitFor(() => expect(themeColor()).not.toBeNull()); - - await user.click(screen.getByRole('button', { name: 'Dismiss' })); - await waitFor(() => expect(themeColor()).toBeNull()); - }); - - it('prepends its meta so it wins over the app’s own, and leaves that one untouched', async () => { - const appMeta = document.createElement('meta'); - appMeta.name = 'theme-color'; - appMeta.content = 'rgb(10, 20, 30)'; - document.head.append(appMeta); - - render(Body); - await waitFor(() => expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(2)); - - const metas = document.head.querySelectorAll('meta[name="theme-color"]'); - // First in tree order is what the UA uses, so ours has to be first — and theirs unchanged. - expect(metas).toHaveLength(2); - expect(metas[0]).not.toBe(appMeta); - expect(appMeta.content).toBe('rgb(10, 20, 30)'); - }); - - it('opts out with syncBrowserChrome={false}', () => { - render( - - Body - , - ); - - expect(themeColor()).toBeNull(); - }); - - it('keeps the tint when a dialog re-opens before the previous teardown fires', async () => { - // Regression: closing schedules the meta's removal after the fade. React StrictMode's - // mount → cleanup → mount, or simply opening again quickly, used to let that deferred - // removal fire and strip the tint from a dialog that was still open. - const user = userEvent.setup(); - const { rerender } = render(Body); - await waitFor(() => expect(themeColor()).not.toBeNull()); - - rerender(Body); - rerender(Body); - - await new Promise(resolve => setTimeout(resolve, 250)); - expect(themeColor()).not.toBeNull(); - await user.keyboard('{Escape}'); - }); - - it('keeps one meta for stacked dialogs and removes it only with the last', async () => { - const user = userEvent.setup(); - render( - -
Outer
- -
Inner
-
-
, - ); - - await user.click(screen.getByRole('button', { name: 'Add email' })); - expect(document.head.querySelectorAll('meta[name="theme-color"]')).toHaveLength(1); - - await user.keyboard('{Escape}'); - expect(themeColor()).not.toBeNull(); - }); -}); - describe('accessible name warning', () => { it('warns when the dialog has no accessible name', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx index 3af9f141000..e80949b1f86 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -10,7 +10,6 @@ import { mergeStyleProps, themeProps } from '../../props'; import { Button } from '../button'; import { Icon } from '../icon'; import { reset } from '../reset.styles'; -import { acquireBrowserChrome } from './browser-chrome'; import { backdropMotion, closeInsets, popupMotion, sizes, styles, viewportSizes } from './dialog.styles'; import { acquireKeyboardInset } from './keyboard-inset'; @@ -20,17 +19,6 @@ 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; - /** - * Tint the mobile browser's own chrome — the address bar, and the canvas behind the overscroll - * gutter — to match the dialog's scrim, so an open dialog reads as one continuous surface. - * - * On by default. It ships no colour of its own (the target is derived from the backdrop - * composited over whatever the page already had), reverts exactly on close, and is inert - * wherever `theme-color` is ignored. Pass `false` if the app drives `theme-color` itself. - * - * @default true - */ - syncBrowserChrome?: boolean; } /** @@ -41,56 +29,6 @@ export interface DialogRootProps extends HeadlessDialogProps { */ const DialogSizeContext = React.createContext('prompt'); -/** Whether the dialog tints the mobile browser's chrome to match its scrim. See `browser-chrome.ts`. */ -const DialogChromeContext = React.createContext(true); - -/** - * Drives the browser-chrome tint off the backdrop element itself, so both the colour and the timing - * come from the CSS rather than from constants duplicated in JS. - * - * Keyed on the NODE via state rather than a ref: the effect has to run once the backdrop is in the - * DOM and its computed style is readable, and a ref gives no signal when that happens. - */ -function useBrowserChrome(node: HTMLElement | null, enabled: boolean) { - React.useEffect(() => { - if (!enabled || !node) { - return; - } - - // Driven by the backdrop's own transition attributes rather than by mount and unmount, so the - // colour runs on exactly the same clock as the scrim in both directions. - // - // Both attributes matter, for different reasons. `data-ending-style` because the headless - // layer keeps the backdrop mounted until its exit animation finishes, so releasing at unmount - // starts the revert only once the scrim has already gone. And `data-starting-style` because - // that frame carries an inline `transition: none` — acquiring there reads a duration of `0s` - // and the fade becomes a snap. Waiting for both to be absent is precisely waiting for the - // scrim's transition to arm. - // - // Two-way, because an exit can be interrupted: re-opening mid-exit clears the attribute on the - // same element, and the tint has to come back without waiting for a remount. - let handle: (() => void) | null = null; - const sync = () => { - const transitioning = node.hasAttribute('data-starting-style') || node.hasAttribute('data-ending-style'); - if (transitioning && handle) { - handle(); - handle = null; - } else if (!transitioning && !handle) { - handle = acquireBrowserChrome(node); - } - }; - - const observer = new MutationObserver(sync); - observer.observe(node, { attributes: true, attributeFilter: ['data-starting-style', 'data-ending-style'] }); - sync(); - - return () => { - observer.disconnect(); - handle?.(); - }; - }, [node, enabled]); -} - /** * The headless parts type their props (and the `render` callback's argument) against * the raw tag props, which carry the non-standard HTML `color` attribute typed @@ -119,12 +57,10 @@ export interface DialogViewportProps extends MosaicComponentProps<'div'> { export type DialogPopupProps = MosaicComponentProps<'div'>; /** Owns the open state and the size both the backdrop and the popup read. */ -function Root({ size = 'prompt', syncBrowserChrome = true, children, ...rest }: DialogRootProps) { +function Root({ size = 'prompt', children, ...rest }: DialogRootProps) { return ( - - {children} - + {children} ); } @@ -213,22 +149,9 @@ const Backdrop = React.forwardRef(function ref, ) { const size = React.useContext(DialogSizeContext); - const [node, setNode] = React.useState(null); - useBrowserChrome(node, React.useContext(DialogChromeContext)); - const mergedRef = React.useCallback( - (el: HTMLDivElement | null) => { - setNode(el); - if (typeof ref === 'function') { - ref(el); - } else if (ref) { - ref.current = el; - } - }, - [ref], - ); return ( void }) => ReactNode); /** Width, and for `panel` also height, of the dialog surface. @default 'prompt' */ size?: DialogSize; - /** Tint the mobile browser chrome to match the scrim. @default true */ - syncBrowserChrome?: boolean; } function DialogContent({ children }: { children: DialogProps['children'] }) { @@ -336,21 +257,10 @@ function DialogContent({ children }: { children: DialogProps['children'] }) { * 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, - syncBrowserChrome, - open, - defaultOpen, - onOpenChange, - modal, - closedBy, -}: DialogProps) { +export function Dialog({ trigger, children, size, open, defaultOpen, onOpenChange, modal, closedBy }: DialogProps) { return ( Date: Wed, 12 Aug 2026 14:56:55 -0600 Subject: [PATCH 8/9] docs(repo): correct the Dialog parts and state-attribute tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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-` for every variant it is handed, and `size` is passed to both. --- .../swingset/src/stories/dialog.component.mdx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index 6e390cc1cfe..ffae00cc94f 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -180,18 +180,18 @@ user input, so a stray backdrop click cannot discard it. ## Parts -| Part | Slot | Description | -| -------------------- | --------------------- | ----------------------------------------------------------------------- | -| `Dialog.Root` | — | State provider; owns `size`, open/close, `modal`, `closedBy`, `handle`. | -| `Dialog.Trigger` | — | Opens the dialog; accepts `render`. | -| `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). | -| `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 `size`, open/close, `modal`, `closedBy`. | +| `Dialog.Trigger` | — | Opens the dialog; accepts `render`. | +| `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). | +| `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.Title` and `Dialog.Description` are unstyled passthroughs from the headless layer — render them through your own typography (`Heading`, `Text`) via `render`. @@ -221,7 +221,7 @@ State attributes from the headless layer are available for CSS targeting: | `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` | Popup | Resolved size (`prompt` / `card` / `panel`) | +| `data-size` | Viewport, Popup | Resolved size (`prompt` / `card` / `panel`) | | `data-nested` | Backdrop, Viewport, Popup | Present when opened inside another overlay | ### Motion From 9d59b7dd0808de2fb1c2fde649c81b9fb4168ba0 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 12 Aug 2026 15:14:14 -0600 Subject: [PATCH 9/9] fix(ui): drop a dialog test helper left unused by the browser-chrome split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `addEmailTriggerShared` was only ever rendered by the browser-chrome tests, which moved to their own branch — `eslint --quiet` fails the package on the unused binding. It travels with those tests rather than staying behind here. --- packages/ui/src/mosaic/components/dialog/dialog.test.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index 882362ba994..23f93e0b83c 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -155,15 +155,6 @@ describe('Mosaic Dialog', () => { // 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. -const addEmailTriggerShared = (props: MosaicComponentProps<'button'>) => ( - -); - describe('nested Mosaic Dialogs', () => { const addEmailTrigger = (props: MosaicComponentProps<'button'>) => (