diff --git a/packages/react/src/components/ComposedModal/index.js b/packages/react/src/components/ComposedModal/index.js index 597a1f655581..c8a5f7b8d15a 100644 --- a/packages/react/src/components/ComposedModal/index.js +++ b/packages/react/src/components/ComposedModal/index.js @@ -7,7 +7,9 @@ import * as FeatureFlags from '@carbon/feature-flags'; import { ModalHeader as ModalHeaderNext } from './next/ModalHeader'; -import ComposedModal, { +import { default as ComposedModalNext } from './next/ComposedModal'; +import { + default as ComposedModalClassic, ModalHeader as ModalHeaderClassic, ModalBody, ModalFooter, @@ -17,6 +19,10 @@ export const ModalHeader = FeatureFlags.enabled('enable-v11-release') ? ModalHeaderNext : ModalHeaderClassic; -export { ComposedModal, ModalBody, ModalFooter }; +export const ComposedModal = FeatureFlags.enabled('enable-v11-release') + ? ComposedModalNext + : ComposedModalClassic; + +export { ModalBody, ModalFooter }; export default from './ComposedModal'; diff --git a/packages/react/src/components/ComposedModal/next/ComposedModal-test.js b/packages/react/src/components/ComposedModal/next/ComposedModal-test.js new file mode 100644 index 000000000000..2fffd29af51c --- /dev/null +++ b/packages/react/src/components/ComposedModal/next/ComposedModal-test.js @@ -0,0 +1,118 @@ +/** + * 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 { mount } from 'enzyme'; +import ComposedModal from './ComposedModal'; +import { ModalHeader } from './ModalHeader'; +import { ModalFooter } from '../ComposedModal'; +import { settings } from 'carbon-components'; + +const { prefix } = settings; + +describe('', () => { + let container; + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + container = null; + }); + + it('renders', () => { + const wrapper = mount(); + expect(wrapper).toMatchSnapshot(); + }); + + it('changes the open state upon change in props', () => { + const wrapper = mount(); + + expect( + document.body.classList.contains('bx--body--with-modal-open') + ).toEqual(true); + wrapper.setProps({ open: false }); + expect( + document.body.classList.contains('bx--body--with-modal-open') + ).toEqual(false); + }); + + it('should change class of upon open state', () => { + const wrapper = mount(); + expect( + document.body.classList.contains('bx--body--with-modal-open') + ).toEqual(true); + wrapper.unmount(); + expect( + document.body.classList.contains('bx--body--with-modal-open') + ).toEqual(false); + mount(); + expect( + document.body.classList.contains('bx--body--with-modal-open') + ).toEqual(false); + }); + + it('calls onClick upon user-initiated closing', () => { + const onClose = jest.fn(); + const wrapper = mount( + + + + ); + const button = wrapper.find(`.${prefix}--modal-close`).first(); + button.simulate('click'); + expect( + document.body.classList.contains('bx--body--with-modal-open') + ).toEqual(false); + expect(onClose.mock.calls.length).toBe(1); + }); + + it('provides a way to prevent upon user-initiated closing', () => { + const onClose = jest.fn(() => false); + const wrapper = mount( + + + + ); + const button = wrapper.find(`.${prefix}--modal-close`).first(); + button.simulate('click'); + expect( + document.body.classList.contains('bx--body--with-modal-open') + ).toEqual(true); + }); + + it('should focus on the primary actionable button in ModalFooter by default', () => { + container = document.createElement('div'); + container.id = 'container'; + document.body.appendChild(container); + mount( + + + , + { attachTo: document.querySelector('#container') } + ); + expect( + document.activeElement.classList.contains(`${prefix}--btn--primary`) + ).toEqual(true); + }); + + it('should focus on the element that matches selectorPrimaryFocus', () => { + container = document.createElement('div'); + container.id = 'container'; + document.body.appendChild(container); + mount( + + + + , + { attachTo: document.querySelector('#container') } + ); + expect( + document.activeElement.classList.contains(`${prefix}--modal-close`) + ).toEqual(true); + }); +}); diff --git a/packages/react/src/components/ComposedModal/next/ComposedModal.js b/packages/react/src/components/ComposedModal/next/ComposedModal.js new file mode 100644 index 000000000000..7f1c77230bf5 --- /dev/null +++ b/packages/react/src/components/ComposedModal/next/ComposedModal.js @@ -0,0 +1,274 @@ +import React, { useRef, useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { ModalHeader } from './ModalHeader'; +import { ModalFooter } from '../ComposedModal'; + +import classNames from 'classnames'; + +import toggleClass from '../../../tools/toggleClass'; + +import wrapFocus from '../../../internal/wrapFocus'; +import { usePrefix } from '../../../internal/usePrefix'; + +const ComposedModal = React.forwardRef(function ComposedModal( + { + ['aria-labelledby']: ariaLabelledBy, + ['aria-label']: ariaLabel, + children, + className, + containerClassName, + danger, + onClose, + onKeyDown, + open, + preventCloseOnClickOutside, + selectorPrimaryFocus, + selectorsFloatingMenus, + size, + ...rest + }, + ref +) { + const prefix = usePrefix(); + const [isOpen, setisOpen] = useState(open); + const [prevOpen, setPrevOpen] = useState(open); + const innerModal = useRef(); + const button = useRef(); + const startSentinel = useRef(); + const endSentinel = useRef(); + + if (open !== prevOpen) { + setisOpen(open); + setPrevOpen(open); + } + + function handleKeyDown(evt) { + // Esc key + if (evt.which === 27) { + closeModal(evt); + } + + onKeyDown(evt); + } + + function handleClick(evt) { + if ( + !innerModal.current.contains(evt.target) && + preventCloseOnClickOutside + ) { + return; + } + if (innerModal.current && !innerModal.current.contains(evt.target)) { + closeModal(evt); + } + } + + function handleBlur({ + target: oldActiveNode, + relatedTarget: currentActiveNode, + }) { + if (open && currentActiveNode && oldActiveNode) { + const { current: bodyNode } = innerModal; + const { current: startSentinelNode } = startSentinel; + const { current: endSentinelNode } = endSentinel; + wrapFocus({ + bodyNode, + startSentinelNode, + endSentinelNode, + currentActiveNode, + oldActiveNode, + selectorsFloatingMenus, + }); + } + } + + function closeModal(evt) { + if (!onClose || onClose(evt) !== false) { + setisOpen(false); + } + } + + const modalClass = classNames({ + [`${prefix}--modal`]: true, + 'is-visible': isOpen, + [className]: className, + [`${prefix}--modal--danger`]: danger, + }); + + const containerClass = classNames({ + [`${prefix}--modal-container`]: true, + [`${prefix}--modal-container--${size}`]: size, + [containerClassName]: containerClassName, + }); + + // Generate aria-label based on Modal Header label if one is not provided (L253) + let generatedAriaLabel; + const childrenWithProps = React.Children.toArray(children).map((child) => { + switch (child.type) { + case React.createElement(ModalHeader).type: + generatedAriaLabel = child.props.label; + return React.cloneElement(child, { + closeModal: closeModal, + }); + case React.createElement(ModalFooter).type: + return React.cloneElement(child, { + closeModal: closeModal, + inputref: button, + }); + default: + return child; + } + }); + + useEffect(() => { + if (prevOpen !== isOpen) { + toggleClass(document.body, `${prefix}--body--with-modal-open`, isOpen); + } + }); + + useEffect(() => { + return () => + toggleClass(document.body, `${prefix}--body--with-modal-open`, false); + }); + + useEffect(() => { + toggleClass(document.body, `${prefix}--body--with-modal-open`, open); + }, [open, prefix]); + + useEffect(() => { + const focusButton = (focusContainerElement) => { + if (focusContainerElement) { + const primaryFocusElement = focusContainerElement.querySelector( + selectorPrimaryFocus + ); + if (primaryFocusElement) { + primaryFocusElement.focus(); + return; + } + if (button.current) { + button.current.focus(); + } + } + }; + + if (!open) { + return; + } + + if (innerModal.current) { + focusButton(innerModal.current); + } + }, [open, selectorPrimaryFocus]); + + return ( +
+ {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel + +
+ {childrenWithProps} +
+ {/* Non-translatable: Focus-wrap code makes this `` not actually read by screen readers */} + + Focus sentinel + +
+ ); +}); + +ComposedModal.propTypes = { + /** + * Specify the aria-label for bx--modal-container + */ + ['aria-label']: PropTypes.string, + + /** + * Specify the aria-labelledby for bx--modal-container + */ + ['aria-labelledby']: PropTypes.string, + + /** + * Specify the content to be placed in the ComposedModal + */ + children: PropTypes.node, + + /** + * Specify an optional className to be applied to the modal root node + */ + className: PropTypes.string, + + /** + * Specify an optional className to be applied to the modal node + */ + containerClassName: PropTypes.string, + + /** + * 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, + + /** + * Specify an optional handler for closing modal. + * Returning `false` here prevents closing modal. + */ + onClose: PropTypes.func, + + /** + * Specify an optional handler for the `onKeyDown` event. Called for all + * `onKeyDown` events that do not close the modal + */ + onKeyDown: PropTypes.func, + + /** + * Specify whether the Modal is currently open + */ + open: PropTypes.bool, + + preventCloseOnClickOutside: PropTypes.bool, + + /** + * Specify a CSS selector that matches the DOM element that should be + * focused when the Modal opens + */ + selectorPrimaryFocus: PropTypes.string, + + /** + * Specify the CSS selectors that match the floating menus + */ + selectorsFloatingMenus: PropTypes.string, + + /** + * Specify the size variant. + */ + size: PropTypes.oneOf(['xs', 'sm', 'md', 'lg']), +}; + +ComposedModal.defaultProps = { + onKeyDown: () => {}, + selectorPrimaryFocus: '[data-modal-primary-focus]', +}; + +export default ComposedModal; diff --git a/packages/react/src/components/ComposedModal/next/ComposedModal.stories.js b/packages/react/src/components/ComposedModal/next/ComposedModal.stories.js new file mode 100644 index 000000000000..089132f59999 --- /dev/null +++ b/packages/react/src/components/ComposedModal/next/ComposedModal.stories.js @@ -0,0 +1,327 @@ +/** + * 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 { ModalBody, ModalFooter } from '../ComposedModal'; +import ComposedModal from './ComposedModal'; +import { ModalHeader } from './ModalHeader'; +import Select from '../../Select'; +import SelectItem from '../../SelectItem'; +import TextInput from '../../TextInput'; +import Button from '../../Button'; +import mdx from '../ComposedModal.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 = { + composedModal: () => ({ + numberOfButtons: options('Number of Buttons', buttons, '2', { + display: 'inline-radio', + }), + open: boolean('Open (open in )', true), + onKeyDown: action('onKeyDown'), + selectorPrimaryFocus: text( + 'Primary focus element selector (selectorPrimaryFocus)', + '[data-modal-primary-focus]' + ), + size: select('Size (size)', sizes, 'md'), + preventCloseOnClickOutside: boolean( + 'Prevent closing on click outside of modal (preventCloseOnClickOutside)', + true + ), + }), + modalHeader: ({ titleOnly } = {}) => ({ + label: text('Optional Label (label in )', 'Label'), + title: text( + 'Optional title (title in )', + titleOnly + ? ` + Passive modal title as the message. Should be direct and 3 lines or less. + `.trim() + : 'Modal heading' + ), + iconDescription: text( + 'Close icon description (iconDescription in )', + 'Close' + ), + buttonOnClick: action('buttonOnClick'), + }), + modalBody: () => ({ + hasScrollingContent: boolean( + 'Modal contains scrollable content (hasScrollingContent)', + false + ), + 'aria-label': text('ARIA label for content', 'Example modal content'), + }), + 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 { + danger: boolean('Primary button danger (danger)', false), + primaryButtonText: text( + 'Primary button text (primaryButtonText in )', + 'Primary button' + ), + primaryButtonDisabled: boolean( + 'Primary button disabled (primaryButtonDisabled in )', + false + ), + ...secondaryButtons(numberOfButtons), + onRequestClose: action('onRequestClose'), + onRequestSubmit: action('onRequestSubmit'), + }; + }, +}; + +const scrollingContent = ( + <> +

+ 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 default { + title: 'Components/ComposedModal', + decorators: [withKnobs], + parameters: { + component: ComposedModal, + subcomponents: { + ModalHeader, + ModalBody, + ModalFooter, + }, + docs: { + page: mdx, + }, + }, +}; + +export const Playground = () => { + const { size, numberOfButtons, ...rest } = props.composedModal(); + const { hasScrollingContent } = props.modalBody(); + 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 && scrollingContent} +
+ {numberOfButtons > 0 && ( + + )} +
+ ); +}; + +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. +

+ + +
+ +
+ ); +}; + +Default.story = { + name: 'Composed Modal', +}; + +export const PassiveModal = () => { + return ( + + + + + ); +}; + +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. +

+ + +
+ +
+ )} +
+ ); +}; diff --git a/packages/react/src/components/ComposedModal/next/__snapshots__/ComposedModal-test.js.snap b/packages/react/src/components/ComposedModal/next/__snapshots__/ComposedModal-test.js.snap new file mode 100644 index 000000000000..57a8aba6ab66 --- /dev/null +++ b/packages/react/src/components/ComposedModal/next/__snapshots__/ComposedModal-test.js.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders 1`] = ` + +
+ + Focus sentinel + +
+ + Focus sentinel + +
+ +`;