UNPKG

@wordpress/components

Version:
512 lines (434 loc) 16.5 kB
import _extends from "@babel/runtime/helpers/esm/extends"; import { createElement } from "@wordpress/element"; /** * External dependencies */ import classnames from 'classnames'; /** * WordPress dependencies */ import { useRef, useState, useLayoutEffect } from '@wordpress/element'; import { getRectangleFromRange } from '@wordpress/dom'; import { ESCAPE } from '@wordpress/keycodes'; import deprecated from '@wordpress/deprecated'; import { useViewportMatch, useResizeObserver, useFocusOnMount, __experimentalUseFocusOutside as useFocusOutside, useConstrainedTabbing, useFocusReturn, useMergeRefs } from '@wordpress/compose'; import { close } from '@wordpress/icons'; /** * Internal dependencies */ import { computePopoverPosition, offsetIframe } from './utils'; import Button from '../button'; import ScrollLock from '../scroll-lock'; import { Slot, Fill, useSlot } from '../slot-fill'; import { getAnimateClassName } from '../animate'; /** * Name of slot in which popover should fill. * * @type {string} */ const SLOT_NAME = 'Popover'; function computeAnchorRect(anchorRefFallback, anchorRect, getAnchorRect, anchorRef = false, shouldAnchorIncludePadding) { if (anchorRect) { return anchorRect; } if (getAnchorRect) { if (!anchorRefFallback.current) { return; } return offsetIframe(getAnchorRect(anchorRefFallback.current), anchorRefFallback.current.ownerDocument); } if (anchorRef !== false) { if (!anchorRef || !window.Range || !window.Element || !window.DOMRect) { return; } // Duck-type to check if `anchorRef` is an instance of Range // `anchorRef instanceof window.Range` checks will break across document boundaries // such as in an iframe if (typeof (anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.cloneRange) === 'function') { return offsetIframe(getRectangleFromRange(anchorRef), anchorRef.endContainer.ownerDocument); } // Duck-type to check if `anchorRef` is an instance of Element // `anchorRef instanceof window.Element` checks will break across document boundaries // such as in an iframe if (typeof (anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.getBoundingClientRect) === 'function') { const rect = offsetIframe(anchorRef.getBoundingClientRect(), anchorRef.ownerDocument); if (shouldAnchorIncludePadding) { return rect; } return withoutPadding(rect, anchorRef); } const { top, bottom } = anchorRef; const topRect = top.getBoundingClientRect(); const bottomRect = bottom.getBoundingClientRect(); const rect = offsetIframe(new window.DOMRect(topRect.left, topRect.top, topRect.width, bottomRect.bottom - topRect.top), top.ownerDocument); if (shouldAnchorIncludePadding) { return rect; } return withoutPadding(rect, anchorRef); } if (!anchorRefFallback.current) { return; } const { parentNode } = anchorRefFallback.current; const rect = parentNode.getBoundingClientRect(); if (shouldAnchorIncludePadding) { return rect; } return withoutPadding(rect, parentNode); } function getComputedStyle(node) { return node.ownerDocument.defaultView.getComputedStyle(node); } function withoutPadding(rect, element) { const { paddingTop, paddingBottom, paddingLeft, paddingRight } = getComputedStyle(element); const top = paddingTop ? parseInt(paddingTop, 10) : 0; const bottom = paddingBottom ? parseInt(paddingBottom, 10) : 0; const left = paddingLeft ? parseInt(paddingLeft, 10) : 0; const right = paddingRight ? parseInt(paddingRight, 10) : 0; return { x: rect.left + left, y: rect.top + top, width: rect.width - left - right, height: rect.height - top - bottom, left: rect.left + left, right: rect.right - right, top: rect.top + top, bottom: rect.bottom - bottom }; } /** * Sets or removes an element attribute. * * @param {Element} element The element to modify. * @param {string} name The attribute name to set or remove. * @param {?string} value The value to set. A falsy value will remove the * attribute. */ function setAttribute(element, name, value) { if (!value) { if (element.hasAttribute(name)) { element.removeAttribute(name); } } else if (element.getAttribute(name) !== value) { element.setAttribute(name, value); } } /** * Sets or removes an element style property. * * @param {Element} element The element to modify. * @param {string} property The property to set or remove. * @param {?string} value The value to set. A falsy value will remove the * property. */ function setStyle(element, property, value = '') { if (element.style[property] !== value) { element.style[property] = value; } } /** * Sets or removes an element class. * * @param {Element} element The element to modify. * @param {string} name The class to set or remove. * @param {boolean} toggle True to set the class, false to remove. */ function setClass(element, name, toggle) { if (toggle) { if (!element.classList.contains(name)) { element.classList.add(name); } } else if (element.classList.contains(name)) { element.classList.remove(name); } } function getAnchorDocument(anchor) { if (!anchor) { return; } if (anchor.endContainer) { return anchor.endContainer.ownerDocument; } if (anchor.top) { return anchor.top.ownerDocument; } return anchor.ownerDocument; } const Popover = ({ headerTitle, onClose, onKeyDown, children, className, noArrow = true, isAlternate, // Disable reason: We generate the `...contentProps` rest as remainder // of props which aren't explicitly handled by this component. /* eslint-disable no-unused-vars */ position = 'bottom right', range, focusOnMount = 'firstElement', anchorRef, shouldAnchorIncludePadding, anchorRect, getAnchorRect, expandOnMobile, animate = true, onClickOutside, onFocusOutside, __unstableStickyBoundaryElement, __unstableSlotName = SLOT_NAME, __unstableObserveElement, __unstableBoundaryParent, __unstableForcePosition, /* eslint-enable no-unused-vars */ ...contentProps }) => { const anchorRefFallback = useRef(null); const contentRef = useRef(null); const containerRef = useRef(); const isMobileViewport = useViewportMatch('medium', '<'); const [animateOrigin, setAnimateOrigin] = useState(); const slot = useSlot(__unstableSlotName); const isExpanded = expandOnMobile && isMobileViewport; const [containerResizeListener, contentSize] = useResizeObserver(); noArrow = isExpanded || noArrow; useLayoutEffect(() => { if (isExpanded) { setClass(containerRef.current, 'is-without-arrow', noArrow); setClass(containerRef.current, 'is-alternate', isAlternate); setAttribute(containerRef.current, 'data-x-axis'); setAttribute(containerRef.current, 'data-y-axis'); setStyle(containerRef.current, 'top'); setStyle(containerRef.current, 'left'); setStyle(contentRef.current, 'maxHeight'); setStyle(contentRef.current, 'maxWidth'); return; } const refresh = () => { if (!containerRef.current || !contentRef.current) { return; } let anchor = computeAnchorRect(anchorRefFallback, anchorRect, getAnchorRect, anchorRef, shouldAnchorIncludePadding); if (!anchor) { return; } const { offsetParent, ownerDocument } = containerRef.current; let relativeOffsetTop = 0; // If there is a positioned ancestor element that is not the body, // subtract the position from the anchor rect. If the position of // the popover is fixed, the offset parent is null or the body // element, in which case the position is relative to the viewport. // See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent if (offsetParent && offsetParent !== ownerDocument.body) { const offsetParentRect = offsetParent.getBoundingClientRect(); relativeOffsetTop = offsetParentRect.top; anchor = new window.DOMRect(anchor.left - offsetParentRect.left, anchor.top - offsetParentRect.top, anchor.width, anchor.height); } let boundaryElement; if (__unstableBoundaryParent) { var _containerRef$current; boundaryElement = (_containerRef$current = containerRef.current.closest('.popover-slot')) === null || _containerRef$current === void 0 ? void 0 : _containerRef$current.parentNode; } const usedContentSize = !contentSize.height ? contentRef.current.getBoundingClientRect() : contentSize; const { popoverTop, popoverLeft, xAxis, yAxis, contentHeight, contentWidth } = computePopoverPosition(anchor, usedContentSize, position, __unstableStickyBoundaryElement, containerRef.current, relativeOffsetTop, boundaryElement, __unstableForcePosition); if (typeof popoverTop === 'number' && typeof popoverLeft === 'number') { setStyle(containerRef.current, 'top', popoverTop + 'px'); setStyle(containerRef.current, 'left', popoverLeft + 'px'); } setClass(containerRef.current, 'is-without-arrow', noArrow || xAxis === 'center' && yAxis === 'middle'); setClass(containerRef.current, 'is-alternate', isAlternate); setAttribute(containerRef.current, 'data-x-axis', xAxis); setAttribute(containerRef.current, 'data-y-axis', yAxis); setStyle(contentRef.current, 'maxHeight', typeof contentHeight === 'number' ? contentHeight + 'px' : ''); setStyle(contentRef.current, 'maxWidth', typeof contentWidth === 'number' ? contentWidth + 'px' : ''); // Compute the animation position const yAxisMapping = { top: 'bottom', bottom: 'top' }; const xAxisMapping = { left: 'right', right: 'left' }; const animateYAxis = yAxisMapping[yAxis] || 'middle'; const animateXAxis = xAxisMapping[xAxis] || 'center'; setAnimateOrigin(animateXAxis + ' ' + animateYAxis); }; refresh(); const { ownerDocument } = containerRef.current; const { defaultView } = ownerDocument; /* * There are sometimes we need to reposition or resize the popover that * are not handled by the resize/scroll window events (i.e. CSS changes * in the layout that changes the position of the anchor). * * For these situations, we refresh the popover every 0.5s */ const intervalHandle = defaultView.setInterval(refresh, 500); let rafId; const refreshOnAnimationFrame = () => { defaultView.cancelAnimationFrame(rafId); rafId = defaultView.requestAnimationFrame(refresh); }; // Sometimes a click trigger a layout change that affects the popover // position. This is an opportunity to immediately refresh rather than // at the interval. defaultView.addEventListener('click', refreshOnAnimationFrame); defaultView.addEventListener('resize', refresh); defaultView.addEventListener('scroll', refresh, true); const anchorDocument = getAnchorDocument(anchorRef); // If the anchor is within an iframe, the popover position also needs // to refrest when the iframe content is scrolled or resized. if (anchorDocument && anchorDocument !== ownerDocument) { anchorDocument.defaultView.addEventListener('resize', refresh); anchorDocument.defaultView.addEventListener('scroll', refresh, true); } let observer; if (__unstableObserveElement) { observer = new defaultView.MutationObserver(refresh); observer.observe(__unstableObserveElement, { attributes: true }); } return () => { defaultView.clearInterval(intervalHandle); defaultView.removeEventListener('resize', refresh); defaultView.removeEventListener('scroll', refresh, true); defaultView.removeEventListener('click', refreshOnAnimationFrame); defaultView.cancelAnimationFrame(rafId); if (anchorDocument && anchorDocument !== ownerDocument) { anchorDocument.defaultView.removeEventListener('resize', refresh); anchorDocument.defaultView.removeEventListener('scroll', refresh, true); } if (observer) { observer.disconnect(); } }; }, [isExpanded, anchorRect, getAnchorRect, anchorRef, shouldAnchorIncludePadding, position, contentSize, __unstableStickyBoundaryElement, __unstableObserveElement, __unstableBoundaryParent]); const constrainedTabbingRef = useConstrainedTabbing(); const focusReturnRef = useFocusReturn(); const focusOnMountRef = useFocusOnMount(focusOnMount); const focusOutsideProps = useFocusOutside(handleOnFocusOutside); const mergedRefs = useMergeRefs([containerRef, focusOnMount ? constrainedTabbingRef : null, focusOnMount ? focusReturnRef : null, focusOnMount ? focusOnMountRef : null]); // Event handlers const maybeClose = event => { // Close on escape if (event.keyCode === ESCAPE && onClose) { event.stopPropagation(); onClose(); } // Preserve original content prop behavior if (onKeyDown) { onKeyDown(event); } }; /** * Shims an onFocusOutside callback to be compatible with a deprecated * onClickOutside prop function, if provided. * * @param {FocusEvent} event Focus event from onFocusOutside. */ function handleOnFocusOutside(event) { // Defer to given `onFocusOutside` if specified. Call `onClose` only if // both `onFocusOutside` and `onClickOutside` are unspecified. Doing so // assures backwards-compatibility for prior `onClickOutside` default. if (onFocusOutside) { onFocusOutside(event); return; } else if (!onClickOutside) { if (onClose) { onClose(); } return; } // Simulate MouseEvent using FocusEvent#relatedTarget as emulated click // target. MouseEvent constructor is unsupported in Internet Explorer. let clickEvent; try { clickEvent = new window.MouseEvent('click'); } catch (error) { clickEvent = document.createEvent('MouseEvent'); clickEvent.initMouseEvent('click', true, true, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); } Object.defineProperty(clickEvent, 'target', { get: () => event.relatedTarget }); deprecated('Popover onClickOutside prop', { since: '5.3', alternative: 'onFocusOutside' }); onClickOutside(clickEvent); } /** @type {false | string} */ const animateClassName = Boolean(animate && animateOrigin) && getAnimateClassName({ type: 'appear', origin: animateOrigin }); // Disable reason: We care to capture the _bubbled_ events from inputs // within popover as inferring close intent. let content = // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions // eslint-disable-next-line jsx-a11y/no-static-element-interactions createElement("div", _extends({ className: classnames('components-popover', className, animateClassName, { 'is-expanded': isExpanded, 'is-without-arrow': noArrow, 'is-alternate': isAlternate }) }, contentProps, { onKeyDown: maybeClose }, focusOutsideProps, { ref: mergedRefs, tabIndex: "-1" }), isExpanded && createElement(ScrollLock, null), isExpanded && createElement("div", { className: "components-popover__header" }, createElement("span", { className: "components-popover__header-title" }, headerTitle), createElement(Button, { className: "components-popover__close", icon: close, onClick: onClose })), createElement("div", { ref: contentRef, className: "components-popover__content" }, createElement("div", { style: { position: 'relative' } }, containerResizeListener, children))); if (slot.ref) { content = createElement(Fill, { name: __unstableSlotName }, content); } if (anchorRef || anchorRect) { return content; } return createElement("span", { ref: anchorRefFallback }, content); }; const PopoverContainer = Popover; PopoverContainer.Slot = ({ name = SLOT_NAME }) => createElement(Slot, { bubblesVirtually: true, name: name, className: "popover-slot" }); export default PopoverContainer; //# sourceMappingURL=index.js.map