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/.changeset/spicy-clocks-argue.md b/.changeset/spicy-clocks-argue.md new file mode 100644 index 00000000000..ee9c392f1f7 --- /dev/null +++ b/.changeset/spicy-clocks-argue.md @@ -0,0 +1,12 @@ +--- +'@clerk/headless': patch +'@clerk/ui': patch +--- + +Add Base UI–style composition APIs to the Dialog, in both the headless primitive and the Mosaic component. + +**Detached triggers.** `Dialog.createHandle()` returns a handle; pass the same handle to a `Dialog.Trigger` and a `Dialog.Root`, and the trigger drives the dialog from anywhere in the tree — no JSX nesting required. The handle also has imperative `open()` / `close()` / `isOpen` members; calls made while no root is mounted are ignored. + +**Multiple triggers and payloads.** Several triggers can share one dialog. Each can carry an `id` and a `payload`, and the root's children can be a function receiving `{ payload }` from the active trigger, so one dialog renders per-trigger content. Type the payload through the handle: `Dialog.createHandle()`. Everything keyed to "the trigger" now follows the one actually used: the dialog scales out of it and returns focus to it on close. In controlled mode, `triggerId` on `Dialog.Root` names the active trigger, and `onOpenChange` gains a second `details` argument (`{ trigger, triggerId, event }`) reporting the trigger behind each change — existing single-argument callbacks are unaffected. Setting `triggerId` alongside a programmatic `open` attributes the open to that trigger, which also gives controlled, trigger-less dialogs the origin-aware open animation. + +**Custom focus management.** `initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves on open and close. Each accepts `true` (the default behaviour), `false` (do not move focus), a ref, or a function of the interaction type behind the change (`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty when programmatic) returning any of those. Defaults are unchanged: first tabbable on open; the trigger on close, except after a pointer-driven dismissal, where focus stays where the pointer put it. diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index 3ae2a9f1f76..dea9698cc18 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -45,6 +45,80 @@ const [open, setOpen] = useState(false); {/* Focus is not trapped, page remains interactive */} ``` +### Detached triggers + +A trigger does not have to be nested inside its root. `Dialog.createHandle()` returns a handle; +pass the same handle to both, and the trigger drives the root from anywhere in the tree. The +handle also has imperative `open()` / `close()` / `isOpen` members; calls made while no root is +mounted are ignored. + +```tsx +const feedbackDialog = Dialog.createHandle(); + +Give feedback; + +{/* ... */}; +``` + +### Multiple triggers and payloads + +Each trigger can carry an `id` and a `payload`. The root's children can be a function receiving +the active trigger's payload, so one dialog renders per-trigger content. Type the payload through +the handle: `Dialog.createHandle()`. + +```tsx +const detail = Dialog.createHandle<{ name: string }>(); + +Alice +Bob + + + {({ payload }) => {payload?.name}} + +``` + +In controlled mode, track which trigger is active with `triggerId` — `onOpenChange`'s second +argument reports the trigger behind each change: + +```tsx +const [open, setOpen] = useState(false); +const [triggerId, setTriggerId] = useState(null); + + { + setOpen(next); + setTriggerId(details.triggerId); + }} +> + {/* ... */} +; +``` + +Setting `triggerId` alongside a programmatic `open` also attributes the open to that trigger — +the dialog scales out of it (`--cl-dialog-origin`) and returns focus to it on close, exactly as +if it had been clicked. + +### Custom focus management + +`initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves on open and close. +Each accepts `true` (the default behaviour), `false` (do not move focus), a ref, or a function of +the interaction type behind the open/close (`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty +for programmatic) returning any of those: + +```tsx + (interactionType === 'keyboard' ? firstFieldRef.current : false)} + finalFocus={finalFocusRef} +> + {/* ... */} + +``` + +The defaults stay what they were: first tabbable element on open; on close, the trigger — unless +the close was pointer-driven, where focus is left where the pointer put it (see `useReturnFocus`). + ## Parts | Part | Default Element | Description | @@ -63,13 +137,16 @@ const [open, setOpen] = useState(false); ### `Dialog.Root` -| Prop | Type | Default | Description | -| -------------- | ----------------------------------- | ------- | --------------------------------------- | -| `open` | `boolean` | — | Controlled open state | -| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | -| `onOpenChange` | `(open: boolean) => void` | — | Called when open state changes | -| `modal` | `boolean` | `true` | Traps focus and blocks page interaction | -| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog | +| Prop | Type | Default | Description | +| -------------- | ----------------------------------------------------------- | ------- | --------------------------------------------------------------------- | +| `open` | `boolean` | — | Controlled open state | +| `defaultOpen` | `boolean` | `false` | Initial open state (uncontrolled) | +| `onOpenChange` | `(open: boolean, details: DialogOpenChangeDetails) => void` | — | Called when open state changes; `details` names the trigger behind it | +| `modal` | `boolean` | `true` | Traps focus and blocks page interaction | +| `closedBy` | `'any' \| 'closerequest' \| 'none'` | `'any'` | Which gestures dismiss the dialog | +| `handle` | `DialogHandle` | — | Connects detached triggers (see `Dialog.createHandle()`) | +| `triggerId` | `string \| null` | — | Controls which trigger the open is attributed to | +| `children` | `ReactNode \| ({ payload }) => ReactNode` | — | Content, or a render function of the active trigger's `payload` | #### `closedBy` @@ -107,7 +184,24 @@ When `root` is provided, the dialog is portaled into that container instead of ` | ------------ | --------- | ------- | ------------------------------- | | `lockScroll` | `boolean` | `true` | Prevents body scroll while open | -### `Dialog.Backdrop`, `Dialog.Trigger`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close` +### `Dialog.Trigger` + +| Prop | Type | Default | Description | +| --------- | -------------- | ------- | -------------------------------------------------------- | +| `handle` | `DialogHandle` | — | Drives a root elsewhere in the tree (detached trigger) | +| `id` | `string` | auto | Names this trigger for the root's `triggerId` | +| `payload` | `Payload` | — | Delivered to the root's children render function on open | + +### `Dialog.Popup` + +| Prop | Type | Default | Description | +| -------------- | ------------------- | ------- | --------------------------------------- | +| `initialFocus` | `DialogFocusTarget` | `true` | Where focus moves when the dialog opens | +| `finalFocus` | `DialogFocusTarget` | `true` | Where focus returns when it closes | + +`DialogFocusTarget` is `boolean | RefObject | (interactionType) => boolean | void | HTMLElement | null`. + +### `Dialog.Backdrop`, `Dialog.Title`, `Dialog.Description`, `Dialog.Close` No additional props beyond standard HTML attributes and the `render` prop. @@ -120,9 +214,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 +229,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..d4f2775036a 100644 --- a/packages/headless/src/primitives/dialog/dialog-context.ts +++ b/packages/headless/src/primitives/dialog/dialog-context.ts @@ -2,18 +2,42 @@ import type { ExtendedRefs, FloatingContext, ReferenceType, UseInteractionsRetur import { createContext, useContext } from 'react'; import type { TransitionProps } from '../../hooks/use-transition'; +import type { DialogHandle } from './dialog-handle'; export interface DialogContextValue { open: boolean; setOpen: (open: boolean) => void; floatingContext: FloatingContext; refs: ExtendedRefs; - getReferenceProps: UseInteractionsReturn['getReferenceProps']; getFloatingProps: UseInteractionsReturn['getFloatingProps']; popupRef: React.RefObject; /** Where focus goes when the dialog closes, or `null` to leave focus alone. */ returnFocusRef: React.MutableRefObject; + /** + * The store connecting this root to its triggers — the `handle` prop when one was passed, + * otherwise a private store the root created. Triggers nested inside the root reach it here; + * detached triggers hold the same object through their `handle` prop. + */ + store: DialogHandle; + /** + * Set by `Dialog.Popup` when its `finalFocus` is a function, and invoked by the root + * synchronously on every close — dismissal or programmatic — with the event behind it, if + * any. Resolving inside the close call is what guarantees the result is in place before + * `FloatingFocusManager` restores focus; an effect can lose that race when close and unmount + * land in the same commit. + */ + finalFocusResolverRef: React.MutableRefObject<((event: Event | undefined) => void) | null>; 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; @@ -29,3 +53,8 @@ export function useDialogContext() { } return ctx; } + +/** Context access for parts that can also live outside the root — a trigger given a `handle`. */ +export function useOptionalDialogContext() { + return useContext(DialogContext); +} diff --git a/packages/headless/src/primitives/dialog/dialog-handle.ts b/packages/headless/src/primitives/dialog/dialog-handle.ts new file mode 100644 index 00000000000..9524da4a11b --- /dev/null +++ b/packages/headless/src/primitives/dialog/dialog-handle.ts @@ -0,0 +1,141 @@ +/** + * A handle connects `Dialog.Trigger` and `Dialog.Root` without JSX nesting, mirroring Base UI's + * `Dialog.createHandle()`: create one at module scope (or in state), pass it to both, and a + * trigger anywhere in the tree drives a root it is not nested under. + * + * The same store also backs in-context triggers — a root with no `handle` prop creates a private + * one — so nested and detached triggers share a single registration and open/close path. + */ + +/** How the trigger that opened (or last opened) the dialog is known to the root. */ +export interface DialogTriggerRegistration { + id: string; + element: HTMLElement; + payload: Payload | undefined; +} + +/** + * What the root exposes to triggers through the handle. Present only while a root is mounted; + * requests made with no root attached are ignored, matching Base UI. + * @internal + */ +export interface DialogRootController { + openFromTrigger: (id: string, event: Event) => void; + closeFromTrigger: (id: string, event: Event) => void; + setOpen: (open: boolean) => void; +} + +/** The slice of root state a trigger renders from: its `data-open` / ARIA wiring. */ +export interface DialogHandleState { + open: boolean; + /** The id of the trigger the open is attributed to, or `null` when none is named. */ + triggerId: string | null; + /** The popup's DOM id while open, for the trigger's `aria-controls`. */ + popupId: string | undefined; +} + +const CLOSED_STATE: DialogHandleState = { open: false, triggerId: null, popupId: undefined }; + +/** + * Links triggers to a dialog root without requiring them to be nested inside it. + * Create with {@link createDialogHandle}; every member is internal wiring. + * + * Members use method syntax deliberately: methods are bivariant in their parameters, which + * lets a `DialogHandle` flow into contexts typed `DialogHandle`. + */ +export interface DialogHandle { + /** Opens the attached root. Ignored while no root is mounted. */ + open(): void; + /** Closes the attached root. Ignored while no root is mounted. */ + close(): void; + /** Whether the attached root is open. `false` while no root is mounted. */ + readonly isOpen: boolean; + /** @internal */ + registerTrigger(registration: DialogTriggerRegistration): () => void; + /** @internal */ + getTrigger(id: string): DialogTriggerRegistration | undefined; + /** @internal */ + getFirstTrigger(): DialogTriggerRegistration | undefined; + /** @internal Bumps whenever the trigger registry changes; lets the root re-resolve its reference element. */ + getRegistryVersion(): number; + /** @internal */ + setRoot(controller: DialogRootController): () => void; + /** @internal */ + requestOpen(id: string, event: Event): void; + /** @internal */ + requestClose(id: string, event: Event): void; + /** @internal */ + publishState(state: DialogHandleState): void; + /** @internal */ + getState(): DialogHandleState; + /** @internal */ + subscribe(listener: () => void): () => void; +} + +/** + * Creates a {@link DialogHandle} to pass to both a `Dialog.Trigger` and a `Dialog.Root`, so a + * detached trigger can drive the dialog. The type parameter types the `payload` carried from + * each trigger into the root's children render function. + */ +export function createDialogHandle(): DialogHandle { + const triggers = new Map>(); + const listeners = new Set<() => void>(); + let root: DialogRootController | null = null; + let state = CLOSED_STATE; + let registryVersion = 0; + + const notify = () => listeners.forEach(listener => listener()); + + return { + open() { + root?.setOpen(true); + }, + close() { + root?.setOpen(false); + }, + get isOpen() { + return state.open; + }, + registerTrigger(registration) { + triggers.set(registration.id, registration); + registryVersion++; + notify(); + return () => { + if (triggers.get(registration.id) === registration) { + triggers.delete(registration.id); + registryVersion++; + notify(); + } + }; + }, + getTrigger: id => triggers.get(id), + getFirstTrigger: () => triggers.values().next().value, + getRegistryVersion: () => registryVersion, + setRoot(controller) { + root = controller; + return () => { + if (root === controller) { + root = null; + } + }; + }, + requestOpen(id, event) { + root?.openFromTrigger(id, event); + }, + requestClose(id, event) { + root?.closeFromTrigger(id, event); + }, + publishState(next) { + if (next.open === state.open && next.triggerId === state.triggerId && next.popupId === state.popupId) { + return; + } + state = next; + notify(); + }, + getState: () => state, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index 98a1d706987..62c3db40bdb 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -4,33 +4,127 @@ import { FloatingFocusManager } from '@floating-ui/react'; import React from 'react'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; +import { type InteractionType, interactionTypeFromEvent } from '../../utils/interaction-modality'; import { useDialogContext } from './dialog-context'; +import { useDialogOrigin } from './use-dialog-origin'; + +/** + * Where focus goes when the dialog opens (`initialFocus`) or closes (`finalFocus`), + * mirroring Base UI: + * + * - `true` or omitted — the default: first tabbable element on open, the trigger (with the + * pointer-close downgrade `useReturnFocus` applies) on close + * - `false` — do not move focus + * - a ref — focus that element + * - a function of the interaction type behind the open/close (`''` when programmatic) — + * returns any of the above, with `void`/`null` meaning the default + */ +export type DialogFocusTarget = + | boolean + | React.RefObject + | ((interactionType: InteractionType) => boolean | void | HTMLElement | null); /** Props for {@link DialogPopup}. */ -export type DialogPopupProps = ComponentProps<'div'>; +export interface DialogPopupProps extends ComponentProps<'div'> { + /** Where focus moves when the dialog opens. Default: the first tabbable element inside it. */ + initialFocus?: DialogFocusTarget; + /** Where focus returns when the dialog closes. Default: the trigger, via `useReturnFocus`. */ + finalFocus?: DialogFocusTarget; +} /** The dialog content container. Manages focus trapping via `FloatingFocusManager` and wires ARIA attributes from `Dialog.Title` and `Dialog.Description`. */ export const DialogPopup = React.forwardRef(function DialogPopup(props, ref) { - const { render, ...otherProps } = props; + const { render, initialFocus, finalFocus, ...otherProps } = props; const { + open, popupRef, refs, getFloatingProps, floatingContext, modal, + isNested, returnFocusRef, + finalFocusResolverRef, labelId, descriptionId, mounted, 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); + + // Resolved at render, into the `number | ref` form `FloatingFocusManager` takes (a negative + // index disables the focus move). The function form reads the open event floating-ui has + // already recorded by the time the popup mounts; it must be pure, as re-renders re-invoke it. + const initialFocusElementRef = React.useRef(null); + const resolvedInitialFocus = React.useMemo((): number | React.MutableRefObject => { + if (!open || initialFocus === undefined || initialFocus === true) { + return 0; + } + if (initialFocus === false) { + return -1; + } + if (typeof initialFocus !== 'function') { + return initialFocus as React.MutableRefObject; + } + const result = initialFocus(interactionTypeFromEvent(floatingContext.dataRef.current.openEvent)); + if (result === false) { + return -1; + } + if (result instanceof HTMLElement) { + initialFocusElementRef.current = result; + return initialFocusElementRef; + } + return 0; + }, [open, initialFocus, floatingContext]); + + // The function form of `finalFocus` resolves inside the root's close call — synchronously, + // before any teardown — into this ref, which is what the focus manager then restores to. + const resolvedFinalFocusRef = React.useRef(null); + const finalFocusLatestRef = React.useRef(finalFocus); + React.useLayoutEffect(() => { + finalFocusLatestRef.current = finalFocus; + }); + React.useLayoutEffect(() => { + finalFocusResolverRef.current = event => { + const target = finalFocusLatestRef.current; + if (typeof target !== 'function') { + return; + } + const result = target(interactionTypeFromEvent(event)); + resolvedFinalFocusRef.current = + result instanceof HTMLElement ? result : result === false ? null : returnFocusRef.current; + }; + return () => { + finalFocusResolverRef.current = null; + }; + }, [finalFocusResolverRef, returnFocusRef]); + + const resolvedReturnFocus = + finalFocus === undefined || finalFocus === true + ? returnFocusRef + : finalFocus === false + ? false + : typeof finalFocus === 'function' + ? resolvedFinalFocusRef + : (finalFocus as React.MutableRefObject); + 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', @@ -53,7 +147,8 @@ export const DialogPopup = React.forwardRef(fu context={floatingContext} modal={modal} outsideElementsInert={modal} - returnFocus={returnFocusRef} + initialFocus={resolvedInitialFocus} + returnFocus={resolvedReturnFocus} > {element} diff --git a/packages/headless/src/primitives/dialog/dialog-root.tsx b/packages/headless/src/primitives/dialog/dialog-root.tsx index 4b5ba8bdaa9..a78a50a014f 100644 --- a/packages/headless/src/primitives/dialog/dialog-root.tsx +++ b/packages/headless/src/primitives/dialog/dialog-root.tsx @@ -3,7 +3,6 @@ import { FloatingNode, FloatingTree, - useClick, useDismiss, useFloating, useFloatingNodeId, @@ -11,12 +10,13 @@ import { useInteractions, useRole, } from '@floating-ui/react'; -import { type ReactNode, useId, useMemo, useRef } from 'react'; +import { type ReactNode, useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useControllableState } from '../../hooks/use-controllable-state'; import { useReturnFocus } from '../../hooks/use-return-focus'; import { useTransition } from '../../hooks/use-transition'; import { DialogContext, type DialogContextValue } from './dialog-context'; +import { createDialogHandle, type DialogHandle } from './dialog-handle'; /** * Which gestures dismiss the dialog, mirroring the native `` attribute. @@ -31,34 +31,152 @@ import { DialogContext, type DialogContextValue } from './dialog-context'; */ export type DialogClosedBy = 'any' | 'closerequest' | 'none'; -export interface DialogProps { +/** What accompanies an `onOpenChange` call, mirroring Base UI's event details. */ +export interface DialogOpenChangeDetails { + /** + * The trigger element behind the change — on open, the trigger that was activated. `null` + * when no trigger drove the change (Escape, outside press, a programmatic close), which is + * what lets a controlled consumer clear its `triggerId` on close. + */ + trigger: HTMLElement | null; + /** That trigger's id, or `null`. */ + triggerId: string | null; + /** The DOM event behind the change; programmatic changes carry none. */ + event: Event | undefined; +} + +export interface DialogProps { open?: boolean; defaultOpen?: boolean; - onOpenChange?: (open: boolean) => void; + onOpenChange?: (open: boolean, details: DialogOpenChangeDetails) => void; /** When true, the dialog traps focus and blocks interaction with the rest of the page. Default: true */ modal?: boolean; /** Which gestures dismiss the dialog. Default: `any` */ closedBy?: DialogClosedBy; - children: ReactNode; + /** + * Connects this root to triggers rendered outside it. Create with `Dialog.createHandle()` + * and pass the same handle to each `Dialog.Trigger`. + */ + handle?: DialogHandle; + /** + * Controls which trigger the open is attributed to, by the trigger's `id`. Leave undefined + * to let the root track it automatically; pass it (driven from `onOpenChange`'s + * `details.triggerId`) when `open` is controlled and more than one trigger exists, or to + * open programmatically as if a given trigger had been activated. + */ + triggerId?: string | null; + /** Content, or a function of `{ payload }` — the `payload` of the active trigger — for per-trigger content. */ + children: ReactNode | ((ctx: { payload: Payload | undefined }) => 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, onOpenChange } = props; - const [open, setOpen] = useControllableState(props.open, props.defaultOpen ?? false, props.onOpenChange); + const fallbackStore = useMemo(() => createDialogHandle(), []); + const store = props.handle ?? fallbackStore; + + const [open, setOpenState] = useControllableState(props.open, props.defaultOpen ?? false); + const [activeTriggerId, setActiveTriggerId] = useControllableState(props.triggerId, null); + const [activePayload, setActivePayload] = useState(undefined); const labelId = useId(); const descriptionId = useId(); const popupRef = useRef(null); + const finalFocusResolverRef = useRef<((event: Event | undefined) => void) | null>(null); + + // Details for a change initiated through a trigger, staged by the controller below and + // consumed by the floating `onOpenChange` the request funnels into. + const pendingDetailsRef = useRef(null); + + // The single funnel every open/close goes through — trigger activations, dismissals, and + // programmatic `setOpen` alike — so `onOpenChange` details and the `finalFocus` resolution + // both happen exactly once, synchronously, before any focus restoration can run. + const applyOpenChange = (nextOpen: boolean, details: DialogOpenChangeDetails) => { + if (!nextOpen) { + finalFocusResolverRef.current?.(details.event); + } + setOpenState(nextOpen); + onOpenChange?.(nextOpen, details); + }; const { refs, context: floatingContext } = useFloating({ nodeId, open, - onOpenChange: setOpen, + onOpenChange: (nextOpen, event) => { + const details = pendingDetailsRef.current ?? { trigger: null, triggerId: null, event }; + pendingDetailsRef.current = null; + applyOpenChange(nextOpen, details); + }, + }); + + // Trigger requests arrive through the store, whose registration must be stable — so the + // controller closes over a ref that is repointed at the latest render's closures. + const latest = useRef({ applyOpenChange, activeTriggerId }); + useLayoutEffect(() => { + latest.current = { applyOpenChange, activeTriggerId }; }); + useLayoutEffect(() => { + return store.setRoot({ + openFromTrigger: (id, event) => { + const registration = store.getTrigger(id); + setActiveTriggerId(id); + setActivePayload(registration?.payload); + if (registration) { + refs.setReference(registration.element); + } + pendingDetailsRef.current = { trigger: registration?.element ?? null, triggerId: id, event }; + floatingContext.onOpenChange(true, event, 'click'); + }, + closeFromTrigger: (id, event) => { + const registration = store.getTrigger(id); + pendingDetailsRef.current = { trigger: registration?.element ?? null, triggerId: id, event }; + floatingContext.onOpenChange(false, event, 'click'); + }, + setOpen: nextOpen => { + latest.current.applyOpenChange(nextOpen, { trigger: null, triggerId: null, event: undefined }); + }, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- floatingContext.onOpenChange, setActiveTriggerId and refs are stable + }, [store]); + + // The floating reference is the ACTIVE trigger — origin measurement, return focus, and + // outside-press exclusion all read `elements.domReference`. With no active trigger the first + // registered one stands in, preserving single-trigger behaviour for `defaultOpen` dialogs. + // + // Subscribed imperatively rather than through `useSyncExternalStore`: re-registration must not + // re-render this component, or a trigger whose `payload` is an inline object literal would + // re-register on every render of its own and the two would feed each other forever. + useLayoutEffect(() => { + const resolve = () => { + const active = activeTriggerId != null ? store.getTrigger(activeTriggerId) : undefined; + refs.setReference(active?.element ?? store.getFirstTrigger()?.element ?? null); + }; + resolve(); + return store.subscribe(resolve); + // eslint-disable-next-line react-hooks/exhaustive-deps -- refs is stable + }, [store, activeTriggerId]); + + // For opens that arrive without a trigger activation — a controlled `open`/`triggerId` pair, + // `defaultOpen` — the payload is looked up from the registry once the dialog is open. Runs + // after the children's layout effects, so triggers rendered inside the root are registered by + // the time it reads, and the pre-paint re-render delivers their payload on the first frame. + useLayoutEffect(() => { + if (open) { + setActivePayload(activeTriggerId != null ? store.getTrigger(activeTriggerId)?.payload : undefined); + } + }, [store, open, activeTriggerId]); + + // What detached triggers render their open state and ARIA wiring from. + useLayoutEffect(() => { + store.publishState({ open, triggerId: activeTriggerId, popupId: floatingContext.floatingId }); + }, [store, open, activeTriggerId, floatingContext.floatingId]); + useLayoutEffect(() => { + return () => store.publishState({ open: false, triggerId: null, popupId: undefined }); + }, [store]); + const returnFocusRef = useReturnFocus(floatingContext); const { mounted, transitionProps } = useTransition({ @@ -66,7 +184,6 @@ function DialogInner(props: DialogProps) { ref: popupRef, }); - const click = useClick(floatingContext); const dismiss = useDismiss(floatingContext, { outsidePressEvent: 'mousedown', escapeKey: closedBy !== 'none', @@ -74,7 +191,11 @@ function DialogInner(props: DialogProps) { }); const role = useRole(floatingContext); - const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss, role]); + const { getFloatingProps } = useInteractions([dismiss, role]); + + const setOpen = useCallback((nextOpen: boolean) => { + latest.current.applyOpenChange(nextOpen, { trigger: null, triggerId: null, event: undefined }); + }, []); const contextValue = useMemo( () => ({ @@ -82,11 +203,13 @@ function DialogInner(props: DialogProps) { setOpen, floatingContext, refs, - getReferenceProps, getFloatingProps, popupRef, returnFocusRef, + store, + finalFocusResolverRef, modal, + isNested, labelId, descriptionId, mounted, @@ -97,10 +220,11 @@ function DialogInner(props: DialogProps) { setOpen, floatingContext, refs, - getReferenceProps, getFloatingProps, returnFocusRef, + store, modal, + isNested, labelId, descriptionId, mounted, @@ -108,23 +232,33 @@ function DialogInner(props: DialogProps) { ], ); + const content = typeof children === 'function' ? children({ payload: activePayload }) : children; + return ( - {children} + {content} ); } -export function DialogRoot(props: DialogProps) { +export function DialogRoot(props: DialogProps) { const parentId = useFloatingParentNodeId(); if (parentId === null) { return ( - + + {...props} + isNested={false} + /> ); } - return ; + return ( + + {...props} + isNested + /> + ); } diff --git a/packages/headless/src/primitives/dialog/dialog-trigger.tsx b/packages/headless/src/primitives/dialog/dialog-trigger.tsx index 97dcc3c6cae..5e3aa017d1a 100644 --- a/packages/headless/src/primitives/dialog/dialog-trigger.tsx +++ b/packages/headless/src/primitives/dialog/dialog-trigger.tsx @@ -3,38 +3,92 @@ import React from 'react'; import { type ComponentProps, type DefaultProps, mergeProps, useRender } from '../../utils'; -import { useDialogContext } from './dialog-context'; +import { useOptionalDialogContext } from './dialog-context'; +import type { DialogHandle } from './dialog-handle'; /** Props for {@link DialogTrigger}. */ -export type DialogTriggerProps = ComponentProps<'button'>; +export interface DialogTriggerProps extends ComponentProps<'button'> { + /** + * Connects this trigger to a root rendered elsewhere in the tree. Create with + * `Dialog.createHandle()` and pass the same handle to the `Dialog.Root`. A trigger nested + * inside a root needs no handle. + */ + handle?: DialogHandle; + /** + * Data delivered to the root when this trigger opens the dialog, for per-trigger content: + * the root's children-as-function receives it as `{ payload }`. + */ + payload?: Payload; +} -/** Button that opens the dialog. Wired to Floating UI's reference element for ARIA and interaction handling. */ +/** + * Button that opens the dialog. Registers itself with the root — directly when nested inside + * one, through a `handle` when detached — which uses the trigger that opened it as the floating + * reference element for ARIA, return focus, and origin measurement. Give each trigger an `id` + * to name it in controlled mode via the root's `triggerId`. + */ export const DialogTrigger = React.forwardRef( function DialogTrigger(props, ref) { - const { render, ...otherProps } = props; - const { open, refs, getReferenceProps } = useDialogContext(); + const { render, handle, payload, ...otherProps } = props; + const ctx = useOptionalDialogContext(); + const store = handle ?? ctx?.store; + if (!store) { + throw new Error(' must be nested in a or given a `handle`.'); + } - const state = { open }; + const autoId = React.useId(); + const triggerId = props.id ?? autoId; + + const { + open, + triggerId: activeTriggerId, + popupId, + } = React.useSyncExternalStore( + React.useCallback(listener => store.subscribe(listener), [store]), + () => store.getState(), + () => store.getState(), + ); + // A dialog opened with no attributed trigger (`defaultOpen`, a controlled open with no + // `triggerId`) reads as open from every trigger; a named open reads as open only from the + // trigger it is attributed to. + const showsOpen = open && (activeTriggerId === null || activeTriggerId === triggerId); + + const elementRef = React.useRef(null); + React.useLayoutEffect(() => { + const element = elementRef.current; + if (!element) { + return; + } + return store.registerTrigger({ id: triggerId, element, payload }); + }, [store, triggerId, payload]); + + const state = { open: showsOpen }; const ownProps = { type: 'button', + 'aria-haspopup': 'dialog', + 'aria-expanded': showsOpen, + ...(showsOpen && popupId ? { 'aria-controls': popupId } : null), + onClick(event: React.MouseEvent) { + if (showsOpen) { + store.requestClose(triggerId, event.nativeEvent); + } else { + store.requestOpen(triggerId, event.nativeEvent); + } + }, } satisfies DefaultProps<'button'>; - const defaultProps = { ...ownProps, ...getReferenceProps() }; - return useRender({ defaultTagName: 'button', render, - // floating-ui types `setReference` as a method signature, but at runtime it's - // a stable callback that doesn't use `this`, so the unbound-method check is a - // false positive here. - // eslint-disable-next-line @typescript-eslint/unbound-method - ref: [refs.setReference, ref], + ref: [elementRef, ref], state, stateAttributesMapping: { open: (v: boolean): Record | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), }, - props: mergeProps<'button'>(defaultProps, otherProps), + props: mergeProps<'button'>(ownProps, otherProps), }); }, -); +) as ( + props: DialogTriggerProps & { ref?: React.Ref }, +) => React.ReactElement; 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/dialog.test.tsx b/packages/headless/src/primitives/dialog/dialog.test.tsx index fc40964b7d5..0fec4189aa7 100644 --- a/packages/headless/src/primitives/dialog/dialog.test.tsx +++ b/packages/headless/src/primitives/dialog/dialog.test.tsx @@ -1,5 +1,6 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../../test-utils/axe'; @@ -61,7 +62,7 @@ describe('Dialog', () => { expect(trigger).toHaveAttribute('data-closed', ''); }); - it('calls onOpenChange when toggled', async () => { + it('calls onOpenChange with details naming the trigger', async () => { const onOpenChange = vi.fn(); const user = userEvent.setup(); renderDialog({ onOpenChange }); @@ -69,7 +70,20 @@ describe('Dialog', () => { const trigger = screen.getByRole('button', { name: 'Open dialog' }); await user.click(trigger); - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith( + true, + expect.objectContaining({ trigger, triggerId: expect.any(String) }), + ); + }); + + it('calls onOpenChange with a null trigger on dismissal', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderDialog({ defaultOpen: true, onOpenChange }); + + await user.keyboard('{Escape}'); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.objectContaining({ trigger: null, triggerId: null })); }); }); @@ -368,6 +382,274 @@ describe('Dialog', () => { }); }); + describe('detached triggers (createHandle)', () => { + it('opens a root from a trigger rendered outside it', async () => { + const user = userEvent.setup(); + const handle = Dialog.createHandle(); + render( + <> + Open detached + + + Detached + Close + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Open detached' }); + expect(trigger).toHaveAttribute('data-closed', ''); + + await user.click(trigger); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + // Queried by text: the open modal marks everything outside itself inert. + expect(screen.getByText('Open detached')).toHaveAttribute('data-open', ''); + }); + + it('returns focus to the detached trigger on Escape', async () => { + const user = userEvent.setup(); + const handle = Dialog.createHandle(); + render( + <> + Open detached + + + Detached + Close + + + , + ); + + const trigger = screen.getByRole('button', { name: 'Open detached' }); + await user.click(trigger); + await user.keyboard('{Escape}'); + + expect(document.activeElement).toBe(trigger); + }); + + it('supports imperative open and close, ignored while no root is attached', () => { + const handle = Dialog.createHandle(); + + // No root mounted: ignored, no crash. + handle.open(); + expect(handle.isOpen).toBe(false); + + render( + + + Imperative + + , + ); + + act(() => handle.open()); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(handle.isOpen).toBe(true); + + act(() => handle.close()); + expect(handle.isOpen).toBe(false); + }); + }); + + describe('multiple triggers and payload', () => { + function renderMultiTrigger(rootProps: Partial>> = {}) { + const handle = Dialog.createHandle(); + render( + <> + + Open A + + + Open B + + + {({ payload }) => ( + + {payload ?? 'no payload'} + Close + + )} + + , + ); + return handle; + } + + it('renders per-trigger content from the payload', async () => { + const user = userEvent.setup(); + renderMultiTrigger(); + + await user.click(screen.getByRole('button', { name: 'Open A' })); + expect(screen.getByRole('dialog', { name: 'payload-a' })).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + await user.click(screen.getByRole('button', { name: 'Open B' })); + expect(screen.getByRole('dialog', { name: 'payload-b' })).toBeInTheDocument(); + }); + + it('attributes the open to the activated trigger only', async () => { + const user = userEvent.setup(); + renderMultiTrigger(); + + await user.click(screen.getByRole('button', { name: 'Open A' })); + + // Queried by text: the open modal marks everything outside itself inert. + expect(screen.getByText('Open A')).toHaveAttribute('data-open', ''); + expect(screen.getByText('Open B')).toHaveAttribute('data-closed', ''); + }); + + it('reports the activated trigger id through onOpenChange details', async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + renderMultiTrigger({ onOpenChange }); + + await user.click(screen.getByRole('button', { name: 'Open B' })); + + expect(onOpenChange).toHaveBeenCalledWith(true, expect.objectContaining({ triggerId: 'trigger-b' })); + }); + + it('resolves the payload from a controlled triggerId on programmatic open', () => { + renderMultiTrigger({ open: true, triggerId: 'trigger-b' }); + + expect(screen.getByRole('dialog', { name: 'payload-b' })).toBeInTheDocument(); + }); + }); + + describe('initialFocus', () => { + type InitialFocus = React.ComponentProps['initialFocus']; + + function InitialFocusFixture({ + initialFocus, + useInputRef, + }: { + initialFocus?: InitialFocus; + useInputRef?: boolean; + }) { + const inputRef = React.useRef(null); + return ( + + Open dialog + + Title + + + + + ); + } + + const settleFocus = () => new Promise(r => requestAnimationFrame(r)); + + it('focuses a ref target instead of the first tabbable', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await settleFocus(); + + expect(document.activeElement).toBe(screen.getByRole('textbox', { name: 'Name' })); + }); + + it('does not move focus when false', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await settleFocus(); + + expect(screen.getByRole('dialog').contains(document.activeElement)).toBe(false); + }); + + it('passes the interaction type to a function form', async () => { + const user = userEvent.setup(); + const initialFocus = vi.fn(() => undefined); + render(); + + const trigger = screen.getByRole('button', { name: 'Open dialog' }); + trigger.focus(); + await user.keyboard('{Enter}'); + await settleFocus(); + + expect(initialFocus).toHaveBeenCalledWith('keyboard'); + }); + }); + + describe('finalFocus', () => { + type FinalFocus = React.ComponentProps['finalFocus']; + + function FinalFocusFixture({ finalFocus, useTargetRef }: { finalFocus?: FinalFocus; useTargetRef?: boolean }) { + const targetRef = React.useRef(null); + return ( + <> + + + Open dialog + + Title + Close + + + + ); + } + + it('restores focus to a ref target on close', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await user.keyboard('{Escape}'); + + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Elsewhere' })); + }); + + it('resolves a function form with the close interaction type', async () => { + const user = userEvent.setup(); + const finalFocus = vi.fn(() => undefined); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await user.keyboard('{Escape}'); + + expect(finalFocus).toHaveBeenCalledWith('keyboard'); + // Default behaviour on `undefined`: back to the trigger. + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Open dialog' })); + }); + + it('resolves the function form with an empty type on programmatic close', async () => { + const user = userEvent.setup(); + const finalFocus = vi.fn(() => undefined); + render(); + + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + await user.click(screen.getByRole('button', { name: 'Close' })); + + expect(finalFocus).toHaveBeenCalledWith(''); + }); + }); + describe('accessibility (axe)', () => { it('has no violations when closed', async () => { const { container } = renderDialog(); diff --git a/packages/headless/src/primitives/dialog/index.ts b/packages/headless/src/primitives/dialog/index.ts index 7233056a816..c8e9789136d 100644 --- a/packages/headless/src/primitives/dialog/index.ts +++ b/packages/headless/src/primitives/dialog/index.ts @@ -8,6 +8,9 @@ export type { DialogClosedBy, DialogCloseProps, DialogDescriptionProps, + DialogFocusTarget, + DialogHandle, + DialogOpenChangeDetails, DialogPopupProps, DialogPortalProps, DialogProps, diff --git a/packages/headless/src/primitives/dialog/parts.ts b/packages/headless/src/primitives/dialog/parts.ts index 527f8e4a357..3c9534100e6 100644 --- a/packages/headless/src/primitives/dialog/parts.ts +++ b/packages/headless/src/primitives/dialog/parts.ts @@ -1,9 +1,10 @@ -export { type DialogClosedBy, type DialogProps, DialogRoot as Root } from './dialog-root'; +export { type DialogClosedBy, type DialogOpenChangeDetails, type DialogProps, DialogRoot as Root } from './dialog-root'; export { type DialogTriggerProps, DialogTrigger as Trigger } from './dialog-trigger'; +export { createDialogHandle as createHandle, type DialogHandle } from './dialog-handle'; export { type DialogPortalProps, DialogPortal as Portal } from './dialog-portal'; export { type DialogBackdropProps, DialogBackdrop as Backdrop } from './dialog-backdrop'; export { type DialogViewportProps, DialogViewport as Viewport } from './dialog-viewport'; -export { type DialogPopupProps, DialogPopup as Popup } from './dialog-popup'; +export { type DialogFocusTarget, type DialogPopupProps, DialogPopup as Popup } from './dialog-popup'; export { type DialogTitleProps, DialogTitle as Title } from './dialog-title'; export { type DialogDescriptionProps, DialogDescription as Description } from './dialog-description'; export { type DialogCloseProps, DialogClose as Close } from './dialog-close'; 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..95bc0c75070 100644 --- a/packages/headless/src/primitives/drawer/drawer-context.ts +++ b/packages/headless/src/primitives/drawer/drawer-context.ts @@ -1,5 +1,6 @@ 'use client'; +import type { UseInteractionsReturn } from '@floating-ui/react'; import { createContext, type PointerEventHandler, useContext } from 'react'; import type { DialogContextValue } from '../dialog/dialog-context'; @@ -26,7 +27,11 @@ export interface NestedDrawerCallbacks { onNestedRelease: (childOpen: boolean) => void; } -export interface DrawerContextValue extends DialogContextValue { +// The dialog-only members are dropped: the drawer has no trigger registry (its detached +// triggers go through `DrawerHandle`), and its triggers still wire through floating-ui's +// reference props, which the dialog's no longer do. +export interface DrawerContextValue extends Omit { + getReferenceProps: UseInteractionsReturn['getReferenceProps']; backdropRef: React.RefObject; drag: DrawerDrag; /** When true (default), a downward release past threshold closes the drawer. */ @@ -44,8 +49,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/headless/src/utils/interaction-modality.ts b/packages/headless/src/utils/interaction-modality.ts index 99436bcc51f..c3cbc32cca7 100644 --- a/packages/headless/src/utils/interaction-modality.ts +++ b/packages/headless/src/utils/interaction-modality.ts @@ -29,3 +29,37 @@ export function isKeyboardOpen(context: Pick): boole return openEvent ? isKeyboardEvent(openEvent) : false; } + +/** + * The kind of input behind an open or close, Base UI's taxonomy: the empty string means there + * was no interaction — the change was programmatic (a state machine, a route, a mutation result). + */ +export type InteractionType = 'mouse' | 'touch' | 'pen' | 'keyboard' | ''; + +/** Classifies the event behind an open/close into an {@link InteractionType}. */ +export function interactionTypeFromEvent(event: Event | undefined): InteractionType { + if (!event) { + return ''; + } + if (isKeyboardEvent(event)) { + return 'keyboard'; + } + if (typeof PointerEvent !== 'undefined' && event instanceof PointerEvent) { + const pointerType = event.pointerType; + if (pointerType === 'mouse' || pointerType === 'touch' || pointerType === 'pen') { + return pointerType; + } + // Whitelisted because the value is not reliable: user-event stringifies a missing + // pointerType into `'undefined'`, which also defeats `isVirtualClick`'s empty-string + // check above. A click with no real pointer type and no coalesced detail is a + // keyboard activation. + return event.detail === 0 ? 'keyboard' : 'mouse'; + } + if (typeof TouchEvent !== 'undefined' && event instanceof TouchEvent) { + return 'touch'; + } + if (event instanceof MouseEvent) { + return 'mouse'; + } + return ''; +} diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index f94c16b1a3a..f292d4774d9 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 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 @@ -23,8 +23,10 @@ render-prop children pattern. { name: 'children', type: 'ReactNode | ((ctx: { close: () => void }) => ReactNode)' }, { name: 'open', type: 'boolean' }, { name: 'defaultOpen', type: 'boolean', default: 'false' }, - { name: 'onOpenChange', type: '(open: boolean) => void' }, + { name: 'onOpenChange', type: '(open: boolean, details: DialogOpenChangeDetails) => void' }, { name: 'modal', type: 'boolean', default: 'true' }, + { name: 'closedBy', type: "'any' | 'closerequest' | 'none'", default: "'any'" }, + { name: 'syncBrowserChrome', type: 'boolean', default: 'true' }, ]} /> @@ -45,11 +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. +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 }> @@ -77,31 +80,315 @@ 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, not a t-shirt step: -## 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` 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. -| 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) | +`size` lives on `Dialog.Root`, not `Dialog.Popup`, because the backdrop reads it too. + +### The inset + +The gap between a dialog and the edge of the screen is a fixed inset, even on all four sides, that +steps up at two breakpoints: + +| Viewport | Inset | +| ------------- | ------------- | +| `< 48rem` | `1rem` (16px) | +| `48rem–90rem` | `2rem` (32px) | +| `>= 90rem` | `3rem` (48px) | + +It lives on `Dialog.Viewport`'s padding: a `prompt` or `card` gets it for free by being +`width: 100%` inside it, and `panel` reads the same value back for its height. + +### On a phone, a prompt is a sheet + +Below `48rem`, 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 — a floating +sheet, not a tray welded to the edge — and its width cap lifts so it spans whatever the inset +leaves. `card` and `panel` are unchanged at every width. Resize the preview under +[Playground](#playground) below `48rem` to see it. + +The sheet slides in fully opaque, and the backdrop keeps its own faster timing — 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 fades instead. + +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. 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 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. +> Point `initialFocus` on `Dialog.Popup` at the field that should take it instead — see +> [Custom focus management](#custom-focus-management). + +### 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`, `handle`. | +| `Dialog.Trigger` | — | Opens the dialog; accepts `render`, and `handle` + `payload` when detached. | +| `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); `initialFocus` / `finalFocus`. | +| `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`. ## Styling -Override per slot through `appearance.elements` — e.g. `{ 'dialog-popup': { borderRadius: 24 } }`. -State attributes from the headless layer are also available for CSS targeting: +The Mosaic dialog is themed with **StyleX**. Each styled part carries a stable `.cl-` class +(the slots in the table above) alongside the StyleX atoms. Consumers never target the hashed atomic +classes — override by targeting the `.cl-*` slot from a CSS layer that wins over +`@clerk/ui/styles.css`: + +```css +@import '@clerk/ui/styles.css' layer(components); + +@layer overrides { + .cl-dialog-popup[data-size='panel'] { + max-width: 60rem; + } +} +``` + +State attributes from the headless layer are available for CSS targeting: + +| 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 | +| `data-size` | Popup | Resolved size (`prompt` / `card` / `panel`) | +| `data-nested` | Backdrop, Viewport, Popup | Present when opened inside another overlay | + +### Motion + +`prompt` and `card` animate; `panel` opens and closes with no animation at all — it is most of the +viewport, and even a 2% scale reads as a zoom at that scale. + +Opacity and scale are driven off `data-starting-style` / `data-ending-style`, with the exit shorter +than the entrance. The popup scales out of **whatever opened it**: the headless layer measures the +trigger's rect on open and writes `--cl-dialog-origin`, which the popup uses as its +`transform-origin`. A dialog with no trigger falls back to `center`. Under +`prefers-reduced-motion: reduce` only `transform` drops out — the fade still runs, since the +vestibular concern is the movement. + +### Browser chrome + +On a phone an open dialog also tints the browser's own chrome, so it reads as one continuous +surface rather than a dimmed page inside undimmed furniture. Two things move together: +`` (the address bar and toolbar) and ``'s background (the overscroll +gutter and the strip revealed as the address bar collapses — areas a `position: fixed` scrim cannot +cover). + +It is on by default and holds no opinion: the colour is derived from the backdrop's computed +background composited over the page's own, the meta is prepended rather than mutated (so it +overrides the app's tag — including framework-managed ones like Next's `viewport.themeColor` — +and removal restores it), the body colour is saved and restored, and stacked dialogs share one +meta. + +Pass `syncBrowserChrome={false}` if the app drives `theme-color` itself. + +### On-screen keyboards + +iOS shrinks the visual viewport when the keyboard opens but leaves layout alone, so a +`position: fixed` overlay would end up behind the keyboard. `Dialog.Viewport` measures the +difference and adds it to its own bottom padding, which gives each size the right behaviour: + +| Size | Alignment | With the keyboard open | +| -------- | --------------------- | ------------------------------------------------------------------- | +| `prompt` | `align-self: end` | rises to sit on top of the keyboard | +| `card` | centred | re-centres in the space left — moves up, height still from content | +| `panel` | `align-self: stretch` | shrinks, which is right for the one size with its own scroll region | + +A card taller than the remaining space aligns to its top rather than losing its head. Pinch-zoom — +which also shrinks the visual viewport — is excluded. + +### Stacked dialogs + +A dialog opened from inside another one carries `data-nested` and paints its own, lighter scrim, so +each level reads as a step further from the page without the backdrops compounding toward an +opaque wall. + +--- + +## Examples + +### Scrolling a panel + +A `panel` is a fixed-height surface that does not scroll itself — putting the scroll on the popup +would take everything anchored to it, starting with `Dialog.CloseButton`, along for the ride. So +the popup clips, and the scroll region is composed inside it out of the `ScrollArea` atoms. + + + +A `panel` carries **no padding of its own** — its regions reach the popup's edges, which lets a +scroll region sit flush so its scrollbar and edge fade land on the true edge, and lets a sidebar +run the full height. Padding goes on the content inside each region. (`card` still pads itself.) + +`scrollAreaRoot` is the positioned ancestor and `scrollAreaViewport()` is the element that actually +scrolls — both are style objects, not components, so they add no DOM of their own: + +```tsx +import { scrollAreaRoot, scrollAreaViewport } from '@clerk/ui/mosaic/components/scroll-area'; + + }> + + }>Settings + +
+ + +
+
+
+
+
+
+
; +``` + +The sidebar is dropped below `48rem` — a fixed 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. + +### Nested dialogs + +The account-profile shape: a `panel` holding the settings surface, with `prompt` dialogs opened +from triggers inside it. Open the panel, then add an email address — the panel stays put behind the +prompt. + + + +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 and backdrop presses close the inner dialog and leave the panel open; they reach the panel only once it is gone. | +| **Scroll stays locked** | The body stays locked until the _last_ dialog closes, not the first. | +| **Focus returns down the stack** | Closing the inner dialog returns focus to its trigger inside the panel, which is still mounted and focus-trapped. | +| **Scrims don't compound** | The inner backdrop is lighter, so two levels stay a step from the page rather than an opaque wall. | + +Give the inner dialog `closedBy='closerequest'` whenever it holds input, so a stray click on its +backdrop cannot discard what was typed. + +### Detached triggers + +A trigger does not have to be nested inside its root. `Dialog.createHandle()` returns a handle; +pass the same handle to both `Dialog.Trigger` and `Dialog.Root`, and the trigger drives the +dialog from anywhere in the tree. The handle also has imperative `open()` / `close()` / `isOpen` +members for opens with no trigger element at all — calls made while no root is mounted are +ignored. + + + +### Multiple triggers + +Several triggers can share one dialog through the same handle. Give each an `id` and a +`payload`, and make the root's children a function — it receives the active trigger's payload, +so one dialog renders per-trigger content. Type the payload through the handle: +`Dialog.createHandle()`. + + + +Everything keyed to "the trigger" follows the one that was actually used: the dialog scales out +of it, and focus returns to it on close. In controlled mode, drive the attribution yourself with +`triggerId` on `Dialog.Root` — `onOpenChange`'s second argument names the trigger behind each +change, and setting `triggerId` alongside a programmatic `open` behaves exactly as if that +trigger had been clicked. + +### Custom focus management + +`initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves when the dialog +opens and closes. Each accepts `true` (the default behavior), `false` (do not move focus), a +ref, or a function of the interaction type behind the change +(`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty when programmatic). + + -| 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 | +This is the answer to the close-button caveat under [Close button](#close-button): when a corner +X would otherwise take the dialog's initial focus, point `initialFocus` at the field that should +have it. diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 051d810540c..eab2bbc5195 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, so a body of ordinary content supplies it. +const panelBody = { + display: 'flex', + flex: 1, + flexDirection: 'column', + gap: '0.75rem', + minHeight: 0, + overflowY: 'auto', + padding: '1.5rem', +} as const; + +const sectionHeader = { + alignItems: 'center', + display: 'flex', + gap: '1rem', + justifyContent: 'space-between', +} as const; + +/** A `prompt` 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} + +
+ + +
+ + )} +
+ ); +} + +/** Triggers sit at three corners of the panel so each dialog visibly scales out of its own trigger. */ +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 + + + + +
+ +
+
+
+ ); +} + +const settingsTrigger = (props: RenderProps) => ; + +const NAV_SECTIONS = ['Profile', 'Security', 'Sessions', 'Connected accounts', 'Billing']; + +// Long enough to overflow the panel even on a large display, or the scroll example shows nothing. +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. */ +export function PanelSidebar() { + return ( + + + + {/* Its own header, so the accessible name survives the nav being hidden on a phone. */} +
+ }>Settings +
+ +
+ {/* The rail has nowhere to go on a phone; `md` is 48rem, the dialog's own mobile band. */} + + + {/* Flush with the popup edge, so the scrollbar and edge fade land on the true edge. */} +
+
+
+ + {SESSIONS.map(session => ( + + + {session.device} + + {session.where} · {session.when} + + + + + + + ))} + +
+
+
+
+
+ ); +} + +/** + * A handle at module scope: the trigger and the root only share it, not a JSX ancestor. + * The same handle also has imperative `open()` / `close()` for opens with no trigger at all. + */ +const notificationsDialog = Dialog.createHandle(); + +export function DetachedTrigger() { + return ( + <> + } + /> + + + + + + + }>Notifications + }>You are all caught up. Good job! + + + + + + ); +} + +const memberDialog = Dialog.createHandle<{ name: string; role: string }>(); + +const MEMBERS = [ + { name: 'Ada Lovelace', role: 'Admin' }, + { name: 'Grace Hopper', role: 'Member' }, + { name: 'Annie Easley', role: 'Member' }, +]; + +/** One dialog, three triggers: each carries a payload the dialog's children render from. */ +export function MultipleTriggers() { + return ( + <> +
+ {MEMBERS.map(member => ( + ( + + )} + /> + ))} +
+ + {({ payload }) => ( + + + + + + }>{payload?.name} + }> + {payload ? `${payload.role} of this organization.` : null} + + + + + )} + + + ); +} + +/** `initialFocus` skips past the close button and the name field; `finalFocus` is left default. */ +export function CustomFocus() { + const feedbackRef = React.useRef(null); + return ( + + } /> + + + + + + }>Feedback + }> + The feedback field takes focus on open — past the close button and the name field. + + + + + + + + ); +} diff --git a/packages/swingset/src/stories/dialog.mdx b/packages/swingset/src/stories/dialog.mdx index f30c9f4c96b..99f17e4c61b 100644 --- a/packages/swingset/src/stories/dialog.mdx +++ b/packages/swingset/src/stories/dialog.mdx @@ -68,6 +68,85 @@ Reach for `closerequest` on anything holding user input or confirming a destruct Reserve `none` for flows the user must complete or explicitly acknowledge — it removes the keyboard exit, so it breaks the usual expectation that Escape dismisses a modal. +### Detached triggers + +A trigger does not have to be nested inside its root. `Dialog.createHandle()` returns a +handle; pass the same handle to both, and the trigger drives the root from anywhere in the +tree. The handle also exposes imperative `open()` / `close()` / `isOpen`; calls made while no +root is mounted are ignored. + +```tsx +const feedbackDialog = Dialog.createHandle(); + +Give feedback; + +{/* portal + popup */}; +``` + +### Multiple triggers and payloads + +Each trigger can carry an `id` and a `payload`, and the root's children can be a function of +the active trigger's payload — one dialog, per-trigger content. Type the payload through the +handle: `Dialog.createHandle()`. + +```tsx +const memberDialog = Dialog.createHandle<{ name: string }>(); + +Alice +Bob + + + {({ payload }) => ( + + + + + {payload?.name} + + + + )} + +``` + +In controlled mode, track which trigger is active with `triggerId` — `onOpenChange`'s second +argument names the trigger behind each change: + +```tsx +const [open, setOpen] = useState(false); +const [triggerId, setTriggerId] = useState(null); + + { + setOpen(next); + setTriggerId(details.triggerId); + }} +> + {/* portal + popup */} +; +``` + +Setting `triggerId` alongside a programmatic `open` attributes the open to that trigger — the +dialog scales out of it and returns focus to it on close, exactly as if it had been clicked. + +### Custom focus management + +`initialFocus` and `finalFocus` on `Dialog.Popup` control where focus moves on open and +close. Each accepts `true` (the default behavior), `false` (do not move focus), a ref, or a +function of the interaction type behind the change +(`'mouse' | 'touch' | 'pen' | 'keyboard' | ''`, empty when programmatic): + +```tsx + (interactionType === 'keyboard' ? fieldRef.current : false)} + finalFocus={summaryRef} +> + {/* ... */} + +``` + ## Parts | Part | Default Element | Description | @@ -91,13 +170,16 @@ for centered, scroll-locked modal behavior nest `Dialog.Popup` inside `Dialog.Vi ### `Dialog.Root` -| Prop | Type | Default | Description | -| ------------------------- | ---------------------------------------------- | ------------------ | ---------------------------------------------- | -| open | boolean | — | Controlled open state | -| defaultOpen | boolean | false | Initial open state (uncontrolled) | -| onOpenChange | (open: boolean) => void | — | Called when the open state changes | -| modal | boolean | true | Trap focus and make the rest of the page inert | -| closedBy | 'any' \| 'closerequest' \| 'none' | 'any' | Which gestures dismiss the dialog | +| Prop | Type | Default | Description | +| ------------------------- | ---------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------- | +| open | boolean | — | Controlled open state | +| defaultOpen | boolean | false | Initial open state (uncontrolled) | +| onOpenChange | (open: boolean, details: DialogOpenChangeDetails) => void | — | Called when the open state changes; `details` names the trigger behind it | +| modal | boolean | true | Trap focus and make the rest of the page inert | +| closedBy | 'any' \| 'closerequest' \| 'none' | 'any' | Which gestures dismiss the dialog | +| handle | DialogHandle | — | Connects detached triggers (see `Dialog.createHandle()`) | +| triggerId | string \| null | tracked | Controls which trigger the open is attributed to | +| children | ReactNode \| (\{ payload \}) => ReactNode | — | Content, or a render function of the active trigger's payload | closedBy mirrors the native <dialog closedby> attribute: @@ -122,9 +204,28 @@ dismisses but Escape does not — stays unrepresentable. | ----------------------- | -------------------- | ----------------- | ----------------------------------------- | | lockScroll | boolean | true | Lock body scroll while the dialog is open | -`Dialog.Trigger`, `Dialog.Backdrop`, `Dialog.Popup`, `Dialog.Title`, `Dialog.Description`, -and `Dialog.Close` take no additional props beyond standard HTML attributes for their -default element. +### `Dialog.Trigger` + +| Prop | Type | Default | Description | +| -------------------- | ------------------------- | ------- | -------------------------------------------------------- | +| handle | DialogHandle | — | Drives a root elsewhere in the tree (detached trigger) | +| id | string | auto | Names this trigger for the root's triggerId | +| payload | Payload | — | Delivered to the root's children render function on open | + +### `Dialog.Popup` + +| Prop | Type | Default | Description | +| ------------------------- | ------------------------------ | ----------------- | --------------------------------------- | +| initialFocus | DialogFocusTarget | true | Where focus moves when the dialog opens | +| finalFocus | DialogFocusTarget | true | Where focus returns when it closes | + +DialogFocusTarget is +boolean | RefObject | (interactionType) => boolean | void | HTMLElement | null. +The defaults stay what they were: first tabbable element on open; on close the trigger, +unless the close was pointer-driven, where focus is left where the pointer put it. + +`Dialog.Backdrop`, `Dialog.Title`, `Dialog.Description`, and `Dialog.Close` take no +additional props beyond standard HTML attributes for their default element. ## Styling diff --git a/packages/ui/src/mosaic/components/button/button.tsx b/packages/ui/src/mosaic/components/button/button.tsx index 201ee833754..7265eb05e70 100644 --- a/packages/ui/src/mosaic/components/button/button.tsx +++ b/packages/ui/src/mosaic/components/button/button.tsx @@ -21,6 +21,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; } /** @@ -92,6 +104,7 @@ export const Button = React.forwardRef(function fullWidth = false, touchTarget = true, disabled = false, + xstyle, className, style, children, @@ -120,6 +133,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..f2e8f42d0c4 --- /dev/null +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -0,0 +1,479 @@ +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. + // + // Dark mode veils rather than darkens: a light grey at low alpha over a dark page, where light + // mode lays black over a light one. Same job, opposite direction, so the two are unrelated + // colours rather than one colour at two opacities — hence `light-dark()` per value. + // + // 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 solved, not picked: alpha over alpha is + // `1 − (1 − a)(1 − b)`, so the nested value is whatever lands two levels on the intended total. + // + // light base 0.4 → total 0.68 ⇒ nested `1 − 0.32/0.6` = 0.4667 + // dark base 0.24 → total 0.408 ⇒ nested `1 − 0.592/0.76` = 0.2211 + // + // The dark total is the same PROPORTIONAL deepening as light's — 1.7× the base — rather than the + // same absolute value, because a 0.68 veil of grey over a dark page would read as fog, not as a + // second surface. Preserving the ratio keeps the step between one dialog and two feeling equal + // in both schemes. + // + // Exact for a two-deep stack, which is the shape that exists; a third level wants its own value + // rather than a third application of this one. `data-nested` comes from the headless layer. + backdrop: { + inset: 0, + backgroundColor: { + default: 'light-dark(rgba(0, 0, 0, 0.4), rgba(115, 115, 115, 0.24))', + ':where([data-nested])': 'light-dark(rgba(0, 0, 0, 0.4667), rgba(115, 115, 115, 0.2211))', + }, + 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'], + // One shadow for both schemes — the same three layers were specified for each, so there is + // nothing for `light-dark()` to choose between. That also sidesteps a constraint worth + // remembering if they ever diverge: `light-dark()` resolves to a COLOUR and cannot carry an + // offset or a blur, and branching the whole shadow on `@media (prefers-color-scheme: dark)` + // would answer the wrong question — that tracks the OS preference, while `light-dark()` tracks + // the `color-scheme` in scope. An app that forces a scheme (swingset's own toggle does, via + // next-themes) would take its colours from one and its shadow from the other. + boxShadow: `0 5px 15px 0 rgba(0, 0, 0, 0.08), + 0 15px 35px -5px rgba(0, 0, 0, 0.2), + 0 0 0 1px rgba(0, 0, 0, 0.06)`, + 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..0dc9df943a1 --- /dev/null +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -0,0 +1,558 @@ +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 default: a corner X rendered before the form is what the dialog opens + // focused on unless `initialFocus` on `Dialog.Popup` says otherwise (next test). + // `FloatingFocusManager` moves focus in an effect, hence the wait. + await waitFor(() => expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus()); + }); +}); + +describe('composition APIs', () => { + it('opens from a detached trigger through a handle', async () => { + const user = userEvent.setup(); + const handle = Dialog.createHandle(); + render( + <> + Open detached + + + Detached + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open detached' })); + + expect(screen.getByRole('dialog', { name: 'Detached' })).toBeInTheDocument(); + }); + + it('renders per-trigger content from the payload', async () => { + const user = userEvent.setup(); + const handle = Dialog.createHandle(); + render( + <> + + Open A + + + {({ payload }) => ( + + {payload ?? 'none'} + + )} + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open A' })); + + expect(screen.getByRole('dialog', { name: 'from-a' })).toBeInTheDocument(); + }); + + it('initialFocus on the popup redirects the open focus past the close button', async () => { + function Fixture() { + const inputRef = React.useRef(null); + return ( + + + + + + + ); + } + render(); + + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Email' })).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..086cf0d9253 --- /dev/null +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -0,0 +1,383 @@ +import type { DialogFocusTarget, DialogHandle, 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'> & { + /** + * Connects this trigger to a root rendered elsewhere in the tree. Create with + * `Dialog.createHandle()` and pass the same handle to the `Dialog.Root`. + */ + handle?: DialogHandle; + /** + * Delivered to the root when this trigger opens it, for per-trigger content: the root's + * children-as-function receives it as `{ payload }`. + */ + payload?: Payload; +}; +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'> & { + /** Where focus moves when the dialog opens. Default: the first tabbable element inside it. */ + initialFocus?: DialogFocusTarget; + /** Where focus returns when the dialog closes. Default: the trigger. */ + finalFocus?: DialogFocusTarget; +}; + +/** Owns the open state and the size both the backdrop and the popup read. */ +function Root({ + size = 'prompt', + syncBrowserChrome = true, + children, + ...rest +}: DialogRootProps) { + return ( + + + {...rest}>{children} + + + ); +} + +/** Opens the dialog. Renders a `