Skip to content

Commit

Permalink
Components: Add experimental ConfirmDialog (#34153)
Browse files Browse the repository at this point in the history
* Proof of Concept

* Improve UX to more closely resembles a native confirm

* Remove unused import and outdated comment

* Improve the story

* Spelling fix

* Remove debug code

* Fix invalid props warning

* Empty dependency array to avoid listener from being re-added at each render

* Remove unused style

* wip

* Address code review suggestions partially, and refactor to use the existing  component

* Improve component by allowing it to be used without the `confirm` helper, remove dependency on `react-confirm`

* Update README

* Update confirm call in post-visibility

* Remove role prop as it is not explicitely used at the moment

* Add basic tests

* Test that the Confirm closes when clicking the overlay

* Test that the Confirm closes when pressing Escape

* Fix types and forwardRef, and adapt to the new functional Modal component

* Fix tests

* Simple naive inline declarative usage without a Context

* Fix z-index to show it above all elements, and forwardRef to Modal's wrapper element

* Improve callback handler name

* Make `onCancel` optional

* Add styles.ts

* Add more tests

* Handle confirm on enter and add corresponding test

* Rename to `ConfirmDialog`

* Redo stories, improve types and `selfClose` handling

* Fix tests after renaming to `ConfirmDialog`

* Update the post-visibility example after renaming to `ConfirmDialog`

* Forward all other props to the underlying `Modal`

* Refactor tests to test controlled and uncontrolled scenarios

* Mark it as experimental, linter autofixes and snapshot updates

* Add a proper README

* Fix typo

* Fix grammar and add section about multiple instances to the README

* Fix stories

* Add message text knob to stories

* Reset components/package.json

* Misc changes after code review

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>

* Abstract polymorphic event

* Simplify tests to not use snapshots and be more explicit

* Update README.md to reflect the new polymorphic event type

* Make the `Cancel` button a `tertiary` and DRY and improve types

- Create two interfaces that extend from a base prop type, and then unify them to let TypeScript structurally decide what to use during compile-time.
- Add an optional `title` prop that will be used soon to implement an optional title in the dialog.

* Make the title optional and adjust some styles

* Fix typo

* Update packages/components/src/confirm-dialog/README.md

Co-authored-by: Haz <hazdiego@gmail.com>

* Update packages/components/src/confirm-dialog/README.md

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>

* Update packages/components/src/confirm-dialog/README.md

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>

* Remove unused imported `MouseEvent`

* Type `handleEvent`'s `callback` argument as optional

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>

* Update packages/components/src/confirm-dialog/README.md

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>

* Update packages/components/src/confirm-dialog/README.md

Co-authored-by: Haz <hazdiego@gmail.com>

* Remove portion about the singleton wrapper component as it will not be included now

* Improve component types

Use `never` for a slightly better semantic for communicating that the `UncontrolledProps` type must have not have `isOpen` set.

* Use `props.children` to pass the dialog message contents

* Add DOM structure tests

* Convert all findBy* to getBy*

We don't really need the additional functionality provided by findBy*.

* Update README to reflect the new API

* Improve title and xCloseButton tests

* Add better and more concise description for the `title` prop in the README

* Try stacked margin

* Update packages/components/tsconfig.json

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>

* Update packages/components/src/modal/index.js

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>

* Destructure props in the function body in `Modal` to prevent TS errors from the consuming TS component, and use React's `forwardRef` to forward refs to `Modal`

* Use VStack for a stacked margin approach and remove ability to set a Modal title

Instead of hacking the visuals for the `Modal` component from `ConfirmDialog`, we actually move the logic to hide the title to the `Modal`. It won't render the header if `title` is falsey + a CSS class applied makes sure the additional margin for the header is reset.

Also simplify the `ConfirmDialog` and do not allow passing an optional `title` as the message passed as the `props.children` should be enough.

* Update snapshots for the `preferences-modal` unit tests

* Update snapshots for the `keyboard-shortcut-help-modal` unit tests

* Revert post-visibility sample changes

* Keep modal's default behavior of showing the title div and remove an old confirm-dialog test that tested a title button that is not rendered anymore

* Fix wrong message string in one of the stories

* Remove unnecessary explanation about unset props

* Rename selfClose to shouldSelfClose

* Memoize callbacks

* Extract `confirm` and `cancel` labels

* Improve stories: use `Heading`, better status sentence

* Temporarily enable knobs

* Fix typo in test name

* More descriptive storybook sentende, take 2

* Sort export alphabetically

* Undo changes to `Modal` component (to be carried out in a separate PR)

* Undo changes to `Modal`-related snapshots (to be carried out in a separate PR)

* Refactor `showTitle` prop on the Modal to `__experimentalHideHeader`

* CHANGELOG

Co-authored-by: Marco Ciampini <marco.ciampo@gmail.com>
Co-authored-by: Haz <hazdiego@gmail.com>
  • Loading branch information
3 people authored Nov 25, 2021
1 parent 73e59a5 commit 3633b17
Show file tree
Hide file tree
Showing 11 changed files with 710 additions and 2 deletions.
6 changes: 6 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
128 changes: 128 additions & 0 deletions packages/components/src/confirm-dialog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# `ConfirmDialog`

<div class="callout callout-alert">
This feature is still experimental. "Experimental" means this is an early implementation subject to drastic and breaking changes.
</div>

`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 (
<ConfirmDialog onConfirm={ () => console.debug(' Confirmed! ') }>
Are you sure? <strong>This action cannot be undone!</strong>
</ConfirmDialog>
);
}
```
### 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 (
<ConfirmDialog
isOpen={ isOpen }
onConfirm={ handleConfirm }
onCancel={ handleCancel }
>
Are you sure? <strong>This action cannot be undone!</strong>
</ConfirmDialog>
);
}
```
### 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
<ConfirmDialog>
Are you sure? <strong>This action cannot be undone!</strong>
</ConfirmDialog>
```
### `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.
114 changes: 114 additions & 0 deletions packages/components/src/confirm-dialog/component.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<Modal
onRequestClose={ handleEvent( onCancel ) }
onKeyDown={ handleEnter }
closeButtonLabel={ cancelLabel }
isDismissible={ true }
ref={ forwardedRef }
__experimentalHideHeader
{ ...otherProps }
>
<VStack spacing={ 8 }>
<Text>{ children }</Text>
<Flex direction="row" justify="flex-end">
<Button
variant="tertiary"
onClick={ handleEvent( onCancel ) }
>
{ cancelLabel }
</Button>
<Button
variant="primary"
onClick={ handleEvent( onConfirm ) }
>
{ confirmLabel }
</Button>
</Flex>
</VStack>
</Modal>
) }
</>
);
}

export default contextConnect( ConfirmDialog, 'ConfirmDialog' );
6 changes: 6 additions & 0 deletions packages/components/src/confirm-dialog/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Internal dependencies
*/
import ConfirmDialog from './component';

export { ConfirmDialog };
120 changes: 120 additions & 0 deletions packages/components/src/confirm-dialog/stories/index.js
Original file line number Diff line number Diff line change
@@ -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 (
<>
<ConfirmDialog onConfirm={ () => setConfirmVal( 'Confirmed!' ) }>
{ daText() }
</ConfirmDialog>
<Heading level={ 1 }>{ confirmVal }</Heading>
</>
);
};

export const WithJSXMessage = () => {
const [ confirmVal, setConfirmVal ] = useState( "Hasn't confirmed yet" );

return (
<>
<ConfirmDialog onConfirm={ () => setConfirmVal( 'Confirmed!' ) }>
<Heading level={ 2 }>{ daText() }</Heading>
</ConfirmDialog>
<Heading level={ 1 }>{ confirmVal }</Heading>
</>
);
};

export const VeeeryLongMessage = () => {
const [ confirmVal, setConfirmVal ] = useState( "Hasn't confirmed yet" );

return (
<>
<ConfirmDialog onConfirm={ () => setConfirmVal( 'Confirmed!' ) }>
{ daText().repeat( 20 ) }
</ConfirmDialog>
<Heading level={ 1 }>{ confirmVal }</Heading>
</>
);
};

export const UncontrolledAndWithExplicitOnCancel = () => {
const [ confirmVal, setConfirmVal ] = useState(
"Hasn't confirmed or cancelled yet"
);

return (
<>
<ConfirmDialog
onConfirm={ () => setConfirmVal( 'Confirmed!' ) }
onCancel={ () => setConfirmVal( 'Cancelled' ) }
>
{ daText() }
</ConfirmDialog>
<Heading level={ 1 }>{ confirmVal }</Heading>
</>
);
};

// 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 (
<>
<ConfirmDialog
isOpen={ isOpen }
onConfirm={ handleConfirm }
onCancel={ handleCancel }
>
{ daText() }
</ConfirmDialog>

<Heading level={ 1 }>{ confirmVal }</Heading>

<Button variant="primary" onClick={ () => setIsOpen( true ) }>
Open ConfirmDialog
</Button>
</>
);
};
Loading

0 comments on commit 3633b17

Please sign in to comment.