@wordpress/components
Version:
UI components for WordPress.
329 lines (314 loc) • 13.6 kB
JavaScript
;
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