UNPKG

terra-overlay

Version:

The Overlay component is a component that creates an semi-transparent overlay screen that blocks interactions with the elements underneath the display. There are two types of overlays: fullscreen and relative to its container.

257 lines (225 loc) 8.43 kB
import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import classNamesBind from 'classnames/bind'; import ThemeContext from 'terra-theme-context'; import FocusTrap from 'focus-trap-react'; import { Portal } from 'react-portal'; import * as KeyCode from 'keycode-js'; import 'mutationobserver-shim'; import './_contains-polyfill'; import './_matches-polyfill'; import styles from './Overlay.module.scss'; import Container from './OverlayContainer'; const cx = classNamesBind.bind(styles); const BackgroundStyles = { LIGHT: 'light', DARK: 'dark', CLEAR: 'clear', }; const zIndexes = ['100', '6000', '7000', '8000', '9000']; const propTypes = { /** * The content to be displayed within the overlay. */ children: PropTypes.node, /** * Indicates if the overlay is open. */ isOpen: PropTypes.bool, /** * The visual theme to be applied to the overlay background. Accepts 'light', 'dark', and 'clear'. */ backgroundStyle: PropTypes.oneOf(['light', 'dark', 'clear']), /** * Indicates if the overlay content is scrollable. */ isScrollable: PropTypes.bool, /** * Callback triggered on overlay click or ESC key. Setting this enables close behavior. */ onRequestClose: PropTypes.func, /** * Indicates if the overlay is relative to the triggering container. */ isRelativeToContainer: PropTypes.bool, /** * Used to select the root mount DOM node. This is used to help prevent focus from shifting outside of the overlay when it is opened in a portal. */ rootSelector: PropTypes.string, /** * Z-Index layer to apply to the ModalContent and ModalOverlay. Valid values are '100', '6000', '7000', '8000', or '9000'. */ zIndex: PropTypes.oneOf(['100', '6000', '7000', '8000', '9000']), }; const defaultProps = { children: null, isOpen: false, backgroundStyle: BackgroundStyles.LIGHT, isScrollable: false, isRelativeToContainer: false, onRequestClose: undefined, rootSelector: '#root', zIndex: '100', }; class Overlay extends React.Component { constructor(props) { super(props); this.setContainer = this.setContainer.bind(this); this.disableContainerChildrenFocus = this.disableContainerChildrenFocus.bind(this); this.enableContainerChildrenFocus = this.enableContainerChildrenFocus.bind(this); this.shouldHandleESCKeydown = this.shouldHandleESCKeydown.bind(this); this.shouldHandleClick = this.shouldHandleClick.bind(this); } componentDidMount() { // eslint-disable-next-line no-prototype-builtins if (!Element.prototype.hasOwnProperty('inert')) { // IE10 throws an error if wicg-inert is imported too early, as wicg-inert tries to set an observer on document.body which may not exist on import // eslint-disable-next-line global-require require('wicg-inert/dist/inert'); } document.addEventListener('keydown', this.shouldHandleESCKeydown); if (this.props.isOpen) { this.disableContainerChildrenFocus(); } } componentDidUpdate(prevProps) { if (this.props.isOpen && !prevProps.isOpen) { this.disableContainerChildrenFocus(); } else if (!this.props.isOpen && prevProps.isOpen) { this.enableContainerChildrenFocus(); } } componentWillUnmount() { document.removeEventListener('keydown', this.shouldHandleESCKeydown); this.enableContainerChildrenFocus(); } handleCloseEvent(event) { if (this.props.onRequestClose) { this.props.onRequestClose(event); } } setContainer(node) { if (!node) { return; } // Ref callbacks happen on mount and unmount, element is null on unmount this.overflow = document.documentElement.style.overflow; if (this.props.isRelativeToContainer) { this.container = node.parentNode; } else { this.container = null; } } disableContainerChildrenFocus() { if (this.props.isRelativeToContainer) { if (this.container && this.container.querySelector('[data-terra-overlay-container-content]')) { this.container.querySelector('[data-terra-overlay-container-content]').setAttribute('inert', ''); } } else { const selector = this.props.rootSelector; if (document.querySelector(selector) && !document.querySelector(selector).hasAttribute('data-overlay-count')) { document.querySelector(selector).setAttribute('data-overlay-count', '1'); document.querySelector(selector).setAttribute('inert', ''); } else if (document.querySelector(selector) && document.querySelector(selector).hasAttribute('data-overlay-count')) { const inert = +document.querySelector(selector).getAttribute('data-overlay-count'); document.querySelector(selector).setAttribute('data-overlay-count', `${inert + 1}`); document.querySelector(selector).setAttribute('inert', ''); } document.documentElement.style.overflow = 'hidden'; } } enableContainerChildrenFocus() { if (this.props.isRelativeToContainer) { if (this.container && this.container.querySelector('[data-terra-overlay-container-content]')) { this.container.querySelector('[data-terra-overlay-container-content]').removeAttribute('inert'); this.container.querySelector('[data-terra-overlay-container-content]').removeAttribute('aria-hidden'); } } else { const selector = this.props.rootSelector; if (document.querySelector(selector)) { // Guard for Jest testing const inert = +document.querySelector(selector).getAttribute('data-overlay-count'); if (inert === 1) { document.querySelector(selector).removeAttribute('data-overlay-count'); document.querySelector(selector).removeAttribute('inert'); document.querySelector(selector).removeAttribute('aria-hidden'); } else if (inert && inert > 1) { document.querySelector(selector).setAttribute('data-overlay-count', `${inert - 1}`); } } document.documentElement.style.overflow = this.overflow; } } shouldHandleESCKeydown(event) { if (this.props.isOpen && event.keyCode === KeyCode.KEY_ESCAPE) { this.handleCloseEvent(event); event.preventDefault(); } } shouldHandleClick(event) { if (this.props.isOpen) { this.handleCloseEvent(event); } } render() { const { children, isOpen, backgroundStyle, isScrollable, isRelativeToContainer, onRequestClose, rootSelector, zIndex, ...customProps } = this.props; const theme = this.context; const type = isRelativeToContainer ? 'container' : 'fullscreen'; if (!isOpen) { return null; } let zIndexLayer = '100'; if (zIndexes.indexOf(zIndex) >= 0) { zIndexLayer = zIndex; } const OverlayClassNames = classNames( cx([ 'overlay', type, backgroundStyle, { scrollable: isScrollable }, `layer-${zIndexLayer}`, theme.className, ]), customProps.className, ); /* tabIndex set to 0 allows screen readers like VoiceOver to read overlay content when its displayed. Key events are added on mount. */ /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-tabindex */ const overlayComponent = ( <div {...customProps} ref={this.setContainer} onClick={this.shouldHandleClick} className={OverlayClassNames} tabIndex="0"> <div className={cx('content')}> {children} </div> </div> ); /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-tabindex */ if (isRelativeToContainer) { return overlayComponent; } const backgroundScrollContent = ( <div className={cx('background-scroll-content')}> <div className={cx('inner')} /> </div> ); return ( <Portal> {backgroundScrollContent} <FocusTrap> {/* div addresses child focus change introduced in focus-trap-react v5.0.0 */} <div> {overlayComponent} </div> </FocusTrap> </Portal> ); } } const Opts = { BackgroundStyles, zIndexes }; Overlay.propTypes = propTypes; Overlay.defaultProps = defaultProps; Overlay.contextType = ThemeContext; Overlay.Opts = Opts; Overlay.Container = Container; export default Overlay;