UNPKG

@wordpress/components

Version:
452 lines (405 loc) 16.5 kB
import _extends from "@babel/runtime/helpers/esm/extends"; import { createElement } from "@wordpress/element"; /** * External dependencies */ import classnames from 'classnames'; import { useFloating, flip as flipMiddleware, shift as shiftMiddleware, autoUpdate, arrow, offset as offsetMiddleware, size } from '@floating-ui/react-dom'; // eslint-disable-next-line no-restricted-imports import { motion, useReducedMotion } from 'framer-motion'; /** * WordPress dependencies */ import { useRef, useLayoutEffect, forwardRef, createContext, useContext, useMemo, useState, useCallback } from '@wordpress/element'; import { useViewportMatch, useMergeRefs, __experimentalUseDialog as useDialog } from '@wordpress/compose'; import { close } from '@wordpress/icons'; import deprecated from '@wordpress/deprecated'; import { Path, SVG } from '@wordpress/primitives'; import { getScrollContainer } from '@wordpress/dom'; /** * Internal dependencies */ import Button from '../button'; import ScrollLock from '../scroll-lock'; import { Slot, Fill, useSlot } from '../slot-fill'; import { getFrameOffset, getFrameScale, positionToPlacement, placementToMotionAnimationProps, getReferenceOwnerDocument, getReferenceElement } from './utils'; import { limitShift as customLimitShift } from './limit-shift'; import { overlayMiddlewares } from './overlay-middlewares'; /** * Name of slot in which popover should fill. * * @type {string} */ const SLOT_NAME = 'Popover'; // An SVG displaying a triangle facing down, filled with a solid // color and bordered in such a way to create an arrow-like effect. // Keeping the SVG's viewbox squared simplify the arrow positioning // calculations. const ArrowTriangle = () => createElement(SVG, { xmlns: "http://www.w3.org/2000/svg", viewBox: `0 0 100 100`, className: "components-popover__triangle", role: "presentation" }, createElement(Path, { className: "components-popover__triangle-bg", d: "M 0 0 L 50 50 L 100 0" }), createElement(Path, { className: "components-popover__triangle-border", d: "M 0 0 L 50 50 L 100 0", vectorEffect: "non-scaling-stroke" })); const AnimatedWrapper = forwardRef((_ref, forwardedRef) => { let { style: receivedInlineStyles, placement, shouldAnimate = false, ...props } = _ref; const shouldReduceMotion = useReducedMotion(); const { style: motionInlineStyles, ...otherMotionProps } = useMemo(() => placementToMotionAnimationProps(placement), [placement]); const computedAnimationProps = shouldAnimate && !shouldReduceMotion ? { style: { ...motionInlineStyles, ...receivedInlineStyles }, ...otherMotionProps } : { animate: false, style: receivedInlineStyles }; return createElement(motion.div, _extends({}, computedAnimationProps, props, { ref: forwardedRef })); }); const slotNameContext = createContext(undefined); const UnforwardedPopover = (props, forwardedRef) => { var _frameOffsetRef$curre, _frameOffsetRef$curre2, _frameOffsetRef$curre3, _frameOffsetRef$curre4; const { animate = true, headerTitle, onClose, children, className, noArrow = true, position, placement: placementProp = 'bottom-start', offset: offsetProp = 0, focusOnMount = 'firstElement', anchor, expandOnMobile, onFocusOutside, __unstableSlotName = SLOT_NAME, flip = true, resize = true, shift = false, variant, // Deprecated props __unstableForcePosition, anchorRef, anchorRect, getAnchorRect, isAlternate, // Rest ...contentProps } = props; let computedFlipProp = flip; let computedResizeProp = resize; if (__unstableForcePosition !== undefined) { deprecated('`__unstableForcePosition` prop in wp.components.Popover', { since: '6.1', version: '6.3', alternative: '`flip={ false }` and `resize={ false }`' }); // Back-compat, set the `flip` and `resize` props // to `false` to replicate `__unstableForcePosition`. computedFlipProp = !__unstableForcePosition; computedResizeProp = !__unstableForcePosition; } if (anchorRef !== undefined) { deprecated('`anchorRef` prop in wp.components.Popover', { since: '6.1', alternative: '`anchor` prop' }); } if (anchorRect !== undefined) { deprecated('`anchorRect` prop in wp.components.Popover', { since: '6.1', alternative: '`anchor` prop' }); } if (getAnchorRect !== undefined) { deprecated('`getAnchorRect` prop in wp.components.Popover', { since: '6.1', alternative: '`anchor` prop' }); } const computedVariant = isAlternate ? 'toolbar' : variant; if (isAlternate !== undefined) { deprecated('`isAlternate` prop in wp.components.Popover', { since: '6.2', alternative: "`variant` prop with the `'toolbar'` value" }); } const arrowRef = useRef(null); const [fallbackReferenceElement, setFallbackReferenceElement] = useState(null); const [referenceOwnerDocument, setReferenceOwnerDocument] = useState(); const anchorRefFallback = useCallback(node => { setFallbackReferenceElement(node); }, []); const isMobileViewport = useViewportMatch('medium', '<'); const isExpanded = expandOnMobile && isMobileViewport; const hasArrow = !isExpanded && !noArrow; const normalizedPlacementFromProps = position ? positionToPlacement(position) : placementProp; /** * Offsets the position of the popover when the anchor is inside an iframe. * * Store the offset in a ref, due to constraints with floating-ui: * https://floating-ui.com/docs/react-dom#variables-inside-middleware-functions. */ const frameOffsetRef = useRef(getFrameOffset(referenceOwnerDocument)); const middleware = [...(placementProp === 'overlay' ? overlayMiddlewares() : []), // Custom middleware which adjusts the popover's position by taking into // account the offset of the anchor's iframe (if any) compared to the page. { name: 'frameOffset', fn(_ref2) { let { x, y } = _ref2; if (!frameOffsetRef.current) { return { x, y }; } return { x: x + frameOffsetRef.current.x, y: y + frameOffsetRef.current.y, data: { // This will be used in the customLimitShift() function. amount: frameOffsetRef.current } }; } }, offsetMiddleware(offsetProp), computedFlipProp ? flipMiddleware() : undefined, computedResizeProp ? size({ apply(sizeProps) { var _refs$floating$curren; const { firstElementChild } = (_refs$floating$curren = refs.floating.current) !== null && _refs$floating$curren !== void 0 ? _refs$floating$curren : {}; // Only HTMLElement instances have the `style` property. if (!(firstElementChild instanceof HTMLElement)) return; // Reduce the height of the popover to the available space. Object.assign(firstElementChild.style, { maxHeight: `${sizeProps.availableHeight}px`, overflow: 'auto' }); } }) : undefined, shift ? shiftMiddleware({ crossAxis: true, limiter: customLimitShift(), padding: 1 // Necessary to avoid flickering at the edge of the viewport. }) : undefined, arrow({ element: arrowRef })].filter(m => m !== undefined); const slotName = useContext(slotNameContext) || __unstableSlotName; const slot = useSlot(slotName); let onDialogClose; if (onClose || onFocusOutside) { onDialogClose = (type, event) => { // Ideally the popover should have just a single onClose prop and // not three props that potentially do the same thing. if (type === 'focus-outside' && onFocusOutside) { onFocusOutside(event); } else if (onClose) { onClose(); } }; } const [dialogRef, dialogProps] = useDialog({ focusOnMount, __unstableOnClose: onDialogClose, // @ts-expect-error The __unstableOnClose property needs to be deprecated first (see https://github.com/WordPress/gutenberg/pull/27675) onClose: onDialogClose }); const { // Positioning coordinates x, y, // Callback refs (not regular refs). This allows the position to be updated. // when either elements change. reference: referenceCallbackRef, floating, // Object with "regular" refs to both "reference" and "floating" refs, // Type of CSS position property to use (absolute or fixed) strategy, update, placement: computedPlacement, middlewareData: { arrow: arrowData } } = useFloating({ placement: normalizedPlacementFromProps === 'overlay' ? undefined : normalizedPlacementFromProps, middleware, whileElementsMounted: (referenceParam, floatingParam, updateParam) => autoUpdate(referenceParam, floatingParam, updateParam, { animationFrame: true }) }); const arrowCallbackRef = useCallback(node => { arrowRef.current = node; update(); }, [update]); // When any of the possible anchor "sources" change, // recompute the reference element (real or virtual) and its owner document. const anchorRefTop = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.top; const anchorRefBottom = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.bottom; const anchorRefStartContainer = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.startContainer; const anchorRefCurrent = anchorRef === null || anchorRef === void 0 ? void 0 : anchorRef.current; useLayoutEffect(() => { const resultingReferenceOwnerDoc = getReferenceOwnerDocument({ anchor, anchorRef, anchorRect, getAnchorRect, fallbackReferenceElement, fallbackDocument: document }); const scale = getFrameScale(resultingReferenceOwnerDoc); const resultingReferenceElement = getReferenceElement({ anchor, anchorRef, anchorRect, getAnchorRect, fallbackReferenceElement, scale }); referenceCallbackRef(resultingReferenceElement); setReferenceOwnerDocument(resultingReferenceOwnerDoc); }, [anchor, anchorRef, anchorRefTop, anchorRefBottom, anchorRefStartContainer, anchorRefCurrent, anchorRect, getAnchorRect, fallbackReferenceElement, referenceCallbackRef]); // If the reference element is in a different ownerDocument (e.g. iFrame), // we need to manually update the floating's position as the reference's owner // document scrolls. Also update the frame offset if the view resizes. useLayoutEffect(() => { var _refs$floating$curren2, _referenceOwnerDocume; if ( // Reference and root documents are the same. referenceOwnerDocument === document || // Reference and floating are in the same document. referenceOwnerDocument === ((_refs$floating$curren2 = refs.floating.current) === null || _refs$floating$curren2 === void 0 ? void 0 : _refs$floating$curren2.ownerDocument) || // The reference's document has no view (i.e. window) // or frame element (ie. it's not an iframe). !(referenceOwnerDocument !== null && referenceOwnerDocument !== void 0 && (_referenceOwnerDocume = referenceOwnerDocument.defaultView) !== null && _referenceOwnerDocume !== void 0 && _referenceOwnerDocume.frameElement)) { frameOffsetRef.current = undefined; return; } const { defaultView } = referenceOwnerDocument; const { frameElement } = defaultView; const scrollContainer = frameElement ? getScrollContainer(frameElement) : null; const updateFrameOffset = () => { frameOffsetRef.current = getFrameOffset(referenceOwnerDocument); update(); }; defaultView.addEventListener('resize', updateFrameOffset); scrollContainer === null || scrollContainer === void 0 ? void 0 : scrollContainer.addEventListener('scroll', updateFrameOffset); updateFrameOffset(); return () => { defaultView.removeEventListener('resize', updateFrameOffset); scrollContainer === null || scrollContainer === void 0 ? void 0 : scrollContainer.removeEventListener('scroll', updateFrameOffset); }; }, [referenceOwnerDocument, update, refs.floating]); const mergedFloatingRef = useMergeRefs([floating, dialogRef, forwardedRef]); // 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(AnimatedWrapper, _extends({ shouldAnimate: animate && !isExpanded, placement: computedPlacement, className: classnames('components-popover', className, { 'is-expanded': isExpanded, 'is-positioned': x !== null && y !== null, // Use the 'alternate' classname for 'toolbar' variant for back compat. [`is-${computedVariant === 'toolbar' ? 'alternate' : computedVariant}`]: computedVariant }) }, contentProps, { ref: mergedFloatingRef }, dialogProps, { tabIndex: -1, style: isExpanded ? undefined : { position: strategy, top: 0, left: 0, // `x` and `y` are framer-motion specific props and are shorthands // for `translateX` and `translateY`. Currently it is not possible // to use `translateX` and `translateY` because those values would // be overridden by the return value of the // `placementToMotionAnimationProps` function in `AnimatedWrapper` x: Math.round(x !== null && x !== void 0 ? x : 0) || undefined, y: Math.round(y !== null && y !== void 0 ? y : 0) || undefined } }), 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", { className: "components-popover__content" }, children), hasArrow && createElement("div", { ref: arrowCallbackRef, className: ['components-popover__arrow', `is-${computedPlacement.split('-')[0]}`].join(' '), style: { left: typeof (arrowData === null || arrowData === void 0 ? void 0 : arrowData.x) !== 'undefined' && Number.isFinite(arrowData.x) ? `${arrowData.x + ((_frameOffsetRef$curre = (_frameOffsetRef$curre2 = frameOffsetRef.current) === null || _frameOffsetRef$curre2 === void 0 ? void 0 : _frameOffsetRef$curre2.x) !== null && _frameOffsetRef$curre !== void 0 ? _frameOffsetRef$curre : 0)}px` : '', top: typeof (arrowData === null || arrowData === void 0 ? void 0 : arrowData.y) !== 'undefined' && Number.isFinite(arrowData.y) ? `${arrowData.y + ((_frameOffsetRef$curre3 = (_frameOffsetRef$curre4 = frameOffsetRef.current) === null || _frameOffsetRef$curre4 === void 0 ? void 0 : _frameOffsetRef$curre4.y) !== null && _frameOffsetRef$curre3 !== void 0 ? _frameOffsetRef$curre3 : 0)}px` : '' } }, createElement(ArrowTriangle, null))); if (slot.ref) { content = createElement(Fill, { name: slotName }, content); } if (anchorRef || anchorRect || anchor) { return content; } return createElement("span", { ref: anchorRefFallback }, content); }; /** * `Popover` renders its content in a floating modal. If no explicit anchor is passed via props, it anchors to its parent element by default. * * ```jsx * import { Button, Popover } from '@wordpress/components'; * import { useState } from '@wordpress/element'; * * const MyPopover = () => { * const [ isVisible, setIsVisible ] = useState( false ); * const toggleVisible = () => { * setIsVisible( ( state ) => ! state ); * }; * * return ( * <Button variant="secondary" onClick={ toggleVisible }> * Toggle Popover! * { isVisible && <Popover>Popover is toggled!</Popover> } * </Button> * ); * }; * ``` * */ export const Popover = forwardRef(UnforwardedPopover); function PopoverSlot(_ref3, ref) { let { name = SLOT_NAME } = _ref3; return createElement(Slot // @ts-expect-error Need to type `SlotFill` , { bubblesVirtually: true, name: name, className: "popover-slot", ref: ref }); } // @ts-expect-error For Legacy Reasons Popover.Slot = forwardRef(PopoverSlot); // @ts-expect-error For Legacy Reasons Popover.__unstableSlotNameProvider = slotNameContext.Provider; export default Popover; //# sourceMappingURL=index.js.map