@wordpress/components
Version:
UI components for WordPress.
512 lines (434 loc) • 16.5 kB
JavaScript
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