diff --git a/src/Components/InlineConfirmationButton/InlineConfirmationButton.js b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js new file mode 100644 index 00000000..01e7cf4b --- /dev/null +++ b/src/Components/InlineConfirmationButton/InlineConfirmationButton.js @@ -0,0 +1,217 @@ +import React, { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; + +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 = { + /** + * 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 pressed. + * Clicking the button with this label with confirm the action and execute the `handleConfirmation` function. + */ + 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 pressed. + * Clicking the button with this label will result in no action being taken. + */ + rejectBtnLabel: PropTypes.string.isRequired, + /** + * 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. + */ + handleConfirmation: PropTypes.func.isRequired, + /** + * Changes the size of the button, giving it more or less padding and font size + * @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 + */ + 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?', +}; + +const variants = { + popUp: () => { + return { + opacity: [0, 1], + x: [24, 0], + transition: { + duration: 0.2, + delay: 0.1, + }, + }; + }, +}; + +/** + * 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 { + children, + className, + confirmationText, + confirmBtnLabel, + confirmDelay, + danger, + disabled, + fullWidth, + handleConfirmation, + icon, + iconAfterText, + onClick, + onFocus, + onBlur, + plain, + primary, + 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 ( + + ); + } + return ( + // TODO: make style options configurable + + + + {confirmationText} + + + + + + + + ); +}); + +InlineConfirmationButton.propTypes = propTypes; +InlineConfirmationButton.defaultProps = defaultProps; +InlineConfirmationButton.displayName = 'InlineConfirmationButton'; + +export default InlineConfirmationButton; diff --git a/src/Components/InlineConfirmationButton/InlineConfirmationButton.test.js b/src/Components/InlineConfirmationButton/InlineConfirmationButton.test.js new file mode 100644 index 00000000..6c541827 --- /dev/null +++ b/src/Components/InlineConfirmationButton/InlineConfirmationButton.test.js @@ -0,0 +1,136 @@ +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 up onClick and handleConfirmation', async () => { + const handlerFn = jest.fn(); + const clickFn = jest.fn(); + const label = 'the button label'; + + const { getByText } = render( + + {label} + , + ); + + expect(getByText(label)).toBeDefined(); + fireEvent.click(getByText(label)); + expect(clickFn).toHaveBeenCalled(); + 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); + }); + + it('renders initial button after waiting for the confirmDelay', async () => { + const handlerFn = jest.fn(); + const label = 'the button label'; + + const { getByText } = render( + + {label} + , + ); + fireEvent.click(getByText(label)); + await wait(() => + expect(getByText('Are you sure?')).toBeDefined(), + ); + // wait for original button to appear + await wait(() => expect(getByText(label)).toBeDefined()); + }); +}); diff --git a/src/Components/InlineConfirmationButton/Readme.md b/src/Components/InlineConfirmationButton/Readme.md new file mode 100644 index 00000000..295ba8ff --- /dev/null +++ b/src/Components/InlineConfirmationButton/Readme.md @@ -0,0 +1,53 @@ +## Examples + +```js +import Block from '../Block/Block'; + + + alert('confirmed')} + danger + > + Danger Confirm + + alert('confirmed')} + > + Default Confirm + + alert('confirmed')} + primary + > + Primary Confirm + + alert('confirmed')} + plain + > + Plain Confirm + + alert('confirmed')} + plain + danger + > + Plain Danger Confirm + +; +``` + +### Customize the Text + +```jsx + alert('confirmed')} + danger +> + Delete Object + +``` 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 + +
+
+));