UNPKG

react-responsive-modal

Version:

A simple responsive and accessible react modal

313 lines (305 loc) 10.6 kB
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