UNPKG

@wordpress/components

Version:
329 lines (314 loc) 13.6 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.default = exports.Modal = void 0; var _clsx = _interopRequireDefault(require("clsx")); var _element = require("@wordpress/element"); var _compose = require("@wordpress/compose"); var _i18n = require("@wordpress/i18n"); var _icons = require("@wordpress/icons"); var _dom = require("@wordpress/dom"); var ariaHelper = _interopRequireWildcard(require("./aria-helper")); var _button = _interopRequireDefault(require("../button")); var _styleProvider = _interopRequireDefault(require("../style-provider")); var _withIgnoreImeEvents = require("../utils/with-ignore-ime-events"); var _spacer = require("../spacer"); var _useModalExitAnimation = require("./use-modal-exit-animation"); var _jsxRuntime = require("react/jsx-runtime"); function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); } function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } /** * External dependencies */ /** * WordPress dependencies */ /** * Internal dependencies */ // Used to track and dismiss the prior modal when another opens unless nested. const ModalContext = (0, _element.createContext)(new Set()); // Used to track body class names applied while modals are open. const bodyOpenClasses = new Map(); function UnforwardedModal(props, forwardedRef) { const { bodyOpenClassName = 'modal-open', role = 'dialog', title = null, focusOnMount = true, shouldCloseOnEsc = true, shouldCloseOnClickOutside = true, isDismissible = true, /* Accessibility. */ aria = { labelledby: undefined, describedby: undefined }, onRequestClose, icon, closeButtonLabel, children, style, overlayClassName: overlayClassnameProp, className, contentLabel, onKeyDown, isFullScreen = false, size, headerActions = null, __experimentalHideHeader = false } = props; const ref = (0, _element.useRef)(); const instanceId = (0, _compose.useInstanceId)(Modal); const headingId = title ? `components-modal-header-${instanceId}` : aria.labelledby; // The focus hook does not support 'firstContentElement' but this is a valid // value for the Modal's focusOnMount prop. The following code ensures the focus // hook will focus the first focusable node within the element to which it is applied. // When `firstContentElement` is passed as the value of the focusOnMount prop, // the focus hook is applied to the Modal's content element. // Otherwise, the focus hook is applied to the Modal's ref. This ensures that the // focus hook will focus the first element in the Modal's **content** when // `firstContentElement` is passed. const focusOnMountRef = (0, _compose.useFocusOnMount)(focusOnMount === 'firstContentElement' ? 'firstElement' : focusOnMount); const constrainedTabbingRef = (0, _compose.useConstrainedTabbing)(); const focusReturnRef = (0, _compose.useFocusReturn)(); const contentRef = (0, _element.useRef)(null); const childrenContainerRef = (0, _element.useRef)(null); const [hasScrolledContent, setHasScrolledContent] = (0, _element.useState)(false); const [hasScrollableContent, setHasScrollableContent] = (0, _element.useState)(false); let sizeClass; if (isFullScreen || size === 'fill') { sizeClass = 'is-full-screen'; } else if (size) { sizeClass = `has-size-${size}`; } // Determines whether the Modal content is scrollable and updates the state. const isContentScrollable = (0, _element.useCallback)(() => { if (!contentRef.current) { return; } const closestScrollContainer = (0, _dom.getScrollContainer)(contentRef.current); if (contentRef.current === closestScrollContainer) { setHasScrollableContent(true); } else { setHasScrollableContent(false); } }, [contentRef]); // Accessibly isolates/unisolates the modal. (0, _element.useEffect)(() => { ariaHelper.modalize(ref.current); return () => ariaHelper.unmodalize(); }, []); // Keeps a fresh ref for the subsequent effect. const onRequestCloseRef = (0, _element.useRef)(); (0, _element.useEffect)(() => { onRequestCloseRef.current = onRequestClose; }, [onRequestClose]); // The list of `onRequestClose` callbacks of open (non-nested) Modals. Only // one should remain open at a time and the list enables closing prior ones. const dismissers = (0, _element.useContext)(ModalContext); // Used for the tracking and dismissing any nested modals. const [nestedDismissers] = (0, _element.useState)(() => new Set()); // Updates the stack tracking open modals at this level and calls // onRequestClose for any prior and/or nested modals as applicable. (0, _element.useEffect)(() => { // add this modal instance to the dismissers set dismissers.add(onRequestCloseRef); // request that all the other modals close themselves for (const dismisser of dismissers) { if (dismisser !== onRequestCloseRef) { dismisser.current?.(); } } return () => { // request that all the nested modals close themselves for (const dismisser of nestedDismissers) { dismisser.current?.(); } // remove this modal instance from the dismissers set dismissers.delete(onRequestCloseRef); }; }, [dismissers, nestedDismissers]); // Adds/removes the value of bodyOpenClassName to body element. (0, _element.useEffect)(() => { var _bodyOpenClasses$get; const theClass = bodyOpenClassName; const oneMore = 1 + ((_bodyOpenClasses$get = bodyOpenClasses.get(theClass)) !== null && _bodyOpenClasses$get !== void 0 ? _bodyOpenClasses$get : 0); bodyOpenClasses.set(theClass, oneMore); document.body.classList.add(bodyOpenClassName); return () => { const oneLess = bodyOpenClasses.get(theClass) - 1; if (oneLess === 0) { document.body.classList.remove(theClass); bodyOpenClasses.delete(theClass); } else { bodyOpenClasses.set(theClass, oneLess); } }; }, [bodyOpenClassName]); const { closeModal, frameRef, frameStyle, overlayClassname } = (0, _useModalExitAnimation.useModalExitAnimation)(); // Calls the isContentScrollable callback when the Modal children container resizes. (0, _element.useLayoutEffect)(() => { if (!window.ResizeObserver || !childrenContainerRef.current) { return; } const resizeObserver = new ResizeObserver(isContentScrollable); resizeObserver.observe(childrenContainerRef.current); isContentScrollable(); return () => { resizeObserver.disconnect(); }; }, [isContentScrollable, childrenContainerRef]); function handleEscapeKeyDown(event) { if (shouldCloseOnEsc && (event.code === 'Escape' || event.key === 'Escape') && !event.defaultPrevented) { event.preventDefault(); closeModal().then(() => onRequestClose(event)); } } const onContentContainerScroll = (0, _element.useCallback)(e => { var _e$currentTarget$scro; const scrollY = (_e$currentTarget$scro = e?.currentTarget?.scrollTop) !== null && _e$currentTarget$scro !== void 0 ? _e$currentTarget$scro : -1; if (!hasScrolledContent && scrollY > 0) { setHasScrolledContent(true); } else if (hasScrolledContent && scrollY <= 0) { setHasScrolledContent(false); } }, [hasScrolledContent]); let pressTarget = null; const overlayPressHandlers = { onPointerDown: event => { if (event.target === event.currentTarget) { pressTarget = event.target; // Avoids focus changing so that focus return works as expected. event.preventDefault(); } }, // Closes the modal with two exceptions. 1. Opening the context menu on // the overlay. 2. Pressing on the overlay then dragging the pointer // over the modal and releasing. Due to the modal being a child of the // overlay, such a gesture is a `click` on the overlay and cannot be // excepted by a `click` handler. Thus the tactic of handling // `pointerup` and comparing its target to that of the `pointerdown`. onPointerUp: ({ target, button }) => { const isSameTarget = target === pressTarget; pressTarget = null; if (button === 0 && isSameTarget) { closeModal().then(() => onRequestClose()); } } }; const modal = /*#__PURE__*/ // eslint-disable-next-line jsx-a11y/no-static-element-interactions (0, _jsxRuntime.jsx)("div", { ref: (0, _compose.useMergeRefs)([ref, forwardedRef]), className: (0, _clsx.default)('components-modal__screen-overlay', overlayClassname, overlayClassnameProp), onKeyDown: (0, _withIgnoreImeEvents.withIgnoreIMEEvents)(handleEscapeKeyDown), ...(shouldCloseOnClickOutside ? overlayPressHandlers : {}), children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_styleProvider.default, { document: document, children: /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { className: (0, _clsx.default)('components-modal__frame', sizeClass, className), style: { ...frameStyle, ...style }, ref: (0, _compose.useMergeRefs)([frameRef, constrainedTabbingRef, focusReturnRef, focusOnMount !== 'firstContentElement' ? focusOnMountRef : null]), role: role, "aria-label": contentLabel, "aria-labelledby": contentLabel ? undefined : headingId, "aria-describedby": aria.describedby, tabIndex: -1, onKeyDown: onKeyDown, children: /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { className: (0, _clsx.default)('components-modal__content', { 'hide-header': __experimentalHideHeader, 'is-scrollable': hasScrollableContent, 'has-scrolled-content': hasScrolledContent }), role: "document", onScroll: onContentContainerScroll, ref: contentRef, "aria-label": hasScrollableContent ? (0, _i18n.__)('Scrollable section') : undefined, tabIndex: hasScrollableContent ? 0 : undefined, children: [!__experimentalHideHeader && /*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { className: "components-modal__header", children: [/*#__PURE__*/(0, _jsxRuntime.jsxs)("div", { className: "components-modal__header-heading-container", children: [icon && /*#__PURE__*/(0, _jsxRuntime.jsx)("span", { className: "components-modal__icon-container", "aria-hidden": true, children: icon }), title && /*#__PURE__*/(0, _jsxRuntime.jsx)("h1", { id: headingId, className: "components-modal__header-heading", children: title })] }), headerActions, isDismissible && /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, { children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_spacer.Spacer, { marginBottom: 0, marginLeft: 2 }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_button.default, { size: "compact", onClick: event => closeModal().then(() => onRequestClose(event)), icon: _icons.close, label: closeButtonLabel || (0, _i18n.__)('Close') })] })] }), /*#__PURE__*/(0, _jsxRuntime.jsx)("div", { ref: (0, _compose.useMergeRefs)([childrenContainerRef, focusOnMount === 'firstContentElement' ? focusOnMountRef : null]), children: children })] }) }) }) }); return (0, _element.createPortal)(/*#__PURE__*/(0, _jsxRuntime.jsx)(ModalContext.Provider, { value: nestedDismissers, children: modal }), document.body); } /** * Modals give users information and choices related to a task they’re trying to * accomplish. They can contain critical information, require decisions, or * involve multiple tasks. * * ```jsx * import { Button, Modal } from '@wordpress/components'; * import { useState } from '@wordpress/element'; * * const MyModal = () => { * const [ isOpen, setOpen ] = useState( false ); * const openModal = () => setOpen( true ); * const closeModal = () => setOpen( false ); * * return ( * <> * <Button variant="secondary" onClick={ openModal }> * Open Modal * </Button> * { isOpen && ( * <Modal title="This is my modal" onRequestClose={ closeModal }> * <Button variant="secondary" onClick={ closeModal }> * My custom close button * </Button> * </Modal> * ) } * </> * ); * }; * ``` */ const Modal = exports.Modal = (0, _element.forwardRef)(UnforwardedModal); var _default = exports.default = Modal; //# sourceMappingURL=index.js.map