Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/confirm-dialog/context.tsx
Original file line number Diff line number Diff line change
@@ -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 };
3 changes: 3 additions & 0 deletions packages/ui/src/confirm-dialog/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Root } from './root';
export { Trigger } from './trigger';
export { Popup } from './popup';
48 changes: 48 additions & 0 deletions packages/ui/src/confirm-dialog/popup.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog.Popup ref={ ref }>
<Dialog.Header>
<Dialog.Title>{ title }</Dialog.Title>
</Dialog.Header>
{ children }
<Dialog.Footer>
<Dialog.Action variant="minimal">
{ cancelButtonText }
</Dialog.Action>
<Dialog.Action
className={
isIrreversible
? styles[ 'irreversible-action' ]
: undefined
}
onClick={ onConfirm }
>
{ confirmButtonText }
</Dialog.Action>
</Dialog.Footer>
</Dialog.Popup>
);
}
);

export { Popup };
81 changes: 81 additions & 0 deletions packages/ui/src/confirm-dialog/root.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog.Root
open={ open }
onOpenChange={ handleOpenChange }
defaultOpen={ defaultOpen }
>
<ConfirmDialogContext.Provider value={ contextValue }>
{ children }
</ConfirmDialogContext.Provider>
</Dialog.Root>
);
}

export { Root };
201 changes: 201 additions & 0 deletions packages/ui/src/confirm-dialog/stories/index.story.tsx
Original file line number Diff line number Diff line change
@@ -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: (
<>
<ConfirmDialog.Trigger>Move to trash</ConfirmDialog.Trigger>
<ConfirmDialog.Popup onConfirm={ action( 'onConfirm' ) }>
This post will be moved to trash. You can restore it later.
</ConfirmDialog.Popup>
</>
),
},
};

/**
* 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: (
<>
<ConfirmDialog.Trigger>
Delete permanently
</ConfirmDialog.Trigger>
<ConfirmDialog.Popup
onConfirm={ action( 'onConfirm' ) }
confirmButtonText="Delete permanently"
>
This action cannot be undone. All data will be lost.
</ConfirmDialog.Popup>
</>
),
},
};

/**
* Example with custom button text for both confirm and cancel buttons.
*/
export const CustomButtonText: Story = {
args: {
title: 'Send feedback?',
children: (
<>
<ConfirmDialog.Trigger>Send feedback</ConfirmDialog.Trigger>
<ConfirmDialog.Popup
onConfirm={ action( 'onConfirm' ) }
confirmButtonText="Send feedback"
cancelButtonText="Not now"
>
Your feedback helps us improve. Would you like to send it
now?
</ConfirmDialog.Popup>
</>
),
},
};

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 (
<>
<Menu.Root onOpenChange={ setMenuOpen } open={ menuOpen }>
<Menu.Trigger>Actions ▾</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup style={ menuPopupStyles }>
<Menu.Item style={ menuItemStyles }>
Edit
</Menu.Item>
<ConfirmDialog.Root { ...args }>
<Menu.Item
render={
<ConfirmDialog.Trigger
// Quick fix to remove `button`-specific styles.
// This shouldn't be an issue once we use the DS `Menu`
// component, which will come with item styles.
render={ <div /> }
/>
}
style={ menuItemStyles }
closeOnClick={ false }
>
Delete...
<ConfirmDialog.Popup
onConfirm={ () => {
setMenuOpen( false );
} }
confirmButtonText="Delete permanently"
>
This action cannot be undone. All
data will be lost.
</ConfirmDialog.Popup>
</Menu.Item>
</ConfirmDialog.Root>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</>
);
},
};

/**
* 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 (
<>
<button onClick={ () => setIsOpen( true ) }>Open Dialog</button>
<ConfirmDialog.Root
{ ...args }
open={ isOpen }
onOpenChange={ ( open, eventDetails ) => {
setIsOpen( open );
args.onOpenChange?.( open, eventDetails );
} }
>
<ConfirmDialog.Popup onConfirm={ action( 'onConfirm' ) }>
This post will be moved to trash. You can restore it
later.
</ConfirmDialog.Popup>
</ConfirmDialog.Root>
</>
);
},
args: {
title: 'Move to trash?',
},
};
18 changes: 18 additions & 0 deletions packages/ui/src/confirm-dialog/style.module.css
Original file line number Diff line number Diff line change
@@ -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(

Check failure on line 5 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Unexpected whitespace after "("

Check failure on line 5 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Expected newline after ":" with a multi-line declaration
--wpds-color-bg-interactive-error-strong
);

Check failure on line 7 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Unexpected whitespace before ")"
--wp-ui-button-background-color-active: var(

Check failure on line 8 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Unexpected whitespace after "("

Check failure on line 8 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Expected newline after ":" with a multi-line declaration
--wpds-color-bg-interactive-error-strong-active
);

Check failure on line 10 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Unexpected whitespace before ")"
--wp-ui-button-foreground-color: var(

Check failure on line 11 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Unexpected whitespace after "("

Check failure on line 11 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Expected newline after ":" with a multi-line declaration
--wpds-color-fg-interactive-error-strong
);

Check failure on line 13 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Unexpected whitespace before ")"
--wp-ui-button-foreground-color-active: var(

Check failure on line 14 in packages/ui/src/confirm-dialog/style.module.css

View workflow job for this annotation

GitHub Actions / All (Node.js 24 on Linux)

Expected newline after ":" with a multi-line declaration
--wpds-color-fg-interactive-error-strong-active
);
}
}
Loading
Loading