Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Components: Add experimental ConfirmDialog #34153

Merged
merged 92 commits into from
Nov 25, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
92 commits
Select commit Hold shift + click to select a range
72cd96b
Proof of Concept
fullofcaffeine Aug 19, 2021
335053a
Improve UX to more closely resembles a native confirm
fullofcaffeine Aug 20, 2021
f4a3396
Remove unused import and outdated comment
fullofcaffeine Aug 20, 2021
1b2f104
Improve the story
fullofcaffeine Aug 20, 2021
d5e8d73
Spelling fix
fullofcaffeine Aug 20, 2021
f7250b6
Remove debug code
fullofcaffeine Aug 20, 2021
6062648
Fix invalid props warning
fullofcaffeine Aug 21, 2021
a01225f
Empty dependency array to avoid listener from being re-added at each …
fullofcaffeine Aug 24, 2021
a4d2453
Remove unused style
fullofcaffeine Aug 24, 2021
18e0231
wip
fullofcaffeine Aug 25, 2021
2391e35
Address code review suggestions partially, and refactor to use the ex…
fullofcaffeine Aug 25, 2021
a878802
Improve component by allowing it to be used without the `confirm` hel…
fullofcaffeine Aug 25, 2021
b09651b
Update README
fullofcaffeine Aug 26, 2021
c221e9d
Update confirm call in post-visibility
fullofcaffeine Aug 26, 2021
cac2732
Remove role prop as it is not explicitely used at the moment
fullofcaffeine Aug 26, 2021
0942707
Add basic tests
fullofcaffeine Aug 26, 2021
7d629b8
Test that the Confirm closes when clicking the overlay
fullofcaffeine Aug 27, 2021
12f0b18
Test that the Confirm closes when pressing Escape
fullofcaffeine Aug 27, 2021
f99c357
Merge branch 'trunk' into replace/confirm-et-al
fullofcaffeine Sep 9, 2021
f746119
Fix types and forwardRef, and adapt to the new functional Modal compo…
fullofcaffeine Sep 9, 2021
5fd979f
Merge branch 'trunk' into replace/confirm-et-al
fullofcaffeine Sep 9, 2021
13bf86a
Fix tests
fullofcaffeine Sep 9, 2021
d68508f
Simple naive inline declarative usage without a Context
fullofcaffeine Sep 9, 2021
56ea610
Fix z-index to show it above all elements, and forwardRef to Modal's …
fullofcaffeine Sep 9, 2021
6245385
Improve callback handler name
fullofcaffeine Sep 10, 2021
814fc6f
Make `onCancel` optional
fullofcaffeine Sep 10, 2021
2b47a46
Add styles.ts
fullofcaffeine Sep 10, 2021
bf9c062
Add more tests
fullofcaffeine Sep 11, 2021
37befa2
Handle confirm on enter and add corresponding test
fullofcaffeine Sep 11, 2021
31b88e2
Rename to `ConfirmDialog`
fullofcaffeine Sep 13, 2021
2cd86e9
Redo stories, improve types and `selfClose` handling
fullofcaffeine Sep 14, 2021
21600b0
Fix tests after renaming to `ConfirmDialog`
fullofcaffeine Sep 14, 2021
b21c175
Update the post-visibility example after renaming to `ConfirmDialog`
fullofcaffeine Sep 14, 2021
922c103
Forward all other props to the underlying `Modal`
fullofcaffeine Sep 14, 2021
9c931c6
Refactor tests to test controlled and uncontrolled scenarios
fullofcaffeine Sep 15, 2021
5494585
Mark it as experimental, linter autofixes and snapshot updates
fullofcaffeine Sep 15, 2021
2c43770
Add a proper README
fullofcaffeine Sep 15, 2021
fdab69f
Fix typo
fullofcaffeine Sep 15, 2021
835c2e6
Fix grammar and add section about multiple instances to the README
fullofcaffeine Sep 15, 2021
61f7c1a
Fix stories
fullofcaffeine Sep 15, 2021
c1cf65a
Add message text knob to stories
fullofcaffeine Sep 16, 2021
20e2d23
Reset components/package.json
fullofcaffeine Sep 16, 2021
33e97e1
Merge branch 'trunk' into replace/confirm-et-al
fullofcaffeine Sep 16, 2021
566fe18
Misc changes after code review
fullofcaffeine Sep 24, 2021
d1be655
Abstract polymorphic event
fullofcaffeine Sep 24, 2021
81d9e76
Simplify tests to not use snapshots and be more explicit
fullofcaffeine Sep 24, 2021
8665ad6
Update README.md to reflect the new polymorphic event type
fullofcaffeine Sep 24, 2021
29c8c3a
Make the `Cancel` button a `tertiary` and DRY and improve types
fullofcaffeine Oct 7, 2021
f59553d
Make the title optional and adjust some styles
fullofcaffeine Oct 7, 2021
55ac46a
Fix typo
fullofcaffeine Oct 8, 2021
c96f575
Update packages/components/src/confirm-dialog/README.md
fullofcaffeine Oct 13, 2021
3abfc81
Update packages/components/src/confirm-dialog/README.md
fullofcaffeine Oct 13, 2021
605c2ae
Update packages/components/src/confirm-dialog/README.md
fullofcaffeine Oct 13, 2021
6a60f92
Remove unused imported `MouseEvent`
fullofcaffeine Oct 13, 2021
995f45f
Type `handleEvent`'s `callback` argument as optional
fullofcaffeine Oct 13, 2021
40cb594
Update packages/components/src/confirm-dialog/README.md
fullofcaffeine Oct 13, 2021
ceabb07
Update packages/components/src/confirm-dialog/README.md
fullofcaffeine Oct 13, 2021
aa7025d
Remove portion about the singleton wrapper component as it will not b…
fullofcaffeine Oct 13, 2021
d130ed1
Improve component types
fullofcaffeine Oct 13, 2021
054caae
Use `props.children` to pass the dialog message contents
fullofcaffeine Oct 13, 2021
85e9510
Add DOM structure tests
fullofcaffeine Oct 13, 2021
c20f85d
Convert all findBy* to getBy*
fullofcaffeine Oct 13, 2021
e9ee813
Update README to reflect the new API
fullofcaffeine Oct 13, 2021
316afc5
Improve title and xCloseButton tests
fullofcaffeine Oct 13, 2021
5b2b026
Add better and more concise description for the `title` prop in the R…
fullofcaffeine Oct 13, 2021
4e5abed
Try stacked margin
fullofcaffeine Oct 14, 2021
9bf6f88
Update packages/components/tsconfig.json
fullofcaffeine Oct 14, 2021
64c2816
Update packages/components/src/modal/index.js
fullofcaffeine Oct 14, 2021
414ab15
Destructure props in the function body in `Modal` to prevent TS error…
fullofcaffeine Oct 14, 2021
49eaa0e
Merge branch 'trunk' into replace/confirm-et-al
fullofcaffeine Oct 21, 2021
9f62ad3
Use VStack for a stacked margin approach and remove ability to set a …
fullofcaffeine Oct 21, 2021
af340b2
Update snapshots for the `preferences-modal` unit tests
fullofcaffeine Oct 22, 2021
710932d
Update snapshots for the `keyboard-shortcut-help-modal` unit tests
fullofcaffeine Oct 23, 2021
5e24ff0
Merge branch 'trunk' into replace/confirm-et-al
fullofcaffeine Oct 25, 2021
7d5860c
Merge branch 'trunk' into replace/confirm-et-al
fullofcaffeine Nov 20, 2021
febda4a
Revert post-visibility sample changes
fullofcaffeine Nov 20, 2021
8bed797
Keep modal's default behavior of showing the title div and remove an …
fullofcaffeine Nov 20, 2021
16ce455
Fix wrong message string in one of the stories
fullofcaffeine Nov 20, 2021
2ddbabc
Remove unnecessary explanation about unset props
ciampo Nov 24, 2021
bd6eb33
Rename selfClose to shouldSelfClose
ciampo Nov 24, 2021
94f8b47
Memoize callbacks
ciampo Nov 24, 2021
4d35f84
Extract `confirm` and `cancel` labels
ciampo Nov 24, 2021
74c5293
Improve stories: use `Heading`, better status sentence
ciampo Nov 24, 2021
f69a973
Temporarily enable knobs
ciampo Nov 24, 2021
541490c
Fix typo in test name
ciampo Nov 24, 2021
8eeb0c7
More descriptive storybook sentende, take 2
ciampo Nov 24, 2021
3c3f49a
Sort export alphabetically
ciampo Nov 24, 2021
173f2d4
Undo changes to `Modal` component (to be carried out in a separate PR)
ciampo Nov 24, 2021
4a81ba6
Undo changes to `Modal`-related snapshots (to be carried out in a sep…
ciampo Nov 24, 2021
e2ce3ef
Merge branch 'trunk' into replace/confirm-et-al
ciampo Nov 25, 2021
c2a9adb
Refactor `showTitle` prop on the Modal to `__experimentalHideHeader`
ciampo Nov 25, 2021
a320d85
CHANGELOG
ciampo Nov 25, 2021
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
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
fullofcaffeine marked this conversation as resolved.
Show resolved Hide resolved

### `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 );
Copy link
Contributor

@ciampo ciampo Nov 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder what happens if:

  • isOpenProp is set to false — the dialog is not shown
  • later, isOpenProp is unset — would the dialog suddenly show?

Definitely somewhat of an edge case, can be tackled in a follow-up PR

Copy link
Member Author

@fullofcaffeine fullofcaffeine Nov 24, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, not sure. I can add a test case for that in a follow-up PR, I'll take note!

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' );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍🏻


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 },
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move away from knobs and rewrite this story to use controls instead — perfect task for a follow-up PR !

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good! Note taken!

},
};

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