UNPKG

@salesforce/design-system-react

Version:

Salesforce Lightning Design System for React

490 lines (449 loc) 13.9 kB
/* Copyright (c) 2015-present, salesforce.com, inc. All rights reserved */ /* Licensed under BSD 3-Clause - see LICENSE.txt or git.io/sfdc-license */ /* eslint-disable react/prefer-es6-class, jsx-a11y/no-noninteractive-element-interactions */ // Implements the [Modal design pattern](https://lightningdesignsystem.com/components/modals/) in React. // Based on SLDS v2.2.1 import React from 'react'; import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; import ReactModal from 'react-modal'; // ### isBoolean import isBoolean from 'lodash.isboolean'; // ### shortid // [npmjs.com/package/shortid](https://www.npmjs.com/package/shortid) // shortid is a short, non-sequential, url-friendly, unique id generator import shortid from 'shortid'; // This component's `checkProps` which issues warnings to developers about properties when in development mode (similar to React's built in development tools) import checkProps from './check-props'; import checkAppElementIsSet from '../../utilities/warning/check-app-element-set'; import Button from '../button'; import { MODAL } from '../../utilities/constants'; import componentDoc from './docs.json'; const documentDefined = typeof document !== 'undefined'; const windowDefined = typeof window !== 'undefined'; const propTypes = { /** * Vertical alignment of Modal. */ align: PropTypes.oneOf(['top', 'center']), /** * Boolean indicating if the appElement should be hidden. */ ariaHideApp: PropTypes.bool, /** * **Assistive text for accessibility.** * This object is merged with the default props object on every render. * * `dialogLabel`: This is a visually hidden label for the dialog. If not provided, `title` is used. * * `closeButton`: This is a visually hidden label for the close button. */ assistiveText: PropTypes.shape({ dialogLabel: PropTypes.string, closeButton: PropTypes.string, }), /** * Modal content. */ children: PropTypes.node.isRequired, /** * Custom CSS classes for the modal's container. This is the element with `.slds-modal__container`. Use `classNames` [API](https://github.com/JedWatson/classnames). */ containerClassName: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * Custom CSS classes for the modal's body. This is the element that has overflow rules and should be used to set a static height if desired. Use `classNames` [API](https://github.com/JedWatson/classnames). */ contentClassName: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * Custom styles for the modal's body. This is the element that has overflow rules and should be used to set a static height if desired. */ contentStyle: PropTypes.object, /** * If true, modal footer buttons render left and right. An example use case would be for "back" and "next" buttons. */ directional: PropTypes.bool, /** * If true, Modals can be dismissed by clicking on the close icon or pressing esc key. */ dismissible: PropTypes.bool, /** * If true, Modals can be dismissed by clicking outside of modal. If unspecified, defaults to dismissible. */ dismissOnClickOutside: PropTypes.bool, /** * Callback to fire with Modal is dismissed */ onRequestClose: PropTypes.func, /** * Accepts a node or array of nodes that are typically a `Button` or `ProgressIndicator`. If an array, the nodes render on the right side first but are then floated left and right if <code>directional</code> prop is `true`. */ footer: PropTypes.oneOfType([PropTypes.array, PropTypes.node]), /** * Allows for a custom modal header that does not scroll with modal content. If this is defined, `title` and `tagline` will be ignored. The close button will still be present. */ header: PropTypes.node, /** * Adds CSS classes to the container surrounding the modal header and the close button. Use `classNames` [API](https://github.com/JedWatson/classnames). */ headerClassName: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * Forces the modal to be open or closed. */ isOpen: PropTypes.bool.isRequired, /** * Function whose return value is the mount node to insert the Modal element into. The default is `() => document.body`. */ parentSelector: PropTypes.func, /** * Custom CSS classes for the portal DOM node. This node is a direct descendant of the `body` and is the parent of `ReactModal__Overlay`. Use `classNames` [API](https://github.com/JedWatson/classnames). */ portalClassName: PropTypes.oneOfType([ PropTypes.array, PropTypes.object, PropTypes.string, ]), /** * Styles the modal as a prompt. */ prompt: PropTypes.oneOf([ 'success', 'warning', 'error', 'wrench', 'offline', 'info', ]), /** * Specifiies the modal's width. May be deprecated in favor of `width` in the future. */ size: PropTypes.oneOf(['medium', 'large']), /** * Content underneath the title in the modal header. */ tagline: PropTypes.node, /** * Text heading at the top of a modal. */ title: PropTypes.node, /** * Allows adding additional notifications within the modal. */ toast: PropTypes.node, }; const defaultProps = { assistiveText: { dialogLabel: '', closeButton: 'Close', }, align: 'center', ariaHideApp: true, dismissible: true, }; /** * The Modal component is used for the Lightning Design System Modal and Notification > Prompt components. The Modal opens from a state change outside of the component itself (pass this state to the <code>isOpen</code> prop). For more details on the Prompt markup, please review the <a href="http://www.lightningdesignsystem.com/components/notifications#prompt">Notifications > Prompt</a>. * * By default, `Modal` will add `aria-hidden=true` to the `body` tag, but this disables some assistive technologies. To prevent this you can add the following to your application with `#mount` being the root node of your application that you would like to hide from assistive technologies when the `Modal` is open. * ``` * import settings from 'design-system-react/components/settings'; * settings.setAppElement('#mount'); * ``` * This component uses a portalMount (a disconnected React subtree mount) to create a modal as a child of `body`. */ class Modal extends React.Component { constructor(props) { super(props); this.state = { isClosing: false, }; // Bind this.handleModalClick = this.handleModalClick.bind(this); this.closeModal = this.closeModal.bind(this); this.dismissModalOnClickOutside = this.dismissModalOnClickOutside.bind( this ); } componentWillMount() { this.generatedId = shortid.generate(); checkProps(MODAL, this.props, componentDoc); if (this.props.ariaHideApp) { checkAppElementIsSet(); } } componentDidMount() { this.setReturnFocus(); this.updateBodyScroll(); } componentDidUpdate(prevProps, prevState) { if (this.props.isOpen !== prevProps.isOpen) { this.updateBodyScroll(); } if (this.state.isClosing !== prevState.isClosing) { if (this.state.isClosing) { // This section of code should be removed once trigger.jsx // and manager.jsx are removed. They appear to have // been created in order to do modals in portals. if (!this.isUnmounting) { const el = ReactDOM.findDOMNode(this); // eslint-disable-line react/no-find-dom-node if ( el && el.parentNode && el.parentNode.getAttribute('data-slds-modal') ) { ReactDOM.unmountComponentAtNode(el); document.body.removeChild(el); } } } } } componentWillUnmount() { this.isUnmounting = true; this.clearBodyScroll(); } getId() { return this.props.id || this.generatedId; } getModal() { const modalStyle = this.props.align === 'top' ? { justifyContent: 'flex-start' } : null; const borderRadius = this.props.title || this.props.header ? {} : { borderRadius: '.25rem' }; const contentStyleFromProps = this.props.contentStyle || {}; const contentStyle = { ...borderRadius, ...contentStyleFromProps, }; return ( // temporarily disabling eslint for the onClicks on the div tags /* eslint-disable */ <div aria-label={this.props.assistiveText.dialogLabel} aria-labelledby={ !this.props.assistiveText.dialogLabel && this.props.title ? this.getId() : null } className={classNames({ 'slds-modal': true, 'slds-fade-in-open': true, 'slds-modal--large': this.props.size === 'large', 'slds-modal--prompt': this.isPrompt(), })} onClick={this.dismissModalOnClickOutside} role={this.props.dismissible ? 'dialog' : 'alertdialog'} > <div className={classNames( 'slds-modal__container', this.props.containerClassName )} style={modalStyle} > {this.headerComponent()} <div className={classNames( 'slds-modal__content', this.props.contentClassName )} style={contentStyle} onClick={this.handleModalClick} > {this.props.children} </div> {this.footerComponent()} </div> </div> /* eslint-enable */ ); } setReturnFocus() { this.setState({ returnFocusTo: documentDefined ? document.activeElement : null, }); } // eslint-disable-next-line class-methods-use-this clearBodyScroll() { if (windowDefined && documentDefined && document.body) { document.body.style.overflow = 'inherit'; } } closeModal() { if (this.props.dismissible) { this.dismissModal(); } } dismissModal() { this.setState({ isClosing: true }); if (this.state.returnFocusTo && this.state.returnFocusTo.focus) { this.state.returnFocusTo.focus(); } if (this.props.onRequestClose) { this.props.onRequestClose(); } } dismissModalOnClickOutside() { // if dismissOnClickOutside is not set, default its value to dismissible const dismissOnClickOutside = isBoolean(this.props.dismissOnClickOutside) ? this.props.dismissOnClickOutside : this.props.dismissible; if (dismissOnClickOutside) { this.dismissModal(); } } footerComponent() { let footer = null; const hasFooter = this.props.footer; const footerClass = { 'slds-modal__footer': true, 'slds-modal__footer--directional': this.props.directional, 'slds-theme--default': this.isPrompt(), }; if (hasFooter) { footer = ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/no-noninteractive-element-interactions <footer className={classNames(footerClass, this.props.footerClassNames)} onClick={this.handleModalClick} > {this.props.footer} </footer> ); } return footer; } // eslint-disable-next-line class-methods-use-this handleModalClick(event) { if (event && event.stopPropagation) { event.stopPropagation(); } } handleSubmitModal() { this.closeModal(); } headerComponent() { let headerContent = this.props.header; const headerEmpty = !headerContent && !this.props.title && !this.props.tagline; const assistiveText = { ...defaultProps.assistiveText, ...this.props.assistiveText, }; const closeButtonAssistiveText = this.props.closeButtonAssistiveText || assistiveText.closeButton; const closeButton = ( <Button assistiveText={{ icon: closeButtonAssistiveText }} iconCategory="utility" iconName="close" iconSize="large" inverse className="slds-modal__close" onClick={this.closeModal} title={closeButtonAssistiveText} variant="icon" /> ); if ((!headerContent && this.props.title) || this.props.tagline) { headerContent = ( <div> {this.props.toast} <h2 className={classNames({ 'slds-text-heading--small': this.isPrompt(), 'slds-text-heading--medium': !this.isPrompt(), })} id={this.getId()} > {this.props.title} </h2> {this.props.tagline ? ( <p className="slds-m-top--x-small">{this.props.tagline}</p> ) : null} </div> ); } return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions <header className={classNames( 'slds-modal__header', { 'slds-modal__header--empty': headerEmpty, [`slds-theme--${this.props.prompt}`]: this.isPrompt(), 'slds-theme--alert-texture': this.isPrompt(), }, this.props.headerClassName )} onClick={this.handleModalClick} > {this.props.dismissible ? closeButton : null} {headerContent} </header> ); } isPrompt() { return this.props.prompt !== undefined; } updateBodyScroll() { if (windowDefined && documentDefined && document.body) { if (this.props.isOpen) { document.body.style.overflow = 'hidden'; } else { document.body.style.overflow = 'inherit'; } } } render() { const customStyles = { content: { position: 'default', top: 'default', left: 'default', right: 'default', bottom: 'default', border: 'default', background: 'default', overflow: 'default', WebkitOverflowScrolling: 'default', borderRadius: 'default', outline: 'default', padding: 'default', }, overlay: { zIndex: 8000, // following SLDS guideline for z-index overlay backgroundColor: 'default', }, }; return ( <ReactModal ariaHideApp={this.props.ariaHideApp} contentLabel="Modal" isOpen={this.props.isOpen} onRequestClose={this.closeModal} style={customStyles} parentSelector={this.props.parentSelector} portalClassName={classNames( 'ReactModalPortal', this.props.portalClassName )} > {this.getModal()} <div className="slds-backdrop slds-backdrop--open" /> </ReactModal> ); } } Modal.displayName = MODAL; Modal.propTypes = propTypes; Modal.defaultProps = defaultProps; export default Modal;