diff --git a/.changeset/little-beans-laugh.md b/.changeset/little-beans-laugh.md new file mode 100644 index 0000000000..775fe54ee4 --- /dev/null +++ b/.changeset/little-beans-laugh.md @@ -0,0 +1,6 @@ +--- +'@twilio-paste/alert-dialog': minor +'@twilio-paste/core': minor +--- + +[Alert Dialog] Add prop `onConfirmDisabled` to allow for the confirm button on destructive Alert Dialogs to be conditionally disabled. This interaction is primarily for use in the High Severity Delete Pattern. diff --git a/packages/paste-core/components/alert-dialog/__tests__/index.spec.tsx b/packages/paste-core/components/alert-dialog/__tests__/index.spec.tsx index 20574e449d..4da7f1a06d 100644 --- a/packages/paste-core/components/alert-dialog/__tests__/index.spec.tsx +++ b/packages/paste-core/components/alert-dialog/__tests__/index.spec.tsx @@ -3,7 +3,11 @@ import {render, screen} from '@testing-library/react'; import type {RenderOptions} from '@testing-library/react'; import {Theme} from '@twilio-paste/theme'; -import {AlertDialogWithTwoActions, DestructiveAlertDialog} from '../stories/index.stories'; +import { + AlertDialogWithTwoActions, + DestructiveAlertDialog, + DisabledButtonDestructiveAlertDialog, +} from '../stories/index.stories'; const ThemeWrapper: RenderOptions['wrapper'] = ({children}) => ( {children} @@ -29,6 +33,12 @@ describe('Alert Dialog', () => { expect(button).toHaveStyleRule('background-color', 'rgb(214, 31, 31)'); }); + it('Should have a disabled destructive button style when the onConfirmDisabled prop is included', () => { + render(, {wrapper: ThemeWrapper}); + const button = screen.getByRole('button', {name: 'Delete'}); + expect(button).toHaveStyleRule('background-color', 'rgb(225, 227, 234)'); + }); + it('Should have a heading the same as the heading prop', () => { render(); expect(screen.getByRole('heading')).toHaveTextContent('Submit application'); @@ -48,6 +58,11 @@ describe('Alert Dialog', () => { ); }); + it('Should have correct attributes when button is disabled', () => { + render(, {wrapper: ThemeWrapper}); + expect(screen.getByRole('button', {name: 'Delete'})).toHaveAttribute('disabled'); + }); + it('Should have the initial focus land on the first focusable item', () => { render(); expect(document.activeElement).toEqual(screen.getAllByRole('button')[0]); diff --git a/packages/paste-core/components/alert-dialog/src/AlertDialogFooter.tsx b/packages/paste-core/components/alert-dialog/src/AlertDialogFooter.tsx index 73fb2e3d5a..9112c5c6fc 100644 --- a/packages/paste-core/components/alert-dialog/src/AlertDialogFooter.tsx +++ b/packages/paste-core/components/alert-dialog/src/AlertDialogFooter.tsx @@ -12,11 +12,21 @@ export interface AlertDialogFooterProps extends HTMLPasteProps<'div'>, Pick void; onDismissLabel: string; + onConfirmDisabled?: boolean; } export const AlertDialogFooter = React.forwardRef( ( - {destructive, element = 'ALERT_DIALOG_FOOTER', onConfirm, onConfirmLabel, onDismiss, onDismissLabel, ...props}, + { + destructive, + element = 'ALERT_DIALOG_FOOTER', + onConfirm, + onConfirmLabel, + onDismiss, + onDismissLabel, + onConfirmDisabled = false, + ...props + }, ref ) => { const primaryVariant = destructive ? 'destructive' : 'primary'; @@ -38,7 +48,7 @@ export const AlertDialogFooter = React.forwardRef {onDismissLabel} - diff --git a/packages/paste-core/components/alert-dialog/src/index.tsx b/packages/paste-core/components/alert-dialog/src/index.tsx index 88dad4c75e..13327184b2 100644 --- a/packages/paste-core/components/alert-dialog/src/index.tsx +++ b/packages/paste-core/components/alert-dialog/src/index.tsx @@ -34,6 +34,7 @@ export interface AlertDialogProps extends HTMLPasteProps<'div'>, Pick void; onDismissLabel: string; + onConfirmDisabled?: boolean; } export const AlertDialog = React.forwardRef( @@ -48,6 +49,7 @@ export const AlertDialog = React.forwardRef( onConfirmLabel, onDismiss, onDismissLabel, + onConfirmDisabled, ...props }, ref @@ -86,6 +88,7 @@ export const AlertDialog = React.forwardRef( onDismissLabel={onDismissLabel} onConfirm={onConfirm} onConfirmLabel={onConfirmLabel} + onConfirmDisabled={onConfirmDisabled} /> diff --git a/packages/paste-core/components/alert-dialog/stories/index.stories.tsx b/packages/paste-core/components/alert-dialog/stories/index.stories.tsx index ef4b8a44d1..d3faa24443 100644 --- a/packages/paste-core/components/alert-dialog/stories/index.stories.tsx +++ b/packages/paste-core/components/alert-dialog/stories/index.stories.tsx @@ -7,6 +7,10 @@ import {Modal, ModalBody, ModalFooter, ModalFooterActions, ModalHeader, ModalHea import {Paragraph} from '@twilio-paste/paragraph'; import {CustomizationProvider} from '@twilio-paste/customization'; import {useTheme} from '@twilio-paste/theme'; +import {Box} from '@twilio-paste/box'; +import {Input} from '@twilio-paste/input'; +import {Label} from '@twilio-paste/label'; +import {HelpText} from '@twilio-paste/help-text'; import {AlertDialog} from '../src'; import {AlertDialogHeader} from '../src/AlertDialogHeader'; @@ -83,6 +87,74 @@ DestructiveAlertDialogStory.parameters = { }, }; +export const DisabledButtonDestructiveAlertDialog = ({dialogIsOpen = false}): JSX.Element => { + const [isOpen, setIsOpen] = React.useState(dialogIsOpen); + const [inputString, setInputString] = React.useState(''); + const [inputHasError, setInputHasError] = React.useState(false); + const [isDisabled, setIsDisabled] = React.useState(true); + const handleOpen = (): void => { + if (inputString !== '') setIsDisabled(false); + setIsOpen(true); + }; + const handleDismiss = (): void => { + setIsDisabled(true); + setIsOpen(false); + setInputHasError(false); + }; + const handleConfirm = (): void => { + if (inputString === 'Toyota TCB Automobile (Gevelsberg)') { + setIsOpen(false); + setInputString(''); + setInputHasError(false); + setIsDisabled(true); + } else { + setInputHasError(true); + } + }; + const handleChange = (e): void => { + setInputString(e.target.value); + if (e.target.value !== '') setIsDisabled(false); + else setIsDisabled(true); + }; + return ( + <> + + + You're about to delete “Toyota TCB Automobile (Gevelsberg)“ and all data associated with it. + This regulatory bundle will be deleted immediately. You can't undo this action. + + + handleChange(e)} + hasError={inputHasError} + value={inputString} + /> + + To confirm this deletion, please input the name of this regulatory bundle. + + + + + ); +}; + export const OpenAlertDialogFromButton = (): JSX.Element => { const [isOpen, setIsOpen] = React.useState(false); const handleOpen = (): void => setIsOpen(true); diff --git a/packages/paste-website/src/component-examples/DeletePatternExamples.ts b/packages/paste-website/src/component-examples/DeletePatternExamples.ts new file mode 100644 index 0000000000..5ebfcd9b16 --- /dev/null +++ b/packages/paste-website/src/component-examples/DeletePatternExamples.ts @@ -0,0 +1,139 @@ +export const LowSeverityExample = ` + const LowSeverityDelete = () => { + return ( + + ) + } + + render( + + ) +`.trim(); + +export const MediumSeverityExample = ` + const MediumSeverityDelete = () => { + const [isOpen, setIsOpen] = React.useState(false); + const handleOpen = () => setIsOpen(true); + const handleClose = () => setIsOpen(false); + return ( + <> + + + You're about to delete "Plan A Productions, LLC" from this regulatory bundle. This does not impact any other regulatory bundles. + + + ) + } + render( + + ) +`.trim(); + +export const HighSeverityExample = ` + const HighSeverityDelete = () => { + const [isDisabled, setIsDisabled] = React.useState(true); + const [isOpen, setIsOpen] = React.useState(false); + const [inputString, setInputString] = React.useState(''); + const [inputHasError, setInputHasError] = React.useState(false); + const handleOpen = () => { + if (inputString !== '') setIsDisabled(false); + setIsOpen(true); + }; + const handleDismiss = () => { + setIsDisabled(true) + setIsOpen(false) + }; + const handleConfirm = () => { + if (inputString === 'Toyota TCB Automobile (Gevelsberg)') { + setIsOpen(false) + setInputString('') + setInputHasError(false) + } + else { + setInputHasError(true) + } + }; + const handleChange = (e) => { + setInputString(e.target.value) + if (e.target.value !== '') setIsDisabled(false); + else setIsDisabled(true); + }; + + return ( + <> + + + You're about to delete "Toyota TCB Automobile (Gevelsberg)" and all associated data. The bundle will be deleted immediately. You cannot undo this action. + + + handleChange(e)} hasError={inputHasError} value={inputString}/> + + Enter the name of the bundle being deleted. Entries are case-sensitive. + + + + + ) +} + +render( + +) +`.trim(); + +export const PostDeletionExample = ` + const MediumSeverityDelete = () => { + + const [isOpen, setIsOpen] = React.useState(false); + const handleOpen = () => setIsOpen(true); + const handleDismiss = () => setIsOpen(false); + const toaster = useToaster(); + + const handleConfirm = () => { + setIsOpen(false) + toaster.push({ + message: '"Plan A Productions, LLC" was deleted. To undo deletion, return to the regulatory bundle overview.', + variant: 'success', + }) + } + return ( + <> + + + You're about to delete "Plan A Productions, LLC" from this regulatory bundle. This does not impact any other regulatory bundles. + + + + ) +} +render( + +) +`.trim(); diff --git a/packages/paste-website/src/pages/components/alert-dialog/index.mdx b/packages/paste-website/src/pages/components/alert-dialog/index.mdx index dbacc56683..52c021d697 100644 --- a/packages/paste-website/src/pages/components/alert-dialog/index.mdx +++ b/packages/paste-website/src/pages/components/alert-dialog/index.mdx @@ -158,16 +158,17 @@ const AlertDialogExample = () => { #### Props -| Prop | Type | Description | Default | -| -------------- | -------------------- | ----------------------------------------------------------------------------------------- | -------------- | -| children | `ReactNode` | | null | -| isOpen | `boolean` | | null | -| destructive? | `boolean` | | null | -| onConfirmLabel | `string` | | null | -| onConfirm | `Function() => void` | | null | -| onDismissLabel | `string` | | null | -| onDismiss | `Function() => void` | | null | -| element? | `string` | Overrides the default element name to apply unique styles with the Customization Provider | 'ALERT_DIALOG' | +| Prop | Type | Description | Default | +| ------------------ | -------------------- | ------------------------------------------------------------------------------------------------- | -------------- | +| children | `ReactNode` | | null | +| isOpen | `boolean` | | null | +| destructive? | `boolean` | | null | +| onConfirmDisabled? | `boolean` | Disables the confirm button on destructive Alert Dialogs for high severity deletion confirmations | null | +| onConfirmLabel | `string` | | null | +| onConfirm | `Function() => void` | | null | +| onDismissLabel | `string` | | null | +| onDismiss | `Function() => void` | | null | +| element? | `string` | Overrides the default element name to apply unique styles with the Customization Provider | 'ALERT_DIALOG' | diff --git a/packages/paste-website/src/pages/patterns/delete/index.mdx b/packages/paste-website/src/pages/patterns/delete/index.mdx index a466e1c68a..536f5130af 100644 --- a/packages/paste-website/src/pages/patterns/delete/index.mdx +++ b/packages/paste-website/src/pages/patterns/delete/index.mdx @@ -7,6 +7,9 @@ export const meta = { import {Anchor} from '@twilio-paste/anchor'; import {Box} from '@twilio-paste/box'; +import {Label} from '@twilio-paste/label'; +import {Input} from '@twilio-paste/input'; +import {HelpText} from '@twilio-paste/help-text'; import {Card} from '@twilio-paste/card'; import {Disclosure, DisclosureHeading, DisclosureContent} from '@twilio-paste/disclosure'; import {Grid, Column} from '@twilio-paste/grid'; @@ -14,11 +17,20 @@ import {Heading} from '@twilio-paste/heading'; import {Paragraph} from '@twilio-paste/paragraph'; import {Text} from '@twilio-paste/text'; import {useUID} from '@twilio-paste/uid-library'; +import {AlertDialog} from '@twilio-paste/alert-dialog'; +import {Button} from '@twilio-paste/button'; +import {Toaster, useToaster} from '@twilio-paste/toast'; import {ResponsiveImage} from '../../../components/ResponsiveImage'; import DeleteMediumSeverityImage from '../../../assets/images/patterns/delete-medium-severity.png'; import DeleteHighSeverityImage from '../../../assets/images/patterns/delete-high-severity.png'; import DefaultLayout from '../../../layouts/DefaultLayout'; import {getFeature, getNavigationData} from '../../../utils/api'; +import { + LowSeverityExample, + MediumSeverityExample, + HighSeverityExample, + PostDeletionExample, +} from '../../../component-examples/DeletePatternExamples.ts'; export default DefaultLayout; @@ -62,7 +74,7 @@ export const getStaticProps = async () => { - Modal + Alert Dialog @@ -86,10 +98,10 @@ export const getStaticProps = async () => { // import all ingredients for the delete patterns import {​ Button } from "@twilio-paste/core/button"; -import { Modal, ModalBody, ModalFooter, ModalFooterActions, ModalHeader, ModalHeading } from "@twilio-paste/core/modal"; +import { AlertDialog } from "@twilio-paste/core/alert-dialog"; import { Input } from "@twilio-paste/core/input"; import { Label } from "@twilio-paste/core/label"; -import { HelpText } from "@twilio-paste/core/help-text; +import { HelpText } from "@twilio-paste/core/help-text"; import { Toast } from "@twilio-paste/core/toast"; ``` @@ -106,6 +118,8 @@ The delete action should: - Not usually be the primary action on the page. There are many variants of differing visual prominence to choose from to achieve the correct hierarchy for the delete action trigger. - Follow certain guidelines based on the [severity](#variations) of the deletion. +According to the Paste [Word List](/foundations/content/word-list), the word "remove" should be used when the item being deleted is still available and/or the user can undo the action. Use "delete" when the action is permanent and the item is unretrievable. + ## Variations ### Low-severity @@ -114,57 +128,75 @@ A deletion is considered low-severity when it is trivial to undo the deletion or Low-severity deletions can be triggered by a single click and do not require further warning or confirmation. + + {LowSeverityExample} + + ### Medium-severity A deletion is considered medium-severity when the action cannot be undone, and the data cannot be recreated easily. This pattern is also useful for bulk deletions of low- or medium-severity. -For medium-severity deletions, show a confirmation modal that explains what is being deleted and the consequences of the deletion. - - +For medium-severity deletions, show an [Alert Dialog](/components/alert-dialog) that explains what is being deleted and the consequences of the deletion. - - - - Show live example - - Coming soon! - - + + {MediumSeverityExample} + ### High-severity A deletion is considered high-severity when the action cannot be undone, and it would be very time-consuming, or perhaps impossible, to recreate the deleted data. An action that deletes a large amount of data or has significant downstream impact would also be considered a high-severity deletion. -For high-severity deletions, show a confirmation modal that explains what is being deleted and the consequences of the deletion, and have the user manually confirm the deletion by typing the name of the object they are deleting. - - - - - - - Show live example - - Coming soon! - - +For high-severity deletions, show an [Alert Dialog](/components/alert-dialog) that explains what is being deleted and the consequences of the deletion, and have the user manually confirm the deletion by typing the name of the object they are deleting. + + + {HighSeverityExample} + ## Post-deletion After the user has deleted the object, navigate them to the index page, where they can see a list of all remaining objects, and show a success [Toast](/components/toast) informing them that the object has successfully been deleted. If it is possible to undo the deletion, give the user the option to do so, and tell them how long they have to undo the deletion if it is time-bound. -If the delete action fails, keep the modal open and display an error [Toast](/components/toast) that explains what went wrong and how to try again. +If the delete action fails, keep the Alert Dialog open and display an error [Toast](/components/toast) that explains what went wrong and how to try again. For more information, check out our [Notifications and Feedback patterns](/patterns/notifications-and-feedback). -## Starter kits - -### CodeSandbox - -Coming soon - -### Figma - -Coming soon + + {PostDeletionExample} +