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