diff --git a/docs/manifest.json b/docs/manifest.json index 2848119e4017f8..d65006c624d049 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -773,6 +773,12 @@ "markdown_source": "../packages/components/src/combobox-control/README.md", "parent": "components" }, + { + "title": "ConfirmDialog", + "slug": "confirm-dialog", + "markdown_source": "../packages/components/src/confirm-dialog/README.md", + "parent": "components" + }, { "title": "CustomSelectControl", "slug": "custom-select-control", diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 44f7b5c70d0f86..8ee3398e3d3e61 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -14,6 +14,7 @@ - Added support for RTL behavior for the `ZStack`'s `offset` prop ([#36769](https://github.com/WordPress/gutenberg/pull/36769)) - Fixed race conditions causing conditionally displayed `ToolsPanelItem` components to be erroneously deregistered ([36588](https://github.com/WordPress/gutenberg/pull/36588)). - Added `__experimentalHideHeader` prop to `Modal` component ([#36831](https://github.com/WordPress/gutenberg/pull/36831)). +- Added experimental `ConfirmDialog` component ([#34153](https://github.com/WordPress/gutenberg/pull/34153)). ### Bug Fix diff --git a/packages/components/src/confirm-dialog/README.md b/packages/components/src/confirm-dialog/README.md new file mode 100644 index 00000000000000..246b1bcc9638d6 --- /dev/null +++ b/packages/components/src/confirm-dialog/README.md @@ -0,0 +1,128 @@ +# `ConfirmDialog` + +
+This feature is still experimental. "Experimental" means this is an early implementation subject to drastic and breaking changes. +
+ +`ConfirmDialog` is built of top of [`Modal`](/packages/components/src/modal/README.md] and displays a confirmation dialog, with _confirm_ and _cancel_ buttons. + +The dialog is confirmed by clicking the _confirm_ button or by pressing the `Enter` key. It is cancelled (closed) by clicking the _cancel_ button, by pressing the `ESC` key, or by clicking outside the dialog focus (i.e, the overlay). + +## Usage + +`ConfirmDialog` has two main implicit modes: controlled and uncontrolled. + +### Uncontrolled mode + +Allows the component to be used standalone, just by declaring it as part of another React's component render method: + * It will be automatically open (displayed) upon mounting; + * It will be automatically closed when when clicking the _cancel_ button, by pressing the `ESC` key, or by clicking outside the dialog focus (i.e, the overlay); + * `onCancel` is not mandatory but can be passed. Even if passed, the dialog will still be able to close itself. + +Activating this mode is as simple as omitting the `isOpen` prop. The only mandatory prop, in this case, is the `onConfirm` callback. The message is passed as the `children`. You can pass any JSX you'd like, which allows to further format the message or include sub-component if you'd like: + +```jsx +import { + __experimentalConfirmDialog as ConfirmDialog +} from '@wordpress/components'; + +function Example() { + return ( + console.debug(' Confirmed! ') }> + Are you sure? This action cannot be undone! + + ); +} +``` + +### Controlled mode + +Let the parent component control when the dialog is open/closed. It's activated when a boolean value is passed to `isOpen`: + * It will not be automatically closed. You need to let it know when to open/close by updating the value of the `isOpen` prop; + * Both `onConfirm` and the `onCancel` callbacks are mandatory props in this mode; + * You'll want to update the state that controls `isOpen` by updating it from the `onCancel` and `onConfirm` callbacks. + + +```jsx +import { + __experimentalConfirmDialog as ConfirmDialog +} from '@wordpress/components'; + +function Example() { + const [ isOpen, setIsOpen ] = useState( true ); + + const handleConfirm = () => { + console.debug( 'Confirmed!' ); + setIsOpen( false ); + } + + const handleCancel = () => { + console.debug( 'Cancelled!' ); + setIsOpen( false ); + } + + return ( + + Are you sure? This action cannot be undone! + + ); +} +``` + +### Unsupported: Multiple instances + +Multiple `ConfirmDialog's is an edge case that's currently not officially supported by this component. At the moment, new instances will end up closing the last instance due to the way the `Modal` is implemented. + +## Custom Types + +```ts +type DialogInputEvent = + | KeyboardEvent< HTMLDivElement > + | MouseEvent< HTMLButtonElement > +``` + +## Props + +### `title`: `string` + +- Required: No + +An optional `title` for the dialog. Setting a title will render it in a title bar at the top of the dialog, making it a bit taller. The bar will also include an `x` close button at the top-right corner. + +### `children`: `React.ReactNode` + +- Required: Yes + +The actual message for the dialog. It's passed as children and any valid `ReactNode` is accepted: + +```jsx + + Are you sure? This action cannot be undone! + +``` + +### `isOpen`: `boolean` + +- Required: No + +Defines if the dialog is open (displayed) or closed (not rendered/displayed). It also implicitly toggles the controlled mode if set or the uncontrolled mode if it's not set. + +### `onConfirm`: `( event: DialogInputEvent ) => void` + +- Required: Yes + +The callback that's called when the user confirms. A confirmation can happen when the `OK` button is clicked or when `Enter` is pressed. + +### `onCancel`: `( event: DialogInputEvent ) => void` + +- Required: Only if `isOpen` is not set + +The callback that's called when the user cancels. A cancellation can happen when the `Cancel` button is clicked, when the `ESC` key is pressed, or when a click outside of the dialog focus is detected (i.e. in the overlay). + +It's not required if `isOpen` is not set (uncontrolled mode), as the component will take care of closing itself, but you can still pass a callback if something must be done upon cancelling (the component will still close itself in this case). + +If `isOpen` is set (controlled mode), then it's required, and you need to set the state that defines `isOpen` to `false` as part of this callback if you want the dialog to close when the user cancels. diff --git a/packages/components/src/confirm-dialog/component.tsx b/packages/components/src/confirm-dialog/component.tsx new file mode 100644 index 00000000000000..d33a07aa7a1536 --- /dev/null +++ b/packages/components/src/confirm-dialog/component.tsx @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import React, { useEffect, useState } from 'react'; +// eslint-disable-next-line no-restricted-imports +import type { Ref, KeyboardEvent } from 'react'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Modal from '../modal'; +import type { OwnProps, DialogInputEvent } from './types'; +import { + useContextSystem, + contextConnect, + WordPressComponentProps, +} from '../ui/context'; +import { Flex } from '../flex'; +import Button from '../button'; +import { Text } from '../text'; +import { VStack } from '../v-stack'; + +function ConfirmDialog( + props: WordPressComponentProps< OwnProps, 'div', false >, + forwardedRef: Ref< any > +) { + const { + isOpen: isOpenProp, + onConfirm, + onCancel, + children, + ...otherProps + } = useContextSystem( props, 'ConfirmDialog' ); + + const [ isOpen, setIsOpen ] = useState< boolean >(); + const [ shouldSelfClose, setShouldSelfClose ] = useState< boolean >(); + + useEffect( () => { + // We only allow the dialog to close itself if `isOpenProp` is *not* set. + // If `isOpenProp` is set, then it (probably) means it's controlled by a + // parent component. In that case, `shouldSelfClose` might do more harm than + // good, so we disable it. + const isIsOpenSet = typeof isOpenProp !== 'undefined'; + setIsOpen( isIsOpenSet ? isOpenProp : true ); + setShouldSelfClose( ! isIsOpenSet ); + }, [ isOpenProp ] ); + + const handleEvent = useCallback( + ( callback?: ( event: DialogInputEvent ) => void ) => ( + event: DialogInputEvent + ) => { + callback?.( event ); + if ( shouldSelfClose ) { + setIsOpen( false ); + } + }, + [ shouldSelfClose, setIsOpen ] + ); + + const handleEnter = useCallback( + ( event: KeyboardEvent< HTMLDivElement > ) => { + if ( event.key === 'Enter' ) { + handleEvent( onConfirm )( event ); + } + }, + [ handleEvent, onConfirm ] + ); + + const cancelLabel = __( 'Cancel' ); + const confirmLabel = __( 'OK' ); + + return ( + <> + { isOpen && ( + + + { children } + + + + + + + ) } + + ); +} + +export default contextConnect( ConfirmDialog, 'ConfirmDialog' ); diff --git a/packages/components/src/confirm-dialog/index.tsx b/packages/components/src/confirm-dialog/index.tsx new file mode 100644 index 00000000000000..c7a35aabe032fe --- /dev/null +++ b/packages/components/src/confirm-dialog/index.tsx @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import ConfirmDialog from './component'; + +export { ConfirmDialog }; diff --git a/packages/components/src/confirm-dialog/stories/index.js b/packages/components/src/confirm-dialog/stories/index.js new file mode 100644 index 00000000000000..3c067f0d178ec0 --- /dev/null +++ b/packages/components/src/confirm-dialog/stories/index.js @@ -0,0 +1,120 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import React, { useState } from 'react'; +import { text } from '@storybook/addon-knobs'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import { Heading } from '../../heading'; +import { ConfirmDialog } from '..'; + +export default { + component: ConfirmDialog, + title: 'Components (Experimental)/ConfirmDialog', + parameters: { + knobs: { disabled: false }, + }, +}; + +const daText = () => + text( 'message', 'Would you like to privately publish the post now?' ); + +// Simplest usage: just declare the component with the required `onConfirm` prop. +export const _default = () => { + const [ confirmVal, setConfirmVal ] = useState( "Hasn't confirmed yet" ); + + return ( + <> + setConfirmVal( 'Confirmed!' ) }> + { daText() } + + { confirmVal } + + ); +}; + +export const WithJSXMessage = () => { + const [ confirmVal, setConfirmVal ] = useState( "Hasn't confirmed yet" ); + + return ( + <> + setConfirmVal( 'Confirmed!' ) }> + { daText() } + + { confirmVal } + + ); +}; + +export const VeeeryLongMessage = () => { + const [ confirmVal, setConfirmVal ] = useState( "Hasn't confirmed yet" ); + + return ( + <> + setConfirmVal( 'Confirmed!' ) }> + { daText().repeat( 20 ) } + + { confirmVal } + + ); +}; + +export const UncontrolledAndWithExplicitOnCancel = () => { + const [ confirmVal, setConfirmVal ] = useState( + "Hasn't confirmed or cancelled yet" + ); + + return ( + <> + setConfirmVal( 'Confirmed!' ) } + onCancel={ () => setConfirmVal( 'Cancelled' ) } + > + { daText() } + + { confirmVal } + + ); +}; + +// Controlled `ConfirmDialog`s require both `onConfirm` *and* `onCancel to be passed +// It's expected that the user will then use it to hide the dialog, too (see the +// `setIsOpen` calls below). +export const Controlled = () => { + const [ isOpen, setIsOpen ] = useState( false ); + const [ confirmVal, setConfirmVal ] = useState( + "Hasn't confirmed or cancelled yet" + ); + + const handleConfirm = () => { + setConfirmVal( 'Confirmed!' ); + setIsOpen( false ); + }; + + const handleCancel = () => { + setConfirmVal( 'Cancelled' ); + setIsOpen( false ); + }; + + return ( + <> + + { daText() } + + + { confirmVal } + + + + ); +}; diff --git a/packages/components/src/confirm-dialog/test/index.js b/packages/components/src/confirm-dialog/test/index.js new file mode 100644 index 00000000000000..5cff6715445eea --- /dev/null +++ b/packages/components/src/confirm-dialog/test/index.js @@ -0,0 +1,302 @@ +/** + * External dependencies + */ +import { + render, + fireEvent, + waitForElementToBeRemoved, +} from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { ConfirmDialog } from '..'; + +const noop = () => {}; + +describe( 'Confirm', () => { + describe( 'Confirm component', () => { + describe( 'Structure', () => { + it( 'should render correctly', () => { + const wrapper = render( + + Are you sure? + + ); + + const dialog = wrapper.getByRole( 'dialog' ); + const elementsTexts = [ 'Are you sure?', 'OK', 'Cancel' ]; + + expect( dialog ).toBeInTheDocument(); + + elementsTexts.forEach( ( txt ) => { + const el = wrapper.getByText( txt ); + expect( el ).toBeInTheDocument(); + } ); + } ); + } ); + + describe( 'When uncontrolled', () => { + it( 'should render', () => { + const wrapper = render( + + Are you sure? + + ); + + const confirmDialog = wrapper.getByRole( 'dialog' ); + + expect( confirmDialog ).toBeInTheDocument(); + } ); + + it( 'should not render if closed by clicking `OK`, and the `onConfirm` callback should be called', async () => { + const onConfirm = jest.fn().mockName( 'onConfirm()' ); + + const wrapper = render( + + Are you sure? + + ); + + const confirmDialog = wrapper.getByRole( 'dialog' ); + const button = wrapper.getByText( 'OK' ); + + fireEvent.click( button ); + + expect( confirmDialog ).not.toBeInTheDocument(); + expect( onConfirm ).toHaveBeenCalled(); + } ); + + it( 'should not render if closed by clicking `Cancel`, and the `onCancel` callback should be called', async () => { + const onCancel = jest.fn().mockName( 'onCancel()' ); + + const wrapper = render( + + Are you sure? + + ); + + const confirmDialog = wrapper.getByRole( 'dialog' ); + const button = wrapper.getByText( 'Cancel' ); + + fireEvent.click( button ); + + expect( confirmDialog ).not.toBeInTheDocument(); + expect( onCancel ).toHaveBeenCalled(); + } ); + + it( 'should be dismissable even if an `onCancel` callback is not provided', async () => { + const wrapper = render( + + Are you sure? + + ); + + const confirmDialog = wrapper.getByRole( 'dialog' ); + const button = wrapper.getByText( 'Cancel' ); + + fireEvent.click( button ); + + expect( confirmDialog ).not.toBeInTheDocument(); + } ); + + it( 'should not render if dialog is closed by clicking the overlay, and the `onCancel` callback should be called', async () => { + const onCancel = jest.fn().mockName( 'onCancel()' ); + + const wrapper = render( + + Are you sure? + + ); + + const confirmDialog = wrapper.getByRole( 'dialog' ); + + //The overlay click is handled by detecting an onBlur from the modal frame. + fireEvent.blur( confirmDialog ); + + await waitForElementToBeRemoved( confirmDialog ); + + expect( confirmDialog ).not.toBeInTheDocument(); + expect( onCancel ).toHaveBeenCalled(); + } ); + + it( 'should not render if dialog is closed by pressing `Escape`, and the `onCancel` callback should be called', async () => { + const onCancel = jest.fn().mockName( 'onCancel()' ); + + const wrapper = render( + + Are you sure? + + ); + + const confirmDialog = wrapper.getByRole( 'dialog' ); + + fireEvent.keyDown( confirmDialog, { keyCode: 27 } ); + + expect( confirmDialog ).not.toBeInTheDocument(); + expect( onCancel ).toHaveBeenCalled(); + } ); + + it( 'should not render if dialog is closed by pressing `Enter`, and the `onConfirm` callback should be called', async () => { + const onConfirm = jest.fn().mockName( 'onConfirm()' ); + + const wrapper = render( + + Are you sure? + + ); + + const confirmDialog = wrapper.getByRole( 'dialog' ); + + fireEvent.keyDown( confirmDialog, { keyCode: 13 } ); + + expect( confirmDialog ).not.toBeInTheDocument(); + expect( onConfirm ).toHaveBeenCalled(); + } ); + } ); + } ); + + describe( 'When controlled (isOpen is not `undefined`)', () => { + it( 'should render when `isOpen` is set to `true`', async () => { + const wrapper = render( + + Are you sure? + + ); + + const confirmDialog = wrapper.getByRole( 'dialog' ); + + expect( confirmDialog ).toBeInTheDocument(); + } ); + + it( 'should not render if `isOpen` is set to false', async () => { + const wrapper = render( + + Are you sure? + + ); + + // `queryByRole` needs to be used here because in this scenario the + // dialog is never rendered. + const confirmDialog = wrapper.queryByRole( 'dialog' ); + + expect( confirmDialog ).not.toBeInTheDocument(); + } ); + + it( 'should call the `onConfirm` callback if `OK`', async () => { + const onConfirm = jest.fn().mockName( 'onConfirm()' ); + + const wrapper = render( + + Are you sure? + + ); + + const button = wrapper.getByText( 'OK' ); + + fireEvent.click( button ); + + expect( onConfirm ).toHaveBeenCalled(); + } ); + + it( 'should call the `onCancel` callback if `Cancel` is clicked', async () => { + const onCancel = jest.fn().mockName( 'onCancel()' ); + + const wrapper = render( + + Are you sure? + + ); + + const button = wrapper.getByText( 'Cancel' ); + + fireEvent.click( button ); + + expect( onCancel ).toHaveBeenCalled(); + } ); + + it( 'should call the `onCancel` callback if the overlay is clicked', async () => { + jest.useFakeTimers(); + + const onCancel = jest.fn().mockName( 'onCancel()' ); + + const wrapper = render( + + Are you sure? + + ); + + const frame = wrapper.baseElement.querySelector( + '.components-modal__frame' + ); + + //The overlay click is handled by detecting an onBlur from the modal frame. + fireEvent.blur( frame ); + + // We don't wait for a DOM side effect here, so we need to fake the timers + // and "advance" it so that the `queueBlurCheck` in the `useFocusOutside` hook + // properly executes its timeout task. + jest.advanceTimersByTime( 0 ); + + expect( onCancel ).toHaveBeenCalled(); + + jest.useRealTimers(); + } ); + + it( 'should call the `onCancel` callback if the `Escape` key is pressed', async () => { + const onCancel = jest.fn().mockName( 'onCancel()' ); + + const wrapper = render( + + Are you sure? + + ); + + const frame = wrapper.baseElement.querySelector( + '.components-modal__frame' + ); + + fireEvent.keyDown( frame, { keyCode: 27 } ); + + expect( onCancel ).toHaveBeenCalled(); + } ); + + it( 'should call the `onConfirm` callback if the `Enter` key is pressed', async () => { + const onConfirm = jest.fn().mockName( 'onConfirm()' ); + + const wrapper = render( + + Are you sure? + + ); + + const frame = wrapper.baseElement.querySelector( + '.components-modal__frame' + ); + + fireEvent.keyDown( frame, { keyCode: 13 } ); + + expect( onConfirm ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/packages/components/src/confirm-dialog/types.ts b/packages/components/src/confirm-dialog/types.ts new file mode 100644 index 00000000000000..1ff696d806892b --- /dev/null +++ b/packages/components/src/confirm-dialog/types.ts @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import type { MouseEvent, KeyboardEvent, ReactNode } from 'react'; + +export type DialogInputEvent = + | KeyboardEvent< HTMLDivElement > + | MouseEvent< HTMLButtonElement >; + +type BaseProps = { + children: ReactNode; + onConfirm: ( event: DialogInputEvent ) => void; +}; + +type ControlledProps = BaseProps & { + onCancel: ( event: DialogInputEvent ) => void; + isOpen: boolean; +}; + +type UncontrolledProps = BaseProps & { + onCancel?: ( event: DialogInputEvent ) => void; + isOpen?: never; +}; + +export type OwnProps = ControlledProps | UncontrolledProps; diff --git a/packages/components/src/higher-order/with-focus-outside/index.js b/packages/components/src/higher-order/with-focus-outside/index.js index c83de77f009060..41a7d9c9c3ea47 100644 --- a/packages/components/src/higher-order/with-focus-outside/index.js +++ b/packages/components/src/higher-order/with-focus-outside/index.js @@ -1,3 +1,5 @@ +//@ts-nocheck + /** * WordPress dependencies */ diff --git a/packages/components/src/index.js b/packages/components/src/index.js index 5866cb5b24b950..29df91d21779cd 100644 --- a/packages/components/src/index.js +++ b/packages/components/src/index.js @@ -47,6 +47,7 @@ export { CompositeItem as __unstableCompositeItem, useCompositeState as __unstableUseCompositeState, } from './composite'; +export { ConfirmDialog as __experimentalConfirmDialog } from './confirm-dialog'; export { default as CustomSelectControl } from './custom-select-control'; export { default as Dashicon } from './dashicon'; export { default as DateTimePicker, DatePicker, TimePicker } from './date-time'; diff --git a/packages/components/tsconfig.json b/packages/components/tsconfig.json index ad15431b8dab41..29818cc95acf22 100644 --- a/packages/components/tsconfig.json +++ b/packages/components/tsconfig.json @@ -28,6 +28,7 @@ "src/base-field/**/*", "src/button/**/*", "src/card/**/*", + "src/confirm-dialog/**/*", "src/dashicon/**/*", "src/disabled/**/*", "src/divider/**/*", @@ -41,9 +42,10 @@ "src/grid/**/*", "src/h-stack/**/*", "src/heading/**/*", - "src/item-group/**/*", - "src/input-control/**/*", + "src/higher-order/with-focus-outside/**/*", "src/icon/**/*", + "src/input-control/**/*", + "src/item-group/**/*", "src/menu-item/**/*", "src/menu-group/**/*", "src/modal/**/*",