diff --git a/core/core.libraries.yml b/core/core.libraries.yml index 773f2bd..df6ab4c 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -106,13 +106,13 @@ drupal.announce: drupal.autocomplete: version: VERSION js: - misc/autocomplete.js: { weight: -1 } + misc/autocomplete.js: {} dependencies: - core/jquery - core/drupal - core/drupalSettings - core/drupal.ajax - - core/jquery.ui.autocomplete + - core/select2 drupal.batch: version: VERSION @@ -234,6 +234,17 @@ drupal.progress: - core/jquery - core/drupalSettings +drupal.select2: + version: VERSION + js: + misc/select2.js: {} + dependencies: + - core/jquery + - core/drupal + - core/drupalSettings + - core/jquery.once + - core/select2 + drupal.states: version: VERSION js: @@ -857,3 +868,18 @@ underscore: gpl-compatible: true js: assets/vendor/underscore/underscore-min.js: { weight: -20, minified: true } + +select2: + remote: https://github.com/ivaynberg/select2 + version: 3.5.2 + license: + name: GNU-GPL-2.0-or-later + url: https://github.com/ivaynberg/select2/blob/master/LICENSE + gpl-compatible: true + js: + assets/vendor/select2/select2.js: {} + css: + theme: + assets/vendor/select2/select2.css: {} + dependencies: + - core/jquery diff --git a/core/lib/Drupal/Core/Installer/Form/SelectLanguageForm.php b/core/lib/Drupal/Core/Installer/Form/SelectLanguageForm.php index 9342f5c..e8c8af3 100644 --- a/core/lib/Drupal/Core/Installer/Form/SelectLanguageForm.php +++ b/core/lib/Drupal/Core/Installer/Form/SelectLanguageForm.php @@ -64,6 +64,8 @@ public function buildForm(array $form, FormStateInterface $form_state, $install_ '#options' => $select_options, // Use the browser detected language as default or English if nothing found. '#default_value' => !empty($browser_langcode) ? $browser_langcode : 'en', + // Use the Select2 library. + '#select2' => TRUE, ); $link_to_english = install_full_redirect_url(array('parameters' => array('langcode' => 'en'))); $form['help'] = array( diff --git a/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php b/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php index 6d5fe65..746c877 100644 --- a/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php +++ b/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php @@ -198,6 +198,8 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#options' => $countries, '#description' => $this->t('Select the default country for the site.'), '#weight' => 0, + // Use the Select2 library. + '#select2' => TRUE, ); $form['regional_settings']['date_default_timezone'] = array( '#type' => 'select', @@ -208,6 +210,8 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#description' => $this->t('By default, dates in this site will be displayed in the chosen time zone.'), '#weight' => 5, '#attributes' => array('class' => array('timezone-detect')), + // Use the Select2 library. + '#select2' => TRUE, ); $form['update_notifications'] = array( diff --git a/core/lib/Drupal/Core/Render/Element/FormElement.php b/core/lib/Drupal/Core/Render/Element/FormElement.php index 513f103..5960d41 100644 --- a/core/lib/Drupal/Core/Render/Element/FormElement.php +++ b/core/lib/Drupal/Core/Render/Element/FormElement.php @@ -2,6 +2,7 @@ namespace Drupal\Core\Render\Element; +use Drupal\Component\Serialization\Json; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\BubbleableMetadata; use Drupal\Core\Url; @@ -193,13 +194,55 @@ public static function processAutocomplete(&$element, FormStateInterface $form_s // Provide a data attribute for the JavaScript behavior to bind to. $element['#attributes']['data-autocomplete-path'] = $url->getGeneratedUrl(); $metadata = $metadata->merge($url); + + // Provide a data attribute which stores extra Select2 configuration. + $options = isset($element['#autocomplete_options']) ? $element['#autocomplete_options'] : new \stdClass(); + $element['#attributes']['data-autocomplete-options'] = Json::encode($options); } $metadata - ->merge(BubbleableMetadata::createFromObject($access)) + ->merge(BubbleableMetadata::createFromObject($access)) ->applyTo($element); } return $element; } + /** + * Integrates form elements with the Select2 JavaScript library. + * + * @param array $element + * The form element to process. Properties used: + * - #select2: Whether to use the Select2 library for this form element. If + * the value is TRUE, the Select2 library is initialized with its default + * options. If the value is an array, each key and value will map to the + * corresponding Select2 configuration. + * @see http://ivaynberg.github.io/select2/#documentation for the list of + * possible configuration options. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param array $complete_form + * The complete form structure. + * + * @return array + * The form element. + */ + public static function processSelect2(&$element, FormStateInterface $form_state, &$complete_form) { + if (!empty($element['#select2'])) { + // Provide some default options. + $configuration = array( + 'width' => 'resolve', + ); + if (is_array($element['#select2'])) { + $configuration = $element['#select2'] + $configuration; + } + + $element['#attributes']['class'][] = 'form-select2'; + $element['#attached']['library'][] = 'core/drupal.select2'; + // Provide a data attribute which stores the Select2 configuration array. + $element['#attributes']['data-drupal-select2'] = Json::encode($configuration); + } + + return $element; + } + } diff --git a/core/lib/Drupal/Core/Render/Element/Select.php b/core/lib/Drupal/Core/Render/Element/Select.php index f78dfa9..d11a0eb 100644 --- a/core/lib/Drupal/Core/Render/Element/Select.php +++ b/core/lib/Drupal/Core/Render/Element/Select.php @@ -48,6 +48,9 @@ public function getInfo() { '#process' => array( array($class, 'processSelect'), array($class, 'processAjaxForm'), + // Not to be confused with the processor above, this one is about the + // Select2 Javascript library. + array($class, 'processSelect2'), ), '#pre_render' => array( array($class, 'preRenderSelect'), diff --git a/core/misc/autocomplete.js b/core/misc/autocomplete.js index 032a15c..307a436 100644 --- a/core/misc/autocomplete.js +++ b/core/misc/autocomplete.js @@ -3,202 +3,155 @@ * Autocomplete based on jQuery UI. */ -(function ($, Drupal) { +(function ($, Drupal, Select2) { 'use strict'; - var 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. + * Default tokenizer. This function uses breaks the input on substring match of any string from the + * opts.tokenSeparators array and uses opts.createSearchChoice to create the choice object. Both of those + * two options have to be defined in order for the tokenizer to work. + * + * @param input text user has typed so far or pasted into the search field + * @param selection currently selected choices + * @param selectCallback function(choice) callback tho add the choice to selection + * @param opts select2's opts + * @return undefined/null to leave the current input unchanged, or a string to change the input to the returned value */ - function autocompleteSplitValues(value) { - // We will match the value against comma-separated terms. - var result = []; - var quote = false; - var current = ''; - var valueLength = value.length; - var character; + function drupalTokenizer(input, selection, selectCallback, opts) { + + function findDupe(value, list) { + var compareValue = opts.id(value); + var dupe = list.filter(function (selected) { + return compareValue === opts.id(selected); + }); + return !!dupe.length; + } - for (var i = 0; i < valueLength; i++) { - character = value.charAt(i); - if (character === '"') { - current += character; - quote = !quote; - } - else if (character === ',' && !quote) { - result.push(current.trim()); - current = ''; + if (!opts.createSearchChoice || !opts.tokenSeparators || opts.tokenSeparators.length < 1) { + return null; + } + + var original = input; + // Only support one character token. + var inputValues = splitValues(input, opts.tokenSeparators[0], opts.enclosing); + // If we have less than 2 results, there is no splitting going on. + if (inputValues.length < 2) { + return null; + } + else { + // First result is the string typed before ",". + input = inputValues[0]; + // If the string starts with a comma, first result is empty, remove it. + if (inputValues[0].length === 0) { + input = inputValues[1]; } - else { - current += character; + // Create a new entry if there are no duplicates, otherwise discard it. + var token = opts.createSearchChoice.call(this, input, selection); + var definedToken = (typeof token !== 'undefined' && token !== null && typeof opts.id(token) !== 'undefined' && opts.id(token) !== null); + var dupe = findDupe(token, selection); + if (definedToken && !dupe) { + selectCallback(token); } - } - if (value.length > 0) { - result.push($.trim(current)); + input = ''; } - 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) { - var options = autocomplete.options; - var 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; + if (original !== input) { + return input; } - // Only search when the term is at least the minimum length. - return term.length >= options.minLength; } /** - * JQuery UI autocomplete source callback. + * Helper for splitting terms from the autocomplete value. + * + * @param {String} value + * @param {string} token on which to split values + * @param {string} enclosing character for values containing the token * - * @param {object} request - * The request object. - * @param {function} response - * The function to call with the response. + * @return {Array} */ - function sourceData(request, response) { - var elementId = this.element.attr('id'); + function splitValues(value, token, enclosing) { + // We will match the value against comma-separated terms. + token = token || ','; + enclosing = enclosing || '"'; + var result = []; + var quote = false; + var current = ''; + var valueLength = value.length; + var i, character; - if (!(elementId in autocomplete.cache)) { - autocomplete.cache[elementId] = {}; + // If there are no enclosing character splitting is easy. + if (value.indexOf(enclosing) === -1) { + result = value.split(token); } - - /** - * 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) { - var tagged = autocomplete.splitValues(request.term); - var il = tagged.length; - for (var i = 0; i < il; i++) { - var index = suggestions.indexOf(tagged[i]); - if (index >= 0) { - suggestions.splice(index, 1); + // Handle enclosed token. + else { + for (i = 0; i < valueLength; i++) { + character = value.charAt(i); + if (character === enclosing) { + current += character; + quote = !quote; + } + else if (character === token && !quote) { + result.push(current); + current = ''; + } + else { + current += character; } } - response(suggestions); - } - - /** - * 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); + if (valueLength > 0) { + result.push(current); + } } - // Get the desired term and construct the autocomplete URL for it. - var term = autocomplete.extractLastTerm(request.term); - - // Check if the term is already cached. - if (autocomplete.cache[elementId].hasOwnProperty(term)) { - showSuggestions(autocomplete.cache[elementId][term]); - } - else { - var options = $.extend({success: sourceCallbackHandler, data: {q: term}}, autocomplete.ajax); - $.ajax(this.element.attr('data-autocomplete-path'), options); - } + return result.map($.trim); } /** - * Handles an autocompletefocus event. - * - * @return {bool} - * Always returns false. + * Sanitizes and splits the initial value of an autocomplete form element in + * the form required by Select2. */ - function focusHandler() { - return false; + function prepareInitialValue(element, callback) { + var idInText = element.data('autocompleteOptions')['id_in_text'] || false; + + var data = splitValues(element.val()).map(function (value) { + // Strip the identifier from the default value of an autocomplete + // element. The value is expected to be in the form "text (id)". + var textValue = idInText ? value.match(/"?(.+) \(\d+|[\w.]+\)"?/)[1] : value; + return {id: value, text: Select2.util.escapeMarkup(textValue.replace('""', '"', 'g'))}; + }); + callback(data); } - /** - * 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) { - var terms = autocomplete.splitValues(event.target.value); - // Remove the current input. - terms.pop(); - // Add the selected item. - if (ui.item.value.search(',') > 0) { - terms.push('"' + ui.item.value + '"'); - } - else { - terms.push(ui.item.value); + // Generic Select2 options that do not depend on any autocomplete element + // specific configuration. + var select2Options = { + tokenSeparators: [","], + enclosing: '"', + tags: [], + minimumInputLength: 1, + cache: true, + width: 'resolve', + initSelection: prepareInitialValue, + tokenizer: drupalTokenizer, + createSearchChoice: function (term) { return {id: term, text: term}; }, + // We do not want to escape markup since we are displaying html. + escapeMarkup: function (markup) { return markup; }, + ajax: { + dataType: 'json', + quietMillis: 200, + data: function (term, page) { return {q: term}; }, + results: function (data, page) { + return { + // Transform Drupal autocomplete format to Select2 format. + results: data.map(function (result) { + return {id: result.value, text: result.label}; + }) + }; + } } - 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. @@ -213,61 +166,17 @@ Drupal.behaviors.autocomplete = { attach: function (context) { // Act on textfields with the "form-autocomplete" class. - var $autocomplete = $(context).find('input.form-autocomplete').once('autocomplete'); - if ($autocomplete.length) { - // Allow options to be overriden per instance. - var blacklist = $autocomplete.attr('data-autocomplete-first-character-blacklist'); - $.extend(autocomplete.options, { - firstCharacterBlacklist: (blacklist) ? blacklist : '' - }); - // Use jQuery UI Autocomplete on the textfield. - $autocomplete.autocomplete(autocomplete.options) - .each(function() { - $(this).data('ui-autocomplete')._renderItem = autocomplete.options.renderItem; - }); - } - }, - detach: function (context, settings, trigger) { - if (trigger === 'unload') { - $(context).find('input.form-autocomplete') - .removeOnce('autocomplete') - .autocomplete('destroy'); - } - } - }; + $(context).find('[data-autocomplete-path]').once('autocomplete') + .each(function () { + // Merge the default options with the user-defined ones. + var autocompleteOptions = $.extend({}, select2Options, JSON.parse(this.getAttribute('data-autocomplete-options'))); - /** - * Autocomplete object implementation. - * - * @namespace Drupal.autocomplete - */ - autocomplete = { - cache: {}, - // Exposes options to allow overriding by contrib. - splitValues: autocompleteSplitValues, - extractLastTerm: extractLastTerm, - // jQuery UI autocomplete options. + // Set the element-specific AJAX url. + autocompleteOptions.ajax.url = this.getAttribute('data-autocomplete-path'); - /** - * JQuery UI option object. - * - * @name Drupal.autocomplete.options - */ - options: { - source: sourceData, - focus: focusHandler, - search: searchHandler, - select: selectHandler, - renderItem: renderItem, - minLength: 1, - // Custom options, used by Drupal.autocomplete. - firstCharacterBlacklist: '' - }, - ajax: { - dataType: 'json' + $(this).select2(autocompleteOptions); + }); } }; - Drupal.autocomplete = autocomplete; - -})(jQuery, Drupal); +})(jQuery, Drupal, Select2); diff --git a/core/modules/system/src/Form/RegionalForm.php b/core/modules/system/src/Form/RegionalForm.php index 9472a4c..8d794ef 100644 --- a/core/modules/system/src/Form/RegionalForm.php +++ b/core/modules/system/src/Form/RegionalForm.php @@ -80,6 +80,8 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#default_value' => $system_date->get('country.default'), '#options' => $countries, '#attributes' => array('class' => array('country-detect')), + // Use the Select2 library. + '#select2' => TRUE, ); $form['locale']['date_first_day'] = array( @@ -100,6 +102,8 @@ public function buildForm(array $form, FormStateInterface $form_state) { '#title' => t('Default time zone'), '#default_value' => $system_date->get('timezone.default') ?: date_default_timezone_get(), '#options' => $zones, + // Use the Select2 library. + '#select2' => TRUE, ); $configurable_timezones = $system_date->get('timezone.user.configurable'); diff --git a/core/themes/seven/css/components/jquery.ui/theme.css b/core/themes/seven/css/components/jquery.ui/theme.css index 02dc83d..a655200 100644 --- a/core/themes/seven/css/components/jquery.ui/theme.css +++ b/core/themes/seven/css/components/jquery.ui/theme.css @@ -426,22 +426,3 @@ /* Override datepicker.css */ color: inherit; } - -/** - * Autocomplete - */ -.ui-autocomplete { - background: #fff; - border: 1px solid #ccc; -} - -/* Suggestion list */ -.ui-autocomplete .ui-menu-item.ui-state-focus, -.autocomplete .ui-menu-item.ui-state-hover { - background: #0072b9; - margin: 0; -} -.ui-autocomplete .ui-state-focus a, -.autocomplete .ui-state-hover a { - color: #fff; -}