/**
* @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 $('<li>').append($('<a>').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',
jsonp: false,
},
};
Drupal.autocomplete = autocomplete;
})(jQuery, Drupal);