UNPKG

@gravityforms/components

Version:

UI components for use in Gravity Forms development. Both React and vanilla js flavors.

711 lines (668 loc) 25 kB
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>. const attributes = entry.attributes ? entry.attributes.join( ' ' ) : ''; 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 }" ${ attributes }> <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.onItemSelectTimedOut The callback function to run when an item is selected but times out or is canceled. * @param {number} options.onItemSelectTimedOutDelay The delay for the onItemSelectTimedOut callback. * @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() {}, onItemSelectTimedOut() {}, onItemSelectTimedOutDelay: 2500, 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(); } // If an action happens that would result in a change not occurring // or stalling out (such as browser navigation being cancelled) let's // go ahead and hide the spinner after a short delay. if ( ( this.options.onItemSelectTimedOutDelay !== 0 ) && this.state.unloading ) { setTimeout( () => { this.state.unloading = false; if ( this.options.showSpinner ) { this.hideSpinnerEl(); } this.options.onItemSelectTimedOut( e.target.dataset.value ); }, this.options.onItemSelectTimedOutDelay ); } } /** * @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 || item.name } ) ); }; /** * @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 } } ); } }