diff --git a/.changeset/adopt-close-confirmation.md b/.changeset/adopt-close-confirmation.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/adopt-close-confirmation.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/dialog-close-confirmation.md b/.changeset/dialog-close-confirmation.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/dialog-close-confirmation.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/dialog-stack-motion.md b/.changeset/dialog-stack-motion.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/dialog-stack-motion.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/dialog-stacking-state.md b/.changeset/dialog-stacking-state.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/dialog-stacking-state.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/mosaic-alert-dialog.md b/.changeset/mosaic-alert-dialog.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-alert-dialog.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/headless/src/primitives/dialog/README.md b/packages/headless/src/primitives/dialog/README.md index 51ae61f9227..c566d5bba4a 100644 --- a/packages/headless/src/primitives/dialog/README.md +++ b/packages/headless/src/primitives/dialog/README.md @@ -78,6 +78,17 @@ const detail = Dialog.createHandle<{ name: string }>(); ``` +An open with no trigger behind it can supply the payload directly: `handle.open(payload)` is the +programmatic counterpart, for a dialog raised by something that happened rather than by an element +— a confirmation that has to say what it is asking. A trigger-driven open supersedes it, since a +trigger names its own payload. + +```tsx +const confirmation = Dialog.createHandle<{ question: string }>(); + +confirmation.open({ question: 'Discard changes?' }); +``` + In controlled mode, track which trigger is active with `triggerId` — `onOpenChange`'s second argument reports the trigger behind each change: @@ -137,16 +148,17 @@ the close was pointer-driven, where focus is left where the pointer put it (see ### `Dialog.Root` -| 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` | +| 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 | +| `role` | `'dialog' \| 'alertdialog'` | `'dialog'` | The popup's ARIA role | +| `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` @@ -218,10 +230,26 @@ No additional props beyond standard HTML attributes and the `render` prop. | --------------------------- | ---------------------------------- | ------------------------------------------- | | `data-open` / `data-closed` | Trigger, Backdrop, Viewport, Popup | Open state | | `data-nested` | Backdrop, Viewport, Popup | Opened from inside another floating element | +| `data-stacked` | Backdrop, Popup | Layered over an open dialog | +| `data-stack-base` | Popup | Has an open dialog layered over it | + +`data-nested` reflects any floating ancestor: the `FloatingTree` a Menu or Popover establishes +counts too. + +`data-stacked` and `data-stack-base` are narrower, and are what stacking styles should use. They +describe dialog-on-dialog specifically, in the two directions of the same relationship — the one +on top, and the one it covers. A dialog opened from a menu item is `data-nested` but not +`data-stacked`: it has a floating ancestor, yet it sits on the bare page and still owns its scrim. + +Both can be set at once, and that is the ordinary case rather than an edge — in a panel → prompt → +alert stack, the middle dialog is stacked on one surface while another is stacked on it. + +`data-stacked` exists chiefly so the stack shows one scrim: the dialog on top drops its own +backdrop instead of compositing a darker one per level. `data-stack-base` is for whatever the +surface underneath does to signal depth. -`data-nested` is what a stacked overlay styles itself from — chiefly so backdrops don't composite -into an ever-darker scrim as the stack grows. It reflects any floating ancestor, not strictly a -dialog one: the `FloatingTree` a Menu or Popover establishes counts too. +`data-stacked` holds for as long as the dialog underneath is on screen, exit transition included — +otherwise the one on top would paint a second scrim over the fading original. The headless parts are unstyled. Target a part with your own className (or `render` prop) and combine it with the `data-*` state attributes above. @@ -229,7 +257,7 @@ The headless parts are unstyled. Target a part with your own className (or `rend - **`Dialog.Popup` should be a child of `Dialog.Viewport`** for centered, scroll-locked modal behavior. The viewport hosts the fixed overlay container; the popup alone does not handle positioning or scroll lock. - **Title and Description are optional but recommended.** If omitted, `aria-labelledby` / `aria-describedby` are simply absent from the popup. -- **Nested dialogs are supported**, and covered by tests. The `FloatingTree` pattern handles it: `useDismiss` blocks both Escape and outside-press on a parent while any child is open, and `FloatingOverlay`'s scroll lock is refcounted, so the body stays locked until the last dialog closes. +- **Nested dialogs are supported**, and covered by tests. The `FloatingTree` pattern handles it: `useDismiss` blocks both Escape and outside-press on a parent while any child is open, and `FloatingOverlay`'s scroll lock is refcounted, so the body stays locked until the last dialog closes. Style the stack with `data-stacked` / `data-stack-base`, not `data-nested`. - **No positioning middleware.** Dialogs are centered via CSS, not Floating UI positioning. ## Authoring rule for new primitives @@ -238,5 +266,5 @@ Each styleable surface = one part. Layout infrastructure (overlay, scroll lock, ## ARIA -- Popup: `role="dialog"`, `aria-labelledby` (from Title), `aria-describedby` (from Description) +- Popup: `role="dialog"` (or `"alertdialog"`, via the root's `role`), `aria-labelledby` (from Title), `aria-describedby` (from Description) - Trigger: `aria-expanded`, `aria-haspopup="dialog"`, `aria-controls` diff --git a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx index c934d4bfe6d..91f22b5a35d 100644 --- a/packages/headless/src/primitives/dialog/dialog-backdrop.tsx +++ b/packages/headless/src/primitives/dialog/dialog-backdrop.tsx @@ -12,9 +12,12 @@ export type DialogBackdropProps = ComponentProps<'div'>; export const DialogBackdrop = React.forwardRef( function DialogBackdrop(props, ref) { const { render, ...otherProps } = props; - const { open, mounted, isNested, transitionProps } = useDialogContext(); + const { open, mounted, isNested, isStacked, transitionProps } = useDialogContext(); - const state = { open, nested: isNested }; + // No `stacked` counterpart to `data-stack-base` here: what a dialog beneath the stack does is + // recede, and that is the popup's business. The backdrop only needs to know to get out of the + // way when it is not the one scrim the stack shows. + const state = { open, nested: isNested, stacked: isStacked }; const defaultProps = { ...transitionProps, @@ -29,6 +32,7 @@ export const DialogBackdrop = React.forwardRef | null => (v ? { 'data-open': '' } : { 'data-closed': '' }), nested: (v: boolean): Record | null => (v ? { 'data-nested': '' } : null), + stacked: (v: boolean): Record | null => (v ? { 'data-stacked': '' } : null), }, props: mergeProps<'div'>(defaultProps, otherProps), }); diff --git a/packages/headless/src/primitives/dialog/dialog-context.ts b/packages/headless/src/primitives/dialog/dialog-context.ts index 50101526501..a20561a2e64 100644 --- a/packages/headless/src/primitives/dialog/dialog-context.ts +++ b/packages/headless/src/primitives/dialog/dialog-context.ts @@ -31,6 +31,14 @@ export interface DialogContextValue { * cases coincide in practice. */ isNested: boolean; + /** + * Whether this dialog is layered over an open DIALOG — the signal the stacking styles key on, + * where `isNested` is too broad to use. A stacked dialog drops its own backdrop so the stack + * shows one scrim rather than compositing a darker one per level. + */ + isStacked: boolean; + /** How many open dialogs are stacked directly on this one. See `useDialogNesting`. */ + stackedChildCount: number; labelId: string; descriptionId: string; mounted: boolean; diff --git a/packages/headless/src/primitives/dialog/dialog-handle.ts b/packages/headless/src/primitives/dialog/dialog-handle.ts index 2ce8e89e9aa..739cec4364f 100644 --- a/packages/headless/src/primitives/dialog/dialog-handle.ts +++ b/packages/headless/src/primitives/dialog/dialog-handle.ts @@ -20,10 +20,10 @@ export interface DialogTriggerRegistration { * requests made with no root attached are ignored, matching Base UI. * @internal */ -export interface DialogRootController { +export interface DialogRootController { openFromTrigger: (id: string, event: Event) => void; closeFromTrigger: (id: string, event: Event) => void; - setOpen: (open: boolean) => void; + setOpen: (open: boolean, payload?: Payload) => void; } /** The slice of root state a trigger renders from: its `data-open` / ARIA wiring. */ @@ -45,8 +45,16 @@ const CLOSED_STATE: DialogHandleState = { open: false, triggerId: null, popupId: * lets a `DialogHandle` flow into contexts typed `DialogHandle`. */ export interface DialogHandle { - /** Opens the attached root. Ignored while no root is mounted. */ - open(): void; + /** + * Opens the attached root. Ignored while no root is mounted. + * + * The optional `payload` is the programmatic counterpart of a trigger's: it reaches the root's + * children-as-function as `{ payload }`, so an imperative open can carry the content the dialog + * is about — what a confirmation is asking, which record is being deleted — without the caller + * holding a second piece of state alongside `open`. A trigger-driven open supersedes it, since + * a trigger names its own payload. + */ + open(payload?: Payload): 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. */ @@ -58,7 +66,7 @@ export interface DialogHandle { /** @internal */ getFirstTrigger(): DialogTriggerRegistration | undefined; /** @internal */ - setRoot(controller: DialogRootController): () => void; + setRoot(controller: DialogRootController): () => void; /** @internal */ requestOpen(id: string, event: Event): void; /** @internal */ @@ -79,14 +87,14 @@ export interface DialogHandle { export function createDialogHandle(): DialogHandle { const triggers = new Map>(); const listeners = new Set<() => void>(); - let root: DialogRootController | null = null; + let root: DialogRootController | null = null; let state = CLOSED_STATE; const notify = () => listeners.forEach(listener => listener()); return { - open() { - root?.setOpen(true); + open(payload) { + root?.setOpen(true, payload); }, close() { root?.setOpen(false); diff --git a/packages/headless/src/primitives/dialog/dialog-nesting.ts b/packages/headless/src/primitives/dialog/dialog-nesting.ts new file mode 100644 index 00000000000..4521a78549a --- /dev/null +++ b/packages/headless/src/primitives/dialog/dialog-nesting.ts @@ -0,0 +1,92 @@ +'use client'; + +import { createContext, useCallback, useContext, useLayoutEffect, useMemo, useState } from 'react'; + +/** + * How a dialog root reaches the dialog root it renders inside, so the two can style the stack + * they form: the one on top drops its backdrop, the one beneath recedes behind it. + * + * Deliberately separate from `isNested`, which reports any FLOATING ancestor — a Menu or a + * Popover counts. Stacking styles cannot key on that: a dialog opened from a menu item has a + * floating ancestor but sits on the bare page, and must still paint its own scrim. + */ +export interface DialogNestingContextValue { + /** + * Whether the surrounding dialog is still covering the page — open, or closed but still + * mounted for its exit transition. Not the raw `open` flag: a child that un-suppressed its + * backdrop the instant the parent started closing would paint a second scrim over the + * parent's still-fading one. + */ + open: boolean; + /** + * Called by a dialog rendered inside this one, for as long as it is open. Returns the release. + * Stable for the lifetime of the root, so registering never churns. + */ + registerStackedChild: () => () => void; +} + +export const DialogNestingContext = createContext(null); + +/** What a root learns about the stack it belongs to. */ +export interface DialogNesting { + /** Whether this dialog is layered over an open dialog. */ + isStacked: boolean; + /** + * How many open dialogs are stacked directly on this one. Counts DIRECT children only — a + * three-deep stack reports 1 at both lower levels rather than 2 and 1 — which is enough for + * the single recede step that exists today. Making it cumulative means propagating the count + * back up the chain, and getting that to settle when two levels mount in one commit. + */ + stackedChildCount: number; + /** Provided to this root's children, so a dialog inside it registers against this one. */ + context: DialogNestingContextValue; +} + +/** + * Joins a dialog root to the stack it belongs to, in both directions: up, to report itself to + * the dialog it renders inside, and down, to count the dialogs that render inside it. + */ +export function useDialogNesting(open: boolean, mounted: boolean): DialogNesting { + const parent = useContext(DialogNestingContext); + const [stackedChildCount, setStackedChildCount] = useState(0); + + const registerStackedChild = useCallback(() => { + setStackedChildCount(count => count + 1); + let released = false; + return () => { + if (released) { + return; + } + released = true; + setStackedChildCount(count => count - 1); + }; + }, []); + + const registerWithParent = parent?.registerStackedChild; + + // Gated on `open` rather than on being mounted: a closing dialog stays mounted for the length + // of its exit transition, and the surface beneath has to come forward WITH it rather than + // after it. Depends on the registration function, not the whole context value, so a parent + // opening or closing does not re-register. + useLayoutEffect(() => { + if (!open || !registerWithParent) { + return; + } + return registerWithParent(); + }, [open, registerWithParent]); + + const covering = open || mounted; + + const context = useMemo( + () => ({ open: covering, registerStackedChild }), + [covering, registerStackedChild], + ); + + return { + // A parent that is closed AND gone is not something to sit on top of: the child owns the scrim, + // which is what a confirmation root mounted beside its dialog's portal relies on. + isStacked: parent !== null && parent.open, + stackedChildCount, + context, + }; +} diff --git a/packages/headless/src/primitives/dialog/dialog-popup.tsx b/packages/headless/src/primitives/dialog/dialog-popup.tsx index fcb6023e59e..587062ecf2b 100644 --- a/packages/headless/src/primitives/dialog/dialog-popup.tsx +++ b/packages/headless/src/primitives/dialog/dialog-popup.tsx @@ -137,6 +137,8 @@ export const DialogPopup = React.forwardRef(fu floatingContext, modal, isNested, + isStacked, + stackedChildCount, returnFocusRef, labelId, descriptionId, @@ -155,6 +157,11 @@ export const DialogPopup = React.forwardRef(fu const defaultProps = { ...ownProps, ...(isNested ? { 'data-nested': '' } : {}), + // Both can be set at once, and that is the ordinary case rather than an edge: in a + // panel -> prompt -> alert stack the middle dialog is stacked on one surface while another + // is stacked on it. + ...(isStacked ? { 'data-stacked': '' } : {}), + ...(stackedChildCount > 0 ? { 'data-stack-base': '' } : {}), ...getFloatingProps(), ...transitionProps, }; diff --git a/packages/headless/src/primitives/dialog/dialog-root.tsx b/packages/headless/src/primitives/dialog/dialog-root.tsx index 187ba660bc1..c3b1d077097 100644 --- a/packages/headless/src/primitives/dialog/dialog-root.tsx +++ b/packages/headless/src/primitives/dialog/dialog-root.tsx @@ -17,6 +17,7 @@ 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'; +import { DialogNestingContext, useDialogNesting } from './dialog-nesting'; /** * Which gestures dismiss the dialog, mirroring the native `` attribute. @@ -31,6 +32,12 @@ import { createDialogHandle, type DialogHandle } from './dialog-handle'; */ export type DialogClosedBy = 'any' | 'closerequest' | 'none'; +/** + * The popup's ARIA role. `alertdialog` is for a dialog interrupting the user to confirm or warn, + * which assistive technology announces more urgently; everything else is a `dialog`. + */ +export type DialogRole = 'dialog' | 'alertdialog'; + /** What accompanies an `onOpenChange` call, mirroring Base UI's event details. */ export interface DialogOpenChangeDetails { /** @@ -53,6 +60,8 @@ export interface DialogProps { modal?: boolean; /** Which gestures dismiss the dialog. Default: `any` */ closedBy?: DialogClosedBy; + /** The popup's ARIA role. Default: `dialog` */ + role?: DialogRole; /** * Connects this root to triggers rendered outside it. Create with `Dialog.createHandle()` * and pass the same handle to each `Dialog.Trigger`. @@ -71,7 +80,7 @@ export interface DialogProps { function DialogInner(props: DialogProps & { isNested: boolean }) { const nodeId = useFloatingNodeId(); - const { modal = true, closedBy = 'any', isNested, children, onOpenChange } = props; + const { modal = true, closedBy = 'any', role: ariaRole = 'dialog', isNested, children, onOpenChange } = props; const fallbackStore = useMemo(() => createDialogHandle(), []); const store = props.handle ?? fallbackStore; @@ -89,6 +98,10 @@ function DialogInner(props: DialogProps & { isNested: boolean // consumed by the floating `onOpenChange` the request funnels into. const pendingDetailsRef = useRef(null); + // The payload of the most recent programmatic `handle.open(payload)`, kept so the registry + // lookup below has something to fall back to when no trigger is involved. + const directPayloadRef = useRef(undefined); + // 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`, @@ -110,6 +123,9 @@ function DialogInner(props: DialogProps & { isNested: boolean openFromTrigger: (id, event) => { const registration = store.getTrigger(id); setActiveTriggerId(id); + // A trigger names its own payload, so it supersedes anything a previous programmatic + // open supplied — otherwise the stale one would resurface through the effect below. + directPayloadRef.current = undefined; setActivePayload(registration?.getPayload()); if (registration) { refs.setReference(registration.element); @@ -122,7 +138,21 @@ function DialogInner(props: DialogProps & { isNested: boolean pendingDetailsRef.current = { trigger: registration?.element ?? null, triggerId: id, event }; floatingContext.onOpenChange(false, event, 'click'); }, - setOpen: nextOpen => floatingContext.onOpenChange(nextOpen), + setOpen: (nextOpen, payload) => { + // Held in a ref as well as in state because the payload effect below re-runs on `open` + // and would otherwise resolve a trigger-less open to `undefined`, wiping this a commit + // after it was set. Cleared on close as well, so the next payload-less open does not + // resurface this one. + directPayloadRef.current = payload; + // Published only on the way IN. A close carries no payload, and writing it would blank + // the children-as-function while the popup is still mounted for its exit transition — + // rendering the dialog empty as it leaves, or throwing in a consumer that dereferences + // the payload. The next open sets it afresh. + if (nextOpen) { + setActivePayload(payload); + } + floatingContext.onOpenChange(nextOpen); + }, }); // `floatingContext` is rebuilt on open/element changes; re-registering is an idempotent swap. }, [store, refs, floatingContext, setActiveTriggerId]); @@ -146,9 +176,21 @@ function DialogInner(props: DialogProps & { isNested: boolean // `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. + // + // An explicit programmatic payload wins over the registry lookup: `activeTriggerId` is never + // reset on close, so once any trigger has opened the dialog the lookup would otherwise overwrite + // every later `handle.open(payload)` with that trigger's payload. The mirror already holds — + // `openFromTrigger` clears `directPayloadRef`, so a trigger wins the other way. useLayoutEffect(() => { if (open) { - setActivePayload(activeTriggerId != null ? store.getTrigger(activeTriggerId)?.getPayload() : undefined); + const direct = directPayloadRef.current; + setActivePayload( + direct !== undefined + ? direct + : activeTriggerId != null + ? store.getTrigger(activeTriggerId)?.getPayload() + : undefined, + ); } }, [store, open, activeTriggerId]); @@ -167,12 +209,16 @@ function DialogInner(props: DialogProps & { isNested: boolean ref: popupRef, }); + // Below `useTransition` because it needs `mounted`: what a stacked child has to key off is + // whether this dialog is still on screen, not whether it is still open. + const nesting = useDialogNesting(open, mounted); + const dismiss = useDismiss(floatingContext, { outsidePressEvent: 'mousedown', escapeKey: closedBy !== 'none', outsidePress: closedBy === 'any', }); - const role = useRole(floatingContext); + const role = useRole(floatingContext, { role: ariaRole }); const { getFloatingProps } = useInteractions([dismiss, role]); @@ -193,6 +239,8 @@ function DialogInner(props: DialogProps & { isNested: boolean store, modal, isNested, + isStacked: nesting.isStacked, + stackedChildCount: nesting.stackedChildCount, labelId, descriptionId, mounted, @@ -208,6 +256,8 @@ function DialogInner(props: DialogProps & { isNested: boolean store, modal, isNested, + nesting.isStacked, + nesting.stackedChildCount, labelId, descriptionId, mounted, @@ -219,7 +269,9 @@ function DialogInner(props: DialogProps & { isNested: boolean return ( - {content} + + {content} + ); } diff --git a/packages/headless/src/primitives/dialog/dialog.test.tsx b/packages/headless/src/primitives/dialog/dialog.test.tsx index d0513bbbe43..996a3bd05c5 100644 --- a/packages/headless/src/primitives/dialog/dialog.test.tsx +++ b/packages/headless/src/primitives/dialog/dialog.test.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { axe } from '../../test-utils/axe'; +import { Popover } from '../popover'; import { Dialog } from './index'; afterEach(() => cleanup()); @@ -527,6 +528,109 @@ describe('Dialog', () => { expect(screen.getByRole('dialog', { name: 'payload-b' })).toBeInTheDocument(); }); + + // The programmatic counterpart of a trigger's payload, for an open that no element initiated — + // a confirmation raised by a close request, say, which has to say what it is asking. + describe('handle.open(payload)', () => { + function renderDetached() { + const handle = Dialog.createHandle(); + render( + + {({ payload }) => ( + <> + Open A + + Open B + + + + + {payload ?? 'no payload'} + + + + + )} + , + ); + return handle; + } + + it('delivers it to the children render function', () => { + const handle = renderDetached(); + + act(() => handle.open('from-handle')); + + expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument(); + }); + + it('survives the registry lookup that runs once the dialog is open', async () => { + const handle = renderDetached(); + + act(() => handle.open('from-handle')); + // The lookup effect re-runs on `open`; without a fallback it would resolve to `undefined` + // a commit later and blank the dialog. + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument(); + }); + + it('is superseded by a trigger, which names its own payload', async () => { + const user = userEvent.setup(); + const handle = renderDetached(); + + act(() => handle.open('from-handle')); + act(() => handle.close()); + await user.click(screen.getByRole('button', { name: 'Open A' })); + + expect(screen.getByRole('dialog', { name: 'no payload' })).toBeInTheDocument(); + }); + + it('supersedes the trigger that opened the dialog last', async () => { + const user = userEvent.setup(); + const handle = renderDetached(); + + // `activeTriggerId` is never reset on close, so without an explicit precedence the + // registry lookup would hand this open the previous trigger's payload back. + await user.click(screen.getByRole('button', { name: 'Open B' })); + act(() => handle.close()); + act(() => handle.open('from-handle')); + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument(); + }); + + it('survives a `handle.close()` for the length of the exit transition', () => { + // Keep an animation pending so the popup stays mounted after the close. + const original = (Element.prototype as { getAnimations?: unknown }).getAnimations; + (Element.prototype as { getAnimations?: unknown }).getAnimations = () => [ + { finished: new Promise(() => {}) }, + ]; + try { + const handle = renderDetached(); + + act(() => handle.open('from-handle')); + act(() => handle.close()); + + // `close()` carries no payload; blanking it here would render the dialog empty on its + // way out, or throw in a children function that dereferences it. + expect(screen.getByRole('dialog', { name: 'from-handle' })).toBeInTheDocument(); + } finally { + if (original) { + (Element.prototype as { getAnimations?: unknown }).getAnimations = original; + } else { + delete (Element.prototype as { getAnimations?: unknown }).getAnimations; + } + } + }); + }); }); describe('initialFocus', () => { @@ -671,6 +775,209 @@ describe('Dialog', () => { }); }); + describe('role', () => { + it('renders role=alertdialog when asked', () => { + renderDialog({ defaultOpen: true, role: 'alertdialog' }); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('stacking', () => { + // Two dialogs, the inner one rendered inside the outer's popup. `outer`/`inner` prefixes on + // the test ids because both levels render the same parts. + function renderStack({ outerOpen = true, innerOpen = true }: { outerOpen?: boolean; innerOpen?: boolean } = {}) { + return render( + + + + + Outer + + + + + Inner + + + + + + , + ); + } + + it('marks the dialog on top stacked and the one beneath a stack base', () => { + renderStack(); + + expect(screen.getByTestId('inner-popup')).toHaveAttribute('data-stacked', ''); + expect(screen.getByTestId('inner-popup')).not.toHaveAttribute('data-stack-base'); + expect(screen.getByTestId('outer-popup')).toHaveAttribute('data-stack-base', ''); + expect(screen.getByTestId('outer-popup')).not.toHaveAttribute('data-stacked'); + }); + + it('marks the stacked backdrop, so only one scrim in the stack paints', () => { + renderStack(); + + expect(screen.getByTestId('inner-backdrop')).toHaveAttribute('data-stacked', ''); + expect(screen.getByTestId('outer-backdrop')).not.toHaveAttribute('data-stacked'); + }); + + it('drops the stack base marking when the dialog on top closes', () => { + const { rerender } = renderStack(); + expect(screen.getByTestId('outer-popup')).toHaveAttribute('data-stack-base', ''); + + rerender( + + + + Outer + + + + Inner + + + + + + , + ); + + expect(screen.getByTestId('outer-popup')).not.toHaveAttribute('data-stack-base'); + }); + + it('stays stacked while the dialog beneath is exiting', () => { + // Keep an animation pending so the one beneath stays mounted for its exit instead of + // unmounting in the same commit. + const original = (Element.prototype as { getAnimations?: unknown }).getAnimations; + (Element.prototype as { getAnimations?: unknown }).getAnimations = () => [ + { finished: new Promise(() => {}) }, + ]; + try { + const { rerender } = renderStack(); + + // The one beneath closes first. Its backdrop is still on screen for the length of the + // exit, so the one on top has to keep suppressing its own scrim rather than paint a + // second one over the fading original. + rerender( + + + + + Outer + + + + + Inner + + + + + + , + ); + + expect(screen.getByTestId('outer-backdrop')).toBeInTheDocument(); + expect(screen.getByTestId('inner-backdrop')).toHaveAttribute('data-stacked', ''); + } finally { + if (original) { + (Element.prototype as { getAnimations?: unknown }).getAnimations = original; + } else { + delete (Element.prototype as { getAnimations?: unknown }).getAnimations; + } + } + }); + + it('is not stacked on a dialog that is closed', () => { + // A confirmation root mounted beside its dialog's portal is inside the root but outlives + // the open state; on its own it owns the scrim like any root-level dialog. + render( + + + + + + Inner + + + + , + ); + + expect(screen.getByTestId('inner-popup')).not.toHaveAttribute('data-stacked'); + expect(screen.getByTestId('inner-backdrop')).not.toHaveAttribute('data-stacked'); + }); + + it('marks the middle of a three-deep stack as both', () => { + render( + + + + Bottom + + + + Middle + + + + Top + + + + + + + + + , + ); + + const middle = screen.getByTestId('middle-popup'); + expect(middle).toHaveAttribute('data-stacked', ''); + expect(middle).toHaveAttribute('data-stack-base', ''); + expect(screen.getByTestId('bottom-popup')).not.toHaveAttribute('data-stacked'); + expect(screen.getByTestId('top-popup')).not.toHaveAttribute('data-stack-base'); + }); + + it('does not treat a floating but non-dialog ancestor as a stack', async () => { + // The distinction `data-nested` cannot make: a dialog opened from a popover has a floating + // ancestor, but it sits on the bare page and still owns its scrim. + const user = userEvent.setup(); + render( + + Open popover + + + + + Open dialog + + + + Dialog in a popover + + + + + + + , + ); + + await user.click(screen.getByRole('button', { name: 'Open popover' })); + await user.click(screen.getByRole('button', { name: 'Open dialog' })); + + const popup = screen.getByTestId('dialog-popup'); + expect(popup).toHaveAttribute('data-nested', ''); + expect(popup).not.toHaveAttribute('data-stacked'); + expect(screen.getByTestId('dialog-backdrop')).not.toHaveAttribute('data-stacked'); + }); + }); + 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 c8e9789136d..e51ebc56558 100644 --- a/packages/headless/src/primitives/dialog/index.ts +++ b/packages/headless/src/primitives/dialog/index.ts @@ -14,6 +14,7 @@ export type { DialogPopupProps, DialogPortalProps, DialogProps, + DialogRole, DialogTitleProps, DialogTriggerProps, DialogViewportProps, diff --git a/packages/headless/src/primitives/dialog/parts.ts b/packages/headless/src/primitives/dialog/parts.ts index 3c9534100e6..a2af980ca40 100644 --- a/packages/headless/src/primitives/dialog/parts.ts +++ b/packages/headless/src/primitives/dialog/parts.ts @@ -1,4 +1,10 @@ -export { type DialogClosedBy, type DialogOpenChangeDetails, type DialogProps, DialogRoot as Root } from './dialog-root'; +export { + type DialogClosedBy, + type DialogOpenChangeDetails, + type DialogProps, + type DialogRole, + 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'; diff --git a/packages/headless/src/primitives/drawer/drawer-context.ts b/packages/headless/src/primitives/drawer/drawer-context.ts index 0cdd7357352..30cec1dfafc 100644 --- a/packages/headless/src/primitives/drawer/drawer-context.ts +++ b/packages/headless/src/primitives/drawer/drawer-context.ts @@ -30,7 +30,12 @@ export interface NestedDrawerCallbacks { // 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 { +// +// The stacking pair goes with them. A drawer already counts its own nesting as +// `nestedOpenCount` / `onNested`, which is a different question from the dialog's: `isStacked` +// asks whether a DIALOG sits above, and a drawer's stacked-child styling has nothing to read it +// from. Inheriting them would oblige every drawer root to publish two values no drawer part uses. +export interface DrawerContextValue extends Omit { getReferenceProps: UseInteractionsReturn['getReferenceProps']; backdropRef: React.RefObject; drag: DrawerDrag; diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 3895b47ccfe..347a60ca40a 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -38,6 +38,7 @@ const docModules: Record> = { input: dynamic(() => import('../stories/input.mdx')), item: dynamic(() => import('../stories/item.mdx')), dialog: dynamic(() => import('../stories/dialog.component.mdx')), + 'alert-dialog': dynamic(() => import('../stories/alert-dialog.component.mdx')), heading: dynamic(() => import('../stories/heading.mdx')), icon: dynamic(() => import('../stories/icon.mdx')), menu: dynamic(() => import('../stories/menu.component.mdx')), diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 9baf653c30b..48326de4769 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -1,5 +1,10 @@ // Import stories explicitly to control order and avoid type casting through unknown. import { meta as accordionMeta } from '../stories/accordion.stories'; +import { + Default as AlertDialogDefault, + DiscardChanges as AlertDialogDiscardChanges, + meta as alertDialogComponentMeta, +} from '../stories/alert-dialog.component.stories'; import { meta as autocompleteMeta } from '../stories/autocomplete.stories'; import { Fallback as AvatarFallbackStory, @@ -195,6 +200,12 @@ const inputModule: StoryModule = { meta: inputMeta, Default, Sizes: InputSizes, const dialogComponentModule: StoryModule = { meta: dialogComponentMeta, Default: DialogDefault }; +const alertDialogComponentModule: StoryModule = { + meta: alertDialogComponentMeta, + Default: AlertDialogDefault, + DiscardChanges: AlertDialogDiscardChanges, +}; + const popoverComponentModule: StoryModule = { meta: popoverComponentMeta, Default: PopoverComponentDefault, @@ -293,6 +304,7 @@ export const registry: StoryModule[] = [ inputModule, itemModule, dialogComponentModule, + alertDialogComponentModule, headingModule, iconModule, menuComponentModule, diff --git a/packages/swingset/src/stories/alert-dialog.component.mdx b/packages/swingset/src/stories/alert-dialog.component.mdx new file mode 100644 index 00000000000..03500a4ed12 --- /dev/null +++ b/packages/swingset/src/stories/alert-dialog.component.mdx @@ -0,0 +1,197 @@ +import * as AlertDialogStories from './alert-dialog.component.stories'; + +# AlertDialog + +The Mosaic `AlertDialog` — a `Dialog` that interrupts to ask for a decision, and waits for one. +Reach for it when continuing depends on the answer: confirming something destructive, or warning +that leaving loses work. Anything the user can read and dismiss is a `Dialog`. + +It is composed from the same parts as `Dialog`, so the surface, the motion, the stacking and the +scroll lock are all shared. What differs is fixed rather than configurable: it announces itself as +`role="alertdialog"`, an outside press cannot dismiss it, and it is always the `prompt` size. + +## Example + + + +## Usage + +```tsx +import { AlertDialog } from '@clerk/ui/mosaic/components/alert-dialog'; +import { Button } from '@clerk/ui/mosaic/components/button'; + + }> + {({ close }) => ( + <> + Delete Acme Inc? + This cannot be undone. + + }>Cancel + + + + )} + +``` + +`trigger` is optional, and usually absent — an alert is normally raised by something that already +happened rather than by a button that exists to raise it. Drive those with `open` and +`onOpenChange`. + +### A Title and a Description are both required + +An alert dialog is announced as an interruption, and its description is announced with its name at +that moment — so a title and two buttons leave the user choosing between "Cancel" and "Delete" with +nothing saying what is being deleted. Both are checked in development and warn when missing; neither +can be required in the type system, since parts arrive as children. + +### The cancel comes first + +Render the cancel as the first child of `AlertDialog.Actions`. It is the least destructive choice, +and being first makes it the first tabbable element — which is what the dialog opens focused on, with +no `initialFocus` needed. It is also the visual order in both layouts, so the keyboard order and the +screen agree. + +### The action does not close by itself + +`AlertDialog.Close` dismisses on press, which is what the cancel wants. The action usually starts +work, so close it when that work resolves rather than on the press — the render-prop `close` above, +or your own controlled state. That leaves room for a pending state on the button. + +### Returning focus + +`finalFocus` (and `initialFocus`) are accepted on the wrapper as well as on `AlertDialog.Popup`. +Pass one whenever the alert has no trigger: focus returns to the trigger by default, and an alert +raised by something that happened has none, so answering it would otherwise drop the user on the +body. A confirmation guarding a form wants the caret back in the field it asked about — see +[Confirming a discard](#confirming-a-discard) below. + +### Dismissal + +There is no `closedBy` prop. An outside press never dismisses an alert dialog: a question that needs +an answer must not be answerable by clicking next to it. Escape still closes — it is the keyboard's +equivalent of the cancel button, which is always present here. There is no `CloseButton` part for +the same reason: a corner X is a way out without answering. + +Every close request — Escape or `AlertDialog.Close` — routes through `onOpenChange`, so a controlled +consumer can decline one by not committing the state. + +## Confirming a close + +A dialog holding unsaved work should ask before discarding it. That is three pieces: a handle, a +hook that guards the close, and the confirmation itself. + +```tsx +import { AlertDialog, createConfirmHandle, useConfirmedClose } from '@clerk/ui/mosaic/components/alert-dialog'; + +const confirm = React.useMemo(() => createConfirmHandle(), []); + +const onOpenChange = useConfirmedClose({ + handle: confirm, + when: () => value !== '', + onOpenChange: setOpen, + confirm: { + title: 'Discard changes?', + description: 'You have not finished adding this address.', + actionLabel: 'Discard', + cancelLabel: 'Keep editing', + destructive: true, + }, +}); + + + {/* … */} + + +``` + +**Render `AlertDialog.Confirm` inside the dialog it guards** — anywhere in its children. That is +what puts the two in one floating tree, and escape ordering, the stacking styles and the refcounted +scroll lock all read that tree. A confirmation mounted app-globally would be a sibling of the dialog +rather than a child of it, and all three would break. + +**The guarded dialog must be controlled.** A veto is the absence of a commit, and an uncontrolled +dialog has already committed by the time `onOpenChange` runs. + +**What it covers is every close the dialog owns**: Escape, an outside press where `closedBy` allows +one, `Dialog.CloseButton`, `Dialog.Close`, and the `close` the `Dialog` wrapper hands its children. +A button wired to your own `setOpen(false)` never reaches the dialog, so it bypasses the question +silently — route those through `Dialog.Close`. + +`when()` is evaluated at each close request, so a close that no longer needs guarding (the form has +just been submitted, the field cleared) passes straight through. + +### Asking without a close + +`show()` is the same confirmation, awaited directly — for a decision that is not about closing: + +```tsx +if (await confirm.show({ title: 'Delete this key?', description: 'Applications using it stop working.' })) { + await deleteKey(); +} +``` + +It resolves `true` for the action and `false` for cancel or any dismissal. Calling it while a +confirmation is already showing returns the in-flight promise rather than opening a second one, so +repeated close requests ask once. + +**`AlertDialog.Confirm` must be mounted when `show()` is called** — it is the thing that opens, and +a `show()` with nothing mounted to answer it never resolves. Since the confirmation lives inside the +dialog it guards, that means asking from inside that dialog, while it is open. A confirmation that +unmounts with a question in flight answers `false` rather than leaving the `await` hanging. + +## Parts + +| Part | Slot | Description | +| ------------------------- | ---------------------- | -------------------------------------------------------------------------------------------- | +| `AlertDialog.Root` | — | State provider; owns open/close, `modal`, `handle`. `role`, `closedBy` and `size` are fixed. | +| `AlertDialog.Trigger` | — | Opens the alert; accepts `render`, and `handle` + `payload` when detached. | +| `AlertDialog.Portal` | — | Portals the overlay out of the tree. | +| `AlertDialog.Backdrop` | `dialog-backdrop` | The scrim behind the alert. | +| `AlertDialog.Viewport` | `dialog-viewport` | Centering container; owns the scroll lock. | +| `AlertDialog.Popup` | `dialog-popup` | The surface (`role="alertdialog"`, focus-trapped); `initialFocus` / `finalFocus`. | +| `AlertDialog.Title` | — | Heading; wired to the popup's `aria-labelledby`. Required. | +| `AlertDialog.Description` | — | Description; wired to the popup's `aria-describedby`. Required. | +| `AlertDialog.Close` | — | Dismisses the alert; unstyled, accepts a `render` prop. | +| `AlertDialog.Actions` | `alert-dialog-actions` | The response row. Cancel first. | +| `AlertDialog.Confirm` | `dialog-popup` | A whole confirmation rendered from a `show()` call. See below. | + +Every part except `Popup` and `Actions` is `Dialog`'s own component, not a wrapper around it — one +implementation, so the two cannot drift. `Title` and `Description` are unstyled passthroughs from the +headless layer; render them through your own typography (`Heading`, `Text`) via `render`. + +## Styling + +The alert dialog carries the same `.cl-dialog-*` slots as `Dialog`, and is themed the same way — see +the [Dialog](/components/dialog) page for the surface, the motion, the inset, and the state +attributes, all of which apply unchanged. Only the response row is its own: + +```css +@import '@clerk/ui/styles.css' layer(components); + +@layer overrides { + .cl-alert-dialog-actions { + margin-block-start: 1.5rem; + } +} +``` + +`AlertDialog.Actions` is a grid rather than a flex row, which is what lets one declaration cover +both cases without the buttons knowing anything. Every button takes an equal share of the row, so a +single action fills it and two split it in half, at every width — the convention for a `prompt` +generally, not a rule about alert dialogs. Nothing about it is media-scoped, so a third button +divides the same row into thirds rather than finding an edge case. + +--- + +## Examples + +### Confirming a discard + + diff --git a/packages/swingset/src/stories/alert-dialog.component.stories.tsx b/packages/swingset/src/stories/alert-dialog.component.stories.tsx new file mode 100644 index 00000000000..dc246efa812 --- /dev/null +++ b/packages/swingset/src/stories/alert-dialog.component.stories.tsx @@ -0,0 +1,141 @@ +/** @jsxImportSource @emotion/react */ +import type { RenderProps } from '@clerk/headless/utils'; +import { AlertDialog, createConfirmHandle, useConfirmedClose } from '@clerk/ui/mosaic/components/alert-dialog'; +import { Button } from '@clerk/ui/mosaic/components/button'; +import { Dialog } from '@clerk/ui/mosaic/components/dialog'; +import { Heading } from '@clerk/ui/mosaic/components/heading'; +import { Input } from '@clerk/ui/mosaic/components/input'; +import { Text } from '@clerk/ui/mosaic/components/text'; +import React from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +// Exposes this file's own source (via the `?raw` webpack rule) so each `` example +// renders a code footer with its function's source. See `StoryModule.__source`. +export { default as __source } from './alert-dialog.component.stories?raw'; + +export const meta: StoryMeta = { + group: 'Components', + title: 'AlertDialog', + source: 'packages/ui/src/mosaic/components/alert-dialog/alert-dialog.tsx', + styleEngine: 'stylex', +}; + +const deleteTrigger = (props: RenderProps) => ( + +); + +export function Default() { + return ( + + {({ close }) => ( + <> + }>Delete Acme Inc? + }> + The organization and everything in it will be permanently removed. This cannot be undone. + + + }>Cancel + {/* Not an `AlertDialog.Close`: the action is where the work happens, so the caller + closes once it resolves rather than the button closing on press. */} + + + + )} + + ); +} + +const addEmailTrigger = (props: RenderProps) => ; + +/** + * The case the stack was built for: a form prompt raising a confirmation over itself rather than + * discarding what was typed. + * + * `useConfirmedClose` wraps the dialog's own `onOpenChange`, so every close the dialog owns — + * Escape, the corner X, `Dialog.Close` — is guarded by the one hook, and the veto is simply the + * absence of a commit. `AlertDialog.Confirm` renders inside the dialog it guards, which is what + * puts the two in the same floating tree: escape ordering, the stacking styles and the refcounted + * scroll lock all depend on it. + * + * `finalFocus` puts the caret back in the field. Without it there is nowhere to return to — the + * confirmation is raised by a close request rather than by a trigger — so keeping editing would + * leave focus on the body, at the top of the page rather than where the work was. + */ +export function DiscardChanges() { + const confirm = React.useMemo(() => createConfirmHandle(), []); + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + const inputRef = React.useRef(null); + + const onOpenChange = useConfirmedClose({ + handle: confirm, + when: () => value.trim() !== '', + onOpenChange: next => { + setOpen(next); + if (!next) { + setValue(''); + } + }, + confirm: { + title: 'Discard changes?', + description: 'You have not finished adding this address. It will not be saved.', + actionLabel: 'Discard', + cancelLabel: 'Keep editing', + destructive: true, + }, + }); + + return ( + + {({ close }) => ( + <> + + }>Add email address + }> + You will need to verify this address before it can be used. + + setValue(event.target.value)} + /> +
+ }>Cancel + {/* Adding is the one close that must NOT be questioned, so it clears the field the + guard reads before closing. */} + +
+ + + + )} +
+ ); +} diff --git a/packages/swingset/src/stories/dialog.component.mdx b/packages/swingset/src/stories/dialog.component.mdx index ebe7b975a50..0128da7fd06 100644 --- a/packages/swingset/src/stories/dialog.component.mdx +++ b/packages/swingset/src/stories/dialog.component.mdx @@ -146,6 +146,11 @@ The sheet fades over the full length of its slide, while the backdrop keeps its the scrim answers the tap first, then the sheet arrives into an already-dimmed page. Under `prefers-reduced-motion: reduce` the sheet holds flat and only the fade runs. +A sheet arriving over another dialog takes the shorter desktop fade instead. The long one earns +itself against the page, where it gives the travel somewhere to resolve into; over an opaque +surface it just shows the dialog underneath through the one arriving, and the two read as one muddy +surface. The slide is unchanged, and carries the arrival on its own. + Drag-to-dismiss is deliberately absent — `Drawer` owns the drag engine, and a second one should not grow inside `Dialog`. @@ -249,11 +254,26 @@ difference and adds it to its own bottom padding, which gives each size the righ A card taller than the remaining space aligns to its top rather than losing its head. Pinch-zoom — which also shrinks the visual viewport — is excluded. -### Stacked dialogs +### Nested dialogs and stacks + +Two different relationships, which look different on purpose. + +A **nested** dialog is one opened over a `panel` or a `card` — a new surface over a page-like one. +It paints its own scrim, lighter than the base so the two composite to the intended darkness rather +than doubling it. Nothing else changes. -A dialog opened from inside another one carries `data-nested` and paints its own, lighter scrim, so -each level reads as a step further from the page without the backdrops compounding toward an -opaque wall. +A **stack** is successive `prompt`s: the confirmation over the form it is confirming. The same +conversation, one step further in. A stacked prompt paints **no** scrim — one backdrop serves the +whole stack, so how dark the page goes never depends on how deep the stack is. Depth comes from the +prompt beneath instead: its contents dim toward its own background, and it recedes, scaling down +slightly and lifting, with its radius divided by the same factor so the corners render unchanged. + +Whichever it is, the thing that opens is always a `prompt`. `panel` and `card` are root-level +surfaces — they host, they are never hosted — and a dialog opened inside another one warns in +development if it is any other size. + +Under `prefers-reduced-motion: reduce` the recede still happens, it just arrives in a single frame +with nothing interpolating — the setting asks for no animation, not for no distinction. --- @@ -342,6 +362,22 @@ prompt. storyModule={DialogStories} /> +This is the nested case, not a stack: the prompt paints its own scrim over the panel, and the panel +neither dims nor recedes. + +Type into **Add email address** and then try to close it — Escape, the corner X, or Cancel — and a +confirmation stacks on top instead, making the panel → prompt → prompt case reachable. The veto is +a controlled `open` whose `onOpenChange` declines to commit; every close request routes through it, +so one check covers all of them. + +Stack a prompt on a prompt and the relationship changes — the shape a close confirmation +takes: + + + Nest by rendering a `Dialog` inside another one's children. Nothing else is required — the inner dialog finds the outer through Floating UI's tree and wires up its own stacking: diff --git a/packages/swingset/src/stories/dialog.component.stories.tsx b/packages/swingset/src/stories/dialog.component.stories.tsx index 2d5e857a58d..9ddac2b644c 100644 --- a/packages/swingset/src/stories/dialog.component.stories.tsx +++ b/packages/swingset/src/stories/dialog.component.stories.tsx @@ -107,7 +107,15 @@ const sectionHeader = { justifyContent: 'space-between', } as const; -/** A `prompt` dialog opened from inside the `panel` — the shape the account profile uses. */ +/** + * A `prompt` dialog opened from inside the `panel` — the shape the account profile uses. + * + * With `confirmDiscard`, closing it while the field holds anything opens a confirmation stacked on + * top rather than closing: `panel -> prompt -> prompt`, and the veto is nothing more than a + * controlled `open` whose `onOpenChange` declines to commit. Hand-rolled here on purpose, to show + * that a veto needs no machinery; `AlertDialog`'s `useConfirmedClose` is the same thing packaged, + * and its page has the composed version. + */ function AddValueDialog({ trigger, title, @@ -115,6 +123,7 @@ function AddValueDialog({ placeholder, confirmLabel = 'Continue', confirmColor, + confirmDiscard = false, }: { trigger: (props: RenderProps) => React.ReactElement; title: string; @@ -122,39 +131,87 @@ function AddValueDialog({ placeholder: string; confirmLabel?: string; confirmColor?: 'negative'; + confirmDiscard?: boolean; }) { + const [open, setOpen] = React.useState(false); + const [discardOpen, setDiscardOpen] = React.useState(false); + const [value, setValue] = React.useState(''); + + const dismiss = () => { + setValue(''); + setOpen(false); + }; + return ( { + // The veto. Every close request lands here — Escape, the corner X, `Dialog.Close` — so + // declining to commit covers all of them at once. A footer button wired to a bare + // `setOpen(false)` would go around it, which is the argument for `Dialog.Close`. + if (!next && confirmDiscard && value.trim() !== '') { + setDiscardOpen(true); + return; + } + if (!next) { + setValue(''); + } + setOpen(next); + }} > - {({ close }) => ( - <> - - }>{title} - }>{description} - + + }>{title} + }>{description} + setValue(event.target.value)} + /> +
+ }>Cancel + +
+ {confirmDiscard ? ( + + }>Discard changes? + }> + You have not finished adding this address. It will not be saved. +
- - )} +
+ ) : null}
); } -/** A `panel` account surface with `card` dialogs opened from inside it. */ +/** A `panel` account surface with `prompt` dialogs opened from inside it. */ export function Nested() { return ( @@ -252,6 +310,73 @@ const SESSIONS = Array.from({ length: 40 }, (_, index) => ({ when: SESSION_TIMES[index % SESSION_TIMES.length], })); +const editProfileTrigger = (props: RenderProps) => ; + +const discardTrigger = (props: RenderProps) => ( + +); + +/** + * A prompt stacked on a prompt — the shape a close confirmation takes. The second prompt paints + * no scrim of its own; the one beneath it recedes instead. + */ +export function StackedPrompts() { + return ( + + {({ close }) => ( + <> + + }>Update profile + }>Change the name people see on your account. + +
+ + {({ close: closeConfirmation }) => ( + <> + }>Discard changes? + }>Your edits will be lost. +
+ + +
+ + )} +
+ +
+ + )} +
+ ); +} + /** The panel clips rather than scrolling, so the scroll region is composed inside it. */ export function PanelSidebar() { return ( diff --git a/packages/ui/src/mosaic/block/destructive.tsx b/packages/ui/src/mosaic/block/destructive.tsx index 73a4d24f381..9ccb0307017 100644 --- a/packages/ui/src/mosaic/block/destructive.tsx +++ b/packages/ui/src/mosaic/block/destructive.tsx @@ -56,7 +56,11 @@ export function Destructive({ return ( Cancel diff --git a/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts new file mode 100644 index 00000000000..7c56f170be2 --- /dev/null +++ b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.styles.ts @@ -0,0 +1,36 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + /** + * The response row. An alert dialog exists to be answered, so its buttons are anatomy rather + * than content — the one part `Dialog` deliberately does not ship, because a dialog's footer is + * whatever the consumer composes and an alert dialog's is always the same two choices. + * + * One layout at every width, because that is the convention for a `prompt` generally rather than + * a rule about alert dialogs: its buttons span the surface, one full width or two at even halves. + * A right-aligned pair sized to its labels was tried first and is what the designs do not do. + * + * A GRID rather than a flex row, and that is what makes both cases the same declaration. Filling + * the row needs `flex: 1` on each CHILD, which a parent cannot set — StyleX has no child + * selector, and reaching into the children would mean every call site remembering to pass + * something. `grid-auto-flow: column` with `grid-auto-columns: 1fr` puts it on the container + * instead: every button takes an equal share of the row, so one fills it and two split it, with + * no branch and nothing for a third to break. + * + * DOM order is the visual order: the cancel comes first, which is also what makes it the first + * tabbable element and therefore what opens focused — the least destructive choice, with no + * `initialFocus` plumbing. Keep it first; reversing the row visually would leave the keyboard + * order disagreeing with the screen. + */ + actions: { + gap: space['3'], + display: 'grid', + gridAutoColumns: '1fr', + gridAutoFlow: 'column', + // On top of the popup's own `gap`, so the response separates from the question it answers + // rather than reading as a third paragraph. + marginBlockStart: space['2'], + }, +}); diff --git a/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx new file mode 100644 index 00000000000..e3ecd2cfbe1 --- /dev/null +++ b/packages/ui/src/mosaic/components/alert-dialog/alert-dialog.test.tsx @@ -0,0 +1,335 @@ +import { act, cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Dialog } from '../dialog'; +import { AlertDialog } from './alert-dialog'; + +afterEach(() => cleanup()); + +// Both dev warnings defer by a task, so the assertions have to let one elapse. +const settle = () => + act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); + +function Confirm({ onOpenChange }: { onOpenChange?: (open: boolean) => void } = {}) { + return ( + + Discard changes? + This address has not been saved. + + Keep editing + + + + ); +} + +describe('Mosaic AlertDialog', () => { + it('renders as an alertdialog, named and described by its parts', () => { + render(); + + const popup = screen.getByRole('alertdialog', { name: 'Discard changes?' }); + expect(popup).toHaveAccessibleDescription('This address has not been saved.'); + }); + + it('carries the dialog slot classes, so it inherits the surface and its motion', () => { + render(); + + expect(document.querySelector('.cl-dialog-backdrop')).toBeInTheDocument(); + expect(document.querySelector('.cl-dialog-viewport')).toBeInTheDocument(); + expect(document.querySelector('.cl-dialog-popup')).toBeInTheDocument(); + expect(document.querySelector('.cl-alert-dialog-actions')).toBeInTheDocument(); + }); + + it('is always the prompt size', () => { + render(); + + expect(document.querySelector('.cl-dialog-popup')).toHaveAttribute('data-size', 'prompt'); + }); + + it('opens from a trigger', async () => { + const user = userEvent.setup(); + render( + ( + + )} + > + Delete this key? + Applications using it stop working. + , + ); + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Delete' })); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + it('opens focused on the cancel button, as the first element in the actions row', async () => { + render(); + + // `FloatingFocusManager` moves focus asynchronously after mount, so this waits rather than + // letting a single task elapse — under a loaded run the one task is not always enough. + await waitFor(() => expect(screen.getByRole('button', { name: 'Keep editing' })).toHaveFocus()); + }); + + it('closes on AlertDialog.Close, reporting it through onOpenChange', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'Keep editing' })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('hands the render-prop form a close that routes through onOpenChange', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + {({ close }) => ( + <> + Discard changes? + This address has not been saved. + + + + + )} + , + ); + + await user.click(screen.getByRole('button', { name: 'Keep editing' })); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + }); +}); + +// The dismissal policy is the behavioural half of what makes this an alert dialog: it cannot be +// answered by clicking next to it, but Escape — the keyboard's cancel — still works. +// An alert raised by a veto has no trigger, so without `finalFocus` there is nothing for focus to +// return to and answering the question drops the user on the body. +describe('focus', () => { + it('returns focus where finalFocus points when it closes', async () => { + const user = userEvent.setup(); + + function Guarded() { + const [confirmOpen, setConfirmOpen] = React.useState(true); + const inputRef = React.useRef(null); + return ( + <> + + + Discard changes? + This address has not been saved. + + Keep editing + + + + ); + } + render(); + + await user.click(screen.getByRole('button', { name: 'Keep editing' })); + + await waitFor(() => expect(screen.getByRole('textbox', { name: 'Email address' })).toHaveFocus()); + }); +}); + +describe('dismissal', () => { + it('does not close on an outside press', async () => { + const user = userEvent.setup(); + render(); + + await user.click(document.querySelector('.cl-dialog-backdrop') as HTMLElement); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + it('closes on Escape', async () => { + const user = userEvent.setup(); + render(); + + await user.keyboard('{Escape}'); + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument(); + }); + + it('lets a controlled consumer decline a close', async () => { + const user = userEvent.setup(); + + function Guarded() { + const [open, setOpen] = React.useState(true); + return ( + { + if (next) { + setOpen(true); + } + }} + > + Discard changes? + This address has not been saved. + + Keep editing + + + ); + } + render(); + + await user.keyboard('{Escape}'); + await user.click(screen.getByRole('button', { name: 'Keep editing' })); + + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); +}); + +describe('dev warnings', () => { + it('warns when the alert dialog has no description', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + Discard changes? + , + ); + + await settle(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no description')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); + warn.mockRestore(); + }); + + // The name warning skipped any role but `dialog` before this component existed, which would have + // made it silently inert for every alert dialog. + it('warns when it has no accessible name, and names the alert dialog parts', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + This address has not been saved. + , + ); + + await settle(); + + expect(warn).toHaveBeenCalledWith(expect.stringContaining('no accessible name')); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('')); + warn.mockRestore(); + }); + + it('stays quiet when both are supplied', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render(); + + await settle(); + + expect(warn).not.toHaveBeenCalled(); + warn.mockRestore(); + }); +}); + +describe('AlertDialog.Actions', () => { + it('merges consumer className and style', () => { + render( + + Discard changes? + This address has not been saved. + + Keep editing + + , + ); + + const actions = screen.getByTestId('actions'); + expect(actions).toHaveClass('cl-alert-dialog-actions'); + expect(actions).toHaveClass('custom'); + expect(actions).toHaveStyle({ marginBlockStart: '2rem' }); + }); + + it('renders as another element through render', () => { + render( + + Discard changes? + This address has not been saved. +