From e9d4d79e2030cfd3e83496d8a947b6d2f02cc844 Mon Sep 17 00:00:00 2001 From: Jerry Wong Sick Hong Date: Wed, 11 Dec 2019 09:42:14 -0800 Subject: [PATCH 1/4] adds inline confirmation button logic --- .../InlineConfirmationButton.js | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/Components/InlineConfirmationButton/InlineConfirmationButton.js diff --git a/src/Components/InlineConfirmationButton/InlineConfirmationButton.js b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js new file mode 100644 index 00000000..a5b0ef69 --- /dev/null +++ b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; + +import Block from '../Block/Block'; +import Button from '../Button/Button'; +import ButtonGroup from '../ButtonGroup/ButtonGroup'; +import PropTypes from 'prop-types'; +import Text from '../Text/Text'; + +const propTypes = { + /** + * Disables the button, making it inoperable + */ + disabled: PropTypes.bool, + /** + * The text for the confirmation button, visible after the initial button is clicked. + * Clicking the button with this label with confirm the action and execute the `handleConfirmation` function. + */ + confirmBtnLabel: PropTypes.string, + /** + * The text for the rejection button, visible after the initial button is clicked. + * Clicking the button with this label will result in no action being taken. + */ + rejectBtnLabel: PropTypes.string, + /** + * The question displayed after the initial button is clicked. + */ + confirmationQuestion: PropTypes.string, + /** + * The function to execute if the confirmation button is clicked. + */ + handleConfirmation: PropTypes.func, + /** + * Contents of the button + */ + children: PropTypes.node, +}; + +const defaultProps = { + disabled: false, +}; + +const InlineConfirmationButton = React.forwardRef((props, ref) => { + const { + disabled, + confirmBtnLabel, + rejectBtnLabel, + confirmationQuestion, + handleConfirmation, + children, + } = props; + const [initiated, setInitiated] = useState(false); + if (!initiated) { + return ( + + ); + } + return ( + // TODO: make style options configurable + + {confirmationQuestion} + + + + + + ); +}); + +InlineConfirmationButton.propTypes = propTypes; +InlineConfirmationButton.defaultProps = defaultProps; +InlineConfirmationButton.displayName = 'InlineConfirmationButton'; + +export default InlineConfirmationButton; From b6fce5217be041b9902386b7d6863312b8406d5f Mon Sep 17 00:00:00 2001 From: Nathan Young Date: Fri, 13 Dec 2019 17:41:03 -0800 Subject: [PATCH 2/4] animate confirmation message, add tests and stories --- .../InlineConfirmationButton.js | 122 +++++++++++++----- .../InlineConfirmationButton.test.js | 111 ++++++++++++++++ .../InlineConfirmationButton/stories.js | 109 ++++++++++++++++ 3 files changed, 312 insertions(+), 30 deletions(-) create mode 100644 src/Components/InlineConfirmationButton/InlineConfirmationButton.test.js create mode 100644 src/Components/InlineConfirmationButton/stories.js diff --git a/src/Components/InlineConfirmationButton/InlineConfirmationButton.js b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js index a5b0ef69..91ac8fbf 100644 --- a/src/Components/InlineConfirmationButton/InlineConfirmationButton.js +++ b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { motion } from 'framer-motion'; import Block from '../Block/Block'; import Button from '../Button/Button'; @@ -7,75 +8,136 @@ import PropTypes from 'prop-types'; import Text from '../Text/Text'; const propTypes = { + /** + * Additional classes to apply to container + */ + className: PropTypes.string, /** * Disables the button, making it inoperable */ disabled: PropTypes.bool, /** - * The text for the confirmation button, visible after the initial button is clicked. + * The text for the confirmation button, visible after the initial button is pressed. * Clicking the button with this label with confirm the action and execute the `handleConfirmation` function. */ - confirmBtnLabel: PropTypes.string, + confirmBtnLabel: PropTypes.string.isRequired, + /** + * Name of the [icon](/#/Components/Icon) to place before the button label text + */ + icon: PropTypes.string, + /** + * Name of the [icon](/#/Components/Icon) to add after the button label text + */ + iconAfterText: PropTypes.string, /** - * The text for the rejection button, visible after the initial button is clicked. + * The text for the rejection button, visible after the initial button is pressed. * Clicking the button with this label will result in no action being taken. */ - rejectBtnLabel: PropTypes.string, + rejectBtnLabel: PropTypes.string.isRequired, /** - * The question displayed after the initial button is clicked. + * The text displayed after the initial button is pressed. */ - confirmationQuestion: PropTypes.string, + confirmationText: PropTypes.string.isRequired, /** - * The function to execute if the confirmation button is clicked. + * The function to execute if the confirmation button is pressed. */ - handleConfirmation: PropTypes.func, + handleConfirmation: PropTypes.func.isRequired, /** - * Contents of the button + * Changes the size of the button, giving it more or less padding and font size + * @type {PropTypes.Requireable} */ - children: PropTypes.node, + size: PropTypes.oneOf(['small', 'medium', 'large']), + /** + * Indicate that the button will perform a destructive action + */ + danger: PropTypes.bool, + /** + * Contents of the initial button + */ + children: PropTypes.node.isRequired, }; const defaultProps = { disabled: false, + confirmBtnLabel: 'Yes', + rejectBtnLabel: 'No', + confirmationText: 'Are you sure?', +}; + +const variants = { + popUp: custom => { + return { + opacity: [0, 1], + x: [24, 0], + transition: { + duration: 0.2, + delay: 0.1, + }, + }; + }, }; const InlineConfirmationButton = React.forwardRef((props, ref) => { const { disabled, + className, confirmBtnLabel, rejectBtnLabel, - confirmationQuestion, + confirmationText, handleConfirmation, + icon, + iconAfterText, + size, + primary, + danger, children, } = props; const [initiated, setInitiated] = useState(false); if (!initiated) { return ( - ); } return ( // TODO: make style options configurable - - {confirmationQuestion} - - - - - + + + + {confirmationText} + + + + + + + ); }); diff --git a/src/Components/InlineConfirmationButton/InlineConfirmationButton.test.js b/src/Components/InlineConfirmationButton/InlineConfirmationButton.test.js new file mode 100644 index 00000000..a01476fa --- /dev/null +++ b/src/Components/InlineConfirmationButton/InlineConfirmationButton.test.js @@ -0,0 +1,111 @@ +import React from 'react'; +import { render, fireEvent, wait } from '@testing-library/react'; + +import InlineConfirmationButton from './InlineConfirmationButton'; + +describe('InlineConfirmationButton', () => { + it('renders expected elements with default values and wires handleConfirmation function', async () => { + const handlerFn = jest.fn(); + const label = 'the button label'; + + const { getByText } = render( + + {label} + , + ); + + expect(getByText(label)).toBeDefined(); + fireEvent.click(getByText(label)); + await wait(() => + expect(getByText('Are you sure?')).toBeDefined(), + ); + expect(getByText('No')).toBeDefined(); + expect(getByText('Yes')).toBeDefined(); + fireEvent.click(getByText('Yes')); + expect(handlerFn).toHaveBeenCalled(); + }); + + it('renders expected elements with prop values and wires handleConfirmation function', async () => { + const handlerFn = jest.fn(); + const confirmTxt = 'confirmation text'; + const label = 'the button label'; + const confirmBtnLabel = 'confirm'; + const rejectBtnLabel = 'reject'; + + const { getByText } = render( + + {label} + , + ); + + expect(getByText(label)).toBeDefined(); + fireEvent.click(getByText(label)); + await wait(() => expect(getByText(confirmTxt)).toBeDefined()); + expect(getByText(rejectBtnLabel)).toBeDefined(); + expect(getByText(confirmBtnLabel)).toBeDefined(); + fireEvent.click(getByText(confirmBtnLabel)); + expect(handlerFn).toHaveBeenCalled(); + }); + + it('renders initial button when confirmation is rejected', async () => { + const handlerFn = jest.fn(); + const label = 'the button label'; + + const { getByText } = render( + + {label} + , + ); + + expect(getByText(label)).toBeDefined(); + fireEvent.click(getByText(label)); + await wait(() => + expect(getByText('Are you sure?')).toBeDefined(), + ); + fireEvent.click(getByText('No')); + expect(getByText(label)).toBeDefined(); + }); + + it('applies the small size to the buttons and text', async () => { + const handlerFn = jest.fn(); + const label = 'the button label'; + + const { getByText } = render( + + {label} + , + ); + + expect(document.getElementsByClassName('btn-sm')).toHaveLength(1); + fireEvent.click(getByText(label)); + expect(document.getElementsByClassName('fs-6')).toHaveLength(1); + expect(document.getElementsByClassName('btn-sm')).toHaveLength(2); + }); + + it('applies the large size to the buttons and text', async () => { + const handlerFn = jest.fn(); + const label = 'the button label'; + + const { getByText } = render( + + {label} + , + ); + + expect(document.getElementsByClassName('btn-lg')).toHaveLength(1); + fireEvent.click(getByText(label)); + expect(document.getElementsByClassName('fs-4')).toHaveLength(1); + expect(document.getElementsByClassName('btn-lg')).toHaveLength(2); + }); +}); diff --git a/src/Components/InlineConfirmationButton/stories.js b/src/Components/InlineConfirmationButton/stories.js new file mode 100644 index 00000000..a5840300 --- /dev/null +++ b/src/Components/InlineConfirmationButton/stories.js @@ -0,0 +1,109 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import InlineConfirmationButton from './InlineConfirmationButton'; +import Block from '../Block/Block'; + +storiesOf('InlineConfirm', module).add('all', () => ( + +
+ + default large + +
+
+ + default medium + +
+
+ + default small + +
+
+ + primary large + +
+
+ + primary medium + +
+
+ + primary small + +
+ +
+ + destructive large + +
+
+ + destructive medium + +
+
+ + destructive small + +
+
+ + With icons + +
+
+)); From a67daefcfcb54c622667411879ee5029759a2bb0 Mon Sep 17 00:00:00 2001 From: Nathan Young Date: Fri, 13 Dec 2019 17:44:51 -0800 Subject: [PATCH 3/4] cleanup --- .../InlineConfirmationButton/InlineConfirmationButton.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Components/InlineConfirmationButton/InlineConfirmationButton.js b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js index 91ac8fbf..a522ddba 100644 --- a/src/Components/InlineConfirmationButton/InlineConfirmationButton.js +++ b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js @@ -47,6 +47,10 @@ const propTypes = { * @type {PropTypes.Requireable} */ size: PropTypes.oneOf(['small', 'medium', 'large']), + /** + * Make the button have more visual weight to identify the primary call to action + */ + primary: PropTypes.bool, /** * Indicate that the button will perform a destructive action */ @@ -65,7 +69,7 @@ const defaultProps = { }; const variants = { - popUp: custom => { + popUp: () => { return { opacity: [0, 1], x: [24, 0], @@ -104,6 +108,7 @@ const InlineConfirmationButton = React.forwardRef((props, ref) => { iconAfterText={iconAfterText} onClick={() => setInitiated(true)} className={className} + ref={ref} > {children} From c751c21df6edf722828cf125b6546ff3d8ea18fc Mon Sep 17 00:00:00 2001 From: Nathan Young Date: Sun, 22 Dec 2019 10:05:17 -0800 Subject: [PATCH 4/4] automatically hide confirmation message after delay --- .../InlineConfirmationButton.js | 80 +++++++++++++++++-- .../InlineConfirmationButton.test.js | 29 ++++++- .../InlineConfirmationButton/Readme.md | 53 ++++++++++++ 3 files changed, 152 insertions(+), 10 deletions(-) create mode 100644 src/Components/InlineConfirmationButton/Readme.md diff --git a/src/Components/InlineConfirmationButton/InlineConfirmationButton.js b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js index a522ddba..01e7cf4b 100644 --- a/src/Components/InlineConfirmationButton/InlineConfirmationButton.js +++ b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { motion } from 'framer-motion'; import Block from '../Block/Block'; @@ -38,6 +38,11 @@ const propTypes = { * The text displayed after the initial button is pressed. */ confirmationText: PropTypes.string.isRequired, + /** + * Time in milliseconds, the confirmation message should + * stay visible until it hides itself + */ + confirmDelay: PropTypes.number.isRequired, /** * The function to execute if the confirmation button is pressed. */ @@ -55,15 +60,36 @@ const propTypes = { * Indicate that the button will perform a destructive action */ danger: PropTypes.bool, + /** + * Render the button as inline text without padding + */ + plain: PropTypes.bool, + /** + * Button takes up the full width of its parent container + */ + fullWidth: PropTypes.bool, /** * Contents of the initial button */ children: PropTypes.node.isRequired, + /** + * Callback when button is pressed + */ + onClick: PropTypes.func, + /** + * Callback when button receives focus + */ + onFocus: PropTypes.func, + /** + * Callback when focus leaves button + */ + onBlur: PropTypes.func, }; const defaultProps = { disabled: false, confirmBtnLabel: 'Yes', + confirmDelay: 5000, rejectBtnLabel: 'No', confirmationText: 'Are you sure?', }; @@ -81,22 +107,55 @@ const variants = { }, }; +/** + * Inline confirmation buttons may be used when an additional + * response is required by the user to perform an action + * (deleting or changing the state). This is a less disruptive + * alternative to using a [Modal](/#/Components/Modal) + * to confirm an action, because the confirmation is displayed in + * the button's original on screen location. + */ + const InlineConfirmationButton = React.forwardRef((props, ref) => { const { - disabled, + children, className, - confirmBtnLabel, - rejectBtnLabel, confirmationText, + confirmBtnLabel, + confirmDelay, + danger, + disabled, + fullWidth, handleConfirmation, icon, iconAfterText, - size, + onClick, + onFocus, + onBlur, + plain, primary, - danger, - children, + rejectBtnLabel, + size, } = props; const [initiated, setInitiated] = useState(false); + + const handleClick = () => { + setInitiated(true); + + onClick && onClick(); + }; + + useEffect(() => { + if (!initiated) return; + + const cancelConfirm = setTimeout(() => { + setInitiated(false); + }, confirmDelay); + return () => { + clearTimeout(cancelConfirm); + }; + }, [confirmDelay, initiated]); + if (!initiated) { return (