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 diff --git a/packages/ui/src/confirm-dialog/context.tsx b/packages/ui/src/confirm-dialog/context.tsx new file mode 100644 index 00000000000000..170fe4bf507cd1 --- /dev/null +++ b/packages/ui/src/confirm-dialog/context.tsx @@ -0,0 +1,13 @@ +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/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..7de29f069f92ad --- /dev/null +++ b/packages/ui/src/confirm-dialog/popup.tsx @@ -0,0 +1,48 @@ +import { forwardRef, useContext } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +import * as Dialog from '../dialog'; +import { ConfirmDialogContext } from './context'; +import styles from './style.module.css'; +import type { PopupProps } from './types'; + +const Popup = forwardRef< HTMLDivElement, PopupProps >( + function ConfirmDialogPopup( + { + children, + onConfirm, + confirmButtonText = __( 'OK' ), + cancelButtonText = __( 'Cancel' ), + }, + ref + ) { + const { intent, title } = useContext( ConfirmDialogContext ); + const isIrreversible = intent === 'irreversible'; + + return ( + + + { title } + + { 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..13d4497ddf2eb5 --- /dev/null +++ b/packages/ui/src/confirm-dialog/root.tsx @@ -0,0 +1,81 @@ +import { useMemo } from '@wordpress/element'; + +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, title } ), + [ intent, title ] + ); + + return ( + + + { children } + + + ); +} + +export { Root }; diff --git a/packages/ui/src/confirm-dialog/stories/index.story.tsx b/packages/ui/src/confirm-dialog/stories/index.story.tsx new file mode 100644 index 00000000000000..f313fe901af36b --- /dev/null +++ b/packages/ui/src/confirm-dialog/stories/index.story.tsx @@ -0,0 +1,201 @@ +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 { ConfirmDialog } from '../..'; + +const meta: Meta< typeof ConfirmDialog.Root > = { + title: 'Design System/Components/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/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..831990af91a0fe --- /dev/null +++ b/packages/ui/src/confirm-dialog/test/index.test.tsx @@ -0,0 +1,318 @@ +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', () => { + 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..04bc5b120c521a --- /dev/null +++ b/packages/ui/src/confirm-dialog/trigger.tsx @@ -0,0 +1,15 @@ +import { forwardRef } from '@wordpress/element'; + +import * as Dialog from '../dialog'; +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 ; + } +); + +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..56c6690afa8634 --- /dev/null +++ b/packages/ui/src/confirm-dialog/types.ts @@ -0,0 +1,65 @@ +import type { ReactNode } from 'react'; + +import type { + RootProps as DialogRootProps, + TriggerProps as DialogTriggerProps, +} from '../dialog/types'; + +export interface RootProps + extends Pick< DialogRootProps, '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'; + + /** + * 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; + +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; +} 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( { 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';