react-responsive-modal
Version:
A simple responsive and accessible react modal
466 lines (395 loc) • 16.1 kB
JavaScript
import React, { useRef, useEffect, useState } from 'react';
import ReactDom from 'react-dom';
import cx from 'classnames';
import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock';
import useForwardedRef from '@bedrock-layout/use-forwarded-ref';
function _extends() {
_extends = Object.assign || function (target) {
for (var i = 1; i < arguments.length; i++) {
var source = arguments[i];
for (var key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
target[key] = source[key];
}
}
}
return target;
};
return _extends.apply(this, arguments);
}
var CloseIcon = function CloseIcon(_ref) {
var classes = _ref.classes,
classNames = _ref.classNames,
styles = _ref.styles,
id = _ref.id,
closeIcon = _ref.closeIcon,
onClick = _ref.onClick;
return React.createElement("button", {
id: id,
className: cx(classes.closeButton, classNames == null ? void 0 : classNames.closeButton),
style: styles == null ? void 0 : styles.closeButton,
onClick: onClick,
"data-testid": "close-button"
}, closeIcon ? closeIcon : React.createElement("svg", {
className: classNames == null ? void 0 : classNames.closeIcon,
style: styles == null ? void 0 : styles.closeIcon,
width: 28,
height: 28,
viewBox: "0 0 36 36",
"data-testid": "close-icon"
}, React.createElement("path", {
d: "M28.5 9.62L26.38 7.5 18 15.88 9.62 7.5 7.5 9.62 15.88 18 7.5 26.38l2.12 2.12L18 20.12l8.38 8.38 2.12-2.12L20.12 18z"
})));
};
var isBrowser = typeof window !== 'undefined';
// https://github.com/alexandrzavalii/focus-trap-js/blob/master/src/index.js v1.1.0
var candidateSelectors = ['input', 'select', 'textarea', 'a[href]', 'button', '[tabindex]', 'audio[controls]', 'video[controls]', '[contenteditable]:not([contenteditable="false"])'];
function isHidden(node) {
// offsetParent being null will allow detecting cases where an element is invisible or inside an invisible element,
// as long as the element does not use position: fixed. For them, their visibility has to be checked directly as well.
return node.offsetParent === null || getComputedStyle(node).visibility === 'hidden';
}
function getCheckedRadio(nodes, form) {
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].checked && nodes[i].form === form) {
return nodes[i];
}
}
}
function isNotRadioOrTabbableRadio(node) {
if (node.tagName !== 'INPUT' || node.type !== 'radio' || !node.name) {
return true;
}
var radioScope = node.form || node.ownerDocument;
var radioSet = radioScope.querySelectorAll('input[type="radio"][name="' + node.name + '"]');
var checked = getCheckedRadio(radioSet, node.form);
return checked === node || checked === undefined && radioSet[0] === node;
}
function getAllTabbingElements(parentElem) {
var currentActiveElement = document.activeElement;
var tabbableNodes = parentElem.querySelectorAll(candidateSelectors.join(','));
var onlyTabbable = [];
for (var i = 0; i < tabbableNodes.length; i++) {
var node = tabbableNodes[i];
if (currentActiveElement === node || !node.disabled && getTabindex(node) > -1 && !isHidden(node) && isNotRadioOrTabbableRadio(node)) {
onlyTabbable.push(node);
}
}
return onlyTabbable;
}
function tabTrappingKey(event, parentElem) {
// check if current event keyCode is tab
if (!event || event.key !== 'Tab') return;
if (!parentElem || !parentElem.contains) {
if (process && process.env.NODE_ENV === 'development') {
console.warn('focus-trap-js: parent element is not defined');
}
return false;
}
if (!parentElem.contains(event.target)) {
return false;
}
var allTabbingElements = getAllTabbingElements(parentElem);
var firstFocusableElement = allTabbingElements[0];
var lastFocusableElement = allTabbingElements[allTabbingElements.length - 1];
if (event.shiftKey && event.target === firstFocusableElement) {
lastFocusableElement.focus();
event.preventDefault();
return true;
} else if (!event.shiftKey && event.target === lastFocusableElement) {
firstFocusableElement.focus();
event.preventDefault();
return true;
}
return false;
}
function getTabindex(node) {
var tabindexAttr = parseInt(node.getAttribute('tabindex'), 10);
if (!isNaN(tabindexAttr)) return tabindexAttr; // Browsers do not return tabIndex correctly for contentEditable nodes;
// so if they don't have a tabindex attribute specifically set, assume it's 0.
if (isContentEditable(node)) return 0;
return node.tabIndex;
}
function isContentEditable(node) {
return node.getAttribute('contentEditable');
}
var FocusTrap = function FocusTrap(_ref) {
var container = _ref.container,
initialFocusRef = _ref.initialFocusRef;
var refLastFocus = useRef();
/**
* Handle focus lock on the modal
*/
useEffect(function () {
var handleKeyEvent = function handleKeyEvent(event) {
if (container == null ? void 0 : container.current) {
tabTrappingKey(event, container.current);
}
};
if (isBrowser) {
document.addEventListener('keydown', handleKeyEvent);
} // On mount we focus on the first focusable element in the modal if there is one
if (isBrowser && (container == null ? void 0 : container.current)) {
var savePreviousFocus = function savePreviousFocus() {
// First we save the last focused element
// only if it's a focusable element
if (candidateSelectors.findIndex(function (selector) {
var _document$activeEleme;
return (_document$activeEleme = document.activeElement) == null ? void 0 : _document$activeEleme.matches(selector);
}) !== -1) {
refLastFocus.current = document.activeElement;
}
};
if (initialFocusRef) {
savePreviousFocus(); // We need to schedule focusing on a next frame - this allows to focus on the modal root
requestAnimationFrame(function () {
var _initialFocusRef$curr;
(_initialFocusRef$curr = initialFocusRef.current) == null ? void 0 : _initialFocusRef$curr.focus();
});
} else {
var allTabbingElements = getAllTabbingElements(container.current);
if (allTabbingElements[0]) {
savePreviousFocus();
allTabbingElements[0].focus();
}
}
}
return function () {
if (isBrowser) {
var _refLastFocus$current;
document.removeEventListener('keydown', handleKeyEvent); // On unmount we restore the focus to the last focused element
(_refLastFocus$current = refLastFocus.current) == null ? void 0 : _refLastFocus$current.focus();
}
};
}, [container, initialFocusRef]);
return null;
};
var modals = [];
/**
* Handle the order of the modals.
* Inspired by the material-ui implementation.
*/
var modalManager = {
/**
* Register a new modal
*/
add: function add(newModal) {
modals.push(newModal);
},
/**
* Remove a modal
*/
remove: function remove(oldModal) {
modals = modals.filter(function (modal) {
return modal !== oldModal;
});
},
/**
* When multiple modals are rendered will return true if current modal is the last one
*/
isTopModal: function isTopModal(modal) {
return !!modals.length && modals[modals.length - 1] === modal;
}
};
function useModalManager(ref, open) {
useEffect(function () {
if (open) {
modalManager.add(ref);
}
return function () {
modalManager.remove(ref);
};
}, [open, ref]);
}
var useScrollLock = function useScrollLock(refModal, open, showPortal, blockScroll, reserveScrollBarGap) {
var oldRef = useRef(null);
useEffect(function () {
if (open && refModal.current && blockScroll) {
oldRef.current = refModal.current;
disableBodyScroll(refModal.current, {
reserveScrollBarGap: reserveScrollBarGap
});
}
return function () {
if (oldRef.current) {
enableBodyScroll(oldRef.current);
oldRef.current = null;
}
};
}, [open, showPortal, refModal, blockScroll, reserveScrollBarGap]);
};
var classes = {
root: 'react-responsive-modal-root',
overlay: 'react-responsive-modal-overlay',
overlayAnimationIn: 'react-responsive-modal-overlay-in',
overlayAnimationOut: 'react-responsive-modal-overlay-out',
modalContainer: 'react-responsive-modal-container',
modalContainerCenter: 'react-responsive-modal-containerCenter',
modal: 'react-responsive-modal-modal',
modalAnimationIn: 'react-responsive-modal-modal-in',
modalAnimationOut: 'react-responsive-modal-modal-out',
closeButton: 'react-responsive-modal-closeButton'
};
var Modal = /*#__PURE__*/React.forwardRef(function (_ref, ref) {
var _classNames$overlayAn, _classNames$overlayAn2, _classNames$modalAnim, _classNames$modalAnim2;
var open = _ref.open,
center = _ref.center,
_ref$blockScroll = _ref.blockScroll,
blockScroll = _ref$blockScroll === void 0 ? true : _ref$blockScroll,
_ref$closeOnEsc = _ref.closeOnEsc,
closeOnEsc = _ref$closeOnEsc === void 0 ? true : _ref$closeOnEsc,
_ref$closeOnOverlayCl = _ref.closeOnOverlayClick,
closeOnOverlayClick = _ref$closeOnOverlayCl === void 0 ? true : _ref$closeOnOverlayCl,
container = _ref.container,
_ref$showCloseIcon = _ref.showCloseIcon,
showCloseIcon = _ref$showCloseIcon === void 0 ? true : _ref$showCloseIcon,
closeIconId = _ref.closeIconId,
closeIcon = _ref.closeIcon,
_ref$focusTrapped = _ref.focusTrapped,
focusTrapped = _ref$focusTrapped === void 0 ? true : _ref$focusTrapped,
_ref$initialFocusRef = _ref.initialFocusRef,
initialFocusRef = _ref$initialFocusRef === void 0 ? undefined : _ref$initialFocusRef,
_ref$animationDuratio = _ref.animationDuration,
animationDuration = _ref$animationDuratio === void 0 ? 300 : _ref$animationDuratio,
classNames = _ref.classNames,
styles = _ref.styles,
_ref$role = _ref.role,
role = _ref$role === void 0 ? 'dialog' : _ref$role,
ariaDescribedby = _ref.ariaDescribedby,
ariaLabelledby = _ref.ariaLabelledby,
containerId = _ref.containerId,
modalId = _ref.modalId,
onClose = _ref.onClose,
onEscKeyDown = _ref.onEscKeyDown,
onOverlayClick = _ref.onOverlayClick,
onAnimationEnd = _ref.onAnimationEnd,
children = _ref.children,
reserveScrollBarGap = _ref.reserveScrollBarGap;
var refDialog = useForwardedRef(ref);
var refModal = useRef(null);
var refShouldClose = useRef(null);
var refContainer = useRef(null); // Lazily create the ref instance
// https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily
if (refContainer.current === null && isBrowser) {
refContainer.current = document.createElement('div');
} // The value should be false for srr, that way when the component is hydrated client side,
// it will match the server rendered content
var _useState = useState(false),
showPortal = _useState[0],
setShowPortal = _useState[1]; // Hook used to manage multiple modals opened at the same time
useModalManager(refModal, open); // Hook used to manage the scroll
useScrollLock(refModal, open, showPortal, blockScroll, reserveScrollBarGap);
var handleOpen = function handleOpen() {
if (refContainer.current && !container && !document.body.contains(refContainer.current)) {
document.body.appendChild(refContainer.current);
}
document.addEventListener('keydown', handleKeydown);
};
var handleClose = function handleClose() {
if (refContainer.current && !container && document.body.contains(refContainer.current)) {
document.body.removeChild(refContainer.current);
}
document.removeEventListener('keydown', handleKeydown);
};
var handleKeydown = function handleKeydown(event) {
// Only the last modal need to be escaped when pressing the esc key
if (event.keyCode !== 27 || !modalManager.isTopModal(refModal)) {
return;
}
onEscKeyDown == null ? void 0 : onEscKeyDown(event);
if (closeOnEsc) {
onClose();
}
};
useEffect(function () {
return function () {
if (showPortal) {
// When the modal is closed or removed directly, cleanup the listeners
handleClose();
}
};
}, [showPortal]);
useEffect(function () {
// If the open prop is changing, we need to open the modal
// This is also called on the first render if the open prop is true when the modal is created
if (open && !showPortal) {
setShowPortal(true);
handleOpen();
}
}, [open]);
var handleClickOverlay = function handleClickOverlay(event) {
if (refShouldClose.current === null) {
refShouldClose.current = true;
}
if (!refShouldClose.current) {
refShouldClose.current = null;
return;
}
onOverlayClick == null ? void 0 : onOverlayClick(event);
if (closeOnOverlayClick) {
onClose();
}
refShouldClose.current = null;
};
var handleModalEvent = function handleModalEvent() {
refShouldClose.current = false;
};
var handleAnimationEnd = function handleAnimationEnd() {
if (!open) {
setShowPortal(false);
}
onAnimationEnd == null ? void 0 : onAnimationEnd();
};
var containerModal = container || refContainer.current;
var overlayAnimation = open ? (_classNames$overlayAn = classNames == null ? void 0 : classNames.overlayAnimationIn) != null ? _classNames$overlayAn : classes.overlayAnimationIn : (_classNames$overlayAn2 = classNames == null ? void 0 : classNames.overlayAnimationOut) != null ? _classNames$overlayAn2 : classes.overlayAnimationOut;
var modalAnimation = open ? (_classNames$modalAnim = classNames == null ? void 0 : classNames.modalAnimationIn) != null ? _classNames$modalAnim : classes.modalAnimationIn : (_classNames$modalAnim2 = classNames == null ? void 0 : classNames.modalAnimationOut) != null ? _classNames$modalAnim2 : classes.modalAnimationOut;
return showPortal && containerModal ? ReactDom.createPortal(React.createElement("div", {
className: cx(classes.root, classNames == null ? void 0 : classNames.root),
style: styles == null ? void 0 : styles.root,
"data-testid": "root"
}, React.createElement("div", {
className: cx(classes.overlay, classNames == null ? void 0 : classNames.overlay),
"data-testid": "overlay",
"aria-hidden": true,
style: _extends({
animation: overlayAnimation + " " + animationDuration + "ms"
}, styles == null ? void 0 : styles.overlay)
}), React.createElement("div", {
ref: refModal,
id: containerId,
className: cx(classes.modalContainer, center && classes.modalContainerCenter, classNames == null ? void 0 : classNames.modalContainer),
style: styles == null ? void 0 : styles.modalContainer,
"data-testid": "modal-container",
onClick: handleClickOverlay
}, React.createElement("div", {
ref: refDialog,
className: cx(classes.modal, classNames == null ? void 0 : classNames.modal),
style: _extends({
animation: modalAnimation + " " + animationDuration + "ms"
}, styles == null ? void 0 : styles.modal),
onMouseDown: handleModalEvent,
onMouseUp: handleModalEvent,
onClick: handleModalEvent,
onAnimationEnd: handleAnimationEnd,
id: modalId,
role: role,
"aria-modal": "true",
"aria-labelledby": ariaLabelledby,
"aria-describedby": ariaDescribedby,
"data-testid": "modal",
tabIndex: -1
}, focusTrapped && React.createElement(FocusTrap, {
container: refDialog,
initialFocusRef: initialFocusRef
}), children, showCloseIcon && React.createElement(CloseIcon, {
classes: classes,
classNames: classNames,
styles: styles,
closeIcon: closeIcon,
onClick: onClose,
id: closeIconId
})))), containerModal) : null;
});
export default Modal;
export { Modal };
//# sourceMappingURL=react-responsive-modal.esm.js.map