From 9e2c1037e0441369d2d4a8897705719d4242d1d4 Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Mon, 1 Nov 2021 13:39:02 -0500 Subject: [PATCH 01/11] chore: check in progress --- packages/react/src/components/Modal/index.js | 10 +- .../react/src/components/Modal/next/Modal.js | 561 ++++++++++++++++++ .../components/Modal/next/Modal.stories.js | 347 +++++++++++ 3 files changed, 917 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/components/Modal/next/Modal.js create mode 100644 packages/react/src/components/Modal/next/Modal.stories.js diff --git a/packages/react/src/components/Modal/index.js b/packages/react/src/components/Modal/index.js index b312b576b811..f0e5e145bfb9 100644 --- a/packages/react/src/components/Modal/index.js +++ b/packages/react/src/components/Modal/index.js @@ -4,5 +4,13 @@ * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ +import { default as ModalNext } from './next/Modal'; -export default from './Modal'; +import { default as ModalClassic } from './Modal'; +import { createComponentToggle } from '../../internal/ComponentToggle'; + +export const Modal = createComponentToggle({ + name: 'Modal', + next: ModalNext, + classic: ModalClassic, +}); diff --git a/packages/react/src/components/Modal/next/Modal.js b/packages/react/src/components/Modal/next/Modal.js new file mode 100644 index 000000000000..0e29b0151e0d --- /dev/null +++ b/packages/react/src/components/Modal/next/Modal.js @@ -0,0 +1,561 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import { settings } from 'carbon-components'; +import { Close20 } from '@carbon/icons-react'; +import toggleClass from '../../tools/toggleClass'; +import Button from '../Button'; +import ButtonSet from '../ButtonSet'; +import deprecate from '../../prop-types/deprecate'; +import requiredIfGivenPropIsTruthy from '../../prop-types/requiredIfGivenPropIsTruthy'; +import wrapFocus, { + elementOrParentIsFloatingMenu, +} from '../../internal/wrapFocus'; +import setupGetInstanceId from '../../tools/setupGetInstanceId'; + +const { prefix } = settings; +const getInstanceId = setupGetInstanceId(); + +export default class Modal extends Component { + static propTypes = { + /** + * Specify whether the Modal is displaying an alert, error or warning + * Should go hand in hand with the danger prop. + */ + alert: PropTypes.bool, + + /** + * Required props for the accessibility label of the header + */ + ['aria-label']: requiredIfGivenPropIsTruthy( + 'hasScrollingContent', + PropTypes.string + ), + + /** + * Provide the contents of your Modal + */ + children: PropTypes.node, + + /** + * Specify an optional className to be applied to the modal root node + */ + className: PropTypes.string, + + /** + * Specify an label for the close button of the modal; defaults to close + */ + closeButtonLabel: PropTypes.string, + + /** + * Specify whether the Modal is for dangerous actions + */ + danger: PropTypes.bool, + + /** + * Deprecated; Used for advanced focus-wrapping feature using 3rd party library, + * but it's now achieved without a 3rd party library. + */ + focusTrap: deprecate( + PropTypes.bool, + `\nThe prop \`focusTrap\` for Modal has been deprecated, as the feature of \`focusTrap\` runs by default.` + ), + + /** + * Deprecated: Used to determine whether the modal content has a form element to adjust spacing, + * but now spacing styles account for all types of elements + */ + hasForm: deprecate( + PropTypes.bool, + `\nThe prop \`hasForm\` for Modal has been deprecated, as the feature of \`hasForm\` runs by default.` + ), + + /** + * Specify whether the modal contains scrolling content + */ + hasScrollingContent: PropTypes.bool, + + /** + * Provide a description for "close" icon that can be read by screen readers + */ + iconDescription: deprecate( + PropTypes.string, + 'The iconDescription prop is no longer needed and can be safely removed. This prop will be removed in the next major release of Carbon.' + ), + + /** + * Specify the DOM element ID of the top-level node. + */ + id: PropTypes.string, + + /** + * Specify a label to be read by screen readers on the modal root node + */ + modalAriaLabel: PropTypes.string, + + /** + * Specify the content of the modal header title. + */ + modalHeading: PropTypes.node, + + /** + * Specify the content of the modal header label. + */ + modalLabel: PropTypes.node, + + /** + * Specify a handler for keypresses. + */ + onKeyDown: PropTypes.func, + + /** + * Specify a handler for closing modal. + * The handler should care of closing modal, e.g. changing `open` prop. + */ + onRequestClose: PropTypes.func, + + /** + * Specify a handler for "submitting" modal. + * The handler should care of closing modal, e.g. changing `open` prop, if necessary. + */ + onRequestSubmit: PropTypes.func, + + /** + * Specify a handler for the secondary button. + * Useful if separate handler from `onRequestClose` is desirable + */ + onSecondarySubmit: PropTypes.func, + + /** + * Specify whether the Modal is currently open + */ + open: PropTypes.bool, + + /** + * Specify whether the modal should be button-less + */ + passiveModal: PropTypes.bool, + + /** + * Prevent closing on click outside of modal + */ + preventCloseOnClickOutside: PropTypes.bool, + + /** + * Specify whether the Button should be disabled, or not + */ + primaryButtonDisabled: PropTypes.bool, + + /** + * Specify the text for the primary button + */ + primaryButtonText: PropTypes.node, + + /** + * Specify the text for the secondary button + */ + secondaryButtonText: PropTypes.node, + + /** + * Specify an array of config objects for secondary buttons + * (`Array<{ + * buttonText: string, + * onClick: function, + * }>`). + */ + secondaryButtons: (props, propName, componentName) => { + if (props.secondaryButtons) { + if ( + !Array.isArray(props.secondaryButtons) || + props.secondaryButtons.length !== 2 + ) { + return new Error( + `${propName} needs to be an array of two button config objects` + ); + } + + const shape = { + buttonText: PropTypes.node, + onClick: PropTypes.func, + }; + + props[propName].forEach((secondaryButton) => { + PropTypes.checkPropTypes( + shape, + secondaryButton, + propName, + componentName + ); + }); + } + + return null; + }, + + /** + * Specify a CSS selector that matches the DOM element that should + * be focused when the Modal opens + */ + selectorPrimaryFocus: PropTypes.string, + + /** + * Specify CSS selectors that match DOM elements working as floating menus. + * Focusing on those elements won't trigger "focus-wrap" behavior + */ + selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string), + + /** + * Specify if Enter key should be used as "submit" action + */ + shouldSubmitOnEnter: PropTypes.bool, + + /** + * Specify the size variant. + */ + size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), + }; + + static defaultProps = { + onRequestClose: () => {}, + onRequestSubmit: () => {}, + primaryButtonDisabled: false, + onKeyDown: () => {}, + passiveModal: false, + modalHeading: '', + modalLabel: '', + preventCloseOnClickOutside: false, + selectorPrimaryFocus: '[data-modal-primary-focus]', + hasScrollingContent: false, + }; + + button = React.createRef(); + secondaryButton = React.createRef(); + outerModal = React.createRef(); + innerModal = React.createRef(); + startTrap = React.createRef(); + endTrap = React.createRef(); + modalInstanceId = `modal-${getInstanceId()}`; + modalLabelId = `${prefix}--modal-header__label--${this.modalInstanceId}`; + modalHeadingId = `${prefix}--modal-header__heading--${this.modalInstanceId}`; + modalBodyId = `${prefix}--modal-body--${this.modalInstanceId}`; + modalCloseButtonClass = `${prefix}--modal-close`; + + isCloseButton = (element) => { + return ( + (!this.props.onSecondarySubmit && + element === this.secondaryButton.current) || + element.classList.contains(this.modalCloseButtonClass) + ); + }; + + handleKeyDown = (evt) => { + if (this.props.open) { + if (evt.which === 27) { + this.props.onRequestClose(evt); + } + if ( + evt.which === 13 && + this.props.shouldSubmitOnEnter && + !this.isCloseButton(evt.target) + ) { + this.props.onRequestSubmit(evt); + } + } + }; + + handleMousedown = (evt) => { + if ( + this.innerModal.current && + !this.innerModal.current.contains(evt.target) && + !elementOrParentIsFloatingMenu( + evt.target, + this.props.selectorsFloatingMenus + ) && + !this.props.preventCloseOnClickOutside + ) { + this.props.onRequestClose(evt); + } + }; + + handleBlur = ({ + target: oldActiveNode, + relatedTarget: currentActiveNode, + }) => { + const { open, selectorsFloatingMenus } = this.props; + if (open && currentActiveNode && oldActiveNode) { + const { current: bodyNode } = this.innerModal; + const { current: startTrapNode } = this.startTrap; + const { current: endTrapNode } = this.endTrap; + wrapFocus({ + bodyNode, + startTrapNode, + endTrapNode, + currentActiveNode, + oldActiveNode, + selectorsFloatingMenus, + }); + } + }; + + componentDidUpdate(prevProps) { + if (!prevProps.open && this.props.open) { + this.beingOpen = true; + } else if (prevProps.open && !this.props.open) { + this.beingOpen = false; + } + toggleClass( + document.body, + `${prefix}--body--with-modal-open`, + this.props.open + ); + } + + initialFocus = (focusContainerElement) => { + const containerElement = focusContainerElement || this.innerModal.current; + const primaryFocusElement = containerElement + ? containerElement.querySelector(this.props.selectorPrimaryFocus) + : null; + + if (primaryFocusElement) { + return primaryFocusElement; + } + + return this.button && this.button.current; + }; + + focusButton = (focusContainerElement) => { + const target = this.initialFocus(focusContainerElement); + if (target) { + target.focus(); + } + }; + + componentWillUnmount() { + toggleClass(document.body, `${prefix}--body--with-modal-open`, false); + } + + componentDidMount() { + toggleClass( + document.body, + `${prefix}--body--with-modal-open`, + this.props.open + ); + if (!this.props.open) { + return; + } + this.focusButton(this.innerModal.current); + } + + handleTransitionEnd = (evt) => { + if ( + evt.target === evt.currentTarget && // Not to handle `onTransitionEnd` on child DOM nodes + this.outerModal.current && + this.outerModal.current.offsetWidth && + this.outerModal.current.offsetHeight && + this.beingOpen + ) { + this.focusButton(evt.currentTarget); + this.beingOpen = false; + } + }; + + render() { + const { + modalHeading, + modalLabel, + modalAriaLabel, + passiveModal, + hasForm, + secondaryButtonText, + primaryButtonText, + open, + onRequestClose, + onRequestSubmit, + onSecondarySubmit, + iconDescription, + primaryButtonDisabled, + danger, + alert, + secondaryButtons, + selectorPrimaryFocus, // eslint-disable-line + selectorsFloatingMenus, // eslint-disable-line + shouldSubmitOnEnter, // eslint-disable-line + size, + hasScrollingContent, + closeButtonLabel, + preventCloseOnClickOutside, // eslint-disable-line + ...other + } = this.props; + + const onSecondaryButtonClick = onSecondarySubmit + ? onSecondarySubmit + : onRequestClose; + + const modalClasses = classNames({ + [`${prefix}--modal`]: true, + [`${prefix}--modal-tall`]: !passiveModal, + 'is-visible': open, + [`${prefix}--modal--danger`]: this.props.danger, + [this.props.className]: this.props.className, + }); + + const containerClasses = classNames(`${prefix}--modal-container`, { + [`${prefix}--modal-container--${size}`]: size, + }); + + const contentClasses = classNames(`${prefix}--modal-content`, { + [`${prefix}--modal-content--with-form`]: hasForm, //TO-DO: deprecate & remove this with v11 + [`${prefix}--modal-scroll-content`]: hasScrollingContent, + }); + + const footerClasses = classNames(`${prefix}--modal-footer`, { + [`${prefix}--modal-footer--three-button`]: + Array.isArray(secondaryButtons) && secondaryButtons.length === 2, + }); + + const modalButton = ( + + ); + + const ariaLabel = + modalLabel || this.props['aria-label'] || modalAriaLabel || modalHeading; + const getAriaLabelledBy = modalLabel + ? this.modalLabelId + : this.modalHeadingId; + + const hasScrollingContentProps = hasScrollingContent + ? { + tabIndex: 0, + role: 'region', + 'aria-label': ariaLabel, + 'aria-labelledby': getAriaLabelledBy, + } + : {}; + + const alertDialogProps = {}; + if (alert && passiveModal) { + alertDialogProps.role = 'alert'; + } + if (alert && !passiveModal) { + alertDialogProps.role = 'alertdialog'; + alertDialogProps['aria-describedby'] = this.modalBodyId; + } + + const modalBody = ( +
+
+ {passiveModal && modalButton} + {modalLabel && ( +

+ {modalLabel} +

+ )} +

+ {modalHeading} +

+ {!passiveModal && modalButton} +
+
+ {this.props.children} +
+ {hasScrollingContent && ( +
+ )} + {!passiveModal && ( + + {Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 + ? secondaryButtons.map( + ({ buttonText, onClick: onButtonClick }, i) => ( + + ) + ) + : secondaryButtonText && ( + + )} + + + )} +
+ ); + + return ( +
+ {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel + + {modalBody} + {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel + +
+ ); + } +} diff --git a/packages/react/src/components/Modal/next/Modal.stories.js b/packages/react/src/components/Modal/next/Modal.stories.js new file mode 100644 index 000000000000..86556df7f226 --- /dev/null +++ b/packages/react/src/components/Modal/next/Modal.stories.js @@ -0,0 +1,347 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import ReactDOM from 'react-dom'; +import { action } from '@storybook/addon-actions'; +import { + boolean, + object, + optionsKnob as options, + select, + text, + withKnobs, +} from '@storybook/addon-knobs'; +import Modal from '../Modal'; +import Button from '../Button'; +import Select from '../Select'; +import MultiSelect from '../MultiSelect'; +import Dropdown from '../Dropdown'; +import SelectItem from '../SelectItem'; +import TextInput from '../TextInput'; +import mdx from './Modal.mdx'; + +const sizes = { + 'Extra small (xs)': 'xs', + 'Small (sm)': 'sm', + 'Medium (md)': 'md', + 'Large (lg)': 'lg', +}; + +const buttons = { + 'None (0)': '0', + 'One (1)': '1', + 'Two (2)': '2', + 'Three (3)': '3', +}; + +const props = { + modal: () => ({ + numberOfButtons: options('Number of Buttons', buttons, '2', { + display: 'inline-radio', + }), + className: 'some-class', + open: boolean('Open (open)', true), + danger: boolean('Danger mode (danger)', false), + alert: boolean('Alert mode (alert)', false), + shouldSubmitOnEnter: boolean( + 'Enter key to submit (shouldSubmitOnEnter)', + false + ), + hasScrollingContent: boolean( + 'Modal contains scrollable content (hasScrollingContent)', + false + ), + hasForm: boolean('Modal contains a form (hasForm)', false), + modalHeading: text('Modal heading (modalHeading)', 'Modal heading'), + modalLabel: text('Optional label (modalLabel)', 'Label'), + modalAriaLabel: text( + 'ARIA label, used only if modalLabel not provided (modalAriaLabel)', + 'A label to be read by screen readers on the modal root node' + ), + selectorPrimaryFocus: text( + 'Primary focus element selector (selectorPrimaryFocus)', + '[data-modal-primary-focus]' + ), + size: select('Size (size)', sizes, 'md'), + onBlur: action('onBlur'), + onClick: action('onClick'), + onFocus: action('onFocus'), + onRequestClose: action('onRequestClose'), + onRequestSubmit: action('onRequestSubmit'), + onSecondarySubmit: action('onSecondarySubmit'), + preventCloseOnClickOutside: boolean( + 'Prevent closing on click outside of modal (preventCloseOnClickOutside)', + true + ), + primaryButtonDisabled: boolean( + 'Disable primary button (primaryButtonDisabled)', + false + ), + primaryButtonText: text( + 'Primary button text (primaryButtonText)', + 'Primary button' + ), + }), + modalFooter: (numberOfButtons) => { + const secondaryButtons = () => { + switch (numberOfButtons) { + case '2': + return { + secondaryButtonText: text( + 'Secondary button text (secondaryButtonText in )', + 'Secondary button' + ), + }; + case '3': + return { + secondaryButtons: object( + 'Secondary button config array (secondaryButtons)', + [ + { + buttonText: 'Keep both', + onClick: action('onClick'), + }, + { + buttonText: 'Rename', + onClick: action('onClick'), + }, + ] + ), + }; + default: + return null; + } + }; + return { + passiveModal: boolean( + 'Without footer (passiveModal)', + false || numberOfButtons === '0' + ), + ...secondaryButtons(), + }; + }, +}; + +export default { + title: 'Components/Modal', + decorators: [withKnobs], + parameters: { + component: Modal, + docs: { + page: mdx, + }, + }, +}; + +export const Default = () => { + return ( + +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+ + + + (item ? item.text : '')} + /> +
+ ); +}; + +export const Playground = () => { + const { + size, + numberOfButtons, + hasScrollingContent, + ...modalProps + } = props.modal(); + const { passiveModal, ...footerProps } = props.modalFooter(numberOfButtons); + return ( + +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+ + +
+ {hasScrollingContent && ( + <> +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie + tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie + tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie + tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

Lorem ipsum

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie + tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie + tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie + tellus. Quisque consectetur non risus eu rutrum.{' '} +

+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean id + accumsan augue. Phasellus consequat augue vitae tellus tincidunt + posuere. Curabitur justo urna, consectetur vel elit iaculis, + ultrices condimentum risus. Nulla facilisi. Etiam venenatis molestie + tellus. Quisque consectetur non risus eu rutrum.{' '} +

+ + )} +
+ ); +}; + +export const WithStateManager = () => { + /** + * Simple state manager for modals. + */ + const ModalStateManager = ({ + renderLauncher: LauncherContent, + children: ModalContent, + }) => { + const [open, setOpen] = useState(false); + return ( + <> + {!ModalContent || typeof document === 'undefined' + ? null + : ReactDOM.createPortal( + , + document.body + )} + {LauncherContent && } + + ); + }; + return ( + ( + + )}> + {({ open, setOpen }) => ( + setOpen(false)}> +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a shared + domain, a shared subdomain, or a shared domain and host. +

+ + +
+ )} +
+ ); +}; + +export const PassiveModal = () => { + return ( + + ); +}; From e13412650d211660622a4d0b471229611dfe74e6 Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Mon, 1 Nov 2021 15:46:57 -0500 Subject: [PATCH 02/11] chore: check in progress --- .../react/src/components/Modal/next/Modal.js | 530 +++++++++++++++++- 1 file changed, 526 insertions(+), 4 deletions(-) diff --git a/packages/react/src/components/Modal/next/Modal.js b/packages/react/src/components/Modal/next/Modal.js index 0e29b0151e0d..f8e24cb0f483 100644 --- a/packages/react/src/components/Modal/next/Modal.js +++ b/packages/react/src/components/Modal/next/Modal.js @@ -6,9 +6,8 @@ */ import PropTypes from 'prop-types'; -import React, { Component } from 'react'; +import React, { Component, useState, useRef } from 'react'; import classNames from 'classnames'; -import { settings } from 'carbon-components'; import { Close20 } from '@carbon/icons-react'; import toggleClass from '../../tools/toggleClass'; import Button from '../Button'; @@ -19,11 +18,534 @@ import wrapFocus, { elementOrParentIsFloatingMenu, } from '../../internal/wrapFocus'; import setupGetInstanceId from '../../tools/setupGetInstanceId'; +import { usePrefix } from '../../../internal/usePrefix'; -const { prefix } = settings; const getInstanceId = setupGetInstanceId(); -export default class Modal extends Component { +function Modal({ + modalHeading, + modalLabel, + modalAriaLabel, + passiveModal, + hasForm, + secondaryButtonText, + primaryButtonText, + open, + onRequestClose, + onRequestSubmit, + onSecondarySubmit, + iconDescription, + primaryButtonDisabled, + danger, + alert, + secondaryButtons, + selectorPrimaryFocus, // eslint-disable-line + selectorsFloatingMenus, // eslint-disable-line + shouldSubmitOnEnter, // eslint-disable-line + size, + hasScrollingContent, + closeButtonLabel, + preventCloseOnClickOutside, // eslint-disable-line + ...other +}) { + const prefix = usePrefix(); + const button = useRef(); + const secondaryButton = useRef(); + const outerModal = useRef(); + const innerModal = useRef(); + const startTrap = useRef(); + const endTrap = useRef(); + const modalInstanceId = `modal-${getInstanceId()}`; + const modalLabelId = `${prefix}--modal-header__label--${modalInstanceId}`; + const modalHeadingId = `${prefix}--modal-header__heading--${modalInstanceId}`; + const modalBodyId = `${prefix}--modal-body--${modalInstanceId}`; + const modalCloseButtonClass = `${prefix}--modal-close`; + + const isCloseButton = (element) => { + return ( + (!onSecondarySubmit && element === secondaryButton.current) || + element.classList.contains(modalCloseButtonClass) + ); + }; + + const handleKeyDown = (evt) => { + if (open) { + if (evt.which === 27) { + onRequestClose(evt); + } + if ( + evt.which === 13 && + shouldSubmitOnEnter && + !isCloseButton(evt.target) + ) { + onRequestSubmit(evt); + } + } + }; + + const handleMousedown = (evt) => { + if ( + innerModal.current && + !innerModal.current.contains(evt.target) && + !elementOrParentIsFloatingMenu(evt.target, selectorsFloatingMenus) && + !preventCloseOnClickOutside + ) { + onRequestClose(evt); + } + }; + + const handleBlur = ({ + target: oldActiveNode, + relatedTarget: currentActiveNode, + }) => { + const { open, selectorsFloatingMenus } = props; + if (open && currentActiveNode && oldActiveNode) { + const { current: bodyNode } = innerModal; + const { current: startTrapNode } = startTrap; + const { current: endTrapNode } = endTrap; + wrapFocus({ + bodyNode, + startTrapNode, + endTrapNode, + currentActiveNode, + oldActiveNode, + selectorsFloatingMenus, + }); + } + }; + + // componentDidUpdate(prevProps) { + // if (!prevProps.open && open) { + // beingOpen = true; + // } else if (prevProps.open && !open) { + // beingOpen = false; + // } + // toggleClass( + // document.body, + // `${prefix}--body--with-modal-open`, + // open + // ); + // } + + const initialFocus = (focusContainerElement) => { + const containerElement = focusContainerElement || innerModal.current; + const primaryFocusElement = containerElement + ? containerElement.querySelector(selectorPrimaryFocus) + : null; + + if (primaryFocusElement) { + return primaryFocusElement; + } + + return button && button.current; + }; + + const focusButton = (focusContainerElement) => { + const target = initialFocus(focusContainerElement); + if (target) { + target.focus(); + } + }; + + // componentWillUnmount() { + // toggleClass(document.body, `${prefix}--body--with-modal-open`, false); + // } + + // componentDidMount() { + // toggleClass( + // document.body, + // `${prefix}--body--with-modal-open`, + // open + // ); + // if (!open) { + // return; + // } + // focusButton(innerModal.current); + // } + + const handleTransitionEnd = (evt) => { + if ( + evt.target === evt.currentTarget && // Not to handle `onTransitionEnd` on child DOM nodes + outerModal.current && + outerModal.current.offsetWidth && + outerModal.current.offsetHeight && + beingOpen + ) { + focusButton(evt.currentTarget); + beingOpen = false; + } + }; + + const onSecondaryButtonClick = onSecondarySubmit + ? onSecondarySubmit + : onRequestClose; + + const modalClasses = classNames(`${prefix}--modal`, { + [`${prefix}--modal-tall`]: !passiveModal, + 'is-visible': open, + [`${prefix}--modal--danger`]: danger, + [className]: className, + }); + + const containerClasses = classNames(`${prefix}--modal-container`, { + [`${prefix}--modal-container--${size}`]: size, + }); + + const contentClasses = classNames(`${prefix}--modal-content`, { + [`${prefix}--modal-content--with-form`]: hasForm, //TO-DO: deprecate & remove this with v11 + [`${prefix}--modal-scroll-content`]: hasScrollingContent, + }); + + const footerClasses = classNames(`${prefix}--modal-footer`, { + [`${prefix}--modal-footer--three-button`]: + Array.isArray(secondaryButtons) && secondaryButtons.length === 2, + }); + + const modalButton = ( + + ); + + const ariaLabel = + modalLabel || props['aria-label'] || modalAriaLabel || modalHeading; + const getAriaLabelledBy = modalLabel ? modalLabelId : modalHeadingId; + + const hasScrollingContentProps = hasScrollingContent + ? { + tabIndex: 0, + role: 'region', + 'aria-label': ariaLabel, + 'aria-labelledby': getAriaLabelledBy, + } + : {}; + + const alertDialogProps = {}; + if (alert && passiveModal) { + alertDialogProps.role = 'alert'; + } + if (alert && !passiveModal) { + alertDialogProps.role = 'alertdialog'; + alertDialogProps['aria-describedby'] = modalBodyId; + } + + const modalBody = ( +
+
+ {passiveModal && modalButton} + {modalLabel && ( +

+ {modalLabel} +

+ )} +

+ {modalHeading} +

+ {!passiveModal && modalButton} +
+
+ {children} +
+ {hasScrollingContent && ( +
+ )} + {!passiveModal && ( + + {Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 + ? secondaryButtons.map( + ({ buttonText, onClick: onButtonClick }, i) => ( + + ) + ) + : secondaryButtonText && ( + + )} + + + )} +
+ ); + + return ( +
+ {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel + + {modalBody} + {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel + +
+ ); +} + +Modal.propTypes = { + /** + * Specify whether the Modal is displaying an alert, error or warning + * Should go hand in hand with the danger prop. + */ + alert: PropTypes.bool, + + /** + * Required props for the accessibility label of the header + */ + ['aria-label']: requiredIfGivenPropIsTruthy( + 'hasScrollingContent', + PropTypes.string + ), + + /** + * Provide the contents of your Modal + */ + children: PropTypes.node, + + /** + * Specify an optional className to be applied to the modal root node + */ + className: PropTypes.string, + + /** + * Specify an label for the close button of the modal; defaults to close + */ + closeButtonLabel: PropTypes.string, + + /** + * Specify whether the Modal is for dangerous actions + */ + danger: PropTypes.bool, + + /** + * Deprecated; Used for advanced focus-wrapping feature using 3rd party library, + * but it's now achieved without a 3rd party library. + */ + focusTrap: deprecate( + PropTypes.bool, + `\nThe prop \`focusTrap\` for Modal has been deprecated, as the feature of \`focusTrap\` runs by default.` + ), + + /** + * Deprecated: Used to determine whether the modal content has a form element to adjust spacing, + * but now spacing styles account for all types of elements + */ + hasForm: deprecate( + PropTypes.bool, + `\nThe prop \`hasForm\` for Modal has been deprecated, as the feature of \`hasForm\` runs by default.` + ), + + /** + * Specify whether the modal contains scrolling content + */ + hasScrollingContent: PropTypes.bool, + + /** + * Provide a description for "close" icon that can be read by screen readers + */ + iconDescription: deprecate( + PropTypes.string, + 'The iconDescription prop is no longer needed and can be safely removed. This prop will be removed in the next major release of Carbon.' + ), + + /** + * Specify the DOM element ID of the top-level node. + */ + id: PropTypes.string, + + /** + * Specify a label to be read by screen readers on the modal root node + */ + modalAriaLabel: PropTypes.string, + + /** + * Specify the content of the modal header title. + */ + modalHeading: PropTypes.node, + + /** + * Specify the content of the modal header label. + */ + modalLabel: PropTypes.node, + + /** + * Specify a handler for keypresses. + */ + onKeyDown: PropTypes.func, + + /** + * Specify a handler for closing modal. + * The handler should care of closing modal, e.g. changing `open` prop. + */ + onRequestClose: PropTypes.func, + + /** + * Specify a handler for "submitting" modal. + * The handler should care of closing modal, e.g. changing `open` prop, if necessary. + */ + onRequestSubmit: PropTypes.func, + + /** + * Specify a handler for the secondary button. + * Useful if separate handler from `onRequestClose` is desirable + */ + onSecondarySubmit: PropTypes.func, + + /** + * Specify whether the Modal is currently open + */ + open: PropTypes.bool, + + /** + * Specify whether the modal should be button-less + */ + passiveModal: PropTypes.bool, + + /** + * Prevent closing on click outside of modal + */ + preventCloseOnClickOutside: PropTypes.bool, + + /** + * Specify whether the Button should be disabled, or not + */ + primaryButtonDisabled: PropTypes.bool, + + /** + * Specify the text for the primary button + */ + primaryButtonText: PropTypes.node, + + /** + * Specify the text for the secondary button + */ + secondaryButtonText: PropTypes.node, + + /** + * Specify an array of config objects for secondary buttons + * (`Array<{ + * buttonText: string, + * onClick: function, + * }>`). + */ + secondaryButtons: (props, propName, componentName) => { + if (props.secondaryButtons) { + if ( + !Array.isArray(props.secondaryButtons) || + props.secondaryButtons.length !== 2 + ) { + return new Error( + `${propName} needs to be an array of two button config objects` + ); + } + + const shape = { + buttonText: PropTypes.node, + onClick: PropTypes.func, + }; + + props[propName].forEach((secondaryButton) => { + PropTypes.checkPropTypes( + shape, + secondaryButton, + propName, + componentName + ); + }); + } + + return null; + }, + + /** + * Specify a CSS selector that matches the DOM element that should + * be focused when the Modal opens + */ + selectorPrimaryFocus: PropTypes.string, + + /** + * Specify CSS selectors that match DOM elements working as floating menus. + * Focusing on those elements won't trigger "focus-wrap" behavior + */ + selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string), + + /** + * Specify if Enter key should be used as "submit" action + */ + shouldSubmitOnEnter: PropTypes.bool, + + /** + * Specify the size variant. + */ + size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), +}; + +Modal.defaultProps = { + onRequestClose: () => {}, + onRequestSubmit: () => {}, + primaryButtonDisabled: false, + onKeyDown: () => {}, + passiveModal: false, + modalHeading: '', + modalLabel: '', + preventCloseOnClickOutside: false, + selectorPrimaryFocus: '[data-modal-primary-focus]', + hasScrollingContent: false, +}; + +export default class Modalz extends Component { static propTypes = { /** * Specify whether the Modal is displaying an alert, error or warning From 04b7a20d299a5a5803c17d8c2f9dd21b00d7601e Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Tue, 2 Nov 2021 11:22:49 -0500 Subject: [PATCH 03/11] chore: check in progress --- .../react/src/components/Modal/next/Modal.js | 75 +++++++++++-------- .../components/Modal/next/Modal.stories.js | 16 ++-- 2 files changed, 53 insertions(+), 38 deletions(-) diff --git a/packages/react/src/components/Modal/next/Modal.js b/packages/react/src/components/Modal/next/Modal.js index f8e24cb0f483..9759bc5feb2c 100644 --- a/packages/react/src/components/Modal/next/Modal.js +++ b/packages/react/src/components/Modal/next/Modal.js @@ -6,7 +6,7 @@ */ import PropTypes from 'prop-types'; -import React, { Component, useState, useRef } from 'react'; +import React, { Component, useState, useRef, useEffect } from 'react'; import classNames from 'classnames'; import { Close20 } from '@carbon/icons-react'; import toggleClass from '../../tools/toggleClass'; @@ -60,6 +60,50 @@ function Modal({ const modalHeadingId = `${prefix}--modal-header__heading--${modalInstanceId}`; const modalBodyId = `${prefix}--modal-body--${modalInstanceId}`; const modalCloseButtonClass = `${prefix}--modal-close`; + const [isOpen, setIsOpen] = useState(open); + const [prevOpen, setPrevOpen] = useState(open); + + if (open !== prevOpen) { + setIsOpen(open); + setPrevOpen(open); + } + + useEffect(() => { + toggleClass(document.body, `${prefix}--body--with-modal-open`, open); + if (open) { + focusButton(innerModal.current); + } + return () => + toggleClass(document.body, `${prefix}--body--with-modal-open`, false); + }, [open]); + + // componentDidMount() { + // toggleClass( + // document.body, + // `${prefix}--body--with-modal-open`, + // open + // ); + // if (!open) { + // return; + // } + // focusButton(innerModal.current); + // } + // componentDidUpdate(prevProps) { + // if (!prevProps.open && open) { + // beingOpen = true; + // } else if (prevProps.open && !open) { + // beingOpen = false; + // } + // toggleClass( + // document.body, + // `${prefix}--body--with-modal-open`, + // open + // ); + // } + + // componentWillUnmount() { + // toggleClass(document.body, `${prefix}--body--with-modal-open`, false); + // } const isCloseButton = (element) => { return ( @@ -114,19 +158,6 @@ function Modal({ } }; - // componentDidUpdate(prevProps) { - // if (!prevProps.open && open) { - // beingOpen = true; - // } else if (prevProps.open && !open) { - // beingOpen = false; - // } - // toggleClass( - // document.body, - // `${prefix}--body--with-modal-open`, - // open - // ); - // } - const initialFocus = (focusContainerElement) => { const containerElement = focusContainerElement || innerModal.current; const primaryFocusElement = containerElement @@ -147,22 +178,6 @@ function Modal({ } }; - // componentWillUnmount() { - // toggleClass(document.body, `${prefix}--body--with-modal-open`, false); - // } - - // componentDidMount() { - // toggleClass( - // document.body, - // `${prefix}--body--with-modal-open`, - // open - // ); - // if (!open) { - // return; - // } - // focusButton(innerModal.current); - // } - const handleTransitionEnd = (evt) => { if ( evt.target === evt.currentTarget && // Not to handle `onTransitionEnd` on child DOM nodes diff --git a/packages/react/src/components/Modal/next/Modal.stories.js b/packages/react/src/components/Modal/next/Modal.stories.js index 86556df7f226..d0848b7f30c8 100644 --- a/packages/react/src/components/Modal/next/Modal.stories.js +++ b/packages/react/src/components/Modal/next/Modal.stories.js @@ -16,14 +16,14 @@ import { text, withKnobs, } from '@storybook/addon-knobs'; -import Modal from '../Modal'; -import Button from '../Button'; -import Select from '../Select'; -import MultiSelect from '../MultiSelect'; -import Dropdown from '../Dropdown'; -import SelectItem from '../SelectItem'; -import TextInput from '../TextInput'; -import mdx from './Modal.mdx'; +import { Modal } from '../Modal'; +import Button from '../../Button'; +import Select from '../../Select'; +import MultiSelect from '../../MultiSelect'; +import Dropdown from '../../Dropdown'; +import SelectItem from '../../SelectItem'; +import TextInput from '../../TextInput'; +import mdx from '../Modal.mdx'; const sizes = { 'Extra small (xs)': 'xs', From 84938cb82dd15ef35d5523672034058931bb5d6a Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Wed, 3 Nov 2021 14:29:14 -0500 Subject: [PATCH 04/11] feat(react): update modal and modal footer to functional component --- .../__snapshots__/PublicAPI-test.js.snap | 107 +-- .../src/components/ComposedModal/index.js | 9 +- .../ComposedModal/next/ModalFooter-test.js | 124 ++++ .../ComposedModal/next/ModalFooter.js | 201 +++++ packages/react/src/components/Modal/index.js | 4 +- .../src/components/Modal/next/Modal-test.js | 259 +++++++ .../react/src/components/Modal/next/Modal.js | 694 ++---------------- .../components/Modal/next/Modal.stories.js | 2 +- .../ModalWrapper/ModalWrapper-test.js | 23 + .../__snapshots__/ModalWrapper-test.js.snap | 281 +++---- 10 files changed, 827 insertions(+), 877 deletions(-) create mode 100644 packages/react/src/components/ComposedModal/next/ModalFooter-test.js create mode 100644 packages/react/src/components/ComposedModal/next/ModalFooter.js create mode 100644 packages/react/src/components/Modal/next/Modal-test.js diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 3ca31cf1b651..81b55f804248 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -3810,110 +3810,9 @@ Map { }, }, "Modal" => Object { - "defaultProps": Object { - "hasScrollingContent": false, - "modalHeading": "", - "modalLabel": "", - "onKeyDown": [Function], - "onRequestClose": [Function], - "onRequestSubmit": [Function], - "passiveModal": false, - "preventCloseOnClickOutside": false, - "primaryButtonDisabled": false, - "selectorPrimaryFocus": "[data-modal-primary-focus]", - }, - "propTypes": Object { - "alert": Object { - "type": "bool", - }, - "aria-label": [Function], - "children": Object { - "type": "node", - }, - "className": Object { - "type": "string", - }, - "closeButtonLabel": Object { - "type": "string", - }, - "danger": Object { - "type": "bool", - }, - "focusTrap": [Function], - "hasForm": [Function], - "hasScrollingContent": Object { - "type": "bool", - }, - "iconDescription": [Function], - "id": Object { - "type": "string", - }, - "modalAriaLabel": Object { - "type": "string", - }, - "modalHeading": Object { - "type": "node", - }, - "modalLabel": Object { - "type": "node", - }, - "onKeyDown": Object { - "type": "func", - }, - "onRequestClose": Object { - "type": "func", - }, - "onRequestSubmit": Object { - "type": "func", - }, - "onSecondarySubmit": Object { - "type": "func", - }, - "open": Object { - "type": "bool", - }, - "passiveModal": Object { - "type": "bool", - }, - "preventCloseOnClickOutside": Object { - "type": "bool", - }, - "primaryButtonDisabled": Object { - "type": "bool", - }, - "primaryButtonText": Object { - "type": "node", - }, - "secondaryButtonText": Object { - "type": "node", - }, - "secondaryButtons": [Function], - "selectorPrimaryFocus": Object { - "type": "string", - }, - "selectorsFloatingMenus": Object { - "args": Array [ - Object { - "type": "string", - }, - ], - "type": "arrayOf", - }, - "shouldSubmitOnEnter": Object { - "type": "bool", - }, - "size": Object { - "args": Array [ - Array [ - "xs", - "sm", - "md", - "lg", - ], - ], - "type": "oneOf", - }, - }, + "$$typeof": Symbol(react.forward_ref), + "displayName": "FeatureToggle(Modal)", + "render": [Function], }, "ModalWrapper" => Object { "defaultProps": Object { diff --git a/packages/react/src/components/ComposedModal/index.js b/packages/react/src/components/ComposedModal/index.js index 597a1f655581..70d344bb3087 100644 --- a/packages/react/src/components/ComposedModal/index.js +++ b/packages/react/src/components/ComposedModal/index.js @@ -7,16 +7,21 @@ import * as FeatureFlags from '@carbon/feature-flags'; import { ModalHeader as ModalHeaderNext } from './next/ModalHeader'; +import { ModalFooter as ModalFooterNext } from './next/ModalFooter'; import ComposedModal, { ModalHeader as ModalHeaderClassic, ModalBody, - ModalFooter, + ModalFooter as ModalFooterClassic, } from './ComposedModal'; export const ModalHeader = FeatureFlags.enabled('enable-v11-release') ? ModalHeaderNext : ModalHeaderClassic; -export { ComposedModal, ModalBody, ModalFooter }; +export const ModalFooter = FeatureFlags.enabled('enable-v11-release') + ? ModalFooterNext + : ModalFooterClassic; + +export { ComposedModal, ModalBody }; export default from './ComposedModal'; diff --git a/packages/react/src/components/ComposedModal/next/ModalFooter-test.js b/packages/react/src/components/ComposedModal/next/ModalFooter-test.js new file mode 100644 index 000000000000..748d66627157 --- /dev/null +++ b/packages/react/src/components/ComposedModal/next/ModalFooter-test.js @@ -0,0 +1,124 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import Button from '../../Button'; +import { ModalFooter } from './ModalFooter'; +import InlineLoading from '../../InlineLoading'; +import { settings } from 'carbon-components'; + +const { prefix } = settings; + +describe('', () => { + describe('Renders as expected', () => { + const wrapper = mount( + +

Test

+
+ ); + + it('renders children as expected', () => { + expect(wrapper.find('p').length).toBe(1); + }); + + it('renders wrapper as expected', () => { + expect(wrapper.length).toBe(1); + }); + + it('renders extra classes passed in via className', () => { + expect(wrapper.hasClass('extra-class')).toEqual(true); + }); + }); + + describe('Should render buttons only if appropriate prop passed in', () => { + const wrapper = shallow( + +

Test

+
+ ); + + const primaryWrapper = shallow(); + const secondaryWrapper = mount(); + const multipleSecondaryWrapper = mount( + , + onClick: jest.fn(), + }, + { + buttonText: 'Cancel', + onClick: jest.fn(), + }, + ]} + /> + ); + + it('does not render primary button if no primary text', () => { + expect(wrapper.find(`.${prefix}--btn--primary`).exists()).toBe(false); + }); + + it('does not render secondary button if no secondary text', () => { + expect(wrapper.find(`.${prefix}--btn--secondary`).exists()).toBe(false); + }); + + it('renders primary button if primary text', () => { + const buttonComponent = primaryWrapper.find(Button); + expect(buttonComponent.exists()).toBe(true); + expect(buttonComponent.props().kind).toBe('primary'); + }); + + it('renders primary button if secondary text', () => { + const buttonComponent = secondaryWrapper.find(Button); + expect(buttonComponent.exists()).toBe(true); + expect(buttonComponent.props().kind).toBe('secondary'); + }); + + it('correctly renders multiple secondary buttons', () => { + const buttonComponents = multipleSecondaryWrapper.find(Button); + expect(buttonComponents.length).toEqual(2); + expect(buttonComponents.at(0).props().kind).toBe('secondary'); + expect(buttonComponents.at(1).props().kind).toBe('secondary'); + }); + }); + + describe('Should render the appropriate buttons when `danger` prop is true', () => { + const primaryWrapper = shallow( + + ); + const secondaryWrapper = mount( + + ); + const multipleSecondaryWrapper = mount( + , + onClick: jest.fn(), + }, + { + buttonText: 'Cancel', + onClick: jest.fn(), + }, + ]} + /> + ); + + it('renders danger button if primary text && danger', () => { + const buttonComponent = primaryWrapper.find(Button); + expect(buttonComponent.exists()).toBe(true); + expect(buttonComponent.props().kind).toBe('danger'); + }); + + it('renders secondary button if secondary text && danger', () => { + const buttonComponent = secondaryWrapper.find(Button); + expect(buttonComponent.exists()).toBe(true); + expect(buttonComponent.prop('kind')).toBe('secondary'); + }); + + it('correctly renders multiple secondary buttons', () => { + const buttonComponents = multipleSecondaryWrapper.find(Button); + expect(buttonComponents.length).toEqual(2); + expect(buttonComponents.at(0).props().kind).toBe('secondary'); + expect(buttonComponents.at(1).props().kind).toBe('secondary'); + }); + }); +}); diff --git a/packages/react/src/components/ComposedModal/next/ModalFooter.js b/packages/react/src/components/ComposedModal/next/ModalFooter.js new file mode 100644 index 000000000000..5d11490ddbe6 --- /dev/null +++ b/packages/react/src/components/ComposedModal/next/ModalFooter.js @@ -0,0 +1,201 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from '../../Button'; +import ButtonSet from '../../ButtonSet'; +import classNames from 'classnames'; +import { usePrefix } from '../../../internal/usePrefix'; + +export function ModalFooter({ + className, + primaryClassName, + secondaryButtons, + secondaryClassName, + secondaryButtonText, + primaryButtonText, + primaryButtonDisabled, + closeModal, // eslint-disable-line + onRequestClose, // eslint-disable-line + onRequestSubmit, // eslint-disable-line + children, + danger, + inputref, + ...other +}) { + const prefix = usePrefix(); + + function handleRequestClose(evt) { + closeModal(evt); + onRequestClose(evt); + } + + const footerClass = classNames({ + [`${prefix}--modal-footer`]: true, + [className]: className, + [`${prefix}--modal-footer--three-button`]: + Array.isArray(secondaryButtons) && secondaryButtons.length === 2, + }); + + const primaryClass = classNames({ + [primaryClassName]: primaryClassName, + }); + + const secondaryClass = classNames({ + [secondaryClassName]: secondaryClassName, + }); + + const SecondaryButtonSet = () => { + if (Array.isArray(secondaryButtons) && secondaryButtons.length <= 2) { + return secondaryButtons.map( + ({ buttonText, onClick: onButtonClick }, i) => ( + + ) + ); + } + if (secondaryButtonText) { + return ( + + ); + } + return null; + }; + + return ( + + + {primaryButtonText && ( + + )} + + {children} + + ); +} + +ModalFooter.propTypes = { + /** + * Pass in content that will be rendered in the Modal Footer + */ + children: PropTypes.node, + + /** + * Specify a custom className to be applied to the Modal Footer container + */ + className: PropTypes.string, + + /** + * Specify an optional function that is called whenever the modal is closed + */ + closeModal: PropTypes.func, + + /** + * Specify whether the primary button should be replaced with danger button. + * Note that this prop is not applied if you render primary/danger button by yourself + */ + danger: PropTypes.bool, + + /** + * The `ref` callback for the primary button. + */ + inputref: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.shape({ + current: PropTypes.any, + }), + ]), + + /** + * Specify an optional function for when the modal is requesting to be + * closed + */ + onRequestClose: PropTypes.func, + + /** + * Specify an optional function for when the modal is requesting to be + * submitted + */ + onRequestSubmit: PropTypes.func, + + /** + * Specify whether the primary button should be disabled + */ + primaryButtonDisabled: PropTypes.bool, + + /** + * Specify the text for the primary button + */ + primaryButtonText: PropTypes.string, + + /** + * Specify a custom className to be applied to the primary button + */ + primaryClassName: PropTypes.string, + + /** + * Specify the text for the secondary button + */ + secondaryButtonText: PropTypes.string, + + /** + * Specify an array of config objects for secondary buttons + * (`Array<{ + * buttonText: string, + * onClick: function, + * }>`). + */ + secondaryButtons: (props, propName, componentName) => { + if (props.secondaryButtons) { + if ( + !Array.isArray(props.secondaryButtons) || + props.secondaryButtons.length !== 2 + ) { + return new Error( + `${propName} needs to be an array of two button config objects` + ); + } + + const shape = { + buttonText: PropTypes.node, + onClick: PropTypes.func, + }; + + props[propName].forEach((secondaryButton) => { + PropTypes.checkPropTypes( + shape, + secondaryButton, + propName, + componentName + ); + }); + } + + return null; + }, + + /** + * Specify a custom className to be applied to the secondary button + */ + secondaryClassName: PropTypes.string, +}; + +ModalFooter.defaultProps = { + onRequestClose: () => {}, + onRequestSubmit: () => {}, +}; diff --git a/packages/react/src/components/Modal/index.js b/packages/react/src/components/Modal/index.js index f0e5e145bfb9..6c43412f8d12 100644 --- a/packages/react/src/components/Modal/index.js +++ b/packages/react/src/components/Modal/index.js @@ -9,8 +9,10 @@ import { default as ModalNext } from './next/Modal'; import { default as ModalClassic } from './Modal'; import { createComponentToggle } from '../../internal/ComponentToggle'; -export const Modal = createComponentToggle({ +const Modal = createComponentToggle({ name: 'Modal', next: ModalNext, classic: ModalClassic, }); + +export default Modal; diff --git a/packages/react/src/components/Modal/next/Modal-test.js b/packages/react/src/components/Modal/next/Modal-test.js new file mode 100644 index 000000000000..8b205f01eab2 --- /dev/null +++ b/packages/react/src/components/Modal/next/Modal-test.js @@ -0,0 +1,259 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { Close20 } from '@carbon/icons-react'; +import Modal from './Modal'; +import InlineLoading from '../../InlineLoading'; +import { mount } from 'enzyme'; + +const prefix = 'bx'; + +// The modal is the 0th child inside the wrapper on account of focus-trap-react +const getModal = (wrapper) => wrapper.find('.bx--modal'); +const getModalBody = (wrapper) => wrapper.find('.bx--modal-container'); + +describe('Modal', () => { + describe('Renders as expected', () => { + const wrapper = mount(); + const mounted = mount(); + + it('has the expected classes', () => { + expect(getModal(wrapper).hasClass(`${prefix}--modal`)).toEqual(true); + }); + + it('should add extra classes that are passed via className', () => { + expect(getModal(wrapper).hasClass('extra-class')).toEqual(true); + }); + + it('should not be a passive modal by default', () => { + expect(getModal(wrapper).hasClass(`${prefix}--modal-tall`)).toEqual(true); + }); + + it('should be a passive modal when passiveModal is passed', () => { + wrapper.setProps({ passiveModal: true }); + expect(getModal(wrapper).hasClass(`${prefix}--modal-tall`)).toEqual( + false + ); + }); + + it('should set id if one is passed via props', () => { + const modal = mount(); + expect(getModal(modal).props().id).toEqual('modal-1'); + }); + + it('should not place the svg icon in the accessibility tree', () => { + const ariaHidden = mounted.find(Close20).props()['aria-hidden']; + expect(ariaHidden).toEqual('true'); + }); + + it("icon isn't a focusable tab stop", () => { + const icon = mounted.find(Close20).props().tabIndex; + expect(icon).toEqual('-1'); + }); + + it('enables primary button by default', () => { + const primaryButton = mounted + .find(`.${prefix}--btn.${prefix}--btn--primary`) + .at(0); + expect(primaryButton.prop('disabled')).toEqual(false); + }); + + it('disables primary button when disablePrimaryButton prop is passed', () => { + mounted.setProps({ primaryButtonDisabled: true }); + const primaryButton = mounted + .find(`.${prefix}--btn.${prefix}--btn--primary`) + .at(0); + expect(primaryButton.props().disabled).toEqual(true); + }); + + it('Should have node in primary', () => { + mounted.setProps({ primaryButtonText: }); + const primaryButton = mounted + .find(`.${prefix}--btn.${prefix}--btn--primary`) + .at(0); + expect(primaryButton.find('InlineLoading').exists()).toEqual(true); + }); + + it('Should have node in secondary', () => { + mounted.setProps({ secondaryButtonText: }); + const secondaryButton = mounted + .find(`.${prefix}--btn.${prefix}--btn--secondary`) + .at(0); + expect(secondaryButton.find('InlineLoading').exists()).toEqual(true); + }); + }); + + describe('Renders as expected with secondaryButtons prop', () => { + const mounted = mount(); + + it('Should support node in secondary', () => { + mounted.setProps({ + secondaryButtons: [ + { + buttonText: , + onClick: jest.fn(), + }, + { + buttonText: 'Cancel', + onClick: jest.fn(), + }, + ], + }); + + const secondaryButtons = mounted.find( + `.${prefix}--btn.${prefix}--btn--secondary` + ); + expect(secondaryButtons.length).toEqual(2); + expect(secondaryButtons.at(0).find('InlineLoading').exists()).toEqual( + true + ); + expect(secondaryButtons.at(1).text()).toEqual('Cancel'); + }); + }); + + describe('Adds props as expected to the right children', () => { + it('should set label if one is passed via props', () => { + const wrapper = mount(); + const label = wrapper.find(`.${prefix}--modal-header__label`); + expect(label.props().children).toEqual('modal-1'); + }); + + it('should set modal heading if one is passed via props', () => { + const wrapper = mount(); + const heading = wrapper.find(`.${prefix}--modal-header__heading`); + expect(heading.props().children).toEqual('modal-1'); + }); + + it('should set button text if one is passed via props', () => { + const wrapper = mount( + + ); + const modalButtons = wrapper.find(`.${prefix}--modal-footer Button`); + expect(modalButtons.at(0).props().children).toEqual('Cancel'); + expect(modalButtons.at(1).props().children).toEqual('Submit'); + }); + }); + + describe('events', () => { + it('should handle close keyDown events', () => { + const onRequestClose = jest.fn(); + const wrapper = mount( + + ); + wrapper.simulate('keyDown', { which: 26 }); + expect(onRequestClose).not.toHaveBeenCalled(); + wrapper.simulate('keyDown', { which: 27 }); + expect(onRequestClose).toHaveBeenCalled(); + }); + + it('should handle submit keyDown events with shouldSubmitOnEnter enabled', () => { + const onRequestSubmit = jest.fn(); + const wrapper = mount( + + ); + wrapper.simulate('keyDown', { which: 14 }); + expect(onRequestSubmit).not.toHaveBeenCalled(); + wrapper.simulate('keyDown', { which: 13 }); + expect(onRequestSubmit).toHaveBeenCalled(); + }); + + it('should not handle submit keyDown events with shouldSubmitOnEnter not enabled', () => { + const onRequestSubmit = jest.fn(); + const wrapper = mount( + + ); + wrapper.simulate('keyDown', { which: 14 }); + expect(onRequestSubmit).not.toHaveBeenCalled(); + wrapper.simulate('keyDown', { which: 13 }); + expect(onRequestSubmit).not.toHaveBeenCalled(); + }); + + it('should close by default on secondary button click', () => { + const onRequestClose = jest.fn(); + const modal = mount( + + ); + const secondaryBtn = modal.find(`.${prefix}--btn--secondary`); + secondaryBtn.simulate('click'); + expect(onRequestClose).toHaveBeenCalled(); + }); + + it('should handle custom secondary button events', () => { + const onSecondarySubmit = jest.fn(); + const modal = mount( + + ); + const secondaryBtn = modal.find(`.${prefix}--btn--secondary`); + secondaryBtn.simulate('click'); + expect(onSecondarySubmit).toHaveBeenCalled(); + }); + }); +}); + +describe('Danger Modal', () => { + describe('Renders as expected', () => { + const wrapper = mount( + + ); + + it('has the expected classes', () => { + expect(getModal(wrapper).hasClass(`${prefix}--modal--danger`)).toEqual( + true + ); + }); + + it('has correct button combination', () => { + const modalButtons = wrapper.find( + `.${prefix}--modal-footer.${prefix}--btn-set Button` + ); + expect(modalButtons.length).toEqual(2); + expect(modalButtons.at(0).props().kind).toEqual('secondary'); + expect(modalButtons.at(1).props().kind).toEqual('danger'); + }); + }); +}); +describe('Alert Modal', () => { + describe('Renders as expected', () => { + const wrapper = mount(); + + it('has the expected attributes', () => { + expect(getModalBody(wrapper).props()).toEqual( + expect.objectContaining({ + role: 'alertdialog', + 'aria-describedby': expect.any(String), + }) + ); + }); + + it('should be a passive modal when passiveModal is passed', () => { + wrapper.setProps({ passiveModal: true }); + expect(getModalBody(wrapper).props()).toEqual( + expect.objectContaining({ + role: 'alert', + }) + ); + }); + }); +}); diff --git a/packages/react/src/components/Modal/next/Modal.js b/packages/react/src/components/Modal/next/Modal.js index 9759bc5feb2c..a37709a62617 100644 --- a/packages/react/src/components/Modal/next/Modal.js +++ b/packages/react/src/components/Modal/next/Modal.js @@ -6,23 +6,25 @@ */ import PropTypes from 'prop-types'; -import React, { Component, useState, useRef, useEffect } from 'react'; +import React, { useRef, useEffect } from 'react'; import classNames from 'classnames'; import { Close20 } from '@carbon/icons-react'; -import toggleClass from '../../tools/toggleClass'; -import Button from '../Button'; -import ButtonSet from '../ButtonSet'; -import deprecate from '../../prop-types/deprecate'; -import requiredIfGivenPropIsTruthy from '../../prop-types/requiredIfGivenPropIsTruthy'; +import toggleClass from '../../../tools/toggleClass'; +import Button from '../../Button'; +import ButtonSet from '../../ButtonSet'; +import deprecate from '../../../prop-types/deprecate'; +import requiredIfGivenPropIsTruthy from '../../../prop-types/requiredIfGivenPropIsTruthy'; import wrapFocus, { elementOrParentIsFloatingMenu, -} from '../../internal/wrapFocus'; -import setupGetInstanceId from '../../tools/setupGetInstanceId'; +} from '../../../internal/wrapFocus'; +import setupGetInstanceId from '../../../tools/setupGetInstanceId'; import { usePrefix } from '../../../internal/usePrefix'; const getInstanceId = setupGetInstanceId(); -function Modal({ +export default function Modal({ + children, + className, modalHeading, modalLabel, modalAriaLabel, @@ -60,59 +62,15 @@ function Modal({ const modalHeadingId = `${prefix}--modal-header__heading--${modalInstanceId}`; const modalBodyId = `${prefix}--modal-body--${modalInstanceId}`; const modalCloseButtonClass = `${prefix}--modal-close`; - const [isOpen, setIsOpen] = useState(open); - const [prevOpen, setPrevOpen] = useState(open); - if (open !== prevOpen) { - setIsOpen(open); - setPrevOpen(open); - } - - useEffect(() => { - toggleClass(document.body, `${prefix}--body--with-modal-open`, open); - if (open) { - focusButton(innerModal.current); - } - return () => - toggleClass(document.body, `${prefix}--body--with-modal-open`, false); - }, [open]); - - // componentDidMount() { - // toggleClass( - // document.body, - // `${prefix}--body--with-modal-open`, - // open - // ); - // if (!open) { - // return; - // } - // focusButton(innerModal.current); - // } - // componentDidUpdate(prevProps) { - // if (!prevProps.open && open) { - // beingOpen = true; - // } else if (prevProps.open && !open) { - // beingOpen = false; - // } - // toggleClass( - // document.body, - // `${prefix}--body--with-modal-open`, - // open - // ); - // } - - // componentWillUnmount() { - // toggleClass(document.body, `${prefix}--body--with-modal-open`, false); - // } - - const isCloseButton = (element) => { + function isCloseButton(element) { return ( (!onSecondarySubmit && element === secondaryButton.current) || element.classList.contains(modalCloseButtonClass) ); - }; + } - const handleKeyDown = (evt) => { + function handleKeyDown(evt) { if (open) { if (evt.which === 27) { onRequestClose(evt); @@ -125,9 +83,9 @@ function Modal({ onRequestSubmit(evt); } } - }; + } - const handleMousedown = (evt) => { + function handleMousedown(evt) { if ( innerModal.current && !innerModal.current.contains(evt.target) && @@ -136,13 +94,12 @@ function Modal({ ) { onRequestClose(evt); } - }; + } - const handleBlur = ({ + function handleBlur({ target: oldActiveNode, relatedTarget: currentActiveNode, - }) => { - const { open, selectorsFloatingMenus } = props; + }) { if (open && currentActiveNode && oldActiveNode) { const { current: bodyNode } = innerModal; const { current: startTrapNode } = startTrap; @@ -156,40 +113,7 @@ function Modal({ selectorsFloatingMenus, }); } - }; - - const initialFocus = (focusContainerElement) => { - const containerElement = focusContainerElement || innerModal.current; - const primaryFocusElement = containerElement - ? containerElement.querySelector(selectorPrimaryFocus) - : null; - - if (primaryFocusElement) { - return primaryFocusElement; - } - - return button && button.current; - }; - - const focusButton = (focusContainerElement) => { - const target = initialFocus(focusContainerElement); - if (target) { - target.focus(); - } - }; - - const handleTransitionEnd = (evt) => { - if ( - evt.target === evt.currentTarget && // Not to handle `onTransitionEnd` on child DOM nodes - outerModal.current && - outerModal.current.offsetWidth && - outerModal.current.offsetHeight && - beingOpen - ) { - focusButton(evt.currentTarget); - beingOpen = false; - } - }; + } const onSecondaryButtonClick = onSecondarySubmit ? onSecondarySubmit @@ -233,7 +157,7 @@ function Modal({ ); const ariaLabel = - modalLabel || props['aria-label'] || modalAriaLabel || modalHeading; + modalLabel || ['aria-label'] || modalAriaLabel || modalHeading; const getAriaLabelledBy = modalLabel ? modalLabelId : modalHeadingId; const hasScrollingContentProps = hasScrollingContent @@ -254,6 +178,44 @@ function Modal({ alertDialogProps['aria-describedby'] = modalBodyId; } + // cDM, cWUM + useEffect(() => { + return () => { + toggleClass(document.body, `${prefix}--body--with-modal-open`, false); + }; + }, [prefix]); + + // cDU + useEffect(() => { + toggleClass(document.body, `${prefix}--body--with-modal-open`, open); + }, [open, prefix]); + + useEffect(() => { + const initialFocus = (focusContainerElement) => { + const containerElement = focusContainerElement || innerModal.current; + const primaryFocusElement = containerElement + ? containerElement.querySelector(selectorPrimaryFocus) + : null; + + if (primaryFocusElement) { + return primaryFocusElement; + } + + return button && button.current; + }; + + const focusButton = (focusContainerElement) => { + const target = initialFocus(focusContainerElement); + if (target) { + target.focus(); + } + }; + + if (open) { + focusButton(innerModal.current); + } + }, [open, selectorPrimaryFocus]); + const modalBody = (
{/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} `). - */ - secondaryButtons: (props, propName, componentName) => { - if (props.secondaryButtons) { - if ( - !Array.isArray(props.secondaryButtons) || - props.secondaryButtons.length !== 2 - ) { - return new Error( - `${propName} needs to be an array of two button config objects` - ); - } - - const shape = { - buttonText: PropTypes.node, - onClick: PropTypes.func, - }; - - props[propName].forEach((secondaryButton) => { - PropTypes.checkPropTypes( - shape, - secondaryButton, - propName, - componentName - ); - }); - } - - return null; - }, - - /** - * Specify a CSS selector that matches the DOM element that should - * be focused when the Modal opens - */ - selectorPrimaryFocus: PropTypes.string, - - /** - * Specify CSS selectors that match DOM elements working as floating menus. - * Focusing on those elements won't trigger "focus-wrap" behavior - */ - selectorsFloatingMenus: PropTypes.arrayOf(PropTypes.string), - - /** - * Specify if Enter key should be used as "submit" action - */ - shouldSubmitOnEnter: PropTypes.bool, - - /** - * Specify the size variant. - */ - size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), - }; - - static defaultProps = { - onRequestClose: () => {}, - onRequestSubmit: () => {}, - primaryButtonDisabled: false, - onKeyDown: () => {}, - passiveModal: false, - modalHeading: '', - modalLabel: '', - preventCloseOnClickOutside: false, - selectorPrimaryFocus: '[data-modal-primary-focus]', - hasScrollingContent: false, - }; - - button = React.createRef(); - secondaryButton = React.createRef(); - outerModal = React.createRef(); - innerModal = React.createRef(); - startTrap = React.createRef(); - endTrap = React.createRef(); - modalInstanceId = `modal-${getInstanceId()}`; - modalLabelId = `${prefix}--modal-header__label--${this.modalInstanceId}`; - modalHeadingId = `${prefix}--modal-header__heading--${this.modalInstanceId}`; - modalBodyId = `${prefix}--modal-body--${this.modalInstanceId}`; - modalCloseButtonClass = `${prefix}--modal-close`; - - isCloseButton = (element) => { - return ( - (!this.props.onSecondarySubmit && - element === this.secondaryButton.current) || - element.classList.contains(this.modalCloseButtonClass) - ); - }; - - handleKeyDown = (evt) => { - if (this.props.open) { - if (evt.which === 27) { - this.props.onRequestClose(evt); - } - if ( - evt.which === 13 && - this.props.shouldSubmitOnEnter && - !this.isCloseButton(evt.target) - ) { - this.props.onRequestSubmit(evt); - } - } - }; - - handleMousedown = (evt) => { - if ( - this.innerModal.current && - !this.innerModal.current.contains(evt.target) && - !elementOrParentIsFloatingMenu( - evt.target, - this.props.selectorsFloatingMenus - ) && - !this.props.preventCloseOnClickOutside - ) { - this.props.onRequestClose(evt); - } - }; - - handleBlur = ({ - target: oldActiveNode, - relatedTarget: currentActiveNode, - }) => { - const { open, selectorsFloatingMenus } = this.props; - if (open && currentActiveNode && oldActiveNode) { - const { current: bodyNode } = this.innerModal; - const { current: startTrapNode } = this.startTrap; - const { current: endTrapNode } = this.endTrap; - wrapFocus({ - bodyNode, - startTrapNode, - endTrapNode, - currentActiveNode, - oldActiveNode, - selectorsFloatingMenus, - }); - } - }; - - componentDidUpdate(prevProps) { - if (!prevProps.open && this.props.open) { - this.beingOpen = true; - } else if (prevProps.open && !this.props.open) { - this.beingOpen = false; - } - toggleClass( - document.body, - `${prefix}--body--with-modal-open`, - this.props.open - ); - } - - initialFocus = (focusContainerElement) => { - const containerElement = focusContainerElement || this.innerModal.current; - const primaryFocusElement = containerElement - ? containerElement.querySelector(this.props.selectorPrimaryFocus) - : null; - - if (primaryFocusElement) { - return primaryFocusElement; - } - - return this.button && this.button.current; - }; - - focusButton = (focusContainerElement) => { - const target = this.initialFocus(focusContainerElement); - if (target) { - target.focus(); - } - }; - - componentWillUnmount() { - toggleClass(document.body, `${prefix}--body--with-modal-open`, false); - } - - componentDidMount() { - toggleClass( - document.body, - `${prefix}--body--with-modal-open`, - this.props.open - ); - if (!this.props.open) { - return; - } - this.focusButton(this.innerModal.current); - } - - handleTransitionEnd = (evt) => { - if ( - evt.target === evt.currentTarget && // Not to handle `onTransitionEnd` on child DOM nodes - this.outerModal.current && - this.outerModal.current.offsetWidth && - this.outerModal.current.offsetHeight && - this.beingOpen - ) { - this.focusButton(evt.currentTarget); - this.beingOpen = false; - } - }; - - render() { - const { - modalHeading, - modalLabel, - modalAriaLabel, - passiveModal, - hasForm, - secondaryButtonText, - primaryButtonText, - open, - onRequestClose, - onRequestSubmit, - onSecondarySubmit, - iconDescription, - primaryButtonDisabled, - danger, - alert, - secondaryButtons, - selectorPrimaryFocus, // eslint-disable-line - selectorsFloatingMenus, // eslint-disable-line - shouldSubmitOnEnter, // eslint-disable-line - size, - hasScrollingContent, - closeButtonLabel, - preventCloseOnClickOutside, // eslint-disable-line - ...other - } = this.props; - - const onSecondaryButtonClick = onSecondarySubmit - ? onSecondarySubmit - : onRequestClose; - - const modalClasses = classNames({ - [`${prefix}--modal`]: true, - [`${prefix}--modal-tall`]: !passiveModal, - 'is-visible': open, - [`${prefix}--modal--danger`]: this.props.danger, - [this.props.className]: this.props.className, - }); - - const containerClasses = classNames(`${prefix}--modal-container`, { - [`${prefix}--modal-container--${size}`]: size, - }); - - const contentClasses = classNames(`${prefix}--modal-content`, { - [`${prefix}--modal-content--with-form`]: hasForm, //TO-DO: deprecate & remove this with v11 - [`${prefix}--modal-scroll-content`]: hasScrollingContent, - }); - - const footerClasses = classNames(`${prefix}--modal-footer`, { - [`${prefix}--modal-footer--three-button`]: - Array.isArray(secondaryButtons) && secondaryButtons.length === 2, - }); - - const modalButton = ( - - ); - - const ariaLabel = - modalLabel || this.props['aria-label'] || modalAriaLabel || modalHeading; - const getAriaLabelledBy = modalLabel - ? this.modalLabelId - : this.modalHeadingId; - - const hasScrollingContentProps = hasScrollingContent - ? { - tabIndex: 0, - role: 'region', - 'aria-label': ariaLabel, - 'aria-labelledby': getAriaLabelledBy, - } - : {}; - - const alertDialogProps = {}; - if (alert && passiveModal) { - alertDialogProps.role = 'alert'; - } - if (alert && !passiveModal) { - alertDialogProps.role = 'alertdialog'; - alertDialogProps['aria-describedby'] = this.modalBodyId; - } - - const modalBody = ( -
-
- {passiveModal && modalButton} - {modalLabel && ( -

- {modalLabel} -

- )} -

- {modalHeading} -

- {!passiveModal && modalButton} -
-
- {this.props.children} -
- {hasScrollingContent && ( -
- )} - {!passiveModal && ( - - {Array.isArray(secondaryButtons) && secondaryButtons.length <= 2 - ? secondaryButtons.map( - ({ buttonText, onClick: onButtonClick }, i) => ( - - ) - ) - : secondaryButtonText && ( - - )} - - - )} -
- ); - - return ( -
- {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} - - Focus sentinel - - {modalBody} - {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} - - Focus sentinel - -
- ); - } -} diff --git a/packages/react/src/components/Modal/next/Modal.stories.js b/packages/react/src/components/Modal/next/Modal.stories.js index d0848b7f30c8..ca5a4254fa76 100644 --- a/packages/react/src/components/Modal/next/Modal.stories.js +++ b/packages/react/src/components/Modal/next/Modal.stories.js @@ -16,7 +16,7 @@ import { text, withKnobs, } from '@storybook/addon-knobs'; -import { Modal } from '../Modal'; +import Modal from './Modal'; import Button from '../../Button'; import Select from '../../Select'; import MultiSelect from '../../MultiSelect'; diff --git a/packages/react/src/components/ModalWrapper/ModalWrapper-test.js b/packages/react/src/components/ModalWrapper/ModalWrapper-test.js index 472fa386010a..90fb9e7a54e1 100644 --- a/packages/react/src/components/ModalWrapper/ModalWrapper-test.js +++ b/packages/react/src/components/ModalWrapper/ModalWrapper-test.js @@ -78,4 +78,27 @@ describe('ModalWrapper', () => { wrapper.find({ children: mockProps.primaryButtonText }).simulate('click'); expect(wrapper.state('isOpen')).toBe(true); }); + + it('should default to primary button', () => { + const wrapper = mount(); + expect(wrapper.find(`.${prefix}--btn--primary`).length).toEqual(2); + }); + + it('should render ghost button when ghost is passed', () => { + const wrapper = mount(); + wrapper.setProps({ triggerButtonKind: 'ghost' }); + expect(wrapper.find(`.${prefix}--btn--ghost`).length).toEqual(1); + }); + + it('should render danger button when danger is passed', () => { + const wrapper = mount(); + wrapper.setProps({ triggerButtonKind: 'danger' }); + expect(wrapper.find(`.${prefix}--btn--danger`).length).toEqual(1); + }); + + it('should render secondary button when secondary is passed', () => { + const wrapper = mount(); + wrapper.setProps({ triggerButtonKind: 'secondary' }); + expect(wrapper.find(`.${prefix}--btn--secondary`).length).toEqual(2); + }); }); diff --git a/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap b/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap index 9554f7b53497..7c4d8da27915 100644 --- a/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap +++ b/packages/react/src/components/ModalWrapper/__snapshots__/ModalWrapper-test.js.snap @@ -53,187 +53,200 @@ exports[`ModalWrapper should render 1`] = ` Test Modal - - + +
+ + Focus sentinel +
- - Focus sentinel - -
- + +
`; From 82cb8680f8720becee66b1a7ebd9715d95b6d897 Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Wed, 3 Nov 2021 14:44:55 -0500 Subject: [PATCH 05/11] fix(react): add forwardRef to Modal and ModalFooter --- .../ComposedModal/next/ModalFooter.js | 39 ++++++----- .../react/src/components/Modal/next/Modal.js | 66 ++++++++++--------- 2 files changed, 56 insertions(+), 49 deletions(-) diff --git a/packages/react/src/components/ComposedModal/next/ModalFooter.js b/packages/react/src/components/ComposedModal/next/ModalFooter.js index 5d11490ddbe6..041dd4479dfc 100644 --- a/packages/react/src/components/ComposedModal/next/ModalFooter.js +++ b/packages/react/src/components/ComposedModal/next/ModalFooter.js @@ -5,22 +5,25 @@ import ButtonSet from '../../ButtonSet'; import classNames from 'classnames'; import { usePrefix } from '../../../internal/usePrefix'; -export function ModalFooter({ - className, - primaryClassName, - secondaryButtons, - secondaryClassName, - secondaryButtonText, - primaryButtonText, - primaryButtonDisabled, - closeModal, // eslint-disable-line - onRequestClose, // eslint-disable-line - onRequestSubmit, // eslint-disable-line - children, - danger, - inputref, - ...other -}) { +export const ModalFooter = React.forwardRef(function ModalFooter( + { + className, + primaryClassName, + secondaryButtons, + secondaryClassName, + secondaryButtonText, + primaryButtonText, + primaryButtonDisabled, + closeModal, // eslint-disable-line + onRequestClose, // eslint-disable-line + onRequestSubmit, // eslint-disable-line + children, + danger, + inputref, + ...other + }, + ref +) { const prefix = usePrefix(); function handleRequestClose(evt) { @@ -71,7 +74,7 @@ export function ModalFooter({ }; return ( - + {primaryButtonText && ( + )}> + {({ open, setOpen }) => ( + setOpen(false)}> + + +

+ Custom domains direct requests for your apps in this Cloud Foundry + organization to a URL that you own. A custom domain can be a + shared domain, a shared subdomain, or a shared domain and host. +

+ + +
+ +
+ )} + + ); +}; diff --git a/packages/react/src/components/ComposedModal/next/ModalFooter.js b/packages/react/src/components/ComposedModal/next/ModalFooter.js index 041dd4479dfc..12b6953c5e35 100644 --- a/packages/react/src/components/ComposedModal/next/ModalFooter.js +++ b/packages/react/src/components/ComposedModal/next/ModalFooter.js @@ -5,32 +5,102 @@ import ButtonSet from '../../ButtonSet'; import classNames from 'classnames'; import { usePrefix } from '../../../internal/usePrefix'; +function SecondaryButtonSet({ + secondaryButtons, + secondaryButtonText, + secondaryClassName, + closeModal, + onRequestClose, +}) { + function handleRequestClose(evt) { + closeModal(evt); + onRequestClose(evt); + } + + const secondaryClass = classNames({ + [secondaryClassName]: secondaryClassName, + }); + + if (Array.isArray(secondaryButtons) && secondaryButtons.length <= 2) { + return secondaryButtons.map(({ buttonText, onClick: onButtonClick }, i) => ( + + )); + } + if (secondaryButtonText) { + return ( + + ); + } + return null; +} + +SecondaryButtonSet.propTypes = { + closeModal: PropTypes.func, + onRequestClose: PropTypes.func, + secondaryButtonText: PropTypes.string, + secondaryButtons: (props, propName, componentName) => { + if (props.secondaryButtons) { + if ( + !Array.isArray(props.secondaryButtons) || + props.secondaryButtons.length !== 2 + ) { + return new Error( + `${propName} needs to be an array of two button config objects` + ); + } + + const shape = { + buttonText: PropTypes.node, + onClick: PropTypes.func, + }; + + props[propName].forEach((secondaryButton) => { + PropTypes.checkPropTypes( + shape, + secondaryButton, + propName, + componentName + ); + }); + } + + return null; + }, + secondaryClassName: PropTypes.string, +}; + export const ModalFooter = React.forwardRef(function ModalFooter( { + children, className, + closeModal, + danger, + inputref, + onRequestClose, + onRequestSubmit, + primaryButtonDisabled, + primaryButtonText, primaryClassName, + secondaryButtonText, secondaryButtons, secondaryClassName, - secondaryButtonText, - primaryButtonText, - primaryButtonDisabled, - closeModal, // eslint-disable-line - onRequestClose, // eslint-disable-line - onRequestSubmit, // eslint-disable-line - children, - danger, - inputref, ...other }, ref ) { const prefix = usePrefix(); - function handleRequestClose(evt) { - closeModal(evt); - onRequestClose(evt); - } - const footerClass = classNames({ [`${prefix}--modal-footer`]: true, [className]: className, @@ -42,40 +112,17 @@ export const ModalFooter = React.forwardRef(function ModalFooter( [primaryClassName]: primaryClassName, }); - const secondaryClass = classNames({ - [secondaryClassName]: secondaryClassName, - }); - - const SecondaryButtonSet = () => { - if (Array.isArray(secondaryButtons) && secondaryButtons.length <= 2) { - return secondaryButtons.map( - ({ buttonText, onClick: onButtonClick }, i) => ( - - ) - ); - } - if (secondaryButtonText) { - return ( - - ); - } - return null; + const secondaryButtonProps = { + closeModal, + secondaryButtons, + secondaryButtonText, + secondaryClassName, + onRequestClose, }; return ( - + {primaryButtonText && ( - - + + Focus sentinel + + - - + + Focus sentinel + + + `; From 9f4a47e3efe0a8784d40c053f269ec39f07b7eef Mon Sep 17 00:00:00 2001 From: Abbey Hart Date: Thu, 11 Nov 2021 15:14:35 -0600 Subject: [PATCH 11/11] fix(react): remove knobs from v11 story and change other to rest --- .../react/src/components/Modal/next/Modal.js | 4 +- .../components/Modal/next/Modal.stories.js | 103 +++++------------- 2 files changed, 30 insertions(+), 77 deletions(-) diff --git a/packages/react/src/components/Modal/next/Modal.js b/packages/react/src/components/Modal/next/Modal.js index e6b8079130e8..1e79723c21f7 100644 --- a/packages/react/src/components/Modal/next/Modal.js +++ b/packages/react/src/components/Modal/next/Modal.js @@ -49,7 +49,7 @@ const Modal = React.forwardRef(function Modal( hasScrollingContent, closeButtonLabel, preventCloseOnClickOutside, // eslint-disable-line - ...other + ...rest }, ref ) { @@ -282,7 +282,7 @@ const Modal = React.forwardRef(function Modal( return (
({ - numberOfButtons: options('Number of Buttons', buttons, '2', { - display: 'inline-radio', - }), + numberOfButtons: ('Number of Buttons', buttons, '2'), className: 'some-class', - open: boolean('Open (open)', true), - danger: boolean('Danger mode (danger)', false), - alert: boolean('Alert mode (alert)', false), - shouldSubmitOnEnter: boolean( - 'Enter key to submit (shouldSubmitOnEnter)', - false - ), - hasScrollingContent: boolean( - 'Modal contains scrollable content (hasScrollingContent)', - false - ), - hasForm: boolean('Modal contains a form (hasForm)', false), - modalHeading: text('Modal heading (modalHeading)', 'Modal heading'), - modalLabel: text('Optional label (modalLabel)', 'Label'), - modalAriaLabel: text( - 'ARIA label, used only if modalLabel not provided (modalAriaLabel)', - 'A label to be read by screen readers on the modal root node' - ), - selectorPrimaryFocus: text( - 'Primary focus element selector (selectorPrimaryFocus)', - '[data-modal-primary-focus]' - ), - size: select('Size (size)', sizes, 'md'), + open: true, + danger: false, + alert: false, + shouldSubmitOnEnter: false, + hasScrollingContent: false, + hasForm: false, + modalHeading: 'Modal heading', + modalLabel: 'Label', + modalAriaLabel: + 'A label to be read by screen readers on the modal root node', + selectorPrimaryFocus: '[data-modal-primary-focus]', + size: 'md', onBlur: action('onBlur'), onClick: action('onClick'), onFocus: action('onFocus'), onRequestClose: action('onRequestClose'), onRequestSubmit: action('onRequestSubmit'), onSecondarySubmit: action('onSecondarySubmit'), - preventCloseOnClickOutside: boolean( - 'Prevent closing on click outside of modal (preventCloseOnClickOutside)', - true - ), - primaryButtonDisabled: boolean( - 'Disable primary button (primaryButtonDisabled)', - false - ), - primaryButtonText: text( - 'Primary button text (primaryButtonText)', - 'Primary button' - ), + preventCloseOnClickOutside: true, + primaryButtonDisabled: false, + primaryButtonText: 'Primary button', }), modalFooter: (numberOfButtons) => { const secondaryButtons = () => { switch (numberOfButtons) { case '2': return { - secondaryButtonText: text( - 'Secondary button text (secondaryButtonText in )', - 'Secondary button' - ), + secondaryButtonText: 'Secondary button', }; case '3': return { - secondaryButtons: object( - 'Secondary button config array (secondaryButtons)', - [ - { - buttonText: 'Keep both', - onClick: action('onClick'), - }, - { - buttonText: 'Rename', - onClick: action('onClick'), - }, - ] - ), + secondaryButtons: [ + { + buttonText: 'Keep both', + onClick: action('onClick'), + }, + { + buttonText: 'Rename', + onClick: action('onClick'), + }, + ], }; default: return null; } }; return { - passiveModal: boolean( - 'Without footer (passiveModal)', - false || numberOfButtons === '0' - ), + passiveModal: false || numberOfButtons === '0', ...secondaryButtons(), }; }, @@ -129,7 +83,6 @@ const props = { export default { title: 'Components/Modal', - decorators: [withKnobs], parameters: { component: Modal, docs: {