UNPKG

@wfp/ui

Version:
460 lines (407 loc) 12.4 kB
import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; import { iconClose } from '@wfp/icons'; import Icon from '../Icon'; import Button from '../Button'; import settings from '../../globals/js/settings'; const { prefix } = settings; const matchesFuncName = typeof Element !== 'undefined' && ['matches', 'webkitMatchesSelector', 'msMatchesSelector'].filter( (name) => typeof Element.prototype[name] === 'function' )[0]; const modalRoot = typeof document !== 'undefined' ? document.body : undefined; /** Modals focus the user’s attention exclusively on one task or piece of information via a window that sits on top of the page content. */ export default class Modal extends Component { static propTypes = { /** * 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 whether the modals content should be only loaded when the `Modal` is `open` */ lazyLoad: PropTypes.bool, /** * Specify whether the modal should be button-less */ passiveModal: PropTypes.bool, /** * Specify a handler for closing modal. * The handler should care of closing modal, e.g. changing `open` prop. */ onRequestClose: PropTypes.func, /** * Specify the DOM element ID of the top-level node. */ id: PropTypes.string, /** * Specify the content of the modal header title. */ modalHeading: PropTypes.string, /** * Specify the content of the modal header label. */ modalLabel: PropTypes.node, /** * Specify the content which renders on ther right side of modalLabel. */ modalSecondaryAction: PropTypes.node, /** * Specify the a function which renders a custom ModalFooter. */ modalFooter: PropTypes.func, /** * Specify a label to be read by screen readers on the modal root node */ modalAriaLabel: PropTypes.string, /** * Specify the text for the secondary button */ secondaryButtonText: PropTypes.string, /** * Specify the text for the primary button */ primaryButtonText: PropTypes.string, /** * Specify the background image(url) for your modal */ backgroundImage: PropTypes.string, /** * Specify whether the Modal is currently open */ open: PropTypes.bool, /** * 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 a key press modal */ onKeyDown: PropTypes.func, /** * Provide a description for "close" icon that can be read by screen readers */ iconDescription: PropTypes.string, /** * Specify whether the Button should be disabled, or not */ primaryButtonDisabled: PropTypes.bool, /** * Specify whether the secondary Button should be disabled, or not */ secondaryButtonDisabled: PropTypes.bool, /** * Specify a handler for the secondary button. * Useful if separate handler from `onRequestClose` is desirable */ onSecondarySubmit: PropTypes.func, /** * Specify whether the Modal is for dangerous actions */ danger: PropTypes.bool, /** * Specify whether the Modal close button should be hidden or not */ hideClose: PropTypes.bool, /** * Specify if Enter key should be used as "submit" action */ shouldSubmitOnEnter: PropTypes.bool, /** * 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 a CSS selector that matches the DOM element that should * be focused when the Modal opens. If "false" no selector will be triggered */ selectorPrimaryFocus: PropTypes.oneOfType([ PropTypes.bool, PropTypes.string, ]), /** * Different styling options are available `info`, `warning`, `danger` */ type: PropTypes.oneOf(['info', 'warning', 'danger']), /** * If true the Modal will be rendered inside a portal at the end of the * body element, otherwise at the position it is placed. */ inPortal: PropTypes.bool, /** * If true the Modal will be wider then the regular Modal */ wide: PropTypes.bool, }; static defaultProps = { onRequestClose: () => {}, onRequestSubmit: () => {}, primaryButtonDisabled: false, secondaryButtonDisabled: false, onKeyDown: () => {}, passiveModal: false, iconDescription: 'close the modal', inPortal: true, lazyLoad: false, modalHeading: '', modalLabel: '', selectorPrimaryFocus: '[data-modal-primary-focus]', }; constructor(props) { super(props); this.el = document.createElement('div'); } button = React.createRef(); outerModal = React.createRef(); innerModal = React.createRef(); elementOrParentIsFloatingMenu = (target) => { const { selectorsFloatingMenus = [ `.${prefix}--overflow-menu-options`, `.${prefix}--tooltip`, '.flatpickr-calendar', ], } = this.props; if (target && typeof target.closest === 'function') { return selectorsFloatingMenus.some((selector) => target.closest(selector) ); } else { // Alternative if closest does not exist. while (target) { if (typeof target[matchesFuncName] === 'function') { if ( // eslint-disable-next-line no-loop-func selectorsFloatingMenus.some((selector) => target[matchesFuncName](selector) ) ) { return true; } } target = target.parentNode; } return false; } }; handleKeyDown = (evt) => { if (evt.which === 27) { this.props.onRequestClose(evt, 'key'); } if (evt.which === 13 && this.props.shouldSubmitOnEnter) { this.props.onRequestSubmit(evt, 'key'); } }; handleClick = (evt) => { if ( this.innerModal.current && !this.innerModal.current.contains(evt.target) && !this.elementOrParentIsFloatingMenu(evt.target) ) { this.props.onRequestClose(evt, 'background'); } }; handleCloseButton = (evt) => { this.props.onRequestClose(evt, 'button'); }; focusModal = () => { if (this.outerModal.current) { this.outerModal.current.focus(); } }; handleBlur = (evt) => { // Keyboard trap if ( this.innerModal.current && this.props.open && evt.relatedTarget && !this.innerModal.current.contains(evt.relatedTarget) && !this.elementOrParentIsFloatingMenu(evt.relatedTarget) ) { this.focusModal(); } }; componentDidUpdate(prevProps) { if (!prevProps.open && this.props.open) { this.beingOpen = true; } else if (prevProps.open && !this.props.open) { this.beingOpen = false; } } focusButton = (focusContainerElement) => { if (this.props.selectorPrimaryFocus === false) { return; } const primaryFocusElement = focusContainerElement.querySelector( this.props.selectorPrimaryFocus ); if (primaryFocusElement) { primaryFocusElement.focus(); return; } if (this.button && this.button.current) { this.button.current.focus(); } }; componentDidMount() { modalRoot.appendChild(this.el); if (!this.props.open) { return; } this.focusButton(this.innerModal.current); } componentWillUnmount() { modalRoot.removeChild(this.el); } handleTransitionEnd = (evt) => { if ( this.outerModal.current.offsetWidth && this.outerModal.current.offsetHeight && this.beingOpen ) { this.focusButton(evt.currentTarget); this.beingOpen = false; } }; render() { const { modalHeading, modalLabel, modalFooter, modalSecondaryAction, modalAriaLabel, passiveModal, secondaryButtonText, primaryButtonText, backgroundImage, open, lazyLoad, onRequestClose, onRequestSubmit, onSecondarySubmit, iconDescription, inPortal, primaryButtonDisabled, secondaryButtonDisabled, danger, hideClose, wide, type, selectorPrimaryFocus, // eslint-disable-line selectorsFloatingMenus, // eslint-disable-line shouldSubmitOnEnter, // eslint-disable-line ...other } = this.props; if (open === false && lazyLoad) { return null; } const onSecondaryButtonClick = onSecondarySubmit ? onSecondarySubmit : onRequestClose; const modalClasses = classNames({ [`${prefix}--modal`]: true, [`${prefix}--modal--wide`]: wide, [`${prefix}--modal--tall`]: !passiveModal, [`${prefix}--modal--background-image`]: backgroundImage, 'is-visible': open, [`${prefix}--modal--warning`]: type === 'warning' || this.props.warning, [`${prefix}--modal--danger`]: type === 'danger' || this.props.danger, [this.props.className]: this.props.className, }); const modalButton = !hideClose ? ( <button className={`${prefix}--modal-close`} type="button" onClick={this.handleCloseButton} ref={this.button}> <Icon icon={iconClose} className={`${prefix}--modal-close__icon`} description={iconDescription} /> </button> ) : null; const modalBody = ( <div ref={this.innerModal} role="dialog" className={`${prefix}--modal-container`} aria-label={modalAriaLabel}> <div className={`${prefix}--modal-header`}> {passiveModal && modalButton} <div> {modalLabel && ( <h4 className={`${prefix}--modal-header__label`}>{modalLabel}</h4> )} <h2 className={`${prefix}--modal-header__heading`}> {modalHeading} </h2> </div> {modalSecondaryAction && <>{modalSecondaryAction}</>} {!passiveModal && modalButton} </div> <div className={`${prefix}--modal-content`}>{this.props.children}</div> {!passiveModal && ( <div className={`${prefix}--modal-footer`}> {!modalFooter ? ( <div className={`${prefix}--modal__buttons-container`}> {secondaryButtonText && ( <Button kind={danger ? 'tertiary' : 'secondary'} disabled={secondaryButtonDisabled} onClick={onSecondaryButtonClick}> {secondaryButtonText} </Button> )} <Button kind={danger ? 'danger--primary' : 'primary'} disabled={primaryButtonDisabled} onClick={onRequestSubmit} inputRef={this.button}> {primaryButtonText} </Button> </div> ) : ( <div>{modalFooter(this.props)}</div> )} </div> )} </div> ); const modal = ( <div {...other} onKeyDown={this.handleKeyDown} // onClick={this.handleClick} //using onMouseDown instead of onClick to prevent modal from closing when releasing mouse on background onMouseDown={this.handleClick} onBlur={this.handleBlur} className={modalClasses} style={ backgroundImage ? { backgroundImage: `url(${backgroundImage})` } : undefined } role="presentation" tabIndex={-1} onTransitionEnd={this.props.open ? this.handleTransitionEnd : undefined} ref={this.outerModal}> <div className={`${prefix}--modal-inner`}>{modalBody}</div> </div> ); if (inPortal) { return ReactDOM.createPortal(modal, this.el); } else { return modal; } } }