modules_Flyout_index.js

import { React, SimpleBar, classnames, PropTypes } from '@gravityforms/libraries';
import {
	useId,
	useStateWithDep,
	ConditionalWrapper,
	useFocusTrap,
} from '@gravityforms/react-utils';
import { sprintf } from '@gravityforms/utils';
import Button from '../../elements/Button';
import Heading from '../../elements/Heading';
import Text from '../../elements/Text';
import { ESCAPE, ARROW_LEFT, ARROW_RIGHT } from '../../utils/keymap';

const NEEDS_I18N_LABEL = 'Needs i18n';

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

/**
 * @module Flyout
 * @description A flyout component in react.
 *
 * @since 1.1.18
 *
 * @param {object}              props                                   Component props.
 * @param {JSX.Element}         props.afterContent                      Any custom content to be placed after the body of the flyout.
 * @param {number}              props.animationDelay                    Total runtime of close animation. Synchronize with css if modifying the built-in 250 ms delay.
 * @param {JSX.Element}         props.beforeContent                     Any custom content to be placed before the header of the flyout.
 * @param {JSX.Element}         props.children                          React element children in the flyout body.
 * @param {object}              props.closeButtonCustomAttributes       Custom attributes for the close button.
 * @param {boolean}             props.closeOnMaskClick                  Whether to close if the background mask is clicked.
 * @param {string}              props.componentsPrefix                  Component system prefix, e.g. `gform-admin` or `gravitycrm-admin`.
 * @param {object}              props.customAttributes                  Custom attributes for the component.
 * @param {string|Array|object} props.customBodyClasses                 Custom classes for the flyout body as array.
 * @param {string|Array|object} props.customClasses                     Custom classes for the component.
 * @param {string|Array|object} props.customInnerBodyClasses            Custom classes for the flyout inner_body as array.
 * @param {string|Array|object} props.customMaskClasses                 Custom classes for the mask as array.
 * @param {string}              props.description                       Subheading description for the flyout.
 * @param {string}              props.desktopWidth                      Width for the flyout on desktop.
 * @param {string}              props.direction                         Flyout direction, left or right.
 * @param {boolean}             props.expandable                        Whether to enable the ability to allow expanding to a larger width.
 * @param {object}              props.expandableButtonCustomAttributes  Custom attributes for the expandable button.
 * @param {string}              props.expandableWidthDesktop            Width to expand to if expanded on desktop.
 * @param {JSX.Element}         props.footerChildrenLeft                React element children in the flyout footer on the left.
 * @param {JSX.Element}         props.footerChildrenRight               React element children in the flyout footer on the right.
 * @param {boolean}             props.footerIsFixed                     Whether or not the footer is fixed.
 * @param {JSX.Element}         props.headerChildrenLeft                React element children in the flyout header on the left.
 * @param {JSX.Element}         props.headerChildrenRight               React element children in the flyout header on the right.
 * @param {object}              props.headerDescriptionCustomAttributes Custom attributes for the header description.
 * @param {object}              props.headerHeadingCustomAttributes     Custom attributes for the header heading.
 * @param {boolean}             props.headerIsFixed                     Whether or not the header is fixed.
 * @param {object}              props.i18n                              Translated strings for the UI.
 * @param {string}              props.iconPrefix                        The prefix for the icon library to be used.
 * @param {object}              props.icons                             Icons for the UI.
 * @param {string}              props.id                                Flyout id.
 * @param {boolean}             props.isExpanded                        Prop to control whether the flyout is expanded.
 * @param {boolean}             props.isOpen                            Prop to control whether the dialog is open.
 * @param {boolean}             props.isPinned                          Prop to control whether the flyout is pinned.
 * @param {boolean}             props.maskBlur                          Whether to blur behind the mask for the flyout.
 * @param {Array}               props.maskClickExcludeButtons           Array of button codes that will not close the flyout on mask click, 0 = left click, 1 = middle click, 2 = right click, 3 = back button, 4 = forward button, 5 = X1, 6 = X2.
 * @param {string}              props.maskTheme                         Mask background color scheme: `none`, `light` or `dark`
 * @param {number}              props.maxWidth                          Max width in pixels for the flyout.
 * @param {number}              props.mobileBreakpoint                  Mobile breakpoint in pixels for the flyout.
 * @param {string}              props.mobileWidth                       Width for the flyout on mobile.
 * @param {string}              props.offset                            Top offset for the flyout.
 * @param {boolean}             props.offsetWPAdminBar                  Whether to offset the flyout from the WordPress admin bar.
 * @param {boolean}             props.offsetWPAdminMenu                 Whether to offset the flyout from the WordPress admin menu.
 * @param {Function}            props.onClose                           Function to fire on flyout close.
 * @param {Function}            props.onExpandedChange                  Callback fired when expanded state changes.
 * @param {Function}            props.onOpen                            Function to fire on flyout open.
 * @param {Function}            props.onPinnedChange                    Callback fired when pinned state changes.
 * @param {Function}            props.onPinnedWidthChange               Callback fired when pinned width changes via drag.
 * @param {boolean}             props.pinnable                          Whether to enable the ability to allow pinning of the flyout.
 * @param {string}              props.pinnedActiveContentSelector       Selector for the pinned, active content, to scroll into view.
 * @param {object}              props.pinnedButtonCustomAttributes      Custom attributes for the pinning button.
 * @param {string}              props.pinnedContentSelector             Selector for the content that needs the pinned flyout width offset.
 * @param {number}              props.pinnedContentMinWidth             Min width in pixels for the pinned content.
 * @param {string}              props.pinnedDragHandleAriaValueText     Template for the aria-valuetext attribute. Use %d for width value, e.g. "Flyout width %d pixels".
 * @param {string}              props.pinnedDragHandleLabel             Label for the pinned drag handle.
 * @param {number}              props.pinnedDragHandleKeyboardStep      Step size for the pinned drag handle when using keyboard.
 * @param {number}              props.pinnedDragHandleKeyboardStepLarge Step size for the pinned drag handle when using keyboard and shift key is pressed.
 * @param {number}              props.pinnedDefaultWidth                Default width in pixels when pinned.
 * @param {number}              props.pinnedMaxWidth                    Max width in pixels when pinned.
 * @param {number}              props.pinnedMinWidth                    Min width in pixels when pinned.
 * @param {number}              props.pinnedWidth                       Current pinned width in pixels. Used for persistent state.
 * @param {string}              props.position                          The position of the flyout, `absolute` or `fixed`.
 * @param {boolean}             props.resetScrollOnOpen                 Whether to reset scroll on open.
 * @param {boolean}             props.showDivider                       Whether or not to show the divider border below title.
 * @param {boolean}             props.simplebar                         Whether or not to use SimpleBar on the content.
 * @param {string}              props.title                             The title of the flyout.
 * @param {number}              props.zIndex                            z-index of the flyout.
 * @param {object|null}         ref                                     Ref to the component.
 *
 * @return {JSX.Element} The Flyout component.
 *
 * @example
 * import Flyout from '@gravityforms/components/react/admin/modules/Flyout';
 *
 * // Basic usage
 * return (
 *     <Flyout direction="right" title="Flyout title">
 *         { children }
 *     </Flyout>
 * );
 *
 * @example
 * // With external state management
 * return (
 *     <Flyout
 *         direction="right"
 *         isExpanded={ expandedState }
 *         isOpen={ openState }
 *         isPinned={ pinnedState }
 *         onClose={ () => setOpenState( false ) }
 *         onExpandedChange={ ( val ) => setExpandedState( val ) }
 *         onPinnedChange={ ( val ) => setPinnedState( val ) }
 *         title="Flyout title"
 *     >
 *         { children }
 *     </Flyout>
 * );
 *
 */
const Flyout = forwardRef( ( {
	afterContent = null,
	animationDelay = 250,
	beforeContent = null,
	children = null,
	closeButtonCustomAttributes = {
		customClasses: [],
		icon: 'delete',
		iconPrefix: 'gravity-component-icon',
		label: '',
		size: 'size-xs',
		title: '',
		type: 'round',
	},
	closeOnMaskClick = true,
	componentsPrefix = 'gform-admin',
	customAttributes = {},
	customBodyClasses = [],
	customClasses = [],
	customInnerBodyClasses = [],
	customMaskClasses = [],
	description = '',
	desktopWidth = '0',
	direction = '',
	expandable = false,
	expandableButtonCustomAttributes = {
		customClasses: [],
		size: 'size-xs',
		type: 'round',
	},
	expandableWidthDesktop = 'min(90%, 1085px)',
	footerChildrenLeft = null,
	footerChildrenRight = null,
	footerIsFixed = false,
	headerChildrenLeft = null,
	headerChildrenRight = null,
	headerDescriptionCustomAttributes = {},
	headerHeadingCustomAttributes = {},
	headerIsFixed = false,
	i18n = {},
	iconPrefix = 'gravity-component-icon',
	icons = {
		iconClose: 'delete',
		iconCollapse: 'close-expand',
		iconExpand: 'expand',
		iconPin: 'pin',
		iconUnpin: 'unpin',
	},
	id: defaultId = '',
	isExpanded = false,
	isOpen = false,
	isPinned = false,
	maskBlur = false,
	maskClickExcludeButtons = [],
	maskTheme = 'none',
	maxWidth = 0,
	mobileBreakpoint = 0,
	mobileWidth = '100%',
	offset = '0px',
	offsetWPAdminBar = false,
	offsetWPAdminMenu = false,
	onClose = () => {},
	onExpandedChange = () => {},
	onOpen = () => {},
	onPinnedChange = () => {},
	onPinnedWidthChange = () => {},
	pinnable = false,
	pinnedActiveContentSelector = '',
	pinnedButtonCustomAttributes = {
		customClasses: [],
		size: 'size-xs',
		type: 'round',
	},
	pinnedContentMinWidth = 350,
	pinnedContentSelector = '',
	pinnedDragHandleAriaValueText = '',
	pinnedDragHandleLabel = '',
	pinnedDragHandleKeyboardStep = 10,
	pinnedDragHandleKeyboardStepLarge = 50,
	pinnedDefaultWidth = 500,
	pinnedMaxWidth = 1000,
	pinnedMinWidth = 350,
	pinnedWidth = null,
	position = 'fixed',
	resetScrollOnOpen = false,
	showDivider = true,
	simplebar = false,
	title = '',
	zIndex = 10,
}, ref ) => {
	const [ animationReady, setAnimationReady ] = useState( isOpen );
	const [ animationActive, setAnimationActive ] = useState( isOpen );
	const [ flyoutActive, setFlyoutActive ] = useStateWithDep( isOpen );
	const [ flyoutExpanded, setFlyoutExpanded ] = useStateWithDep( isExpanded );
	const [ flyoutPinned, setFlyoutPinned ] = useStateWithDep( isPinned );
	const [ isDragging, setIsDragging ] = useState( false );
	const [ isKeyboardResizing, setIsKeyboardResizing ] = useState( false );

	const initialPinnedWidth = pinnedWidth ?? pinnedDefaultWidth;
	const effectivePinnedMin = Math.min( pinnedMinWidth, pinnedMaxWidth );
	const clampedInitialPinnedWidth = Math.min( Math.max( initialPinnedWidth, effectivePinnedMin ), pinnedMaxWidth );
	const [ currentPinnedWidth, setCurrentPinnedWidth ] = useStateWithDep( clampedInitialPinnedWidth );

	const trapRef = useFocusTrap( flyoutActive );
	const simplebarRef = useRef( null );
	const bodyRef = useRef( null );
	const dragHandleRef = useRef( null );
	const dragStartX = useRef( 0 );
	const dragStartWidth = useRef( 0 );
	const keyboardResizeTimeout = useRef( null );
	const flyoutRef = useRef( null );
	const id = useId( defaultId );
	const isInitialFlyoutActiveRef = useRef( true );

	const isRtl = document.documentElement.dir === 'rtl' || getComputedStyle( document.documentElement ).direction === 'rtl';
	const shouldInvertDragDirection = ( direction === 'left' ) !== isRtl;

	/**
	 * @description Callback ref to merge internal flyout ref with forwarded ref.
	 *
	 * @since 6.0.10
	 *
	 * @param {HTMLElement|null} node The DOM node.
	 *
	 * @return {void}
	 */
	const setFlyoutRef = useCallback( ( node ) => {
		flyoutRef.current = node;
		if ( typeof ref === 'function' ) {
			ref( node );
		} else if ( ref ) {
			ref.current = node;
		}
	}, [ ref ] );

	/**
	 * @description Clamps a width value between min and max.
	 *
	 * @since 6.0.10
	 *
	 * @param {number} width Width to clamp.
	 *
	 * @return {number} Clamped width.
	 */
	const clampWidth = useCallback( ( width ) => {
		const effectiveMin = Math.min( pinnedMinWidth, pinnedMaxWidth );
		return Math.min( Math.max( width, effectiveMin ), pinnedMaxWidth );
	}, [ pinnedMinWidth, pinnedMaxWidth ] );

	/**
	 * @description Handles mouse move during drag.
	 *
	 * @since 6.0.10
	 *
	 * @param {MouseEvent} event Mouse event.
	 *
	 * @return {void}
	 */
	const handleDragMove = useCallback( ( event ) => {
		if ( ! isDragging ) {
			return;
		}

		const deltaX = shouldInvertDragDirection ? event.clientX - dragStartX.current : dragStartX.current - event.clientX;
		const newWidth = clampWidth( dragStartWidth.current + deltaX );

		setCurrentPinnedWidth( newWidth );
	}, [ isDragging, shouldInvertDragDirection, clampWidth, setCurrentPinnedWidth ] );

	/**
	 * @description Handles mouse up to end drag.
	 *
	 * @since 6.0.10
	 *
	 * @return {void}
	 */
	const handleDragEnd = useCallback( () => {
		if ( ! isDragging ) {
			return;
		}

		setIsDragging( false );
		if ( dragHandleRef.current ) {
			dragHandleRef.current.setAttribute( 'data-dragging', 'false' );
		}

		document.body.style.cursor = '';
		document.body.style.userSelect = '';

		onPinnedWidthChange( currentPinnedWidth );
	}, [ isDragging, currentPinnedWidth, onPinnedWidthChange ] );

	// Set up and clean up drag event listeners
	useEffect( () => {
		if ( isDragging ) {
			document.addEventListener( 'mousemove', handleDragMove );
			document.addEventListener( 'mouseup', handleDragEnd );

			return () => {
				document.removeEventListener( 'mousemove', handleDragMove );
				document.removeEventListener( 'mouseup', handleDragEnd );
			};
		}
	}, [ isDragging, handleDragMove, handleDragEnd ] );

	// Scroll active content into view when pinned
	useEffect( () => {
		if ( ! flyoutPinned || ! flyoutActive || ! pinnedActiveContentSelector ) {
			return;
		}

		const pinButton = flyoutRef.current?.querySelector( '.gform-flyout__pin' );
		const isPinButtonVisible = pinButton && getComputedStyle( pinButton ).display !== 'none';
		if ( ! isPinButtonVisible ) {
			return;
		}

		const activeElement = document.querySelector( pinnedActiveContentSelector );
		if ( activeElement ) {
			activeElement.scrollIntoView( { behavior: 'smooth', block: 'nearest' } );
		}
	}, [ flyoutPinned, flyoutActive, pinnedActiveContentSelector ] );

	// Update drag handle aria attributes when flyout width changes
	useEffect( () => {
		if ( ! flyoutPinned || ! flyoutActive || ! flyoutRef.current || ! dragHandleRef.current ) {
			return;
		}

		const resizeObserver = new ResizeObserver( ( entries ) => {
			for ( const entry of entries ) {
				const observedWidth = Math.round( entry.contentRect.width );
				dragHandleRef.current.setAttribute( 'aria-valuenow', String( observedWidth ) );
				dragHandleRef.current.setAttribute( 'aria-valuetext', sprintf( pinnedDragHandleAriaValueText, observedWidth ) );
			}
		} );

		resizeObserver.observe( flyoutRef.current );

		return () => {
			resizeObserver.disconnect();
		};
	}, [ flyoutPinned, flyoutActive, pinnedDragHandleAriaValueText ] );

	useEffect( () => {
		// WHY: Skip running the open/close side-effects on initial mount.
		if ( isInitialFlyoutActiveRef.current ) {
			isInitialFlyoutActiveRef.current = false;
			return;
		}

		if ( flyoutActive ) {
			showFlyout();
		} else if ( ! flyoutActive ) {
			closeFlyout();
		}
	}, [ flyoutActive ] );

	const handleEscapeRequest = ( e ) => {
		if ( e.key !== ESCAPE ) {
			return;
		}

		e.stopPropagation();
		closeFlyout();
	};

	const pointerDownOrigin = useRef( null );

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

	const handlePointerUp = ( event ) => {
		if (
			pointerDownOrigin.current === event.target &&
			event.target.classList.contains( 'gform-flyout__mask' ) &&
			closeOnMaskClick &&
			! maskClickExcludeButtons.includes( event.button ) &&
			flyoutActive
		) {
			event.stopPropagation();
			setFlyoutActive( false );
		}
		pointerDownOrigin.current = null;
	};

	const maskThemeValue = flyoutPinned ? 'none' : maskTheme;

	const maskProps = {
		className: classnames( {
			'gform-flyout__mask': true,
			'gform-flyout--anim-in-ready': animationReady,
			'gform-flyout--anim-in-active': animationReady && animationActive,
			[ `gform-flyout__mask--position-${ position }` ]: true,
			[ `gform-flyout__mask--theme-${ maskThemeValue }` ]: true,
			'gform-flyout__mask--blur': maskBlur && ! flyoutPinned,
			'gform-flyout--offset-wpadmin-bar': offsetWPAdminBar,
			'gform-flyout--offset-wpadmin-menu': offsetWPAdminMenu,
			'gform-flyout--expandable': expandable,
			'gform-flyout--expanded': flyoutExpanded && ! flyoutPinned,
			'gform-flyout--pinnable': pinnable,
			'gform-flyout--pinned': flyoutPinned,
		}, customMaskClasses ),
		id: `${ id }-mask`,
		onPointerDown: handlePointerDown,
		onPointerUp: handlePointerUp,
		style: {
			zIndex,
		},
	};

	const componentProps = {
		className: classnames( {
			'gform-flyout': true,
			'gform-flyout--anim-in-ready': animationReady,
			'gform-flyout--anim-in-active': animationReady && animationActive,
			[ `gform-flyout--${ direction }` ]: true,
			[ `gform-flyout--${ position }` ]: true,
			'gform-flyout--divider': showDivider,
			'gform-flyout--no-divider': ! showDivider,
			'gform-flyout--no-description': ! description,
			'gform-flyout--scroll-simplebar': simplebar,
			'gform-flyout--scroll-native': ! simplebar,
			'gform-flyout--header-footer-fixed': headerIsFixed && footerIsFixed,
			'gform-flyout--header-fixed': headerIsFixed,
			'gform-flyout--footer-fixed': footerIsFixed,
			'gform-flyout--dragging': isDragging || isKeyboardResizing,
		}, customClasses ),
		id,
		onKeyDown: handleEscapeRequest,
		...customAttributes,
	};

	/**
	 * @function showFlyout
	 * @description Opens the flyout and fires the `onOpen` function if passed in.
	 *
	 * @since 1.1.18
	 *
	 * @return {void}
	 */
	const showFlyout = () => {
		setAnimationReady( true );
		setTimeout( () => {
			setAnimationActive( true );
			if ( resetScrollOnOpen ) {
				if ( simplebar && simplebarRef.current ) {
					const scrollElement = simplebarRef.current.getScrollElement();
					if ( scrollElement ) {
						scrollElement.scrollTop = 0;
					}
				} else if ( headerIsFixed && bodyRef.current ) {
					bodyRef.current.scrollTop = 0;
				} else if ( ref && ref.current ) {
					ref.current.scrollTop = 0;
				}
			}
			onOpen();
		}, 25 );
	};

	/**
	 * @function closeFlyout
	 * @description Closes the flyout and fires the `onClose` function if passed in.
	 *
	 * @since 1.1.18
	 *
	 * @return {void}
	 */
	const closeFlyout = () => {
		setAnimationActive( false );

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

	/**
	 * @function getHeader
	 * @description Returns the header of the flyout that contains the title, description, children, and close button.
	 *
	 * @since 1.1.18
	 *
	 * @return {JSX.Element}
	 */
	const getHeader = () => {
		let buttonType = 'unstyled';
		if ( closeButtonCustomAttributes.type === 'round' ) {
			buttonType = 'white';
		} else if ( closeButtonCustomAttributes.type === 'simplified' ) {
			buttonType = 'simplified';
		}

		const closeButtonProps = {
			circular: closeButtonCustomAttributes.type === 'round',
			customAttributes: {
				...( i18n?.titleClose || closeButtonCustomAttributes.title ? {
					title: i18n?.titleClose || closeButtonCustomAttributes.title,
				} : {} ),
			},
			icon: icons?.iconClose || closeButtonCustomAttributes.icon,
			iconPrefix: closeButtonCustomAttributes.iconPrefix || iconPrefix,
			label: i18n?.labelClose || closeButtonCustomAttributes.label,
			onClick: () => setFlyoutActive( false ),
			size: 'size-height-s',
			type: buttonType,
			...closeButtonCustomAttributes,
			customClasses: classnames(
				{
					'gform-flyout__close': true,
				},
				closeButtonCustomAttributes.customClasses || [],
			),
		};

		const pinnedButtonProps = {
			circular: pinnedButtonCustomAttributes.type === 'round',
			iconPrefix,
			size: 'size-height-s',
			type: buttonType,
			...pinnedButtonCustomAttributes,
			customAttributes: {
				title: flyoutPinned ? ( i18n?.titleUnpin || NEEDS_I18N_LABEL ) : ( i18n?.titlePin || NEEDS_I18N_LABEL ),
			},
			icon: flyoutPinned ? icons?.iconUnpin : icons?.iconPin,
			label: flyoutPinned ? ( i18n?.labelUnpin || NEEDS_I18N_LABEL ) : ( i18n?.labelPin || NEEDS_I18N_LABEL ),
			onClick: ( event ) => {
				const updatedPinnedState = ! flyoutPinned;
				setFlyoutPinned( updatedPinnedState );
				onPinnedChange( updatedPinnedState, event );
			},
			customClasses: classnames(
				{
					'gform-flyout__pin': true,
				},
				pinnedButtonCustomAttributes.customClasses || [],
			),
		};

		const expandableButtonProps = {
			circular: expandableButtonCustomAttributes.type === 'round',
			iconPrefix,
			size: 'size-height-s',
			type: buttonType,
			...expandableButtonCustomAttributes,
			customAttributes: {
				title: flyoutExpanded ? ( i18n?.titleCollapse || NEEDS_I18N_LABEL ) : ( i18n?.titleExpand || NEEDS_I18N_LABEL ),
				disabled: flyoutPinned,
			},
			icon: flyoutExpanded ? icons?.iconCollapse : icons?.iconExpand,
			label: flyoutExpanded ? ( i18n?.labelCollapse || NEEDS_I18N_LABEL ) : ( i18n?.labelExpand || NEEDS_I18N_LABEL ),
			onClick: ( event ) => {
				const updatedExpandedState = ! flyoutExpanded;
				setFlyoutExpanded( updatedExpandedState );
				onExpandedChange( updatedExpandedState, event );
			},
			customClasses: classnames(
				{
					'gform-flyout__expander': true,
				},
				expandableButtonCustomAttributes.customClasses || [],
			),
		};

		const headerProps = {
			className: classnames( {
				'gform-flyout__head': true,
			} ),
		};

		const headingProps = {
			customClasses: classnames( {
				'gform-flyout__title': true,
			} ),
			content: title,
			size: 'display-xs',
			tagName: 'h3',
			weight: 'SemiBold',
			...headerHeadingCustomAttributes,
		};

		const descriptionProps = {
			customClasses: classnames( {
				'gform-flyout__desc': true,
			} ),
			content: description,
			...headerDescriptionCustomAttributes,
		};

		return (
			<header { ...headerProps }>
				<div className="gform-flyout__head-left">
					{ title && <Heading { ...headingProps } /> }
					{ description && <Text { ...descriptionProps } /> }
					{ headerChildrenLeft }
				</div>
				<div className="gform-flyout__head-right">
					{ headerChildrenRight }
					{ pinnable && <Button { ...pinnedButtonProps } /> }
					{ expandable && <Button { ...expandableButtonProps } /> }
					{ <Button { ...closeButtonProps } /> }
				</div>
			</header>
		);
	};

	/**
	 * @function getBody
	 * @description Returns the body that wrap the children of the flyout.
	 *
	 * @since 1.1.18
	 *
	 * @return {JSX.Element}
	 */
	const getBody = () => {
		const bodyProps = {
			className: classnames( {
				'gform-flyout__body': true,
			}, customBodyClasses ),
		};

		const innerBodyProps = {
			className: classnames( {
				'gform-flyout__body-inner': true,
			}, customInnerBodyClasses ),
		};

		return (
			<ConditionalWrapper
				condition={ simplebar && headerIsFixed && footerIsFixed }
				wrapper={ ( ch ) => <SimpleBar className="gform-flyout__simplebar" ref={ simplebarRef }>{ ch }</SimpleBar> }
			>
				<div { ...bodyProps } ref={ bodyRef } >
					<div { ...innerBodyProps } >
						{ children }
					</div>
				</div>
			</ConditionalWrapper>
		);
	};

	/**
	 * @function getFooter
	 * @description Returns the footer of the flyout that contains optional children.
	 *
	 * @since 5.7.4
	 *
	 * @return {JSX.Element}
	 */
	const getFooter = () => {
		if ( ! footerChildrenLeft && ! footerChildrenRight ) {
			return null;
		}

		const footerProps = {
			className: classnames( {
				'gform-flyout__footer': true,
			} ),
		};

		return (
			<footer { ...footerProps }>
				{ footerChildrenLeft && (
					<div className="gform-flyout__footer-left">
						{ footerChildrenLeft }
					</div>
				) }
				{ footerChildrenRight && (
					<div className="gform-flyout__footer-right">
						{ footerChildrenRight }
					</div>
				) }
			</footer>
		);
	};

	/**
	 * @function handleDragStart
	 * @description Handles mouse down to start drag.
	 *
	 * @since 6.0.10
	 *
	 * @param {MouseEvent} event Mouse event.
	 *
	 * @return {void}
	 */
	const handleDragStart = ( event ) => {
		event.preventDefault();
		setIsDragging( true );
		dragStartX.current = event.clientX;
		dragStartWidth.current = currentPinnedWidth;

		if ( dragHandleRef.current ) {
			dragHandleRef.current.setAttribute( 'data-dragging', 'true' );
		}

		document.body.style.cursor = 'col-resize';
		document.body.style.userSelect = 'none';
	};

	/**
	 * @function handleDragKeyDown
	 * @description Handles keyboard navigation for the drag handle.
	 *
	 * @since 6.0.10
	 *
	 * @param {KeyboardEvent} event Keyboard event.
	 *
	 * @return {void}
	 */
	const handleDragKeyDown = ( event ) => {
		const step = event.shiftKey ? pinnedDragHandleKeyboardStepLarge : pinnedDragHandleKeyboardStep;
		let newWidth = currentPinnedWidth;

		const increaseKey = shouldInvertDragDirection ? ARROW_RIGHT : ARROW_LEFT;
		const decreaseKey = shouldInvertDragDirection ? ARROW_LEFT : ARROW_RIGHT;

		switch ( event.key ) {
			case increaseKey:
				newWidth = clampWidth( currentPinnedWidth + step );
				event.preventDefault();
				break;
			case decreaseKey:
				newWidth = clampWidth( currentPinnedWidth - step );
				event.preventDefault();
				break;
			default:
				return;
		}

		setIsKeyboardResizing( true );
		if ( dragHandleRef.current ) {
			dragHandleRef.current.setAttribute( 'data-dragging', 'true' );
		}

		if ( keyboardResizeTimeout.current ) {
			clearTimeout( keyboardResizeTimeout.current );
		}
		keyboardResizeTimeout.current = setTimeout( () => {
			setIsKeyboardResizing( false );
			if ( dragHandleRef.current ) {
				dragHandleRef.current.setAttribute( 'data-dragging', 'false' );
			}
		}, 150 );

		setCurrentPinnedWidth( newWidth );
		onPinnedWidthChange( newWidth );
	};

	/**
	 * @function getPinnedDraggableHandle
	 * @description Returns the draggable handle for the pinned flyout.
	 *
	 * @since 6.1.0
	 *
	 * @return {JSX.Element} The draggable handle element.
	 */
	const getPinnedDraggableHandle = () => {
		const handleStyle = {
			zIndex,
		};

		return (
			<div className="gform-flyout__pinned-drag-handle-wrapper" style={ handleStyle }>
				<div
					ref={ dragHandleRef }
					className="gform-flyout__pinned-drag-handle"
					data-dragging={ isDragging ? 'true' : 'false' }
					role="slider"
					aria-label={ pinnedDragHandleLabel }
					aria-valuemin={ pinnedMinWidth }
					aria-valuemax={ pinnedMaxWidth }
					aria-valuenow={ currentPinnedWidth }
					aria-valuetext={ sprintf( pinnedDragHandleAriaValueText, currentPinnedWidth ) }
					tabIndex="0"
					onMouseDown={ handleDragStart }
					onKeyDown={ handleDragKeyDown }
				/>
			</div>
		);
	};

	/**
	 * @function getCSS
	 * @description Returns the CSS used to handle the width and mobile media query.
	 *
	 * @since 1.1.18
	 *
	 * @return {string} The CSS for the flyout.
	 */
	const getCSS = () => {
		let css = `#${ id } {
			max-width: ${ maxWidth ? `${ maxWidth }px` : 'none' };
			width: ${ mobileWidth };
			z-index: ${ zIndex }
		}
		#${ id }-mask {
			--gform-admin-flyout-top-offset: ${ offset };
		}
		`;

		if ( mobileBreakpoint ) {
			css += `
				@media only screen and (min-width: ${ mobileBreakpoint }px) {
					#${ id } {
						width: ${ desktopWidth };
					}
				}
			`;
		}

		if ( expandable ) {
			if ( mobileBreakpoint ) {
				css += `
					.gform-flyout--expandable #${ id } .gform-flyout__expander {
						display: none;
					}
				`;

				css += `
					@media only screen and (min-width: ${ mobileBreakpoint }px) {
						.gform-flyout--expandable #${ id } .gform-flyout__expander {
							display: inherit;
						}
						.gform-flyout--expanded #${ id } {
							width: ${ expandableWidthDesktop };
						}
					}
				`;
			}
		}

		if ( pinnable ) {
			const pinnedContentPaddingProp = direction === 'left' ? 'inline-start' : 'inline-end';

			css += `
				.${ componentsPrefix }:has(#${ id }-mask.gform-flyout--pinnable) {
					--${ componentsPrefix }-flyout-pinned-content-min-width: ${ pinnedContentMinWidth }px;

					/* Flyout constraints */
					--${ componentsPrefix }-flyout-pinned-min-width: ${ pinnedMinWidth }px;
					--${ componentsPrefix }-flyout-pinned-max-width: min(${ pinnedMaxWidth }px, calc(100vw - var(--${ componentsPrefix }-flyout-pinned-content-min-width) - var(--gform-admin-wp-admin-menu-offset)));
					--${ componentsPrefix }-flyout-pinned-width: ${ currentPinnedWidth }px;
					--${ componentsPrefix }-flyout-pinned-clamped-width: clamp(
						var(--${ componentsPrefix }-flyout-pinned-min-width),
						var(--${ componentsPrefix }-flyout-pinned-width),
						var(--${ componentsPrefix }-flyout-pinned-max-width)
					);

					/* Content constraints */
					--${ componentsPrefix }-flyout-pinned-content-offset: max(
						var(--${ componentsPrefix }-flyout-pinned-content-min-width),
						var(--${ componentsPrefix }-flyout-pinned-clamped-width)
					);
				}
			`;

			if ( mobileBreakpoint ) {
				css += `
					.gform-flyout--pinnable #${ id } .gform-flyout__pin,
					.gform-flyout--pinnable #${ id } .gform-flyout__pinned-drag-handle-wrapper {
						display: none;
					}
				`;

				css += `
					@media only screen and (min-width: ${ mobileBreakpoint }px) {
						.gform-flyout--pinnable #${ id } .gform-flyout__pin {
							display: inherit;
						}
						.gform-flyout--pinnable #${ id } .gform-flyout__pinned-drag-handle-wrapper {
							display: block;
						}
						.gform-flyout--pinned #${ id } {
							width: var(--${ componentsPrefix }-flyout-pinned-clamped-width) !important;
						}
						.${ componentsPrefix }:has(#${ id }-mask.gform-flyout--anim-in-active.gform-flyout--pinned) ${ pinnedContentSelector } {
							padding-${ pinnedContentPaddingProp }: var(--${ componentsPrefix }-flyout-pinned-content-offset) !important;
						}
					}
				`;
			}
		}

		return css;
	};

	return (
		<div { ...maskProps } ref={ trapRef }>
			<article { ...componentProps } ref={ setFlyoutRef } >
				<ConditionalWrapper
					condition={ simplebar && ! ( headerIsFixed && footerIsFixed ) }
					wrapper={ ( ch ) => <SimpleBar className="gform-flyout__simplebar" ref={ simplebarRef }>{ ch }</SimpleBar> }
				>
					{ beforeContent }
					{ getHeader() }
					{ getBody() }
					{ getFooter() }
					{ afterContent }
				</ConditionalWrapper>
				{ pinnable && flyoutPinned && getPinnedDraggableHandle() }
			</article>
			<style>
				{ getCSS() }
			</style>
		</div>
	);
} );

Flyout.propTypes = {
	afterContent: PropTypes.node,
	animationDelay: PropTypes.number,
	beforeContent: PropTypes.node,
	children: PropTypes.oneOfType( [
		PropTypes.arrayOf( PropTypes.node ),
		PropTypes.node,
	] ),
	closeButtonCustomAttributes: PropTypes.object,
	closeOnMaskClick: PropTypes.bool,
	componentsPrefix: PropTypes.string,
	customAttributes: PropTypes.object,
	customBodyClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	customClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	customInnerBodyClasses: PropTypes.oneOfType( [
		PropTypes.string,
		PropTypes.array,
		PropTypes.object,
	] ),
	description: PropTypes.string,
	desktopWidth: PropTypes.string,
	direction: PropTypes.string,
	expandable: PropTypes.bool,
	expandableButtonCustomAttributes: PropTypes.object,
	expandableWidthDesktop: PropTypes.string,
	footerChildrenLeft: PropTypes.oneOfType( [
		PropTypes.arrayOf( PropTypes.node ),
		PropTypes.node,
	] ),
	footerChildrenRight: PropTypes.oneOfType( [
		PropTypes.arrayOf( PropTypes.node ),
		PropTypes.node,
	] ),
	footerIsFixed: PropTypes.bool,
	headerChildrenLeft: PropTypes.oneOfType( [
		PropTypes.arrayOf( PropTypes.node ),
		PropTypes.node,
	] ),
	headerChildrenRight: PropTypes.oneOfType( [
		PropTypes.arrayOf( PropTypes.node ),
		PropTypes.node,
	] ),
	headerDescriptionCustomAttributes: PropTypes.object,
	headerHeadingCustomAttributes: PropTypes.object,
	headerIsFixed: PropTypes.bool,
	i18n: PropTypes.object,
	iconPrefix: PropTypes.string,
	icons: PropTypes.object,
	id: PropTypes.string,
	isExpanded: PropTypes.bool,
	isOpen: PropTypes.bool,
	isPinned: PropTypes.bool,
	maskBlur: PropTypes.bool,
	maskClickExcludeButtons: PropTypes.array,
	maskTheme: PropTypes.string,
	maxWidth: PropTypes.number,
	mobileBreakpoint: PropTypes.number,
	mobileWidth: PropTypes.string,
	offset: PropTypes.string,
	offsetWPAdminBar: PropTypes.bool,
	offsetWPAdminMenu: PropTypes.bool,
	onClose: PropTypes.func,
	onExpandedChange: PropTypes.func,
	onOpen: PropTypes.func,
	onPinnedChange: PropTypes.func,
	onPinnedWidthChange: PropTypes.func,
	pinnable: PropTypes.bool,
	pinnedActiveContentSelector: PropTypes.string,
	pinnedButtonCustomAttributes: PropTypes.object,
	pinnedContentSelector: PropTypes.string,
	pinnedContentMinWidth: PropTypes.number,
	pinnedDragHandleLabel: PropTypes.string,
	pinnedDragHandleKeyboardStep: PropTypes.number,
	pinnedDragHandleKeyboardStepLarge: PropTypes.number,
	pinnedDefaultWidth: PropTypes.number,
	pinnedMaxWidth: PropTypes.number,
	pinnedMinWidth: PropTypes.number,
	pinnedWidth: PropTypes.number,
	position: PropTypes.oneOf( [ 'absolute', 'fixed' ] ),
	resetScrollOnOpen: PropTypes.bool,
	showDivider: PropTypes.bool,
	simplebar: PropTypes.bool,
	title: PropTypes.string,
	zIndex: PropTypes.number,
};

Flyout.displayName = 'Flyout';

export default Flyout;