From f3d461c048a4158a78ce9b44b3317a9e49f0008a Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 26 Mar 2026 16:12:02 +0100 Subject: [PATCH 1/5] initlal version --- packages/ui/src/confirm-dialog/context.tsx | 11 + .../ui/src/confirm-dialog/index.stories.tsx | 200 +++++++++++ packages/ui/src/confirm-dialog/index.ts | 3 + packages/ui/src/confirm-dialog/popup.tsx | 47 +++ packages/ui/src/confirm-dialog/root.tsx | 78 +++++ .../ui/src/confirm-dialog/style.module.css | 18 + .../ui/src/confirm-dialog/test/index.test.tsx | 317 ++++++++++++++++++ packages/ui/src/confirm-dialog/trigger.tsx | 11 + packages/ui/src/confirm-dialog/types.ts | 61 ++++ 9 files changed, 746 insertions(+) create mode 100644 packages/ui/src/confirm-dialog/context.tsx create mode 100644 packages/ui/src/confirm-dialog/index.stories.tsx create mode 100644 packages/ui/src/confirm-dialog/index.ts create mode 100644 packages/ui/src/confirm-dialog/popup.tsx create mode 100644 packages/ui/src/confirm-dialog/root.tsx create mode 100644 packages/ui/src/confirm-dialog/style.module.css create mode 100644 packages/ui/src/confirm-dialog/test/index.test.tsx create mode 100644 packages/ui/src/confirm-dialog/trigger.tsx create mode 100644 packages/ui/src/confirm-dialog/types.ts diff --git a/packages/ui/src/confirm-dialog/context.tsx b/packages/ui/src/confirm-dialog/context.tsx new file mode 100644 index 00000000000000..8e913824645fdf --- /dev/null +++ b/packages/ui/src/confirm-dialog/context.tsx @@ -0,0 +1,11 @@ +import { createContext } from 'react'; + +interface ConfirmDialogContextValue { + intent: 'default' | 'irreversible'; +} + +const ConfirmDialogContext = createContext< ConfirmDialogContextValue >( { + intent: 'default', +} ); + +export { ConfirmDialogContext }; diff --git a/packages/ui/src/confirm-dialog/index.stories.tsx b/packages/ui/src/confirm-dialog/index.stories.tsx new file mode 100644 index 00000000000000..2a64b624ad8890 --- /dev/null +++ b/packages/ui/src/confirm-dialog/index.stories.tsx @@ -0,0 +1,200 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { action } from 'storybook/actions'; +import { fn } from 'storybook/test'; +import { Menu } from '@base-ui/react/menu'; +import { ConfirmDialog } from '..'; + +const meta: Meta< typeof ConfirmDialog.Root > = { + title: 'Design System/ConfirmDialog', + component: ConfirmDialog.Root, + subcomponents: { + 'ConfirmDialog.Trigger': ConfirmDialog.Trigger, + 'ConfirmDialog.Popup': ConfirmDialog.Popup, + }, + argTypes: { + onOpenChange: { action: fn() }, + }, +}; +export default meta; + +type Story = StoryObj< typeof ConfirmDialog.Root >; + +/** + * Standard confirmation dialog for reversible actions. The dialog can be + * dismissed via backdrop click, Escape key, cancel, or confirm button. + */ +export const Default: Story = { + args: { + title: 'Move to trash?', + children: ( + <> + Move to trash + + This post will be moved to trash. You can restore it later. + + + ), + }, +}; + +/** + * Confirmation dialog for irreversible actions that cannot be undone. Users can + * dismiss the dialog via Escape key, cancel, or confirm button, but not via + * backdrop click. The "confirm" action button uses error/danger coloring. + */ +export const Irreversible: Story = { + args: { + title: 'Delete permanently?', + intent: 'irreversible', + children: ( + <> + + Delete permanently + + + This action cannot be undone. All data will be lost. + + + ), + }, +}; + +/** + * Example with custom button text for both confirm and cancel buttons. + */ +export const CustomButtonText: Story = { + args: { + title: 'Send feedback?', + children: ( + <> + Send feedback + + Your feedback helps us improve. Would you like to send it + now? + + + ), + }, +}; + +const menuPopupStyles: React.CSSProperties = { + background: 'var(--wpds-color-bg-surface-neutral-strong)', + border: '1px solid var(--wpds-color-stroke-surface-neutral)', + borderRadius: '8px', + padding: '4px', + minWidth: '160px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', +}; + +const menuItemStyles: React.CSSProperties = { + display: 'block', + width: '100%', + padding: '8px 12px', + borderRadius: '4px', + border: 'none', + background: 'none', + textAlign: 'start', + fontSize: 'inherit', + userSelect: 'none', +}; + +/** + * Example showing composition with a menu. The `ConfirmDialog.Trigger` is + * composed with Base UI's `Menu.Item` using the `render` prop, allowing the + * menu item to directly trigger the confirm dialog. + * + * Note: the example currently uses the `Menu` component from BaseUI, although + * consumers should not use BaseUI directly and instead use the DS `Menu` + * component (not ready yet). + */ +export const MenuTrigger: Story = { + args: { + title: 'Delete permanently?', + intent: 'irreversible', + }, + render: ( args ) => { + const [ menuOpen, setMenuOpen ] = useState( false ); + return ( + <> + + Actions ▾ + + + + + Edit + + + } + /> + } + style={ menuItemStyles } + closeOnClick={ false } + > + Delete... + { + setMenuOpen( false ); + } } + confirmButtonText="Delete permanently" + > + This action cannot be undone. All + data will be lost. + + + + + + + + + ); + }, +}; + +/** + * The `ConfirmDialog.Trigger` element is not necessary when the open state is + * controlled externally. This is useful when the dialog needs to be opened + * from code or from a non-standard trigger element. + */ +export const Controlled: Story = { + render: function Controlled( args ) { + const [ isOpen, setIsOpen ] = useState( false ); + + return ( + <> + + { + setIsOpen( open ); + args.onOpenChange?.( open, eventDetails ); + } } + > + + This post will be moved to trash. You can restore it + later. + + + + ); + }, + args: { + title: 'Move to trash?', + }, +}; diff --git a/packages/ui/src/confirm-dialog/index.ts b/packages/ui/src/confirm-dialog/index.ts new file mode 100644 index 00000000000000..ec1477d0fd6c64 --- /dev/null +++ b/packages/ui/src/confirm-dialog/index.ts @@ -0,0 +1,3 @@ +export { Root } from './root'; +export { Trigger } from './trigger'; +export { Popup } from './popup'; diff --git a/packages/ui/src/confirm-dialog/popup.tsx b/packages/ui/src/confirm-dialog/popup.tsx new file mode 100644 index 00000000000000..9bc834e68a5af3 --- /dev/null +++ b/packages/ui/src/confirm-dialog/popup.tsx @@ -0,0 +1,47 @@ +import { forwardRef, useContext } from 'react'; +import { __ } from '@wordpress/i18n'; +import * as Dialog from '../dialog'; +import { ConfirmDialogContext } from './context'; +import { type PopupProps } from './types'; +import styles from './style.module.css'; + +const Popup = forwardRef< HTMLDivElement, PopupProps >( + function ConfirmDialogPopup( + { + children, + onConfirm, + confirmButtonText = __( 'OK', 'wpds' ), + cancelButtonText = __( 'Cancel', 'wpds' ), + }, + ref + ) { + const { intent } = useContext( ConfirmDialogContext ); + const isIrreversible = intent === 'irreversible'; + + return ( + + + + + { children } + + + { cancelButtonText } + + + { confirmButtonText } + + + + ); + } +); + +export { Popup }; diff --git a/packages/ui/src/confirm-dialog/root.tsx b/packages/ui/src/confirm-dialog/root.tsx new file mode 100644 index 00000000000000..f6e16964e3f323 --- /dev/null +++ b/packages/ui/src/confirm-dialog/root.tsx @@ -0,0 +1,78 @@ +import { useMemo } from 'react'; +import * as Dialog from '../dialog'; +import { type RootProps as DialogRootProps } from '../dialog/types'; +import { ConfirmDialogContext } from './context'; +import { type RootProps } from './types'; + +/** + * A convenience wrapper for Dialog that provides common confirmation dialog + * patterns with confirm and cancel actions. + * + * Use `ConfirmDialog.Trigger` to render a button that opens the dialog. + * Use `ConfirmDialog.Popup` to render the dialog content. + * The `ConfirmDialog.Trigger` is optional — the dialog can also be controlled + * via `open` / `onOpenChange` props. + * + * ## Use cases + * + * - **Default intent**: Standard confirmation dialog for reversible actions. + * The dialog can be dismissed via backdrop click, Escape key, cancel, or + * confirm button. + * - **Irreversible intent**: Confirmation dialog for irreversible actions that + * cannot be undone. Users can dismiss the dialog via Escape key, cancel, or + * confirm button, but not via backdrop click. The "confirm" action button + * uses error/danger coloring. + * + * For use cases outside the standard confirm/cancel pattern, use the lower-level + * `Dialog` component directly. + * + * See the [Destructive Actions guidelines](https://wordpress.github.io/gutenberg/?path=/docs/design-system-patterns-destructive-actions--docs) + * for more details on when to use each pattern. + */ +function Root( { + intent = 'default', + title, + children, + open, + onOpenChange, + defaultOpen, +}: RootProps ) { + const isIrreversible = intent === 'irreversible'; + + const handleOpenChange: DialogRootProps[ 'onOpenChange' ] = ( + nextOpen, + eventDetails + ) => { + const { reason, cancel } = eventDetails; + + if ( + isIrreversible && + ! nextOpen && + ! [ 'close-press', 'escape-key' ].includes( reason ) + ) { + // For irreversible actions, user must explicitly click the + // confirm or cancel button, or press the Escape key. Clicking + // on the backdrop won't close the dialog. + cancel(); + } else { + onOpenChange?.( nextOpen, eventDetails ); + } + }; + + const contextValue = useMemo( () => ( { intent } ), [ intent ] ); + + return ( + + + { children } + + + ); +} + +export { Root }; diff --git a/packages/ui/src/confirm-dialog/style.module.css b/packages/ui/src/confirm-dialog/style.module.css new file mode 100644 index 00000000000000..f8706bf6f0b6fd --- /dev/null +++ b/packages/ui/src/confirm-dialog/style.module.css @@ -0,0 +1,18 @@ +@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides; + +@layer wp-ui-compositions { + .irreversible-action { + --wp-ui-button-background-color: var( + --wpds-color-bg-interactive-error-strong + ); + --wp-ui-button-background-color-active: var( + --wpds-color-bg-interactive-error-strong-active + ); + --wp-ui-button-foreground-color: var( + --wpds-color-fg-interactive-error-strong + ); + --wp-ui-button-foreground-color-active: var( + --wpds-color-fg-interactive-error-strong-active + ); + } +} diff --git a/packages/ui/src/confirm-dialog/test/index.test.tsx b/packages/ui/src/confirm-dialog/test/index.test.tsx new file mode 100644 index 00000000000000..0db0ffe3ae1230 --- /dev/null +++ b/packages/ui/src/confirm-dialog/test/index.test.tsx @@ -0,0 +1,317 @@ +import { createRef } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import * as ConfirmDialog from '..'; + +describe( 'ConfirmDialog', () => { + it( 'forwards ref', () => { + const triggerRef = createRef< HTMLButtonElement >(); + const popupRef = createRef< HTMLDivElement >(); + + render( + + + Open + + + Test message content + + + ); + + expect( triggerRef.current ).toBeInstanceOf( HTMLButtonElement ); + expect( popupRef.current ).toBeInstanceOf( HTMLDivElement ); + } ); + + it( 'renders with title, message, and default buttons', async () => { + render( + + + Test message content + + + ); + + await waitFor( () => { + expect( screen.getByText( 'Test Title' ) ).toBeVisible(); + expect( screen.getByText( 'Test message content' ) ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Close' } ) + ).not.toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: 'OK' } ) + ).toBeVisible(); + expect( + screen.getByRole( 'button', { name: 'Cancel' } ) + ).toBeVisible(); + } ); + } ); + + it( 'calls onConfirm and onOpenChange when confirm button is clicked', async () => { + const onConfirm = jest.fn(); + const onOpenChange = jest.fn(); + + render( + + + Are you sure? + + + ); + + await waitFor( () => { + expect( + screen.getByRole( 'button', { name: 'OK' } ) + ).toBeVisible(); + } ); + + await userEvent.click( screen.getByRole( 'button', { name: 'OK' } ) ); + + expect( onConfirm ).toHaveBeenCalledTimes( 1 ); + expect( onOpenChange ).toHaveBeenCalledWith( + false, + expect.objectContaining( { reason: 'close-press' } ) + ); + } ); + + it( 'calls onOpenChange when cancel button is clicked', async () => { + const onConfirm = jest.fn(); + const onOpenChange = jest.fn(); + + render( + + + Are you sure? + + + ); + + await waitFor( () => { + expect( + screen.getByRole( 'button', { name: 'Cancel' } ) + ).toBeVisible(); + } ); + + await userEvent.click( + screen.getByRole( 'button', { name: 'Cancel' } ) + ); + + expect( onOpenChange ).toHaveBeenCalledWith( + false, + expect.objectContaining( { reason: 'close-press' } ) + ); + expect( onConfirm ).not.toHaveBeenCalled(); + } ); + + it( 'renders with title, message, and default buttons for irreversible intent', async () => { + render( + + + Irreversible message content + + + ); + + await waitFor( () => { + expect( screen.getByText( 'Irreversible Dialog' ) ).toBeVisible(); + expect( + screen.getByText( 'Irreversible message content' ) + ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Close' } ) + ).not.toBeInTheDocument(); + expect( + screen.getByRole( 'button', { name: 'OK' } ) + ).toBeVisible(); + expect( + screen.getByRole( 'button', { name: 'Cancel' } ) + ).toBeVisible(); + } ); + } ); + + it( 'calls onOpenChange on escape key for irreversible intent', async () => { + const onOpenChange = jest.fn(); + + render( + + + Content + + + ); + + await waitFor( () => { + expect( screen.getByText( 'Irreversible Dialog' ) ).toBeVisible(); + } ); + + await userEvent.keyboard( '{Escape}' ); + + expect( onOpenChange ).toHaveBeenCalledWith( + false, + expect.objectContaining( { reason: 'escape-key' } ) + ); + } ); + + it( 'does not call onOpenChange on backdrop click for irreversible intent', async () => { + const onOpenChange = jest.fn(); + + render( + + + Content + + + ); + + await waitFor( () => { + expect( screen.getByText( 'Irreversible Dialog' ) ).toBeVisible(); + } ); + + // Click the backdrop (outside the dialog) + await userEvent.click( document.body ); + + expect( onOpenChange ).not.toHaveBeenCalled(); + } ); + + it( 'calls onOpenChange on cancel button click for irreversible intent', async () => { + const onOpenChange = jest.fn(); + const onConfirm = jest.fn(); + + render( + + + Content + + + ); + + await waitFor( () => { + expect( + screen.getByRole( 'button', { name: 'Cancel' } ) + ).toBeVisible(); + } ); + + await userEvent.click( + screen.getByRole( 'button', { name: 'Cancel' } ) + ); + + expect( onOpenChange ).toHaveBeenCalledWith( + false, + expect.objectContaining( { reason: 'close-press' } ) + ); + expect( onConfirm ).not.toHaveBeenCalled(); + } ); + + it( 'calls onConfirm and onOpenChange on confirm button click for irreversible intent', async () => { + const onOpenChange = jest.fn(); + const onConfirm = jest.fn(); + + render( + + + Content + + + ); + + await waitFor( () => { + expect( + screen.getByRole( 'button', { name: 'OK' } ) + ).toBeVisible(); + } ); + + await userEvent.click( screen.getByRole( 'button', { name: 'OK' } ) ); + + expect( onConfirm ).toHaveBeenCalledTimes( 1 ); + expect( onOpenChange ).toHaveBeenCalledWith( + false, + expect.objectContaining( { reason: 'close-press' } ) + ); + } ); + + it( 'uses custom button text when provided', async () => { + render( + + + Custom message + + + ); + + await waitFor( () => { + expect( + screen.getByRole( 'button', { name: 'Yes, do it' } ) + ).toBeVisible(); + expect( + screen.getByRole( 'button', { name: 'No, go back' } ) + ).toBeVisible(); + } ); + } ); + + it( 'opens dialog when Trigger is clicked', async () => { + render( + + Open + + Dialog content + + + ); + + expect( + screen.queryByText( 'Dialog content' ) + ).not.toBeInTheDocument(); + + await userEvent.click( screen.getByRole( 'button', { name: 'Open' } ) ); + + await waitFor( () => { + expect( screen.getByText( 'Trigger Test' ) ).toBeVisible(); + expect( screen.getByText( 'Dialog content' ) ).toBeVisible(); + } ); + } ); +} ); diff --git a/packages/ui/src/confirm-dialog/trigger.tsx b/packages/ui/src/confirm-dialog/trigger.tsx new file mode 100644 index 00000000000000..d3a32e20868967 --- /dev/null +++ b/packages/ui/src/confirm-dialog/trigger.tsx @@ -0,0 +1,11 @@ +import { forwardRef } from 'react'; +import * as Dialog from '../dialog'; +import { type TriggerProps } from './types'; + +const Trigger = forwardRef< HTMLButtonElement, TriggerProps >( + function ConfirmDialogTrigger( props, ref ) { + return ; + } +); + +export { Trigger }; diff --git a/packages/ui/src/confirm-dialog/types.ts b/packages/ui/src/confirm-dialog/types.ts new file mode 100644 index 00000000000000..a2b4cb6dbf19cb --- /dev/null +++ b/packages/ui/src/confirm-dialog/types.ts @@ -0,0 +1,61 @@ +import { type ReactNode } from 'react'; +import { + type RootProps as DialogRootProps, + type TriggerProps as DialogTriggerProps, +} from '../dialog/types'; + +export interface RootProps + extends Pick< + DialogRootProps, + 'title' | 'open' | 'onOpenChange' | 'defaultOpen' + > { + /** + * The content to be rendered inside the component. Typically includes + * `ConfirmDialog.Trigger` and `ConfirmDialog.Popup`. + */ + children: ReactNode; + + /** + * The semantic intent of the dialog, which determines its behavior and + * styling. + * + * - `'default'`: Standard confirmation dialog for reversible actions. + * The dialog can be dismissed via backdrop click, Escape key, cancel, or + * confirm button. + * - `'irreversible'`: Confirmation dialog for irreversible actions that + * cannot be undone. Users can dismiss the dialog via Escape key, cancel, or + * confirm button, but not via backdrop click. The "confirm" action button + * uses error/danger coloring. + * + * @default 'default' + */ + intent?: 'default' | 'irreversible'; +} + +export type TriggerProps = DialogTriggerProps; + +export interface PopupProps { + /** + * The message content displayed in the dialog body. + */ + children: ReactNode; + + /** + * Callback fired when the user confirms the action. + */ + onConfirm: () => void; + + /** + * Custom text for the confirm button. + * + * @default 'OK' + */ + confirmButtonText?: string; + + /** + * Custom text for the cancel button. + * + * @default 'Cancel' + */ + cancelButtonText?: string; +} From 785ce626ae409449b07705d5aa769a2b4e3f9ac4 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 26 Mar 2026 16:20:08 +0100 Subject: [PATCH 2/5] ConfirmDialog: Fix Dialog API incompatibilities The ConfirmDialog was authored against an older Dialog version that had a `title` prop on `Dialog.Root` and a `Dialog.Heading` subcomponent. The current Dialog uses `Dialog.Title` (with children) and has no `title` prop on Root. - Thread `title` through `ConfirmDialogContext` instead of passing it to `Dialog.Root`. - Replace `` with `{ title }`. - Define `title` directly on `RootProps` instead of picking it from `DialogRootProps`. - Fix invalid `wpds` i18n text domain. Made-with: Cursor --- packages/ui/src/confirm-dialog/context.tsx | 4 +++- packages/ui/src/confirm-dialog/popup.tsx | 13 +++++++------ packages/ui/src/confirm-dialog/root.tsx | 13 ++++++++----- packages/ui/src/confirm-dialog/types.ts | 20 ++++++++++++-------- 4 files changed, 30 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/confirm-dialog/context.tsx b/packages/ui/src/confirm-dialog/context.tsx index 8e913824645fdf..170fe4bf507cd1 100644 --- a/packages/ui/src/confirm-dialog/context.tsx +++ b/packages/ui/src/confirm-dialog/context.tsx @@ -1,11 +1,13 @@ -import { createContext } from 'react'; +import { createContext } from '@wordpress/element'; interface ConfirmDialogContextValue { intent: 'default' | 'irreversible'; + title: string; } const ConfirmDialogContext = createContext< ConfirmDialogContextValue >( { intent: 'default', + title: '', } ); export { ConfirmDialogContext }; diff --git a/packages/ui/src/confirm-dialog/popup.tsx b/packages/ui/src/confirm-dialog/popup.tsx index 9bc834e68a5af3..7de29f069f92ad 100644 --- a/packages/ui/src/confirm-dialog/popup.tsx +++ b/packages/ui/src/confirm-dialog/popup.tsx @@ -1,27 +1,28 @@ -import { forwardRef, useContext } from 'react'; +import { forwardRef, useContext } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; + import * as Dialog from '../dialog'; import { ConfirmDialogContext } from './context'; -import { type PopupProps } from './types'; import styles from './style.module.css'; +import type { PopupProps } from './types'; const Popup = forwardRef< HTMLDivElement, PopupProps >( function ConfirmDialogPopup( { children, onConfirm, - confirmButtonText = __( 'OK', 'wpds' ), - cancelButtonText = __( 'Cancel', 'wpds' ), + confirmButtonText = __( 'OK' ), + cancelButtonText = __( 'Cancel' ), }, ref ) { - const { intent } = useContext( ConfirmDialogContext ); + const { intent, title } = useContext( ConfirmDialogContext ); const isIrreversible = intent === 'irreversible'; return ( - + { title } { children } diff --git a/packages/ui/src/confirm-dialog/root.tsx b/packages/ui/src/confirm-dialog/root.tsx index f6e16964e3f323..13d4497ddf2eb5 100644 --- a/packages/ui/src/confirm-dialog/root.tsx +++ b/packages/ui/src/confirm-dialog/root.tsx @@ -1,8 +1,9 @@ -import { useMemo } from 'react'; +import { useMemo } from '@wordpress/element'; + import * as Dialog from '../dialog'; -import { type RootProps as DialogRootProps } from '../dialog/types'; +import type { RootProps as DialogRootProps } from '../dialog/types'; import { ConfirmDialogContext } from './context'; -import { type RootProps } from './types'; +import type { RootProps } from './types'; /** * A convenience wrapper for Dialog that provides common confirmation dialog @@ -59,14 +60,16 @@ function Root( { } }; - const contextValue = useMemo( () => ( { intent } ), [ intent ] ); + const contextValue = useMemo( + () => ( { intent, title } ), + [ intent, title ] + ); return ( { children } diff --git a/packages/ui/src/confirm-dialog/types.ts b/packages/ui/src/confirm-dialog/types.ts index a2b4cb6dbf19cb..56c6690afa8634 100644 --- a/packages/ui/src/confirm-dialog/types.ts +++ b/packages/ui/src/confirm-dialog/types.ts @@ -1,14 +1,12 @@ -import { type ReactNode } from 'react'; -import { - type RootProps as DialogRootProps, - type TriggerProps as DialogTriggerProps, +import type { ReactNode } from 'react'; + +import type { + RootProps as DialogRootProps, + TriggerProps as DialogTriggerProps, } from '../dialog/types'; export interface RootProps - extends Pick< - DialogRootProps, - 'title' | 'open' | 'onOpenChange' | 'defaultOpen' - > { + extends Pick< DialogRootProps, 'open' | 'onOpenChange' | 'defaultOpen' > { /** * The content to be rendered inside the component. Typically includes * `ConfirmDialog.Trigger` and `ConfirmDialog.Popup`. @@ -30,6 +28,12 @@ export interface RootProps * @default 'default' */ intent?: 'default' | 'irreversible'; + + /** + * The title displayed in the dialog header. This serves as both the + * visible heading and the accessible label for the dialog. + */ + title: string; } export type TriggerProps = DialogTriggerProps; From fe851a2f1e28a83294f3feaa6db8d733abb844de Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 26 Mar 2026 16:20:21 +0100 Subject: [PATCH 3/5] ConfirmDialog: Adapt to @wordpress/ui package conventions - Swap runtime `react` imports for `@wordpress/element`. - Sort imports (externals, then @wordpress/*, then internal). - Use `import type` for type-only imports. - Move Storybook file to `stories/index.story.tsx`. - Update story title to `Design System/Components/ConfirmDialog`. - Fix `waitFor` blocks to use single assertions (lint rule). - Add JSDoc to Trigger component. - Export `ConfirmDialog` namespace from the package index. Made-with: Cursor --- .../index.story.tsx} | 11 ++-- .../ui/src/confirm-dialog/test/index.test.tsx | 55 ++++++++++--------- packages/ui/src/confirm-dialog/trigger.tsx | 8 ++- packages/ui/src/index.ts | 1 + 4 files changed, 41 insertions(+), 34 deletions(-) rename packages/ui/src/confirm-dialog/{index.stories.tsx => stories/index.story.tsx} (96%) diff --git a/packages/ui/src/confirm-dialog/index.stories.tsx b/packages/ui/src/confirm-dialog/stories/index.story.tsx similarity index 96% rename from packages/ui/src/confirm-dialog/index.stories.tsx rename to packages/ui/src/confirm-dialog/stories/index.story.tsx index 2a64b624ad8890..f313fe901af36b 100644 --- a/packages/ui/src/confirm-dialog/index.stories.tsx +++ b/packages/ui/src/confirm-dialog/stories/index.story.tsx @@ -1,12 +1,13 @@ -import { useState } from 'react'; +import { Menu } from '@base-ui/react/menu'; +import { useState } from '@wordpress/element'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { action } from 'storybook/actions'; import { fn } from 'storybook/test'; -import { Menu } from '@base-ui/react/menu'; -import { ConfirmDialog } from '..'; + +import { ConfirmDialog } from '../..'; const meta: Meta< typeof ConfirmDialog.Root > = { - title: 'Design System/ConfirmDialog', + title: 'Design System/Components/ConfirmDialog', component: ConfirmDialog.Root, subcomponents: { 'ConfirmDialog.Trigger': ConfirmDialog.Trigger, @@ -136,7 +137,7 @@ export const MenuTrigger: Story = { } diff --git a/packages/ui/src/confirm-dialog/test/index.test.tsx b/packages/ui/src/confirm-dialog/test/index.test.tsx index 0db0ffe3ae1230..831990af91a0fe 100644 --- a/packages/ui/src/confirm-dialog/test/index.test.tsx +++ b/packages/ui/src/confirm-dialog/test/index.test.tsx @@ -1,6 +1,7 @@ -import { createRef } from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { createRef } from '@wordpress/element'; + import * as ConfirmDialog from '..'; describe( 'ConfirmDialog', () => { @@ -38,17 +39,16 @@ describe( 'ConfirmDialog', () => { await waitFor( () => { expect( screen.getByText( 'Test Title' ) ).toBeVisible(); - expect( screen.getByText( 'Test message content' ) ).toBeVisible(); - expect( - screen.queryByRole( 'button', { name: 'Close' } ) - ).not.toBeInTheDocument(); - expect( - screen.getByRole( 'button', { name: 'OK' } ) - ).toBeVisible(); - expect( - screen.getByRole( 'button', { name: 'Cancel' } ) - ).toBeVisible(); } ); + + expect( screen.getByText( 'Test message content' ) ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Close' } ) + ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'OK' } ) ).toBeVisible(); + expect( + screen.getByRole( 'button', { name: 'Cancel' } ) + ).toBeVisible(); } ); it( 'calls onConfirm and onOpenChange when confirm button is clicked', async () => { @@ -131,19 +131,18 @@ describe( 'ConfirmDialog', () => { await waitFor( () => { expect( screen.getByText( 'Irreversible Dialog' ) ).toBeVisible(); - expect( - screen.getByText( 'Irreversible message content' ) - ).toBeVisible(); - expect( - screen.queryByRole( 'button', { name: 'Close' } ) - ).not.toBeInTheDocument(); - expect( - screen.getByRole( 'button', { name: 'OK' } ) - ).toBeVisible(); - expect( - screen.getByRole( 'button', { name: 'Cancel' } ) - ).toBeVisible(); } ); + + expect( + screen.getByText( 'Irreversible message content' ) + ).toBeVisible(); + expect( + screen.queryByRole( 'button', { name: 'Close' } ) + ).not.toBeInTheDocument(); + expect( screen.getByRole( 'button', { name: 'OK' } ) ).toBeVisible(); + expect( + screen.getByRole( 'button', { name: 'Cancel' } ) + ).toBeVisible(); } ); it( 'calls onOpenChange on escape key for irreversible intent', async () => { @@ -287,10 +286,11 @@ describe( 'ConfirmDialog', () => { expect( screen.getByRole( 'button', { name: 'Yes, do it' } ) ).toBeVisible(); - expect( - screen.getByRole( 'button', { name: 'No, go back' } ) - ).toBeVisible(); } ); + + expect( + screen.getByRole( 'button', { name: 'No, go back' } ) + ).toBeVisible(); } ); it( 'opens dialog when Trigger is clicked', async () => { @@ -311,7 +311,8 @@ describe( 'ConfirmDialog', () => { await waitFor( () => { expect( screen.getByText( 'Trigger Test' ) ).toBeVisible(); - expect( screen.getByText( 'Dialog content' ) ).toBeVisible(); } ); + + expect( screen.getByText( 'Dialog content' ) ).toBeVisible(); } ); } ); diff --git a/packages/ui/src/confirm-dialog/trigger.tsx b/packages/ui/src/confirm-dialog/trigger.tsx index d3a32e20868967..04bc5b120c521a 100644 --- a/packages/ui/src/confirm-dialog/trigger.tsx +++ b/packages/ui/src/confirm-dialog/trigger.tsx @@ -1,7 +1,11 @@ -import { forwardRef } from 'react'; +import { forwardRef } from '@wordpress/element'; + import * as Dialog from '../dialog'; -import { type TriggerProps } from './types'; +import type { TriggerProps } from './types'; +/** + * Renders a button that opens the confirmation dialog when clicked. + */ const Trigger = forwardRef< HTMLButtonElement, TriggerProps >( function ConfirmDialogTrigger( props, ref ) { return ; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3908b6d34b9c13..6cde76f4b5043d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -3,6 +3,7 @@ export * from './button'; export * as Card from './card'; export * as Collapsible from './collapsible'; export * as CollapsibleCard from './collapsible-card'; +export * as ConfirmDialog from './confirm-dialog'; export * as Dialog from './dialog'; export * as EmptyState from './empty-state'; export * from './form'; From d53a8f0082cacdd9c374a6721c19c1ff4b54f6d8 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 26 Mar 2026 16:20:30 +0100 Subject: [PATCH 4/5] ConfirmDialog: Add CHANGELOG entry Made-with: Cursor --- packages/ui/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 8f227f2e3be6a1..5f0a6be4bbf3ae 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -4,6 +4,7 @@ ### New Features +- Add `ConfirmDialog` primitive ([#XXXXX](https://github.com/WordPress/gutenberg/pull/XXXXX)). - Add `InputControl` component ([#76653](https://github.com/WordPress/gutenberg/pull/76653)). ### Bug Fixes From 6868f36730cd5741b46abcdf053695a569b5b089 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 26 Mar 2026 16:21:34 +0100 Subject: [PATCH 5/5] Dialog: Remove ConfirmDialog story now covered by dedicated component Made-with: Cursor --- .../ui/src/dialog/stories/index.story.tsx | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/packages/ui/src/dialog/stories/index.story.tsx b/packages/ui/src/dialog/stories/index.story.tsx index e70362f0241b77..086af1fa06170b 100644 --- a/packages/ui/src/dialog/stories/index.story.tsx +++ b/packages/ui/src/dialog/stories/index.story.tsx @@ -73,34 +73,6 @@ export const _Default: Story = { }, }; -/** - * A confirmation dialog that intentionally omits the close icon. The user - * must explicitly choose "Cancel" or "Confirm" to make their intent clear, - * since it is not obvious what would happen when clicking a close icon. - */ -export const ConfirmDialog: Story = { - args: { - children: ( - <> - Confirm Action - - - Confirm Action - -

- Are you sure you want to proceed? This action cannot be - undone. -

- - Cancel - Confirm - -
- - ), - }, -}; - const ALL_SIZES = [ 'small', 'medium', 'large', 'stretch', 'full' ] as const; function SizeSelector( {