@wordpress/components
Version:
UI components for WordPress.
321 lines (305 loc) • 10.3 kB
text/typescript
/**
* External dependencies
*/
// eslint-disable-next-line no-restricted-imports
import type { MotionProps } from 'framer-motion';
import type { ReferenceType } from '@floating-ui/react-dom';
/**
* Internal dependencies
*/
import type {
PopoverProps,
PopoverAnchorRefReference,
PopoverAnchorRefTopBottom,
} from './types';
const POSITION_TO_PLACEMENT: Record<
NonNullable< PopoverProps[ 'position' ] >,
NonNullable< PopoverProps[ 'placement' ] >
> = {
bottom: 'bottom',
top: 'top',
'middle left': 'left',
'middle right': 'right',
'bottom left': 'bottom-end',
'bottom center': 'bottom',
'bottom right': 'bottom-start',
'top left': 'top-end',
'top center': 'top',
'top right': 'top-start',
'middle left left': 'left',
'middle left right': 'left',
'middle left bottom': 'left-end',
'middle left top': 'left-start',
'middle right left': 'right',
'middle right right': 'right',
'middle right bottom': 'right-end',
'middle right top': 'right-start',
'bottom left left': 'bottom-end',
'bottom left right': 'bottom-end',
'bottom left bottom': 'bottom-end',
'bottom left top': 'bottom-end',
'bottom center left': 'bottom',
'bottom center right': 'bottom',
'bottom center bottom': 'bottom',
'bottom center top': 'bottom',
'bottom right left': 'bottom-start',
'bottom right right': 'bottom-start',
'bottom right bottom': 'bottom-start',
'bottom right top': 'bottom-start',
'top left left': 'top-end',
'top left right': 'top-end',
'top left bottom': 'top-end',
'top left top': 'top-end',
'top center left': 'top',
'top center right': 'top',
'top center bottom': 'top',
'top center top': 'top',
'top right left': 'top-start',
'top right right': 'top-start',
'top right bottom': 'top-start',
'top right top': 'top-start',
// `middle`/`middle center [corner?]` positions are associated to a fallback
// `bottom` placement because there aren't any corresponding placement values.
middle: 'bottom',
'middle center': 'bottom',
'middle center bottom': 'bottom',
'middle center left': 'bottom',
'middle center right': 'bottom',
'middle center top': 'bottom',
};
/**
* Converts the `Popover`'s legacy "position" prop to the new "placement" prop
* (used by `floating-ui`).
*
* @param position The legacy position
* @return The corresponding placement
*/
export const positionToPlacement = (
position: NonNullable< PopoverProps[ 'position' ] >
): NonNullable< PopoverProps[ 'placement' ] > =>
POSITION_TO_PLACEMENT[ position ] ?? 'bottom';
/**
* @typedef AnimationOrigin
* @type {Object}
* @property {number} originX A number between 0 and 1 (in CSS logical properties jargon, 0 is "start", 0.5 is "center", and 1 is "end")
* @property {number} originY A number between 0 and 1 (0 is top, 0.5 is center, and 1 is bottom)
*/
const PLACEMENT_TO_ANIMATION_ORIGIN: Record<
NonNullable< PopoverProps[ 'placement' ] >,
{ originX: number; originY: number }
> = {
top: { originX: 0.5, originY: 1 }, // open from bottom, center
'top-start': { originX: 0, originY: 1 }, // open from bottom, left
'top-end': { originX: 1, originY: 1 }, // open from bottom, right
right: { originX: 0, originY: 0.5 }, // open from middle, left
'right-start': { originX: 0, originY: 0 }, // open from top, left
'right-end': { originX: 0, originY: 1 }, // open from bottom, left
bottom: { originX: 0.5, originY: 0 }, // open from top, center
'bottom-start': { originX: 0, originY: 0 }, // open from top, left
'bottom-end': { originX: 1, originY: 0 }, // open from top, right
left: { originX: 1, originY: 0.5 }, // open from middle, right
'left-start': { originX: 1, originY: 0 }, // open from top, right
'left-end': { originX: 1, originY: 1 }, // open from bottom, right
overlay: { originX: 0.5, originY: 0.5 }, // open from center, center
};
/**
* Given the floating-ui `placement`, compute the framer-motion props for the
* popover's entry animation.
*
* @param placement A placement string from floating ui
* @return The object containing the motion props
*/
export const placementToMotionAnimationProps = (
placement: NonNullable< PopoverProps[ 'placement' ] >
): MotionProps => {
const translateProp =
placement.startsWith( 'top' ) || placement.startsWith( 'bottom' )
? 'translateY'
: 'translateX';
const translateDirection =
placement.startsWith( 'top' ) || placement.startsWith( 'left' )
? 1
: -1;
return {
style: PLACEMENT_TO_ANIMATION_ORIGIN[ placement ],
initial: {
opacity: 0,
scale: 0,
[ translateProp ]: `${ 2 * translateDirection }em`,
},
animate: { opacity: 1, scale: 1, [ translateProp ]: 0 },
transition: { duration: 0.1, ease: [ 0, 0, 0.2, 1 ] },
};
};
/**
* Returns the offset of a document's frame element.
*
* @param document The iframe's owner document.
*
* @return The offset of the document's frame element, or undefined if the
* document has no frame element.
*/
export const getFrameOffset = (
document?: Document
): { x: number; y: number } | undefined => {
const frameElement = document?.defaultView?.frameElement;
if ( ! frameElement ) {
return;
}
const iframeRect = frameElement.getBoundingClientRect();
return { x: iframeRect.left, y: iframeRect.top };
};
export const getFrameScale = (
document?: Document
): {
x: number;
y: number;
} => {
const frameElement = document?.defaultView?.frameElement as HTMLElement;
if ( ! frameElement ) {
return { x: 1, y: 1 };
}
const rect = frameElement.getBoundingClientRect();
return {
x: rect.width / frameElement.offsetWidth,
y: rect.height / frameElement.offsetHeight,
};
};
export const getReferenceOwnerDocument = ( {
anchor,
anchorRef,
anchorRect,
getAnchorRect,
fallbackReferenceElement,
fallbackDocument,
}: Pick<
PopoverProps,
'anchorRef' | 'anchorRect' | 'getAnchorRect' | 'anchor'
> & {
fallbackReferenceElement: Element | null;
fallbackDocument: Document;
} ): Document => {
// In floating-ui's terms:
// - "reference" refers to the popover's anchor element.
// - "floating" refers the floating popover's element.
// A floating element can also be positioned relative to a virtual element,
// instead of a real one. A virtual element is represented by an object
// with the `getBoundingClientRect()` function (like real elements).
// See https://floating-ui.com/docs/virtual-elements for more info.
let resultingReferenceOwnerDoc;
if ( anchor ) {
resultingReferenceOwnerDoc = anchor.ownerDocument;
} else if ( ( anchorRef as PopoverAnchorRefTopBottom | undefined )?.top ) {
resultingReferenceOwnerDoc = ( anchorRef as PopoverAnchorRefTopBottom )
?.top.ownerDocument;
} else if ( ( anchorRef as Range | undefined )?.startContainer ) {
resultingReferenceOwnerDoc = ( anchorRef as Range ).startContainer
.ownerDocument;
} else if (
( anchorRef as PopoverAnchorRefReference | undefined )?.current
) {
resultingReferenceOwnerDoc = (
( anchorRef as PopoverAnchorRefReference ).current as Element
).ownerDocument;
} else if ( anchorRef as Element | undefined ) {
// This one should be deprecated.
resultingReferenceOwnerDoc = ( anchorRef as Element ).ownerDocument;
} else if ( anchorRect && anchorRect?.ownerDocument ) {
resultingReferenceOwnerDoc = anchorRect.ownerDocument;
} else if ( getAnchorRect ) {
resultingReferenceOwnerDoc = getAnchorRect(
fallbackReferenceElement
)?.ownerDocument;
}
return resultingReferenceOwnerDoc ?? fallbackDocument;
};
export const getReferenceElement = ( {
anchor,
anchorRef,
anchorRect,
getAnchorRect,
fallbackReferenceElement,
scale,
}: Pick<
PopoverProps,
'anchorRef' | 'anchorRect' | 'getAnchorRect' | 'anchor'
> & {
fallbackReferenceElement: Element | null;
scale: { x: number; y: number };
} ): ReferenceType | null => {
let referenceElement = null;
if ( anchor ) {
referenceElement = anchor;
} else if ( ( anchorRef as PopoverAnchorRefTopBottom | undefined )?.top ) {
// Create a virtual element for the ref. The expectation is that
// if anchorRef.top is defined, then anchorRef.bottom is defined too.
// Seems to be used by the block toolbar, when multiple blocks are selected
// (top and bottom blocks are used to calculate the resulting rect).
referenceElement = {
getBoundingClientRect() {
const topRect = (
anchorRef as PopoverAnchorRefTopBottom
).top.getBoundingClientRect();
const bottomRect = (
anchorRef as PopoverAnchorRefTopBottom
).bottom.getBoundingClientRect();
return new window.DOMRect(
topRect.x,
topRect.y,
topRect.width,
bottomRect.bottom - topRect.top
);
},
};
} else if (
( anchorRef as PopoverAnchorRefReference | undefined )?.current
) {
// Standard React ref.
referenceElement = ( anchorRef as PopoverAnchorRefReference ).current;
} else if ( anchorRef as Element | undefined ) {
// If `anchorRef` holds directly the element's value (no `current` key)
// This is a weird scenario and should be deprecated.
referenceElement = anchorRef as Element;
} else if ( anchorRect ) {
// Create a virtual element for the ref.
referenceElement = {
getBoundingClientRect() {
return anchorRect;
},
};
} else if ( getAnchorRect ) {
// Create a virtual element for the ref.
referenceElement = {
getBoundingClientRect() {
const rect = getAnchorRect( fallbackReferenceElement );
return new window.DOMRect(
rect.x ?? rect.left,
rect.y ?? rect.top,
rect.width ?? rect.right - rect.left,
rect.height ?? rect.bottom - rect.top
);
},
};
} else if ( fallbackReferenceElement ) {
// If no explicit ref is passed via props, fall back to
// anchoring to the popover's parent node.
referenceElement = fallbackReferenceElement.parentElement;
}
if ( referenceElement && ( scale.x !== 1 || scale.y !== 1 ) ) {
// If the popover is inside an iframe, the coordinates of the
// reference element need to be scaled to match the iframe's scale.
const rect = referenceElement.getBoundingClientRect();
referenceElement = {
getBoundingClientRect() {
return new window.DOMRect(
rect.x * scale.x,
rect.y * scale.y,
rect.width * scale.x,
rect.height * scale.y
);
},
};
}
// Convert any `undefined` value to `null`.
return referenceElement ?? null;
};