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 } } );
}
}