modules_Flyout_index.js

import {
	consoleError,
	consoleInfo,
	delay,
	focusLoop,
	getClosest,
	getNodes,
	matchesOrContainedInSelectors,
	resize,
	simpleBar,
	trigger,
	uniqueId,
	viewport,
} from '@gravityforms/utils';

/**
 * @function flyoutTemplate
 * @description The template function that returns html for the flyout. Options below are passed from constructor and
 * described there.
 *
 * @since 1.0.5
 *
 * @param {object}  options                    The options for the flyout.
 * @param {string}  options.closeButtonClasses The classes for the close button.
 * @param {string}  options.closeButtonTitle   The title for the close button.
 * @param {string}  options.content            The content for the flyout.
 * @param {string}  options.description        The description for the flyout. Found under the title in the header.
 * @param {number}  options.desktopWidth       The flyout desktop width in percentage.
 * @param {string}  options.direction          Direction to fly in from, left or right.
 * @param {boolean} options.expandable         Whether the flyout shows an extra trigger to allow expanding to a larger width.
 * @param {string}  options.expandableTitle    The title for the expandable trigger.
 * @param {string}  options.id                 The id for the flyout.
 * @param {number}  options.maxWidth           The flyout max width in pixels.
 * @param {number}  options.mobileBreakpoint   The breakpoint for mobile in pixels.
 * @param {number}  options.mobileWidth        The flyout mobile width in percentage.
 * @param {string}  options.position           The position of the flyout, fixed or absolute positioning
 * @param {boolean} options.showDivider        Whether to show the divider between the header and content.
 * @param {boolean} options.simplebar          Whether to use simplebar for the content.
 * @param {string}  options.title              The title for the flyout.
 * @param {string}  options.wrapperClasses     The classes for the wrapper.
 * @param {number}  options.zIndex             The z-index for the flyout.
 *
 * @return {string}
 * @example
 * import { flyoutTemplate } from '@gravityforms/components/html/admin/modules/Flyout';
 *
 * function Example() {
 *      const flyoutTemplateHTML = flyoutTemplateTemplate( options );
 *      document.body.insertAdjacentHTML( 'beforeend', flyoutTemplateHTML );
 * }
 *
 */
export const flyoutTemplate = ( {
	id = '',
	closeButtonClasses = '',
	closeButtonTitle = '',
	content = '',
	description = '',
	desktopWidth = 0,
	direction = '',
	expandable = false,
	expandableTitle = '',
	maxWidth = 0,
	mobileBreakpoint = 0,
	mobileWidth = 0,
	position = '',
	showDivider = true,
	simplebar = false,
	title = '',
	wrapperClasses = '',
	zIndex = 10,
} ) =>
	`
	<article 
		id="${ id }" 
		class="${ wrapperClasses } gform-flyout--${ direction } gform-flyout--${ position } ${ showDivider ? 'gform-flyout--divider' : 'gform-flyout--no-divider' }${ description ? '' : ' gform-flyout--no-description' }"
		style="z-index: ${ zIndex };"
		data-js="${ id }"
	>
		<button 
			class="${ closeButtonClasses } gform-button gform-button--secondary gform-button--circular gform-button--size-xs"
			data-js="gform-flyout-close" 
			title="${ closeButtonTitle }"
		>
			<i class="gform-button__icon gform-icon gform-icon--delete"></i>
		</button>
		${ expandable ? `
		<button 
			class="gform-flyout__expand"
			style="z-index: ${ zIndex + 2 };"
			data-js="gform-flyout-expand" 
			title="${ expandableTitle }"
		>
			<span class="gform-flyout__expand-icon gform-icon gform-icon--chevron"></span>
		</button>
		<div class="gform-flyout__expand-rail" style="z-index: ${ zIndex + 1 };"></div>
		` : `` }
		${ title || description ? '<header class="gform-flyout__head">' : '' }
		${ title ? `<h5 class="gform-flyout__title">${ title }</h5>` : '' }
		${ description ? `<div class="gform-flyout__desc"><p>${ description }</p></div>` : '' }
		${ title || description ? '</header>' : '' }
		<div class="gform-flyout__body"${ simplebar ? ' data-simplebar' : '' }><div class="gform-flyout__body-inner" data-js="flyout-content">${ content }</div></div>
	</article>
	<style>
		#${ id } { 
			max-width: ${ maxWidth ? `${ maxWidth }px` : 'none' };
			width: ${ mobileWidth }%; 
		}
		#${ id }.gform-flyout--expanded {
			width: ${ expandable ? `calc( ${ mobileWidth }% - 50px)` : `${ mobileWidth }%` };
		}
		@media only screen and (min-width: ${ mobileBreakpoint }px) {
			#${ id } { 
				width: ${ desktopWidth }%; 
			}
		}
	</style>
	`;

/**
 * @class Flyout
 * @description Embeds an html flyout component to house off canvas content that overlays a container pain from either the
 * right or the left.
 *
 * @since 1.0.5
 *
 * @borrows flyoutTemplate as flyoutTemplate
 *
 * @param {object}   options                               The options for the flyout.
 * @param {number}   options.animationDelay                Total runtime of close animation. must be synced with css.
 * @param {string}   options.closeButtonClasses            Classes for the close button.
 * @param {string}   options.closeButtonTitle              Text for the close button title.
 * @param {boolean}  options.closeOnOutsideClick           Close the flyout on a click outside of it?
 * @param {Array}    options.closeOnOutsideClickExceptions Array of selectors to ignore when checking for outside clicks.
 * @param {string}   options.content                       The html content.
 * @param {boolean}  options.expandable                    Whether the flyout shows an extra trigger to allow expanding to a larger width (set below).
 * @param {string}   options.expandableTitle               Title/a11y text for the expandable button.
 * @param {number}   options.expandableWidth               Width to expand to if expandable is true.
 * @param {string}   options.description                   The optional description for the flyout.
 * @param {number}   options.desktopWidth                  Desktop width in percent.
 * @param {string}   options.direction                     Direction to fly in from, left or right.
 * @param {string}   options.id                            Id for the flyout.
 * @param {string}   options.insertPosition                Insert position relative to target.
 * @param {boolean}  options.lockBody                      Whether to lock body scroll when open.
 * @param {number}   options.maxWidth                      Max width in pixels.
 * @param {number}   options.mobileBreakpoint              Mobile breakpoint.
 * @param {number}   options.mobileWidth                   Mobile width in percent.
 * @param {Function} options.onClose                       Function to fire when closed.
 * @param {Function} options.onOpen                        Function to fire when opened.
 * @param {string}   options.position                      Fixed or absolute positioning.
 * @param {boolean}  options.renderOnInit                  Render on initialization?
 * @param {boolean}  options.showDivider                   Show the divider below optional title?
 * @param {boolean}  options.simplebar                     Enable the simple bar ui for the body content scroll?
 * @param {string}   options.target                        The selector to append the flyout to.
 * @param {string}   options.title                         The optional title for the flyout.
 * @param {string}   options.triggers                      The selector[s] of the trigger that shows it.
 * @param {string}   options.wrapperClasses                Additional classes for the wrapper.
 * @param {number}   options.zIndex                        Z-index for the flyout.
 *
 * @return {Class} The class instance.
 * @example
 * import Flyout from '@gravityforms/components/html/admin/modules/Flyout';
 *
 * function Example() {
 *      const flyoutInstance = new Flyout( {
 *          id: 'example-flyout',
 *          renderOnInit: false,
 *          target: '#example-target',
 *          targetPosition: 'beforeend',
 *          theme: 'cosmos',
 *      } );
 *
 *      // Some time later we can render it. This is only done if we set renderOnInit to false.
 *      // If true it will render on initialization.
 *      flyoutInstance.init();
 * }
 *
 */
export default class Flyout {
	constructor( options = {} ) {
		this.options = {};
		Object.assign(
			this.options,
			{
				animationDelay: 170, // total runtime of close animation. must be synced with css
				closeButtonClasses: 'gform-flyout__close', // classes for the close button
				closeButtonTitle: '', // text for the close button title
				closeOnOutsideClick: true, // close the flyout on a click outside of it?
				closeOnOutsideClickExceptions: [], // array of selectors to ignore when clicking outside the flyout
				content: '', // the html content
				expandable: false, // whether the flyout shows an extra trigger to allow expanding to a larger width (set below)
				expandableTitle: '', // title/a11y text for the expandable button
				expandableWidth: 100, // width to expand to if expandable is true
				description: '', // the optional description for the flyout
				desktopWidth: 60, // desktop width in percent
				direction: 'right', // direction to fly in from, left or right
				id: uniqueId( 'flyout' ), // id for the flyout
				insertPosition: 'beforeend', // insert position relative to target
				lockBody: false, // whether to lock body scroll when open
				maxWidth: 850, // max width in pixels
				mobileBreakpoint: 768, // mobile breakpoint
				mobileWidth: 100, // mobile width in percent
				onClose: () => {}, // function to fire when closed
				onOpen: () => {}, // function to fire when opened
				position: 'fixed', // fixed or absolute positioning
				renderOnInit: true, // render on initialization?
				showDivider: true, // show the divider below optional title?
				simplebar: false, // enable the simple bar ui for the body content scroll?
				target: 'body', // the selector to append the flyout to
				title: '', // the optional title for the flyout
				triggers: '[data-js="gform-trigger-flyout"]', // the selector[s] of the trigger that shows it
				wrapperClasses: 'gform-flyout', // additional classes for the wrapper
				zIndex: 10, // z-index for the flyout
			},
			options
		);

		/**
		 * @event gform/flyout/pre_init
		 * @type {object}
		 * @description Fired before the component has started any internal init functions. A great chance to augment the options.
		 *
		 * @since 1.1.16
		 *
		 * @property {object} instance The Component class instance.
		 */
		trigger( { event: 'gform/flyout/pre_init', native: false, data: { instance: this } } );

		this.elements = {};
		this.state = {
			expanded: false,
			open: false,
			unExpandedWidth: 0,
		};

		if ( this.options.renderOnInit ) {
			this.init();
		}
	}

	/**
	 * @memberof Flyout
	 * @description Opens the flyout and fires the onOpen function that can be passed in.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	showFlyout() {
		const { flyout } = this.elements;
		this.options.onOpen();
		simpleBar.reInitChildren( flyout );
		flyout.classList.add( 'gform-flyout--anim-in-ready' );

		/**
		 * @event gform/flyout/open
		 * @type {object}
		 * @description Fired when the flyout opens.
		 *
		 * @since 3.3.7
		 *
		 * @property {object} instance The Component class instance.
		 */
		trigger( { event: 'gform/flyout/open', native: false, data: { instance: this } } );

		window.setTimeout( () => {
			flyout.classList.add( 'gform-flyout--anim-in-active' );
		}, 25 );
	}

	/**
	 * @memberof Flyout
	 * @description Closes the flyout and fires the onClose function that can be passed in. Can be used by external
	 * developers by firing the method on the instance. Also closes all instances if the event 'gform/flyout/close-all' is
	 * fired on the document.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	closeFlyout = () => {
		const { flyout } = this.elements;
		const { animationDelay, onClose } = this.options;
		if ( ! flyout.classList.contains( 'gform-flyout--anim-in-active' ) ) {
			return;
		}

		flyout.classList.remove( 'gform-flyout--anim-in-active' );

		window.setTimeout( () => {
			flyout.classList.remove( 'gform-flyout--anim-in-ready' );
		}, animationDelay );

		this.state.open = false;
		this.shrinkFlyout();
		onClose();

		/**
		 * @event gform/flyout/close
		 * @type {object}
		 * @description Fired when the flyout closes.
		 *
		 * @since 3.3.7
		 *
		 * @property {object} instance The Component class instance.
		 */
		trigger( { event: 'gform/flyout/close', native: false, data: { instance: this } } );
	};

	/**
	 * @param  e
	 * @memberof Flyout
	 * @description Closes all instances except the one that gets passed the activeId when the event 'gform/flyout/close' is
	 * fired on the document.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	maybeCloseFlyout = ( e ) => {
		if ( e.detail?.activeId === this.options.id ) {
			return;
		}

		this.elements.flyout.classList.remove( 'anim-in-ready' );
		this.elements.flyout.classList.remove( 'anim-in-active' );
		this.elements.flyout.classList.remove( 'anim-out-ready' );
		this.elements.flyout.classList.remove( 'anim-out-active' );
		this.state.open = false;
		this.shrinkFlyout();
	};

	/**
	 * @memberof Flyout
	 * @description Hides the expander button if the flyout fills its available space.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	updateFlyoutWidth() {
		const { animationDelay, expandable } = this.options;
		if ( ! expandable || this.state.expanded ) {
			return;
		}
		const { flyout, expandableTrigger } = this.elements;
		// if resizeParent is set it means we are not position fixed, and hence use parent width, otherwise use viewport
		const containerWidth = this.elements.resizeParent ? this.elements.resizeParent.clientWidth : viewport.width();
		const flyoutWidth = flyout.clientWidth;

		// include the width of the rail (buffer, 50px) outside of the flyout to determine if we need to show/hide
		if ( containerWidth <= flyoutWidth + 50 ) {
			flyout.classList.add( 'gform-flyout--hide-expander' );
			window.setTimeout( () => {
				// set display none so focus is ignored on keyboard nav after animations run
				expandableTrigger.style.display = 'none';
			}, animationDelay );
		} else {
			expandableTrigger.style.display = '';
			window.setTimeout( () => {
				flyout.classList.remove( 'gform-flyout--hide-expander' );
			}, 20 );
		}
	}

	/**
	 * @memberof Flyout
	 * @description Handles accessibility focus looping on the flyout using the focusLoop util.
	 *
	 * @since 1.0.5
	 *
	 * @param {KeyboardEvent} e The event object.
	 *
	 * @return {void}
	 */
	handleKeyEvents = ( e ) =>
		focusLoop(
			e,
			this.elements.activeTrigger,
			this.elements.flyout,
			this.closeFlyout
		);

	/**
	 * @memberof Flyout
	 * @description Handles opening/closing the flyout on a trigger click.
	 *
	 * @since 1.0.5
	 *
	 * @param {PointerEvent} e The event object.
	 *
	 * @return {void}
	 */
	handleTriggerClick = ( e ) => {
		this.elements.activeTrigger = e.target;
		if ( this.state.open ) {
			this.closeFlyout();
			this.elements.activeTrigger.focus();
			this.state.open = false;
		} else {
			this.showFlyout();
			this.elements.closeButton.focus();
			this.state.open = true;
		}
	};

	/**
	 * @memberof Flyout
	 * @description Expands the flyout to the defined expandableWidth while removing the max-width setting.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	expandFlyout() {
		const { expandableWidth, expandable } = this.options;
		if ( ! expandable || this.state.expanded ) {
			return;
		}
		const { flyout } = this.elements;
		this.state.unExpandedWidth = flyout.clientWidth;
		flyout.style.width = `${ this.state.unExpandedWidth }px`;
		flyout.style.transition = 'none';

		delay( () => {
			flyout.style.maxWidth = 'none';
		}, 20 ).delay( () => {
			flyout.style.transition = '';
		}, 20 ).delay( () => {
			flyout.style.width = `calc(${ expandableWidth }% - 50px)`;
			flyout.classList.add( 'gform-flyout--expanded' );
			this.state.expanded = true;
		}, 20 );
	}

	/**
	 * @memberof Flyout
	 * @description Returns the flyout to its natural width. The complexity comes from handling a mix of max-width
	 * (which doesn't animate) and % widths for responsive.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	shrinkFlyout() {
		const { animationDelay, expandable } = this.options;
		if ( ! expandable || ! this.state.expanded ) {
			return;
		}
		const { flyout } = this.elements;
		flyout.style.width = `${ this.state.unExpandedWidth }px`;
		flyout.classList.remove( 'gform-flyout--expanded' );
		window.setTimeout( () => {
			flyout.style.width = '';
			flyout.style.maxWidth = '';
		}, animationDelay );
		this.state.expanded = false;
	}

	/**
	 * @memberof Flyout
	 * @description Handles expanding the flyout on a trigger click if the option is enabled.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	handleExpandable = () => {
		if ( this.state.expanded ) {
			this.shrinkFlyout();
		} else {
			this.expandFlyout();
		}
	};

	/**
	 * @memberof Flyout
	 * @description Handle window resize events for the flyout
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	handleResize = () => {
		this.updateFlyoutWidth();
	};

	/**
	 * @memberof Flyout
	 * @description Stores useful HTMLElements on the instance in the elements namespace after render.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	storeElements() {
		const flyout = getNodes( this.options.id )[ 0 ];
		this.elements = {
			activeTrigger: null,
			content: getNodes( 'flyout-content', false, flyout )[ 0 ],
			closeButton: getNodes( 'gform-flyout-close', false, flyout )[ 0 ],
			expandableTrigger: this.options.expandable ? getNodes( 'gform-flyout-expand', false, flyout )[ 0 ] : null,
			flyout,
			resizeParent: this.options.position === 'fixed' ? null : flyout.parentNode,
			triggers: getNodes( this.options.triggers, true, document, true ),
		};
	}

	/**
	 * @memberof Flyout
	 * @description Renders the component into the dom.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	render() {
		const target = document.querySelectorAll( this.options.target )[ 0 ];
		if ( ! target ) {
			consoleError( `Flyout could not render as ${ this.options.target } could not be found.` );
			return;
		}

		target.insertAdjacentHTML(
			this.options.insertPosition,
			flyoutTemplate( this.options )
		);

		consoleInfo( `Gravity Forms Admin: Initialized flyout component on ${ this.options.target }.` );
	}

	/**
	 * @memberof Flyout
	 * @description Bind events to the component.
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	bindEvents() {
		this.elements.flyout.addEventListener( 'keydown', this.handleKeyEvents );
		this.elements.closeButton.addEventListener( 'click', this.closeFlyout );

		getNodes( this.options.triggers, true, document, true )
			.forEach( ( t ) => t.addEventListener( 'click', this.handleTriggerClick ) );

		resize( this.handleResize );

		document.addEventListener( 'gform/flyout/close', this.maybeCloseFlyout );
		document.addEventListener( 'gform/flyout/close-all', this.closeFlyout );

		if ( this.options.expandable ) {
			this.elements.expandableTrigger.addEventListener( 'click', this.handleExpandable );
		}

		if ( this.options.closeOnOutsideClick ) {
			document.addEventListener( 'click', function( event ) {
				if (
					this.elements.flyout.contains( event.target ) ||
					! this.state.open ||
					getClosest( event.target, '#TB_window' ) ||
					matchesOrContainedInSelectors( event.target, this.options.closeOnOutsideClickExceptions )
				) {
					return;
				}
				this.closeFlyout();
			}.bind( this ) );
		}
	}

	/**
	 * @memberof Flyout
	 * @description Initialize the component.
	 *
	 * @fires gform/flyout/post_render
	 *
	 * @since 1.0.5
	 *
	 * @return {void}
	 */
	init() {
		this.render();
		this.storeElements();
		this.bindEvents();
		this.updateFlyoutWidth();

		/**
		 * @event gform/flyout/post_render
		 * @type {object}
		 * @description Fired when the component has completed rendering and all class init functions have completed.
		 *
		 * @since 1.0.5
		 *
		 * @property {object} instance The Component class instance.
		 */
		trigger( { event: 'gform/flyout/post_render', native: false, data: { instance: this } } );
	}
}