react-responsive-modal
Version:
A simple responsive and accessible react modal
313 lines (305 loc) • 10.6 kB
JavaScript
import React, { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import cx from "classnames";
import { useForwardedRef } from "@bedrock-layout/use-forwarded-ref";
import { jsx, jsxs } from "react/jsx-runtime";
import { disableBodyScroll, enableBodyScroll } from "body-scroll-lock";
//#region src/CloseIcon.tsx
const CloseIcon = ({ classes: classes$1, classNames, styles, id, closeIcon, onClick }) => /* @__PURE__ */ jsx("button", {
id,
className: cx(classes$1.closeButton, classNames?.closeButton),
style: styles?.closeButton,
onClick,
"data-testid": "close-button",
children: closeIcon ? closeIcon : /* @__PURE__ */ jsx("svg", {
className: classNames?.closeIcon,
style: styles?.closeIcon,
width: 28,
height: 28,
viewBox: "0 0 36 36",
"data-testid": "close-icon",
children: /* @__PURE__ */ jsx("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 CloseIcon_default = CloseIcon;
//#endregion
//#region src/utils.ts
const isBrowser = typeof window !== "undefined";
//#endregion
//#region src/lib/focusTrapJs.ts
const candidateSelectors = [
"input",
"select",
"textarea",
"a[href]",
"button",
"[tabindex]",
"audio[controls]",
"video[controls]",
"[contenteditable]:not([contenteditable=\"false\"])"
];
function isHidden(node) {
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 === void 0 && 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) {
if (!event || event.key !== "Tab") return;
if (!parentElem || !parentElem.contains) {
if (process && true) 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;
if (isContentEditable(node)) return 0;
return node.tabIndex;
}
function isContentEditable(node) {
return node.getAttribute("contentEditable");
}
//#endregion
//#region src/FocusTrap.tsx
const FocusTrap = ({ container, initialFocusRef }) => {
const refLastFocus = useRef(null);
/**
* Handle focus lock on the modal
*/
useEffect(() => {
const handleKeyEvent = (event) => {
if (container?.current) tabTrappingKey(event, container.current);
};
if (isBrowser) document.addEventListener("keydown", handleKeyEvent);
if (isBrowser && container?.current) {
const savePreviousFocus = () => {
if (candidateSelectors.findIndex((selector) => document.activeElement?.matches(selector)) !== -1) refLastFocus.current = document.activeElement;
};
if (initialFocusRef) {
savePreviousFocus();
requestAnimationFrame(() => {
initialFocusRef.current?.focus();
});
} else {
const allTabbingElements = getAllTabbingElements(container.current);
if (allTabbingElements[0]) {
savePreviousFocus();
allTabbingElements[0].focus();
}
}
}
return () => {
if (isBrowser) {
document.removeEventListener("keydown", handleKeyEvent);
refLastFocus.current?.focus();
}
};
}, [container, initialFocusRef]);
return null;
};
//#endregion
//#region src/modalManager.ts
let modals = [];
/**
* Handle the order of the modals.
* Inspired by the material-ui implementation.
*/
const modalManager = {
add: (newModal) => {
modals.push(newModal);
},
remove: (oldModal) => {
modals = modals.filter((modal) => modal !== oldModal);
},
isTopModal: (modal) => !!modals.length && modals[modals.length - 1] === modal
};
function useModalManager(ref, open) {
useEffect(() => {
if (open) modalManager.add(ref);
return () => {
modalManager.remove(ref);
};
}, [open, ref]);
}
//#endregion
//#region src/useScrollLock.ts
const useScrollLock = (refModal, open, showPortal, blockScroll, reserveScrollBarGap) => {
const oldRef = useRef(null);
useEffect(() => {
if (open && refModal.current && blockScroll) {
oldRef.current = refModal.current;
disableBodyScroll(refModal.current, { reserveScrollBarGap });
}
return () => {
if (oldRef.current) {
enableBodyScroll(oldRef.current);
oldRef.current = null;
}
};
}, [
open,
showPortal,
refModal,
blockScroll,
reserveScrollBarGap
]);
};
//#endregion
//#region src/index.tsx
const 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"
};
const Modal = React.forwardRef(({ open, center, blockScroll = true, closeOnEsc = true, closeOnOverlayClick = true, container, showCloseIcon = true, closeIconId, closeIcon, focusTrapped = true, initialFocusRef = void 0, animationDuration = 300, classNames, styles, role = "dialog", ariaDescribedby, ariaLabelledby, containerId, modalId, onClose, onEscKeyDown, onOverlayClick, onAnimationEnd, children, reserveScrollBarGap }, ref) => {
const refDialog = useForwardedRef(ref);
const refModal = useRef(null);
const refShouldClose = useRef(null);
const refContainer = useRef(null);
if (refContainer.current === null && isBrowser) refContainer.current = document.createElement("div");
const [showPortal, setShowPortal] = useState(false);
useModalManager(refModal, open);
useScrollLock(refModal, open, showPortal, blockScroll, reserveScrollBarGap);
const handleOpen = () => {
if (refContainer.current && !container && !document.body.contains(refContainer.current)) document.body.appendChild(refContainer.current);
document.addEventListener("keydown", handleKeydown);
};
const handleClose = () => {
if (refContainer.current && !container && document.body.contains(refContainer.current)) document.body.removeChild(refContainer.current);
document.removeEventListener("keydown", handleKeydown);
};
const handleKeydown = (event) => {
if (event.keyCode !== 27 || !modalManager.isTopModal(refModal)) return;
onEscKeyDown?.(event);
if (closeOnEsc) onClose();
};
useEffect(() => {
return () => {
if (showPortal) handleClose();
};
}, [showPortal]);
useEffect(() => {
if (open && !showPortal) {
setShowPortal(true);
handleOpen();
}
}, [open]);
const handleClickOverlay = (event) => {
if (refShouldClose.current === null) refShouldClose.current = true;
if (!refShouldClose.current) {
refShouldClose.current = null;
return;
}
onOverlayClick?.(event);
if (closeOnOverlayClick) onClose();
refShouldClose.current = null;
};
const handleModalEvent = () => {
refShouldClose.current = false;
};
const handleAnimationEnd = () => {
if (!open) setShowPortal(false);
onAnimationEnd?.();
};
const containerModal = container || refContainer.current;
const overlayAnimation = open ? classNames?.overlayAnimationIn ?? classes.overlayAnimationIn : classNames?.overlayAnimationOut ?? classes.overlayAnimationOut;
const modalAnimation = open ? classNames?.modalAnimationIn ?? classes.modalAnimationIn : classNames?.modalAnimationOut ?? classes.modalAnimationOut;
return showPortal && containerModal ? createPortal(/* @__PURE__ */ jsxs("div", {
className: cx(classes.root, classNames?.root),
style: styles?.root,
"data-testid": "root",
children: [/* @__PURE__ */ jsx("div", {
className: cx(classes.overlay, classNames?.overlay),
"data-testid": "overlay",
"aria-hidden": true,
style: {
animation: `${overlayAnimation} ${animationDuration}ms`,
...styles?.overlay
}
}), /* @__PURE__ */ jsx("div", {
ref: refModal,
id: containerId,
className: cx(classes.modalContainer, center && classes.modalContainerCenter, classNames?.modalContainer),
style: styles?.modalContainer,
"data-testid": "modal-container",
onClick: handleClickOverlay,
children: /* @__PURE__ */ jsxs("div", {
ref: refDialog,
className: cx(classes.modal, classNames?.modal),
style: {
animation: `${modalAnimation} ${animationDuration}ms`,
...styles?.modal
},
onMouseDown: handleModalEvent,
onMouseUp: handleModalEvent,
onClick: handleModalEvent,
onAnimationEnd: handleAnimationEnd,
id: modalId,
role,
"aria-modal": "true",
"aria-labelledby": ariaLabelledby,
"aria-describedby": ariaDescribedby,
"data-testid": "modal",
tabIndex: -1,
children: [
focusTrapped && /* @__PURE__ */ jsx(FocusTrap, {
container: refDialog,
initialFocusRef
}),
children,
showCloseIcon && /* @__PURE__ */ jsx(CloseIcon_default, {
classes,
classNames,
styles,
closeIcon,
onClick: onClose,
id: closeIconId
})
]
})
})]
}), containerModal) : null;
});
var src_default = Modal;
//#endregion
export { Modal, src_default as default };
//# sourceMappingURL=index.js.map