/** * @file * Autocomplete based on jQuery UI. */ (function($, Drupal) { let autocomplete; /** * Helper splitting terms from the autocomplete value. * * @function Drupal.autocomplete.splitValues * * @param {string} value * The value being entered by the user. * * @return {Array} * Array of values, split by comma. */ function autocompleteSplitValues(value) { // We will match the value against comma-separated terms. const result = []; let quote = false; let current = ''; const valueLength = value.length; let character; for (let i = 0; i < valueLength; i++) { character = value.charAt(i); if (character === '"') { current += character; quote = !quote; } else if (character === ',' && !quote) { result.push(current.trim()); current = ''; } else { current += character; } } if (value.length > 0) { result.push($.trim(current)); } return result; } /** * Returns the last value of an multi-value textfield. * * @function Drupal.autocomplete.extractLastTerm * * @param {string} terms * The value of the field. * * @return {string} * The last value of the input field. */ function extractLastTerm(terms) { return autocomplete.splitValues(terms).pop(); } /** * The search handler is called before a search is performed. * * @function Drupal.autocomplete.options.search * * @param {object} event * The event triggered. * * @return {bool} * Whether to perform a search or not. */ function searchHandler(event) { const options = autocomplete.options; if (options.isComposing) { return false; } const term = autocomplete.extractLastTerm(event.target.value); // Abort search if the first character is in firstCharacterBlacklist. if ( term.length > 0 && options.firstCharacterBlacklist.indexOf(term[0]) !== -1 ) { return false; } // Only search when the term is at least the minimum length. return term.length >= options.minLength; } /** * JQuery UI autocomplete source callback. * * @param {object} request * The request object. * @param {function} response * The function to call with the response. */ function sourceData(request, response) { const elementId = this.element.attr('id'); if (!(elementId in autocomplete.cache)) { autocomplete.cache[elementId] = {}; } /** * Filter through the suggestions removing all terms already tagged and * display the available terms to the user. * * @param {object} suggestions * Suggestions returned by the server. */ function showSuggestions(suggestions) { const tagged = autocomplete.splitValues(request.term); const il = tagged.length; for (let i = 0; i < il; i++) { const index = suggestions.indexOf(tagged[i]); if (index >= 0) { suggestions.splice(index, 1); } } response(suggestions); } // Get the desired term and construct the autocomplete URL for it. const term = autocomplete.extractLastTerm(request.term); /** * Transforms the data object into an array and update autocomplete results. * * @param {object} data * The data sent back from the server. */ function sourceCallbackHandler(data) { autocomplete.cache[elementId][term] = data; // Send the new string array of terms to the jQuery UI list. showSuggestions(data); } // Check if the term is already cached. if (autocomplete.cache[elementId].hasOwnProperty(term)) { showSuggestions(autocomplete.cache[elementId][term]); } else { const options = $.extend( { success: sourceCallbackHandler, data: { q: term } }, autocomplete.ajax, ); $.ajax(this.element.attr('data-autocomplete-path'), options); } } /** * Handles an autocompletefocus event. * * @return {bool} * Always returns false. */ function focusHandler() { return false; } /** * Handles an autocompleteselect event. * * @param {jQuery.Event} event * The event triggered. * @param {object} ui * The jQuery UI settings object. * * @return {bool} * Returns false to indicate the event status. */ function selectHandler(event, ui) { const terms = autocomplete.splitValues(event.target.value); // Remove the current input. terms.pop(); // Add the selected item. terms.push(ui.item.value); event.target.value = terms.join(', '); // Return false to tell jQuery UI that we've filled in the value already. return false; } /** * Override jQuery UI _renderItem function to output HTML by default. * * @param {jQuery} ul * jQuery collection of the ul element. * @param {object} item * The list item to append. * * @return {jQuery} * jQuery collection of the ul element. */ function renderItem(ul, item) { return $('
  • ') .append($('').html(item.label)) .appendTo(ul); } /** * Attaches the autocomplete behavior to all required fields. * * @type {Drupal~behavior} * * @prop {Drupal~behaviorAttach} attach * Attaches the autocomplete behaviors. * @prop {Drupal~behaviorDetach} detach * Detaches the autocomplete behaviors. */ Drupal.behaviors.autocomplete = { attach(context) { // Act on textfields with the "form-autocomplete" class. const $autocomplete = $(context) .find('input.form-autocomplete') .once('autocomplete'); if ($autocomplete.length) { // Allow options to be overridden per instance. const blacklist = $autocomplete.attr( 'data-autocomplete-first-character-blacklist', ); $.extend(autocomplete.options, { firstCharacterBlacklist: blacklist || '', }); // Use jQuery UI Autocomplete on the textfield. $autocomplete.autocomplete(autocomplete.options).each(function() { $(this).data('ui-autocomplete')._renderItem = autocomplete.options.renderItem; }); // Use CompositionEvent to handle IME inputs. It requests remote server on "compositionend" event only. $autocomplete.on('compositionstart.autocomplete', () => { autocomplete.options.isComposing = true; }); $autocomplete.on('compositionend.autocomplete', () => { autocomplete.options.isComposing = false; }); } }, detach(context, settings, trigger) { if (trigger === 'unload') { $(context) .find('input.form-autocomplete') .removeOnce('autocomplete') .autocomplete('destroy'); } }, }; /** * Autocomplete object implementation. * * @namespace Drupal.autocomplete */ autocomplete = { cache: {}, // Exposes options to allow overriding by contrib. splitValues: autocompleteSplitValues, extractLastTerm, // jQuery UI autocomplete options. /** * JQuery UI option object. * * @name Drupal.autocomplete.options */ options: { source: sourceData, focus: focusHandler, search: searchHandler, select: selectHandler, renderItem, minLength: 1, // Custom options, used by Drupal.autocomplete. firstCharacterBlacklist: '', // Custom options, indicate IME usage status. isComposing: false, }, ajax: { dataType: 'json', }, }; Drupal.autocomplete = autocomplete; })(jQuery, Drupal);