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;