modules_Overlay_index.js

import { React, classnames } from '@gravityforms/libraries';
import { useStateWithDep, useFocusTrap } from '@gravityforms/react-utils';
import { getClosest } from '@gravityforms/utils';
import Button from '../../elements/Button';
import { ENTER, ESCAPE } from '../../utils/keymap';

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

/**
 * @module Overlay
 * @description An overlay component.
 *
 * @since 5.6.0
 *
 * @param {object}              props                           The props for the component template.
 * @param {number}              props.animationDelay            Delay before the animation starts.
 * @param {JSX.Element}         props.children                  React element children.
 * @param {object}              props.customAttributes          Any custom attributes.
 * @param {string|Array|object} props.customClasses             An array of additional classes.
 * @param {boolean}             props.hasConfirm                Whether the overlay has a confirm button.
 * @param {object}              props.i18n                      i18n strings.
 * @param {Array}               props.ignoreOutsideClickClasses Array of selectors to ignore outside click.
 * @param {boolean}             props.isOpen                    Whether the overlay is open.
 * @param {Function}            props.onCancel                  Callback function when the overlay is canceled.
 * @param {Function}            props.onClose                   Callback function when the overlay is closed.
 * @param {Function}            props.onCloseAfterAnimation     Callback function when the overlay is closed after animation.
 * @param {Function}            props.onConfirm                 Callback function when the overlay is confirmed.
 * @param {Function}            props.onOpen                    Callback function when the overlay is opened.
 * @param {Function}            props.onOpenAfterAnimation      Callback function when the overlay is opened after animation.
 * @param {string}              props.placement                 Placement of the overlay.
 * @param {string}              props.position                  Position of the overlay.
 * @param {number}              props.verticalOffset            Vertical offset of the overlay.
 * @param {string}              props.width                     Width of the overlay.
 * @param {number}              props.zIndex                    Z-index of the overlay.
 * @param {object|null}         ref                             Ref to the component.
 *
 * @return {JSX.Element} The overlay component.
 *
 * @example
 * import Overlay from '@gravityforms/components/react/admin/modules/Overlay';
 *
 * return (
 *     <Overlay>
 *         <div>Overlay Content</div>
 *     </Overlay>
 * );
 *
 */
const Overlay = forwardRef( ( {
	animationDelay = 250,
	cancelButtonAttributes = {},
	cancelButtonClasses = [],
	children = null,
	confirmButtonAttributes = {},
	confirmButtonClasses = [],
	customAttributes = {},
	customClasses = [],
	hasConfirm = false,
	i18n = {
		cancel: 'Needs i18n',
		confirm: 'Needs i18n',
	},
	ignoreOutsideClickClasses = [],
	isOpen = false,
	onCancel = () => {},
	onClose = () => {},
	onCloseAfterAnimation = () => {},
	onConfirm = () => {},
	onOpen = () => {},
	onOpenAfterAnimation = () => {},
	placement = 'auto',
	position = 'absolute',
	verticalOffset = 0,
	width = '100%',
	zIndex = 10,
}, ref ) => {
	const [ animationReady, setAnimationReady ] = useState( false );
	const [ animationActive, setAnimationActive ] = useState( false );
	const [ overlayActive, setOverlayActive ] = useStateWithDep( isOpen );
	const [ positionAbove, setPositionAbove ] = useState( false );
	const overlayRef = useRef( null );
	const trapRef = useFocusTrap( overlayActive );
	const mountedRef = useRef( true );
	const debounceTimerRef = useRef( null );

	const checkPosition = () => {
		if ( ! overlayActive || ! overlayRef.current || placement !== 'auto' ) {
			return;
		}

		const overlayRect = overlayRef.current.getBoundingClientRect();
		const spaceAbove = overlayRect.top;
		const spaceBelow = window.innerHeight - overlayRect.top;
		const overlayHeight = overlayRect.height;
		const fitsBelow = spaceBelow >= overlayHeight;
		const fitsAbove = spaceAbove >= overlayHeight;

		if ( fitsBelow ) {
			setPositionAbove( false );
		} else if ( fitsAbove ) {
			setPositionAbove( true );
		} else {
			setPositionAbove( spaceAbove > spaceBelow );
		}
	};

	// Debounced event handler for resize
	const handleViewportChange = () => {
		if ( debounceTimerRef.current ) {
			clearTimeout( debounceTimerRef.current );
		}

		debounceTimerRef.current = setTimeout( () => {
			checkPosition();
			debounceTimerRef.current = null;
		}, 100 );
	};

	useEffect( () => {
		if ( ! mountedRef.current ) {
			return;
		}
		if ( overlayActive && ! animationReady && ! animationActive ) {
			showOverlay();
		} else if ( ! overlayActive && animationReady && animationActive ) {
			closeOverlay();
		}
	}, [ overlayActive ] );

	useEffect( () => {
		document.addEventListener( 'pointerdown', handlePointerDown );
		document.addEventListener( 'pointerup', handlePointerUp );
		window.addEventListener( 'resize', handleViewportChange );

		return () => {
			document.removeEventListener( 'pointerdown', handlePointerDown );
			document.removeEventListener( 'pointerup', handlePointerUp );
			window.removeEventListener( 'resize', handleViewportChange );

			if ( debounceTimerRef.current ) {
				clearTimeout( debounceTimerRef.current );
			}
		};
	}, [] );

	const pointerDownOrigin = useRef( null );

	const handlePointerDown = ( event ) => {
		pointerDownOrigin.current = event.target;
	};

	const handlePointerUp = ( event ) => {
		const pointerDownIsOverlay = pointerDownOrigin.current.classList.contains( 'gform-overlay' );
		const pointerDownInOverlay = getClosest( pointerDownOrigin.current, '.gform-overlay' );
		const pointerDownInIgnore = ignoreOutsideClickClasses.some( ( className ) => (
			pointerDownOrigin.current.classList.contains( className ) || getClosest( pointerDownOrigin.current, `.${ className }` )
		) );
		const clickIsOverlay = event.target.classList.contains( 'gform-overlay' );
		const clickInOverlay = getClosest( event.target, '.gform-overlay' );
		const clickInIgnore = ignoreOutsideClickClasses.some( ( className ) => (
			event.target.classList.contains( className ) || getClosest( event.target, `.${ className }` )
		) );
		if (
			! pointerDownIsOverlay &&
			! pointerDownInOverlay &&
			! pointerDownInIgnore &&
			! clickIsOverlay &&
			! clickInOverlay &&
			! clickInIgnore &&
			overlayActive
		) {
			event.stopPropagation();
			setOverlayActive( false );
		}
		pointerDownOrigin.current = null;
	};

	const handleKeyboardClose = ( e ) => {
		if ( e.key !== ESCAPE && e.key !== ENTER ) {
			return;
		}

		e.stopPropagation();

		if ( e.key === ESCAPE ) {
			onCancel();
		}
		if ( e.key === ENTER ) {
			onConfirm();
		}

		setOverlayActive( false );
	};

	const closeOverlay = () => {
		setAnimationActive( false );

		setTimeout( () => {
			setAnimationReady( false );
			onCloseAfterAnimation();
		}, animationDelay );

		onClose();
	};

	const showOverlay = () => {
		setAnimationReady( true );

		// Check position after a short delay to allow initial rendering
		setTimeout( () => {
			if ( overlayRef.current ) {
				checkPosition();
			}
		}, 25 );

		setTimeout( () => {
			setAnimationActive( true );
			setTimeout( () => {
				onOpenAfterAnimation();
			}, animationDelay );
		}, 25 );

		onOpen();
	};

	const componentProps = {
		className: classnames( {
			'gform-overlay': true,
			'gform-overlay--anim-in-ready': animationReady,
			'gform-overlay--anim-in-active': animationReady && animationActive,
			[ `gform-overlay--placement-${ placement }` ]: true,
			'gform-overlay--position-above': positionAbove && placement === 'auto',
		}, customClasses ),
		onKeyDown: handleKeyboardClose,
		style: {
			marginTop: `-${ verticalOffset }px`,
			position,
			width,
			zIndex,
			...( positionAbove && placement === 'auto' && { transform: 'translateY(-100%)' } ),
		},
		...customAttributes,
	};

	const articleProps = {
		className: classnames( {
			'gform-overlay__inner': true,
		} ),
	};

	const cancelButtonProps = {
		label: i18n.cancel,
		customClasses: classnames( {
			'gform-overlay__action': true,
			'gform-overlay__cancel': true,
		}, cancelButtonClasses ),
		onClick: onCancel,
		size: 'size-height-s',
		spacing: [ 0, 2, 0, 0 ],
		type: 'white',
		...cancelButtonAttributes,
	};

	const confirmButtonProps = {
		label: i18n.confirm,
		customClasses: classnames( {
			'gform-overlay__action': true,
			'gform-overlay__confirm': true,
		}, confirmButtonClasses ),
		onClick: onConfirm,
		size: 'size-height-s',
		...confirmButtonAttributes,
	};

	const Footer = hasConfirm && (
		<div className="gform-overlay__footer">
			<Button
				{ ...cancelButtonProps }
			/>
			<Button
				{ ...confirmButtonProps }
			/>
		</div>
	);

	const setRefs = useCallback( ( node ) => {
		overlayRef.current = node;
		if ( ref ) {
			ref.current = node;
		}
	}, [ ref ] );

	return (
		<div
			{ ...componentProps }
			ref={ setRefs }
		>
			<article { ...articleProps } ref={ trapRef }>
				{ children }
				{ Footer }
			</article>
		</div>
	);
} );

Overlay.displayName = 'Overlay';

export default Overlay;