diff --git a/.changeset/spicy-clocks-argue.md b/.changeset/spicy-clocks-argue.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/spicy-clocks-argue.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/hooks/use-return-focus.test.ts b/packages/headless/src/hooks/use-return-focus.test.ts index 7cca04b4c9d..17e06ade2f0 100644 --- a/packages/headless/src/hooks/use-return-focus.test.ts +++ b/packages/headless/src/hooks/use-return-focus.test.ts @@ -1,10 +1,10 @@ -import type { FloatingEvents } from '@floating-ui/react'; +import type { FloatingEvents, OpenChangeReason } from '@floating-ui/react'; import { renderHook } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { useReturnFocus } from './use-return-focus'; -function createEvents(): FloatingEvents & { close: (event?: Event) => void } { +function createEvents(): FloatingEvents & { close: (event?: Event, reason?: OpenChangeReason) => void } { const handlers = new Map void>>(); return { @@ -20,8 +20,8 @@ function createEvents(): FloatingEvents & { close: (event?: Event) => void } { (handlers.get(event) ?? []).filter(h => h !== handler), ); }, - close(event) { - this.emit('openchange', { open: false, event }); + close(event, reason) { + this.emit('openchange', { open: false, event, reason }); }, }; } @@ -60,7 +60,17 @@ describe('useReturnFocus', () => { const { events, result, open } = renderReturnFocus(trigger); open(true); - events.close(new KeyboardEvent('keydown', { key: 'Escape' })); + events.close(new KeyboardEvent('keydown', { key: 'Escape' }), 'escape-key'); + + expect(result.current.current).toBe(trigger); + }); + + it('keeps the trigger when a forwarded event carries no dismissal reason', () => { + const { events, result, open } = renderReturnFocus(trigger); + open(true); + + // A Close button forwards its click through `setOpen` with no floating-ui reason. + events.close(new MouseEvent('click', { detail: 1 })); expect(result.current.current).toBe(trigger); }); @@ -76,11 +86,11 @@ describe('useReturnFocus', () => { expect(result.current.current).toBe(trigger); }); - it('leaves focus alone when the close came from a pointer', () => { + it('leaves focus alone when the close came from a pointer dismissal', () => { const { events, result, open } = renderReturnFocus(trigger); open(true); - events.close(new MouseEvent('mousedown', { detail: 1 })); + events.close(new MouseEvent('mousedown', { detail: 1 }), 'outside-press'); expect(result.current.current).toBeNull(); }); @@ -88,7 +98,7 @@ describe('useReturnFocus', () => { it('restores the trigger on the next open', () => { const { events, result, open } = renderReturnFocus(trigger); open(true); - events.close(new MouseEvent('mousedown', { detail: 1 })); + events.close(new MouseEvent('mousedown', { detail: 1 }), 'outside-press'); open(false); open(true); diff --git a/packages/headless/src/hooks/use-return-focus.ts b/packages/headless/src/hooks/use-return-focus.ts index 5e45a0ad510..c734b0820ea 100644 --- a/packages/headless/src/hooks/use-return-focus.ts +++ b/packages/headless/src/hooks/use-return-focus.ts @@ -1,6 +1,6 @@ 'use client'; -import type { FloatingContext } from '@floating-ui/react'; +import type { FloatingContext, OpenChangeReason } from '@floating-ui/react'; import { useEffect, useRef } from 'react'; import { isKeyboardEvent } from '../utils/interaction-modality'; @@ -32,11 +32,12 @@ export function useReturnFocus( }, [open, trigger]); useEffect(() => { - // Closes routed straight through the consumer's own state setter (a Close button, an - // item click) never reach floating-ui, so only what floating-ui itself drives can - // downgrade the default. - function onOpenChange({ open, event }: { open: boolean; event?: Event }) { - if (!open && event && !isKeyboardEvent(event)) { + // Only a pointer dismissal downgrades the default, and a `reason` is what marks a close as + // one floating-ui's interaction hooks drove (outside press, a trigger press). An event + // forwarded without a reason — a Close button press — keeps the trigger, and programmatic + // closes carry no event at all. + function onOpenChange({ open, event, reason }: { open: boolean; event?: Event; reason?: OpenChangeReason }) { + if (!open && event && reason && !isKeyboardEvent(event)) { returnFocusRef.current = null; } } diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index 9696c954016..51ae61f9227 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()`. The payload is captured when the trigger opens the +dialog; for data that can change while it is open, carry an id and read live state inside. + +```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 +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. diff --git a/packages/headless/src/primitives/dialog/dialog-close.tsx b/packages/headless/src/primitives/dialog/dialog-close.tsx index 23a388df9da..2788a17c840 100644 --- a/packages/headless/src/primitives/dialog/dialog-close.tsx +++ b/packages/headless/src/primitives/dialog/dialog-close.tsx @@ -8,15 +8,15 @@ import { useDialogContext } from './dialog-context'; /** Props for {@link DialogClose}. */ export type DialogCloseProps = ComponentProps<'button'>; -/** Button that closes the dialog when clicked. Calls `setOpen(false)` from dialog context. */ +/** Button that closes the dialog when clicked, forwarding the event so `finalFocus` sees the interaction type behind the close. */ export const DialogClose = React.forwardRef(function DialogClose(props, ref) { const { render, ...otherProps } = props; const { setOpen } = useDialogContext(); const defaultProps = { type: 'button' as const, - onClick() { - setOpen(false); + onClick(event: React.MouseEvent) { + setOpen(false, event.nativeEvent); }, } satisfies DefaultProps<'button'>; diff --git a/packages/headless/src/primitives/dialog/dialog-context.ts b/packages/headless/src/primitives/dialog/dialog-context.ts index 21a57672b21..50101526501 100644 --- a/packages/headless/src/primitives/dialog/dialog-context.ts +++ b/packages/headless/src/primitives/dialog/dialog-context.ts @@ -2,17 +2,24 @@ 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; + /** The optional event marks the change as user-driven, letting `finalFocus` resolve its interaction type. */ + setOpen: (open: boolean, event?: Event) => 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; modal: boolean; /** * Whether this dialog opened from inside another floating element, so a stacked overlay can @@ -39,3 +46,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..2ce8e89e9aa --- /dev/null +++ b/packages/headless/src/primitives/dialog/dialog-handle.ts @@ -0,0 +1,136 @@ +/** + * 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; + /** Read lazily so a `payload` with unstable identity never re-registers the trigger. */ + getPayload: () => 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 */ + 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; + + 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); + notify(); + return () => { + if (triggers.get(registration.id) === registration) { + triggers.delete(registration.id); + notify(); + } + }; + }, + getTrigger: id => triggers.get(id), + getFirstTrigger: () => triggers.values().next().value, + 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..fcb6023e59e 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -1,18 +1,136 @@ 'use client'; -import { FloatingFocusManager } from '@floating-ui/react'; +import { type FloatingContext, 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); + +/** + * Resolves `initialFocus` 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. + */ +function useInitialFocus( + initialFocus: DialogFocusTarget | undefined, + open: boolean, + floatingContext: FloatingContext, +): number | React.MutableRefObject { + const elementRef = React.useRef(null); + return React.useMemo(() => { + 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) { + elementRef.current = result; + return elementRef; + } + return 0; + }, [open, initialFocus, floatingContext]); +} + +/** + * Resolves `finalFocus` into the `boolean | ref` form `FloatingFocusManager`'s `returnFocus` + * takes. + * + * The function form needs the event behind the close, so it runs inside floating-ui's + * synchronous `openchange` emit — the root routes every close through + * `floatingContext.onOpenChange`, and the emit precedes both the state commit and any focus + * restoration. Only the function's decision is stored; the ref handed to the focus manager + * materialises it lazily, at restore time, by which point `useReturnFocus` has applied its + * pointer-close downgrade to the default. + */ +function useFinalFocus( + finalFocus: DialogFocusTarget | undefined, + returnFocusRef: React.MutableRefObject, + floatingContext: FloatingContext, +): boolean | React.MutableRefObject { + const finalFocusRef = React.useRef(finalFocus); + React.useLayoutEffect(() => { + finalFocusRef.current = finalFocus; + }); + + // The function's last decision: an element, `false` for "don't move focus", `true` for the + // default (the trigger, via `returnFocusRef`). + const decisionRef = React.useRef(true); + const resolvedRef = React.useMemo( + () => ({ + get current() { + const decision = decisionRef.current; + if (decision instanceof HTMLElement) { + return decision; + } + return decision ? returnFocusRef.current : null; + }, + }), + [returnFocusRef], + ); + + React.useLayoutEffect(() => { + function onOpenChange({ open, event }: { open: boolean; event?: Event }) { + const target = finalFocusRef.current; + if (open || typeof target !== 'function') { + return; + } + const result = target(interactionTypeFromEvent(event)); + decisionRef.current = result instanceof HTMLElement ? result : result !== false; + } + floatingContext.events.on('openchange', onOpenChange); + return () => floatingContext.events.off('openchange', onOpenChange); + }, [floatingContext.events]); + + if (finalFocus === undefined || finalFocus === true) { + return returnFocusRef; + } + if (finalFocus === false) { + return false; + } + if (typeof finalFocus === 'function') { + return resolvedRef; + } + return finalFocus as React.MutableRefObject; +} + /** 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, @@ -26,6 +144,9 @@ export const DialogPopup = React.forwardRef(fu transitionProps, } = useDialogContext(); + const resolvedInitialFocus = useInitialFocus(initialFocus, open, floatingContext); + const resolvedReturnFocus = useFinalFocus(finalFocus, returnFocusRef, floatingContext); + const ownProps = { 'aria-labelledby': labelId, 'aria-describedby': descriptionId, @@ -59,7 +180,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..187ba660bc1 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,135 @@ 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); + // 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); + + // Every open/close funnels through `floatingContext.onOpenChange` — trigger activations, + // dismissals, and programmatic `setOpen` alike. floating-ui emits its `openchange` event + // synchronously before invoking this callback, which is what lets listeners (`useReturnFocus`, + // the popup's `finalFocus` resolution) see the change and its event before any focus + // restoration can run. const { refs, context: floatingContext } = useFloating({ nodeId, open, - onOpenChange: setOpen, + onOpenChange: (nextOpen, event) => { + const details = pendingDetailsRef.current ?? { trigger: null, triggerId: null, event }; + pendingDetailsRef.current = null; + setOpenState(nextOpen); + onOpenChange?.(nextOpen, details); + }, }); + useLayoutEffect(() => { + return store.setRoot({ + openFromTrigger: (id, event) => { + const registration = store.getTrigger(id); + setActiveTriggerId(id); + setActivePayload(registration?.getPayload()); + 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 => floatingContext.onOpenChange(nextOpen), + }); + // `floatingContext` is rebuilt on open/element changes; re-registering is an idempotent swap. + }, [store, refs, floatingContext, setActiveTriggerId]); + + // 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`, so triggers mounting + // and unmounting re-resolve the reference without re-rendering the root. + useLayoutEffect(() => { + const resolve = () => { + const active = activeTriggerId != null ? store.getTrigger(activeTriggerId) : undefined; + refs.setReference(active?.element ?? store.getFirstTrigger()?.element ?? null); + }; + resolve(); + return store.subscribe(resolve); + }, [store, activeTriggerId, refs]); + + // 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)?.getPayload() : 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 +167,6 @@ function DialogInner(props: DialogProps & { isNested: boolean }) { ref: popupRef, }); - const click = useClick(floatingContext); const dismiss = useDismiss(floatingContext, { outsidePressEvent: 'mousedown', escapeKey: closedBy !== 'none', @@ -74,7 +174,12 @@ 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, event?: Event) => floatingContext.onOpenChange(nextOpen, event), + [floatingContext], + ); const contextValue = useMemo( () => ({ @@ -82,10 +187,10 @@ function DialogInner(props: DialogProps & { isNested: boolean }) { setOpen, floatingContext, refs, - getReferenceProps, getFloatingProps, popupRef, returnFocusRef, + store, modal, isNested, labelId, @@ -98,9 +203,9 @@ function DialogInner(props: DialogProps & { isNested: boolean }) { setOpen, floatingContext, refs, - getReferenceProps, getFloatingProps, returnFocusRef, + store, modal, isNested, labelId, @@ -110,20 +215,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 +239,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..54f8c219615 100644 --- a/packages/headless/src/primitives/dialog/dialog-trigger.tsx +++ b/packages/headless/src/primitives/dialog/dialog-trigger.tsx @@ -3,38 +3,99 @@ 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 }`. Captured at open; changes + * while the dialog is open are not reflected. + */ + 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); + + // The registration hands out a payload getter rather than a snapshot: an inline-literal + // `payload` changes identity every render, and re-registering on it would notify the store + // (and flap the root's reference element) each time. + const payloadRef = React.useRef(payload); + React.useLayoutEffect(() => { + payloadRef.current = payload; + }); + + const elementRef = React.useRef(null); + React.useLayoutEffect(() => { + const element = elementRef.current; + if (!element) { + return; + } + return store.registerTrigger({ id: triggerId, element, getPayload: () => payloadRef.current }); + }, [store, triggerId]); 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], - state, + ref: [elementRef, ref], + state: { open: showsOpen }, 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..d0513bbbe43 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,295 @@ 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 the forwarded interaction type on Close press', 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('mouse'); + // A Close press is not a dismissal, so the default still returns focus 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', () => { + const handle = Dialog.createHandle(); + const finalFocus = vi.fn(() => undefined); + render( + + + Title + + , + ); + + act(() => handle.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..0cdd7357352 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 `