elements_Popover_index.js

import { React, PropTypes, classnames } from '@gravityforms/libraries';

const { forwardRef, useCallback, useEffect, useState } = React;

const TOP = 'top';
const BOTTOM = 'bottom';
const LEFT = 'left';
const RIGHT = 'right';

/**
 * @function getPlacement
 * @description Calculate the left and top position based on alignment and placement.
 *
 * @since 6.0.1
 *
 * @param {string}      align        The alignment of the popover.
 * @param {string}      placement    The placement of the popover.
 * @param {object|null} popoverRef   Ref to the popover element.
 * @param {object|null} triggerRef   Ref to the trigger element.
 * @param {object|null} containerRef Ref to the container element.
 *
 * @return {object} The calculated left and top position for the popover.
 */
const getPlacement = ( align, placement, popoverRef, triggerRef, containerRef ) => {
	let left = 0;
	let top = 0;
	const triggerRect = triggerRef?.current?.getBoundingClientRect?.();
	const popoverRect = popoverRef?.current?.getBoundingClientRect?.();
	let containerRect = containerRef?.current?.getBoundingClientRect?.();
	if ( ! containerRect ) {
		containerRect = {
			top: 0,
			left: 0,
		};
	}

	if ( triggerRect && popoverRect && containerRect ) {
		switch ( placement ) {
			case TOP:
				top = triggerRect.top - containerRect.top - popoverRect.height;
				if ( align === LEFT ) {
					left = triggerRect.left - containerRect.left;
				} else if ( align === RIGHT ) {
					left = triggerRect.right - containerRect.left - popoverRect.width;
				}
				break;
			case BOTTOM:
				top = triggerRect.bottom - containerRect.top;
				if ( align === LEFT ) {
					left = triggerRect.left - containerRect.left;
				} else if ( align === RIGHT ) {
					left = triggerRect.right - containerRect.left - popoverRect.width;
				}
				break;
			case LEFT:
				left = triggerRect.left - containerRect.left - popoverRect.width;
				if ( align === TOP ) {
					top = triggerRect.top - containerRect.top;
				} else if ( align === BOTTOM ) {
					top = triggerRect.bottom - containerRect.top - popoverRect.height;
				}
				break;
			case RIGHT:
				left = triggerRect.right - containerRect.left;
				if ( align === TOP ) {
					top = triggerRect.top - containerRect.top;
				} else if ( align === BOTTOM ) {
					top = triggerRect.bottom - containerRect.top - popoverRect.height;
				}
				break;
			default:
				break;
		}
	}

	return { left, top };
};

/**
 * @module Popover
 * @description A popover component. Should be used with the `usePopup` hook to manage visibility state.
 *
 * @since 6.0.1
 *
 * @param {object}              props                   Component props.
 * @param {string}              props.align             The alignment of the popover, used with placement, one of `left`, `right`, 'top', or 'bottom'.
 *                                                      if placement is `top` or `bottom`, align controls horizontal alignment.
 *                                                      if placement is `left` or `right`, align controls vertical alignment.
 * @param {boolean}             props.autoPlacement     Whether to autoplace the popover based on the trigger element.
 * @param {JSX.Element}         props.children          The popover content.
 * @param {object|null}         props.containerRef      Ref to the container element.
 * @param {object}              props.customAttributes  Custom attributes for the component.
 * @param {string|Array|object} props.customClasses     Custom classes for the component.
 * @param {boolean}             props.isHide            Whether the popover is hidden.
 * @param {boolean}             props.isOpen            Whether the popover is open.
 * @param {boolean}             props.isReveal          Whether the popover is revealed.
 * @param {string}              props.placement         The placement of the popover, one of `top`, `bottom`, `left`, or `right`.
 * @param {object}              props.popoverAttributes Custom attributes for the popover element.
 * @param {string|Array|object} props.popoverClasses    Custom classes for the popover element.
 * @param {object|null}         props.popoverRef        Ref to the popover element.
 * @param {object|null}         props.triggerRef        Ref to the trigger element.
 * @param {number}              props.width             The width of the popover in pixels.
 * @param {object|null}         ref                     Ref to the component.
 *
 * @return {JSX.Element} The popover component.
 */
const Popover = forwardRef( ( {
	align = LEFT,
	autoPlacement = false,
	children = null,
	containerRef = null,
	customAttributes = {},
	customClasses = [],
	isHide = false,
	isOpen = false,
	isReveal = false,
	placement = BOTTOM,
	popoverAttributes = {},
	popoverClasses = [],
	popoverRef = null,
	triggerRef = null,
	width = 0,
}, ref ) => {
	const {
		left: initialLeft,
		top: initialTop,
	} = getPlacement( align, placement, popoverRef, triggerRef, containerRef );

	const [ left, setLeft ] = useState( initialLeft );
	const [ top, setTop ] = useState( initialTop );

	const updatePlacement = useCallback( () => {
		const { left: newLeft, top: newTop } = getPlacement( align, placement, popoverRef, triggerRef, containerRef );
		setLeft( newLeft );
		setTop( newTop );
	}, [ align, placement, popoverRef, triggerRef, containerRef ] );

	// Update placement when revealed.
	useEffect( () => {
		if ( ! isReveal ) {
			return;
		}
		updatePlacement();
	}, [ isReveal, updatePlacement ] );

	const componentProps = {
		...customAttributes,
		className: classnames( {
			'gform-popover': true,
			[ `gform-popover--align-${ align }` ]: true,
			[ `gform-popover--placement-${ placement }` ]: true,
			'gform-popover--open': isOpen,
			'gform-popover--reveal': isReveal,
			'gform-popover--hide': isHide,
		}, customClasses ),
	};
	if ( autoPlacement ) {
		componentProps.style = {
			insetInlineStart: `${ left }px`,
			insetBlockStart: `${ top }px`,
		};
	}

	const popoverProps = {
		...popoverAttributes,
		className: classnames( {
			'gform-popover__popover': true,
		}, popoverClasses ),
		role: 'listbox',
		tabIndex: '-1',
		style: {
			inlineSize: width ? `${ width }px` : undefined,
		},
	};

	return (
		<div { ...componentProps } ref={ ref }>
			<div { ...popoverProps } ref={ popoverRef }>
				{ children }
			</div>
		</div>
	);
} );

Popover.propTypes = {
	align: PropTypes.oneOf( [ LEFT, RIGHT, TOP, BOTTOM ] ),
	autoPlacement: PropTypes.bool,
	children: PropTypes.node,
	customAttributes: PropTypes.object,
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	isHide: PropTypes.bool,
	isOpen: PropTypes.bool,
	isReveal: PropTypes.bool,
	placement: PropTypes.oneOf( [ TOP, BOTTOM, LEFT, RIGHT ] ),
	popoverAttributes: PropTypes.object,
	popoverClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	popoverRef: PropTypes.object,
	triggerRef: PropTypes.object,
	width: PropTypes.number,
};

Popover.displayName = 'Popover';

export default Popover;