elements_Loader_index.js

import {
	consoleInfo,
	getNode,
	objectToAttributes,
	spacerClasses,
	trigger,
	uniqueId,
	viewport,
} from '@gravityforms/utils';

/**
 * @function loaderTemplate
 * @description Generates the markup for a loader in the admin.
 *
 * @since 1.1.16
 *
 * @param {object}                     options                         The options for the component template.
 * @param {string}                     options.background              The background color for the loader.
 * @param {object}                     options.customAttributes        Any custom attributes.
 * @param {Array}                      options.customClasses           An array of additional classes for the toggle.
 * @param {boolean}                    options.displayText             Whether to display the loader text.
 * @param {string}                     options.foreground              The foreground color for the loader.
 * @param {string}                     options.id                      Id for the loader, auto generated if not passed.
 * @param {string}                     options.mask                    The mask for the loader.
 * @param {string}                     options.maskTheme               The mask theme for the loader.
 * @param {string}                     options.position                The position for the loader.
 * @param {string}                     options.size                    The size for the loader.
 * @param {string|number|Array|object} options.spacing                 The spacing for the component, string, number, object or array.
 * @param {string}                     options.text                    The text for the loader.
 * @param {string}                     options.textColor               The text color for the loader.
 * @param {string}                     options.theme                   Theme for the toggle, primary or cosmos.
 * @param {string}                     options.type                    Type for the loader, spinner or bar.
 * @param {object}                     options.wrapperCustomAttributes Any custom attributes for the textarea wrapper.
 * @param {Array}                      options.wrapperCustomClasses    Any custom classes for the textarea wrapper.
 * @param {string}                     options.wrapperTagName          Tag to use for the textarea wrapper. Defaults to 'div',
 *
 * @return {string}
 * @example
 * import { loaderTemplate } from '@gravityforms/components/html/admin/elements/Loader';
 *
 * function Example() {
 *      const loaderTemplateHTML = loaderTemplateTemplate( options );
 *      document.body.insertAdjacentHTML( 'beforeend', loaderTemplateHTML );
 * }
 *
 */
export const loaderTemplate = ( {
	background = '#ecedf8',
	customAttributes = {},
	customClasses = [],
	displayText = true,
	foreground = '#242748',
	id = '',
	mask = true,
	maskTheme = 'light',
	position = 'center',
	size = 5,
	spacing = '',
	text = '',
	textColor = '#000',
	theme = 'primary',
	type = 'simple',
	wrapperCustomAttributes = {},
	wrapperCustomClasses = [],
	wrapperTagName = 'div',
} ) => {
	const wrapperAttrs = objectToAttributes( {
		...wrapperCustomAttributes,
		id: `${ id }-mask`,
		class: [
			'gform-loader__mask',
			`gform-loader__mask--theme-${ maskTheme }`,
			`gform-loader__mask--theme-${ theme }`,
			`gform-loader__mask--position-${ position }`,
			...wrapperCustomClasses,
		],
		role: 'alert',
	} );
	const componentAttrs = objectToAttributes( {
		...customAttributes,
		id,
		class: [
			'gform-loader',
			`gform-loader--${ type }`,
			`gform-loader--theme-${ theme }`,
			...Object.keys( spacerClasses( spacing ) ),
			...customClasses,
		],
	} );

	return `
			${ mask ? `<${ wrapperTagName } ${ wrapperAttrs }>` : '' }
				${ mask ? `<div id="${ id }-mask-positioner" class="gform-loader__mask-positioner">` : '' }
					<span ${ componentAttrs }></span>
					${ mask && text && displayText ? `<span id="${ id }-text" class="gform-loader__text">${ text }</span>` : '' }
					${ mask && text && ! displayText ? `<span class="gform-visually-hidden">${ text }</span>` : '' }
				${ mask ? `</div>` : '' }
			${ mask ? `</${ wrapperTagName }>` : '' }
			<style id="${ id }-style">
				#${ id } {
					${ type === 'simple' ? `
						border-bottom-color: ${ foreground };
						border-left-color: ${ foreground };
						border-right-color: ${ background };
						border-top-color: ${ background };
						font-size: ${ size }px;
					` : '' }
				}
				#${ id }-text {
					${ textColor ? `color: ${ textColor };` : '' }
				}
			</style>
		`;
};

/**
 * @class Loader
 * @description A loader component that can be used as a simple spinner or a full masked element with spinner and text as needed.
 *
 * @since 1.1.16
 *
 * @borrows loaderTemplate as loaderTemplate
 *
 * @param {object}  options                         The options for the component.
 * @param {string}  options.background              The background color for the loader.
 * @param {object}  options.customAttributes        Any custom attributes for the component.
 * @param {Array}   options.customClasses           An array of additional classes for the component.
 * @param {boolean} options.displayNoneOnHide       On hide should it use display none or just opacity?
 * @param {boolean} options.displayText             Whether to display the loader text.
 * @param {string}  options.foreground              The foreground color for the loader.
 * @param {string}  options.id                      Id for the component, auto generated if not passed.
 * @param {string}  options.mask                    The mask for the loader.
 * @param {string}  options.maskTheme               The mask theme for the loader.
 * @param {string}  options.position                The position for the loader.
 * @param {string}  options.rendered                Is the component already rendered in the dom, eg by php?
 * @param {string}  options.renderOnInit            Render the component on init of the class?
 * @param {string}  options.showOnRender            Show the component on render?
 * @param {string}  options.size                    The size for the loader.
 * @param {string}  options.spacing                 Spacing for the component.
 * @param {string}  options.target                  The target to render to. Any valid css selector string.
 * @param {string}  options.targetPosition          The insert position for the component relative to the target.
 * @param {string}  options.text                    Text to show below the loader or for screen readers based on the displayText option if loader mask is enabled. Required for a11y if mask is enabled.
 * @param {string}  options.textColor               The text color for the loader.
 * @param {string}  options.theme                   Theme for the component, primary or cosmos.
 * @param {string}  options.type                    Type for the loader, spinner or bar.
 * @param {object}  options.wrapperCustomAttributes Any custom attributes for the textarea wrapper.
 * @param {Array}   options.wrapperCustomClasses    Any custom classes for the textarea wrapper.
 * @param {string}  options.wrapperTagName          Tag to use for the textarea wrapper. Defaults to 'div',
 *
 * @return {Class} The class instance.
 * @example
 * import Loader from '@gravityforms/components/html/admin/elements/Loader';
 *
 * function Example() {
 *      const loaderInstance = new Loader( {
 *          id: 'example-loader',
 *          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.
 *      loaderInstance.init();
 * }
 *
 */
export default class Loader {
	constructor( options = {} ) {
		this.options = {};
		Object.assign(
			this.options,
			{
				background: '#ecedf8', // background color for the loader
				customAttributes: {},
				customClasses: [],
				displayNoneOnHide: true, // on hide should it use display none or just opacity?
				displayText: true, // should we display the text or make it only available to screen readers if loader mask is enabled
				foreground: '#242748', // the color of the loader
				id: uniqueId( 'loader' ), // the id for the loader
				mask: true, // should the loader mask an area? If false we assume the implementation will handle the details (layout, a11y, etc.).
				maskTheme: 'light', // background mask theme, none, light, or dark
				position: 'center', // vertical position, auto, top, center, bottom, or sticky if loader mask is enabled
				rendered: false, // is the loader already rendered in the dom?
				renderOnInit: true, // should we render this loader on init?
				showOnRender: true, // visible on render?
				size: 5, // size of the loader, decimal int values
				target: '', // node to inject the loader to (uses insertAdjacentHtml)
				targetPosition: 'afterbegin', // insert position for the loader markup
				text: '', // text to show below the loader or for screen readers based on the displayText option if loader mask is enabled. Required for a11y if mask is enabled.
				textColor: '#000', // text color
				theme: 'cosmos', // theme for the loader, primary or cosmos
				type: 'simple', // loader type
				wrapperCustomAttributes: {},
				wrapperCustomClasses: [],
				wrapperTagName: 'div',
			},
			options
		);

		/**
		 * @event gform/loader/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/loader/pre_init', native: false, data: { instance: this } } );

		this.elements = {};

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

	/**
	 * @memberof Loader
	 * @description Sets the vertical position for the loader.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	positionLoader() {
		const { position, target } = this.options;
		const targetNode = getNode( target, document, true );
		const { maskPositioner } = this.elements;
		const targetVisibleHeight = viewport.elVisibleHeight( targetNode );
		const viewportHeight = viewport.height();
		const { top } = targetNode.getBoundingClientRect();
		let windowScroll = 0;

		// Account for window scroll in our top positioning when:
		// - position auto, target height greater than window height, and scrolled past top of target
		// - position fixed, window height greater than target visible height, and haven't scrolled past top of target
		if (
			( position === 'auto' && ( targetNode.offsetHeight > viewportHeight ) && top < 0 ) ||
			( position === 'sticky' && ( targetVisibleHeight < viewportHeight ) && top > 0 )
		) {
			windowScroll = Math.abs( targetNode.getBoundingClientRect().top );
		}

		maskPositioner.style.top = `${ windowScroll + ( ( targetVisibleHeight / 2 ) - ( maskPositioner.offsetHeight / 2 ) ) }px`;
	}

	/**
	 * @memberof Loader
	 * @description Removes the injected nodes from the dom.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	removeLoader() {
		const { loaderEl, style } = this.elements;

		loaderEl.parentNode.removeChild( loaderEl );
		style.parentNode.removeChild( style );
	}

	/**
	 * @memberof Loader
	 * @description Reveal the loader.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	showLoader() {
		const { mask, position } = this.options;
		const { loaderEl } = this.elements;

		loaderEl.style.display = '';
		loaderEl.style.opacity = '';

		if ( mask && ( position === 'auto' || position === 'sticky' ) ) {
			this.positionLoader();
		}

		trigger( { event: 'gform/loader/post_show', native: false, data: { instance: this } } );
	}

	/**
	 * @memberof Loader
	 * @description Hide the loader.
	 *
	 * @fires gform/loader/post_hide
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	hideLoader() {
		const { displayNoneOnHide } = this.options;
		const { loaderEl } = this.elements;
		if ( displayNoneOnHide ) {
			loaderEl.style.display = 'none';
		} else {
			loaderEl.style.opacity = '0';
		}

		/**
		 * @event gform/loader/post_hide
		 * @type {object}
		 * @description Fired when the component has completed hiding in the dom.
		 *
		 * @since 1.1.16
		 *
		 * @property {object} instance The Component class instance.
		 */
		trigger( { event: 'gform/loader/post_hide', native: false, data: { instance: this } } );
	}

	/**
	 * @memberof Loader
	 * @description Handles initial visibility for the loader. If showOnRender is false, hides the loader.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	setInitialUI() {
		const { mask, position, showOnRender } = this.options;
		if ( ! showOnRender ) {
			this.hideLoader();
		}
		if ( showOnRender && mask && ( position === 'auto' || position === 'sticky' ) ) {
			this.positionLoader();
		}
	}

	/**
	 * @memberof Loader
	 * @description Store the elements on the elements object of this instance.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	storeElements() {
		const { id } = this.options;
		this.elements = {
			loader: getNode( `#${ id }`, document, true ),
			mask: getNode( `#${ id }-mask`, document, true ),
			maskPositioner: getNode( `#${ id }-mask-positioner`, document, true ),
			style: getNode( `#${ id }-style`, document, true ),
		};
		this.elements.loaderEl = this.elements.mask ? this.elements.mask : this.elements.loader;
	}

	/**
	 * @memberof Loader
	 * @description Renders the component into the dom.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	render() {
		const { rendered, target, targetPosition } = this.options;

		if ( ! rendered ) {
			const renderTarget = getNode( target, document, true );
			if ( this.options.mask ) {
				renderTarget.style.position = 'relative';
			}

			renderTarget.insertAdjacentHTML(
				targetPosition,
				loaderTemplate( this.options )
			);
		}

		this.elements.loader = getNode( `#${ this.options.id }`, document, true );
		this.elements.wrapper = this.elements.loader.parentNode;
	}

	/**
	 * @memberof Loader
	 * @description Initialize the component.
	 *
	 * @fires gform/loader/post_render
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	init() {
		this.render();
		this.storeElements();
		this.setInitialUI();

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

		consoleInfo( `Gravity Forms Admin: Initialized loader component.` );
	}
}