elements_Dropdown_index.js

import request from '@gravityforms/request';
import {
	consoleInfo,
	consoleError,
	debounce,
	delegate,
	getNodes,
	getClosest,
	saferHtml,
	trigger,
} from '@gravityforms/utils';

export const dropdownListItems = ( data = [] ) => {
	return data.map( ( entry ) => {
		// Entry has children, make this a group and recursively output children within a <ul>.
		if ( entry.listData ) {
			return saferHtml`
			<li class="gform-dropdown__group">
				<span class="gform-dropdown__group-text">${ entry.label }</span>
				<ul class="gform-dropdown__list gform-dropdown__list--grouped" data-js="gform-dropdown-list">` +
					dropdownListItems( entry.listData ) +
				`</ul>
			</li>
			`;
		}

		// Entry does not have listData, make this an <li>.
		return saferHtml`
		<li class="gform-dropdown__item">
			<button type="button" class="gform-dropdown__trigger ui-state-disabled" data-js="gform-dropdown-trigger" data-value="${ entry.value }">
				<span class="gform-dropdown__trigger-text" data-value="${ entry.value }">${ entry.label }</span>
			</button>
		</li>
		`;
	} ).join( '' );
};

/**
 * @function dropdownTemplate
 * @description Generates the markup for a dropdown in the admin.
 *
 * @since 1.1.16
 *
 * @param {object}  options                        The options for the component template.
 * @param {string}  options.attributes             The attributes to add to the wrapper element, space seperated.
 * @param {string}  options.container              The container to append the dropdown to.
 * @param {string}  options.dropdownListAttributes The attributes to add to the dropdown list element.
 * @param {boolean} options.hasSearch              Whether or not to show the search input.
 * @param {Array}   options.listData               The list data for the dropdown.
 * @param {string}  options.searchAriaText         The aria text for the search input.
 * @param {string}  options.searchInputId          The id for the search input.
 * @param {string}  options.searchPlaceholder      The placeholder for the search input.
 * @param {string}  options.selector               The selector for the dropdown.
 * @param {string}  options.triggerAriaId          The id for the trigger aria.
 * @param {string}  options.triggerAriaText        The aria text for the trigger.
 * @param {string}  options.triggerClasses         The classes for the trigger.
 * @param {string}  options.triggerId              The id for the trigger.
 * @param {string}  options.triggerPlaceholder     The placeholder for the trigger.
 * @param {string}  options.triggerSelected        The selected item for the trigger.
 * @param {string}  options.triggerTitle           The title for the trigger.
 * @param {string}  options.wrapperClasses         The classes for the wrapper, space seperated string.
 *
 * @return {string}
 * @example
 * import { dropdownTemplate } from '@gravityforms/components/html/admin/elements/Dropdown';
 *
 * function Example() {
 *      const dropdownTemplateHTML = dropdownTemplate( options );
 *      document.body.insertAdjacentHTML( 'beforeend', dropdownTemplateHTML );
 * }
 *
 */
export const dropdownTemplate = ( options ) => ( `
	<article class="${ options.wrapperClasses }" data-js="${ options.selector }" ${ options.attributes }>
		${ options.triggerTitle ? '' : `
			<span
				class="gform-visually-hidden"
				id="${ options.triggerAriaId }"
			>${ options.triggerAriaText }</span>
		` }
		<button
			type="button"
			aria-expanded="false"
			aria-haspopup="listbox"
			${ options.triggerTitle ? '' : `aria-labelledby="${ options.triggerAriaId } ${ options.triggerId }"` }
			class="${ options.triggerClasses } gform-dropdown__control${ options.triggerSelected ? `` : ` gform-dropdown__control--placeholder` }"
			data-js="gform-dropdown-control"
			id="${ options.triggerId }"
			${ options.triggerTitle ? `title="${ options.triggerTitle }"` : '' }
		>
			<span 
				class="gform-dropdown__control-text" 
				data-js="gform-dropdown-control-text"
			>
				${ options.triggerSelected ? options.triggerSelected : options.triggerPlaceholder }
			</span>
			<i class="gform-spinner gform-dropdown__spinner"></i>
			<i class="gform-icon gform-icon--chevron gform-dropdown__chevron"></i>
		</button>
		<div
			aria-labelledby="${ options.triggerAriaId }"
			class="gform-dropdown__container"
			role="listbox"
			data-js="gform-dropdown-container"
			tabIndex="-1"
		>
			${ options.hasSearch ? `
			<div class="gform-dropdown__search">
				<label htmlFor="${ options.searchInputId }" class="gform-visually-hidden">${ options.searchAriaText }</label>
				<input
					id="${ options.searchInputId }"
					type="text" class="gform-input gform-dropdown__search-input"
					placeholder="${ options.searchPlaceholder }"
					data-js="gform-dropdown-search"
				/>
				<i class="gform-icon gform-icon--search gform-dropdown__search-icon"></i>
			</div>
			` : '' }
			<div class="gform-dropdown__list-container" ${ options.dropdownListAttributes }>
				<ul class="gform-dropdown__list" data-js="gform-dropdown-list">
					${ dropdownListItems( options.listData ) }
				</ul>
			</div>
		</div>
	</article>
` );

/**
 * @class Dropdown
 * @description A dropdown component that can be used for simple stylized selects, more complex ones with simple fuzzy text search, or async dropdowns that get their data from rest or admin ajax.
 *
 * @since 1.1.16
 *
 * @borrows dropdownTemplate as dropdownTemplate
 *
 * @param {object}   options                            The options for the component.
 * @param {boolean}  options.autoPosition               Whether or not to auto position the dropdown above or below based on available room.
 * @param {string}   options.attributes                 The attributes to add to the wrapper element, space seperated.
 * @param {string}   options.baseUrl                    The base url for the request if async.
 * @param {boolean}  options.closeOnSelect              Whether or not to close the dropdown when an item is selected.
 * @param {string}   options.container                  The container to append the dropdown to.
 * @param {boolean}  options.detectTitleLength          Whether or not to detect the length of the title and adjust the width of the dropdown.
 * @param {string}   options.dropdownListAttributes     The attributes to add to the dropdown list element.
 * @param {object}   options.endpoints                  The endpoints for the request if async.
 * @param {object}   options.endpointArgs               The arguments for the request if async.
 * @param {string}   options.endpointKey                The key for the endpoint if async.
 * @param {object}   options.endpointRequestOptions     The request options for the request if async.
 * @param {boolean}  options.endpointUseRest            Whether or not to use the rest api for the request if async.
 * @param {boolean}  options.hasSearch                  Whether or not to show the search input.
 * @param {string}   options.insertPosition             The position to insert the dropdown in the dom.
 * @param {Array}    options.listData                   The list data for the dropdown.
 * @param {Function} options.onItemSelect               The callback function to run when an item is selected.
 * @param {Function} options.onOpen                     The callback function to run when the dropdown is opened.
 * @param {Function} options.onClose                    The callback function to run when the dropdown is closed.
 * @param {boolean}  options.render                     Whether or not to render the dropdown.
 * @param {boolean}  options.renderListData             Whether or not to render the list data.
 * @param {string}   options.renderTarget               The target to render the dropdown to.
 * @param {string}   options.reveal                     The reveal type for the dropdown.
 * @param {string}   options.searchAriaText             The aria text for the search input.
 * @param {string}   options.searchInputId              The id for the search input.
 * @param {string}   options.searchPlaceholder          The placeholder for the search input.
 * @param {string}   options.searchType                 Basic or async. async requires endpoint config and key, plus baseUrl
 * @param {string}   options.selector                   The selector for the dropdown.
 * @param {boolean}  options.showSpinner                Whether or not to show the spinner when searching for results or page is reloading.
 * @param {boolean}  options.swapLabel                  Whether or not to swap the label and value on select of an item.
 * @param {number}   options.titleLengthThresholdMedium The threshold for the medium title length.
 * @param {number}   options.titleLengthThresholdLong   The threshold for the long title length.
 * @param {string}   options.triggerAriaId              The id for the trigger aria.
 * @param {string}   options.triggerAriaText            The aria text for the trigger.
 * @param {string}   options.triggerClasses             The classes for the trigger.
 * @param {string}   options.triggerId                  The id for the trigger.
 * @param {string}   options.triggerPlaceholder         The placeholder for the trigger.
 * @param {string}   options.triggerSelected            The selected item for the trigger.
 * @param {string}   options.triggerTitle               The title for the trigger.
 * @param {string}   options.wrapperClasses             The classes for the wrapper, space seperated string.
 *
 * @return {Class} The class instance.
 * @example
 * import Dropdown from '@gravityforms/components/html/admin/elements/Dropdown';
 *
 * function Example() {
 *      const dropdownInstance = new Dropdown( {
 *          render: true,
 *          renderTarget: '#example-target',
 *      } );
 * }
 *
 */
export default class Dropdown {
	constructor( options = {} ) {
		this.options = {};
		Object.assign(
			this.options,
			{
				autoPosition: false,
				attributes: '',
				baseUrl: '',
				closeOnSelect: true,
				container: '',
				detectTitleLength: false,
				dropdownListAttributes: 'data-simplebar',
				endpoints: {},
				endpointArgs: {},
				endpointKey: '',
				endpointRequestOptions: {},
				endpointUseRest: false,
				hasSearch: true,
				insertPosition: 'afterbegin',
				listData: [],
				onItemSelect() {},
				onOpen() {},
				onClose() {},
				render: false,
				renderListData: false,
				renderTarget: '',
				reveal: 'click',
				searchAriaText: '',
				searchInputId: 'gform-form-switcher-search',
				searchPlaceholder: '',
				searchType: 'basic', // basic or async. async requires endpoint config and key, plus baseUrl
				selector: 'gform-dropdown',
				showSpinner: false,
				swapLabel: true,
				titleLengthThresholdMedium: 23,
				titleLengthThresholdLong: 32,
				triggerAriaId: 'gform-form-switcher-label',
				triggerAriaText: '',
				triggerClasses: '',
				triggerId: 'gform-form-switcher-control',
				triggerPlaceholder: '',
				triggerSelected: '',
				triggerTitle: '',
				wrapperClasses: 'gform-dropdown',
			},
			options
		);

		this.elements = {};
		this.templates = {
			dropdownListItems,
			dropdownTemplate,
		};

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

		this.state = {
			isMock: this.options.endpoints?.get_posts?.action === 'mock_endpoint',
			open: false,
			unloading: false,
		};

		if ( this.options.render ) {
			this.render();
		}

		this.options.container = this.options.container ? document.querySelectorAll( this.options.container )[ 0 ] : document;
		this.elements.container = getNodes( this.options.selector, false, this.options.container )[ 0 ];

		if ( ! this.elements.container ) {
			consoleError( `Gform dropdown couldn't find [data-js="${ this.options.selector }"] to instantiate on.` );
			return;
		}
		this.elements.titleEl = getNodes( 'gform-dropdown-control-text', false, this.elements.container )[ 0 ];
		this.elements.dropdownList = getNodes( 'gform-dropdown-list', false, this.elements.container )[ 0 ];
		this.elements.dropdownContainer = getNodes( 'gform-dropdown-container', false, this.elements.container )[ 0 ];

		if ( this.options.renderListData && ! this.options.render ) {
			this.renderListData();
		}

		this.init();

		this.hideSpinnerEl = function() {
			this.elements.container.classList.remove( 'gform-dropdown--show-spinner' );
		};

		this.showSpinnerEl = function() {
			this.elements.container.classList.add( 'gform-dropdown--show-spinner' );
		};
	}

	/**
	 * @param  e
	 * @memberof Dropdown
	 * @description Handles item selection in the dropdown list.
	 *
	 * @fires  gform/dropdown/item_selected
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	handleChange( e ) {
		/**
		 * @event gform/dropdown/item_selected
		 * @type {object}
		 * @description Fired when the component has an item selected in the dropdown list.
		 *
		 * @since 1.1.16
		 *
		 * @property {object} instance The Component class instance.
		 * @property {object} event    The Component event object for the list item.
		 */
		trigger( { event: 'gform/dropdown/item_selected', native: false, data: { instance: this, event: e } } );
		this.elements.control.setAttribute( 'data-value', e.target.dataset.value );
		this.options.onItemSelect( e.target.dataset.value );
		if ( this.options.showSpinner ) {
			this.showSpinnerEl();
		}
		if ( this.options.swapLabel ) {
			this.elements.controlText.innerText = e.target.innerText;
			if ( this.elements.controlText.innerText === this.options.triggerPlaceholder ) {
				this.elements.control.classList.add( 'gform-dropdown__control--placeholder' );
			} else {
				this.elements.control.classList.remove( 'gform-dropdown__control--placeholder' );
			}
		}
		if ( this.options.closeOnSelect ) {
			this.handleControl();
		}
	}

	/**
	 * @memberof Dropdown
	 * @description Handles the control trigger being interacted with and either opens or closes the dropdown.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	handleControl() {
		if ( this.state.open ) {
			this.closeDropdown();
		} else {
			this.openDropdown();
		}
	}

	/**
	 * @memberof Dropdown
	 * @description If autoposition is true, automatically places the dropdown above or below the control based on the viewport.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	handlePosition() {
		if ( ! this.options.autoPosition ) {
			return;
		}
		const roomBelow = this.elements.container.parentNode.offsetHeight - (
			this.elements.container.offsetTop +
			this.elements.container.offsetHeight +
			this.elements.dropdownContainer.offsetHeight
		);
		if ( roomBelow < 10 ) {
			this.elements.container.classList.add( 'gform-dropdown--position-top' );
		} else {
			this.elements.container.classList.remove( 'gform-dropdown--position-top' );
		}
	}

	/**
	 * @memberof Dropdown
	 * @description Modifies all needed classes that open the dropdown, and adjust state, plus also calling any callbacks.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	openDropdown() {
		if ( this.state.open ) {
			return;
		}
		this.options.onOpen();
		this.elements.container.classList.add( 'gform-dropdown--reveal' );
		setTimeout( function() {
			this.elements.container.classList.add( 'gform-dropdown--open' );
			this.elements.control.setAttribute( 'aria-expanded', 'true' );
			this.state.open = true;
			this.handlePosition();
		}.bind( this ), 25 );
		setTimeout( function() {
			this.elements.container.classList.remove( 'gform-dropdown--reveal' );
		}.bind( this ), 200 );
	}

	/**
	 * @memberof Dropdown
	 * @description Modifies all needed classes that close the dropdown, and adjust state, plus also calling any callbacks.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	closeDropdown() {
		this.options.onClose();
		this.state.open = false;
		this.elements.container.classList.remove( 'gform-dropdown--open' );
		this.elements.container.classList.add( 'gform-dropdown--hide' );
		this.elements.control.setAttribute( 'aria-expanded', 'false' );
		setTimeout( function() {
			this.elements.container.classList.remove( 'gform-dropdown--hide' );
		}.bind( this ), 150 );
	}

	/**
	 * @memberof Dropdown
	 * @description Opens the dropdown on the hover event.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	handleMouseenter() {
		if ( this.options.reveal !== 'hover' || this.state.open || this.state.unloading ) {
			return;
		}
		this.openDropdown();
	}

	/**
	 * @memberof Dropdown
	 * @description Closes the dropdown on mouseleave if reveal type is hover.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	handleMouseleave() {
		if ( this.options.reveal !== 'hover' || this.state.unloading ) {
			return;
		}
		this.closeDropdown();
	}

	/**
	 * @param  e
	 * @memberof Dropdown
	 * @description Handles accessibility for the dropdown.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	handleA11y( e ) {
		if ( ! this.state.open ) {
			return;
		}
		if ( e.keyCode === 27 ) {
			this.closeDropdown();
			this.elements.control.focus();
			return;
		}
		if ( e.keyCode === 9 && ! getClosest( e.target, '[data-js="' + this.options.selector + '"]' ) ) {
			this.elements.triggers[ 0 ].focus();
		}
	}

	/**
	 * @param  e
	 * @memberof Dropdown
	 * @description Does a basic text search on the dropdown items.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	handleBasicSearch( e ) {
		const search = e.target.value.toLowerCase();
		this.elements.triggers.forEach( ( entry ) => {
			if ( entry.innerText.toLowerCase().includes( search ) ) {
				entry.parentNode.style.display = '';
			} else {
				entry.parentNode.style.display = 'none';
			}
		} );
	}

	/**
	 * @param  items
	 * @memberof Dropdown
	 * @description Handles applying the rest response items as dropdown items.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	parseRestResponse = ( items ) => {
		return items.map( ( item ) => ( { value: item.id, label: item.title.rendered } ) );
	};

	/**
	 * @memberof Dropdown
	 * @description Handles hitting endpoints for lists data according to endpoint arguments passed in options.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	handleAsyncSearch = debounce( async ( e ) => {
		if ( e.target.value.trim().length === 0 ) {
			this.elements.dropdownList.innerHTML = dropdownListItems( this.options.listData );
			return;
		}

		const passedArgs = this.options.endpointArgs;
		const options = {
			baseUrl: this.options.baseUrl,
			method: 'POST',
			body: {
				...passedArgs,
				search: e.target.value,
			},
			...this.options.endpointRequestOptions,
		};
		if ( options.method === 'GET' ) {
			options.params = options.body;
		}
		if ( this.state.isMock ) {
			consoleInfo( 'Mock endpoint, data that would have been sent is:' );
			consoleInfo( options );
			return;
		}
		this.showSpinnerEl();
		const response = await request( this.options.endpointKey, this.options.endpoints, options );
		this.hideSpinnerEl();
		if ( ! this.options.endpointUseRest && response?.data?.success ) {
			this.elements.dropdownList.innerHTML = dropdownListItems( response.data.data );
		}
		if ( this.options.endpointUseRest && response.data.length ) {
			this.elements.dropdownList.innerHTML = dropdownListItems( this.parseRestResponse( response.data ) );
		}
	}, { wait: 300 } );

	/**
	 * @param  e
	 * @memberof Dropdown
	 * @description Delegates search handling to either basic or async handlers based on search type in options.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	handleSearch( e ) {
		if ( this.options.searchType === 'basic' ) {
			this.handleBasicSearch( e );
			return;
		}
		this.handleAsyncSearch( e );
	}

	/**
	 * @memberof Dropdown
	 * @description Stores the dropdown triggers and reveal controls on the instance as HTMLElements.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	storeTriggers() {
		this.elements.control = getNodes( 'gform-dropdown-control', false, this.elements.container )[ 0 ];
		this.elements.controlText = getNodes( 'gform-dropdown-control-text', false, this.elements.control )[ 0 ];
		this.elements.triggers = getNodes( 'gform-dropdown-trigger', true, this.elements.container );
	}

	/**
	 * @memberof Dropdown
	 * @description Renders the component into the dom.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	render() {
		this.options.renderTarget = this.options.renderTarget ? document.querySelectorAll( this.options.renderTarget )[ 0 ] : document.body;
		this.options.renderTarget.insertAdjacentHTML(
			this.options.insertPosition,
			dropdownTemplate( this.options )
		);
	}

	/**
	 * @memberof Dropdown
	 * @description Renders the list data into the already rendered dropdown when called.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	renderListData() {
		this.elements.dropdownList.innerHTML = dropdownListItems( this.options.listData );
	}

	/**
	 * @memberof Dropdown
	 * @description Sets up various dom variables on init, like long title handling.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	setup() {
		if ( this.options.reveal === 'hover' ) {
			this.elements.container.classList.add( 'gform-dropdown--hover' );
		}
		if ( this.options.detectTitleLength ) {
			// add a class to the container of the dropdown if displayed title is long.
			// class doesnt do anything by default, you have to wire css if you want to do some handling for long titles
			// dropdown is just always full width of its container
			const title = this.elements.titleEl ? this.elements.titleEl.innerText : '';
			if ( title.length > this.options.titleLengthThresholdMedium && title.length <= this.options.titleLengthThresholdLong ) {
				this.elements.container.parentNode.classList.add( 'gform-dropdown--medium-title' );
			} else if ( title.length > this.options.titleLengthThresholdLong ) {
				this.elements.container.parentNode.classList.add( 'gform-dropdown--long-title' );
			}
		}

		consoleInfo( `Gravity Forms Admin: Initialized dropdown component on [data-js="${ this.options.selector }"].` );
	}

	/**
	 * @memberof Dropdown
	 * @description Binds all the events for the component.
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	bindEvents() {
		const container = `[data-js="${ this.options.selector }"]`;

		delegate( container, '[data-js="gform-dropdown-trigger"]', 'click', this.handleChange.bind( this ) );
		delegate( container, '[data-js="gform-dropdown-control"]', 'click', this.handleControl.bind( this ) );
		delegate( container, '[data-js="gform-dropdown-search"]', 'keyup', this.handleSearch.bind( this ) );

		this.elements.container.addEventListener( 'mouseenter', this.handleMouseenter.bind( this ) );
		this.elements.container.addEventListener( 'mouseleave', this.handleMouseleave.bind( this ) );
		this.elements.container.addEventListener( 'keyup', this.handleA11y.bind( this ) );

		document.addEventListener( 'keyup', this.handleA11y.bind( this ) );
		document.addEventListener( 'click', function( event ) {
			if ( this.elements.container.contains( event.target ) || ! this.state.open ) {
				return;
			}
			this.handleControl();
		}.bind( this ), true );

		// store unloading state to make sure item stays closed during this event
		addEventListener( 'beforeunload', function() {
			this.state.unloading = true;
		}.bind( this ) );
	}

	/**
	 * @memberof Dropdown
	 * @description Initialize the component.
	 *
	 * @fires gform/dropdown/post_render
	 *
	 * @since 1.1.16
	 *
	 * @return {void}
	 */
	init() {
		this.storeTriggers();
		this.bindEvents();
		this.setup();

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