From 907e0af606306039b4b4d1ddb569d5d4c8252540 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Mon, 10 Aug 2026 19:42:27 -0600 Subject: [PATCH 1/5] feat(ui): add Base UI-style composition APIs to the Dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Dialog.createHandle()` returns a handle passed to both a `Dialog.Trigger` and a `Dialog.Root`, so a trigger drives a dialog it is not nested under. The handle also exposes imperative `open()` / `close()` / `isOpen`. Several triggers can share one dialog, each carrying an `id` and a `payload`, with the root's children as a function of `{ payload }` so one dialog renders per-trigger content. Everything keyed to "the trigger" now follows the one actually used — the dialog scales out of it and returns focus to it — and `triggerId` names the active trigger in controlled mode, which also gives controlled, trigger-less dialogs the origin-aware open. `initialFocus` and `finalFocus` on `Dialog.Popup` take `true`, `false`, a ref, or a function of the interaction type behind the change. Defaults are unchanged: first tabbable on open, the trigger on close, except after a pointer-driven dismissal. Also replaces the popup shadow with one three-layer shadow shared by both schemes. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/spicy-clocks-argue.md | 12 + .../headless/src/primitives/dialog/README.md | 109 ++++++- .../src/primitives/dialog/dialog-context.ts | 21 +- .../src/primitives/dialog/dialog-handle.ts | 141 +++++++++ .../src/primitives/dialog/dialog-popup.tsx | 87 +++++- .../src/primitives/dialog/dialog-root.tsx | 158 ++++++++-- .../src/primitives/dialog/dialog-trigger.tsx | 84 ++++- .../src/primitives/dialog/dialog.test.tsx | 288 +++++++++++++++++- .../headless/src/primitives/dialog/index.ts | 3 + .../headless/src/primitives/dialog/parts.ts | 5 +- .../src/primitives/drawer/drawer-context.ts | 7 +- .../src/utils/interaction-modality.ts | 34 +++ .../swingset/src/stories/dialog.component.mdx | 76 ++++- .../src/stories/dialog.component.stories.tsx | 134 ++++++-- packages/swingset/src/stories/dialog.mdx | 121 +++++++- .../mosaic/components/dialog/dialog.styles.ts | 3 + .../mosaic/components/dialog/dialog.test.tsx | 73 ++++- .../src/mosaic/components/dialog/dialog.tsx | 34 ++- .../ui/src/mosaic/components/dialog/index.ts | 1 + 19 files changed, 1285 insertions(+), 106 deletions(-) create mode 100644 .changeset/spicy-clocks-argue.md create mode 100644 packages/headless/src/primitives/dialog/dialog-handle.ts diff --git a/.changeset/spicy-clocks-argue.md b/.changeset/spicy-clocks-argue.md new file mode 100644 index 00000000000..4caa0816d32 --- /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 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. + +**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 9696c954016..6908f486089 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -45,6 +45,79 @@ 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 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 +136,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 +183,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. diff --git a/packages/headless/src/primitives/dialog/dialog-context.ts b/packages/headless/src/primitives/dialog/dialog-context.ts index 21a57672b21..d4f2775036a 100644 --- a/packages/headless/src/primitives/dialog/dialog-context.ts +++ b/packages/headless/src/primitives/dialog/dialog-context.ts @@ -2,17 +2,31 @@ 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 @@ -39,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 60df3bfd363..6b6e7f1b4ff 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -4,15 +4,38 @@ 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'; +/** + * 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, @@ -20,12 +43,69 @@ export const DialogPopup = React.forwardRef(fu modal, isNested, returnFocusRef, + finalFocusResolverRef, labelId, descriptionId, mounted, transitionProps, } = useDialogContext(); + // 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, @@ -59,7 +139,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 7fd6d7a8e20..e4ec1349cdc 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 & { isNested: boolean }) { +function DialogInner(props: DialogProps & { isNested: boolean }) { const nodeId = useFloatingNodeId(); - const { modal = true, closedBy = 'any', isNested, 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 — return focus and outside-press exclusion + // both 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 & { isNested: boolean }) { ref: popupRef, }); - const click = useClick(floatingContext); const dismiss = useDismiss(floatingContext, { outsidePressEvent: 'mousedown', escapeKey: closedBy !== 'none', @@ -74,7 +191,11 @@ function DialogInner(props: DialogProps & { isNested: boolean }) { }); 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,10 +203,11 @@ function DialogInner(props: DialogProps & { isNested: boolean }) { setOpen, floatingContext, refs, - getReferenceProps, getFloatingProps, popupRef, returnFocusRef, + store, + finalFocusResolverRef, modal, isNested, labelId, @@ -98,9 +220,9 @@ function DialogInner(props: DialogProps & { isNested: boolean }) { setOpen, floatingContext, refs, - getReferenceProps, getFloatingProps, returnFocusRef, + store, modal, isNested, labelId, @@ -110,20 +232,22 @@ function DialogInner(props: DialogProps & { isNested: boolean }) { ], ); + 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} /> @@ -132,7 +256,7 @@ export function DialogRoot(props: DialogProps) { } 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..33e467254ca 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 and return focus. 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.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/drawer/drawer-context.ts b/packages/headless/src/primitives/drawer/drawer-context.ts index d9342969639..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. */ 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 ffae00cc94f..ebe7b975a50 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -23,7 +23,7 @@ primitive's focus trapping, scroll lock, and ARIA wiring. { 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'" }, ]} @@ -169,7 +169,8 @@ It carries an English `Close` label by default; pass `aria-label` to override it > **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. -> Render the close button last if a field should take focus instead. +> Point `initialFocus` on `Dialog.Popup` at the field that should take it instead — see +> [Custom focus management](#custom-focus-management). ### Dismissal @@ -180,18 +181,18 @@ user input, so a stray backdrop click cannot discard it. ## Parts -| Part | Slot | Description | -| -------------------- | --------------------- | ------------------------------------------------------------- | -| `Dialog.Root` | — | State provider; owns `size`, open/close, `modal`, `closedBy`. | -| `Dialog.Trigger` | — | Opens the dialog; accepts `render`. | -| `Dialog.Portal` | — | Portals the overlay out of the tree. | -| `Dialog.Backdrop` | `dialog-backdrop` | The scrim behind the dialog. | -| `Dialog.Viewport` | `dialog-viewport` | Centering container; owns the scroll lock. | -| `Dialog.Popup` | `dialog-popup` | The surface (`role="dialog"`, focus-trapped). | -| `Dialog.Title` | — | Heading; wired to the popup's `aria-labelledby`. | -| `Dialog.Description` | — | Description; wired to the popup's `aria-describedby`. | -| `Dialog.Close` | — | Dismisses the dialog; unstyled, accepts a `render` prop. | -| `Dialog.CloseButton` | `dialog-close-button` | The styled corner X. | +| Part | Slot | Description | +| -------------------- | --------------------- | ---------------------------------------------------------------------------- | +| `Dialog.Root` | — | State provider; owns `size`, open/close, `modal`, `closedBy`, `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`. @@ -377,3 +378,50 @@ What you get without asking for it: 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: 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). + + + +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 473709c8b2b..2d5e857a58d 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -89,8 +89,7 @@ const deleteAccountTrigger = (props: RenderProps) => ( ); -// A `panel` has no padding of its own — its regions reach the popup's edges — so a body that is -// ordinary padded content supplies its own. See `PanelSidebar` for the case that motivates it. +// A `panel` has no padding of its own, so a body of ordinary content supplies it. const panelBody = { display: 'flex', flex: 1, @@ -135,7 +134,6 @@ function AddValueDialog({ }>{title} }>{description} - {/* Hand-rolled, as every dialog's footer is today. A Header/Body/Footer split is planned. */}
} + /> + + + + + + + }>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} + + + + + )} + + + ); +} + /** `size='card'` paints nothing itself — the popup renders AS a `Card`, which supplies the surface. */ export function CardSurface() { return ( @@ -436,3 +499,30 @@ export function OutsideScroll() { ); } + +/** `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..2ca91ba6f92 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 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/dialog/dialog.styles.ts b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts index 97d965c50ec..52fdaf3dd5a 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.styles.ts +++ b/packages/ui/src/mosaic/components/dialog/dialog.styles.ts @@ -7,6 +7,9 @@ export const styles = stylex.create({ // token: it composites over whatever the host app renders, so the same value reads // consistently on any page. // + // Black in both schemes. A grey veil was tried for dark mode — lightening a dark page rather + // than darkening it — and it read as haze over the page rather than as a surface lifting off it. + // // A stacked dialog paints its OWN scrim rather than deferring to the one beneath it, so each // level reads as a step further from the page. It is lighter than the base because the two // COMPOSITE: alpha over alpha is `1 − (1 − a)(1 − b)`, so the nested value is solved for the diff --git a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx index 23f93e0b83c..9b7ff5eb24e 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.test.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.test.tsx @@ -304,13 +304,80 @@ describe('Dialog.CloseButton', () => {
, ); - // Pinning the consequence rather than endorsing it: with no `initialFocus` API, a corner X - // rendered before the form is what the dialog opens focused on. `FloatingFocusManager` moves - // focus in an effect, hence the wait. + // 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('popup padding', () => { // Regression: `sizes[size]` has to actually override `styles.popup`'s padding. StyleX dedupes // by property within one `stylex.props` call, so the size atom should REPLACE the base one diff --git a/packages/ui/src/mosaic/components/dialog/dialog.tsx b/packages/ui/src/mosaic/components/dialog/dialog.tsx index e80949b1f86..14d08dabf45 100644 --- a/packages/ui/src/mosaic/components/dialog/dialog.tsx +++ b/packages/ui/src/mosaic/components/dialog/dialog.tsx @@ -1,4 +1,4 @@ -import type { DialogProps as HeadlessDialogProps } from '@clerk/headless/dialog'; +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'; @@ -16,7 +16,7 @@ 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 { +export interface DialogRootProps extends HeadlessDialogProps { /** Width, and for `panel` also height, of the dialog surface. @default 'prompt' */ size?: DialogSize; } @@ -36,7 +36,18 @@ const DialogSizeContext = React.createContext('prompt'); * callback can spread straight into a Mosaic component whose own `color` is a narrow * variant union. */ -export type DialogTriggerProps = MosaicComponentProps<'button'>; +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'>; @@ -54,13 +65,18 @@ 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'>; +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', children, ...rest }: DialogRootProps) { +function Root({ size = 'prompt', children, ...rest }: DialogRootProps) { return ( - {children} + {...rest}>{children} ); } @@ -73,7 +89,9 @@ const Trigger = React.forwardRef(function {...props} /> ); -}); +}) as ( + props: DialogTriggerProps & { ref?: React.Ref }, +) => React.ReactElement; /** Dismisses the dialog. Renders a `