diff --git a/.eslintrc b/.eslintrc index 97ff862..ed03a93 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,7 +11,8 @@ "matchMedia": true, "Backbone": true, "Modernizr": true, - "CKEDITOR": true + "CKEDITOR": true, + "Select2": true }, "rules": { "eqeqeq": [2, "smart"], diff --git a/core/core.libraries.yml b/core/core.libraries.yml index 0c212ce..12c70d9 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -96,13 +96,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 @@ -217,6 +217,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: @@ -808,3 +819,18 @@ underscore: gpl-compatible: true js: assets/vendor/underscore/underscore.js: { weight: -20 } + +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 79d9082..0e67b5e 100644 --- a/core/lib/Drupal/Core/Installer/Form/SelectLanguageForm.php +++ b/core/lib/Drupal/Core/Installer/Form/SelectLanguageForm.php @@ -67,6 +67,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, ); $form['help'] = array( '#type' => 'item', diff --git a/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php b/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php index 750e967..54018c7 100644 --- a/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php +++ b/core/lib/Drupal/Core/Installer/Form/SiteConfigureForm.php @@ -190,6 +190,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', @@ -199,6 +201,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 401c850..27750dc 100644 --- a/core/lib/Drupal/Core/Render/Element/FormElement.php +++ b/core/lib/Drupal/Core/Render/Element/FormElement.php @@ -7,6 +7,7 @@ namespace Drupal\Core\Render\Element; +use Drupal\Component\Serialization\Json; use Drupal\Core\Form\FormStateInterface; /** @@ -123,6 +124,48 @@ public static function processAutocomplete(&$element, FormStateInterface $form_s $element['#attached']['library'][] = 'core/drupal.autocomplete'; // Provide a data attribute for the JavaScript behavior to bind to. $element['#attributes']['data-autocomplete-path'] = $path; + + // 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); + } + + 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 70f14d6..4507fdb 100644 --- a/core/lib/Drupal/Core/Render/Element/Select.php +++ b/core/lib/Drupal/Core/Render/Element/Select.php @@ -31,6 +31,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 af64521..3817c7d 100644 --- a/core/misc/autocomplete.js +++ b/core/misc/autocomplete.js @@ -1,172 +1,152 @@ -(function ($, Drupal) { +(function ($, Drupal, Select2) { "use strict"; - var autocomplete; - /** - * Helper splitting terms from the autocomplete value. - * - * @param {String} value + * 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. * - * @return {Array} + * @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 i, 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 (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); } + input = ''; } - if (value.length > 0) { - result.push($.trim(current)); - } - - return result; - } - /** - * Returns the last value of an multi-value textfield. - * - * @param {String} terms - * - * @return {String} - */ - function extractLastTerm(terms) { - return autocomplete.splitValues(terms).pop(); + if (original !== input) { + return input; + } } /** - * The search handler is called before a search is performed. + * Helper for splitting terms from the autocomplete value. * - * @param {Object} event - * - * @return {Boolean} - */ - function searchHandler(event) { - // Only search when the term is two characters or larger. - var term = autocomplete.extractLastTerm(event.target.value); - return term.length >= autocomplete.minLength; - } - - /** - * jQuery UI autocomplete source callback. + * @param {String} value + * @param {string} token on which to split values + * @param {string} enclosing character for values containing the token * - * @param {Object} request - * @param {Function} response + * @return {Array} */ - function sourceData(request, response) { - /*jshint validthis:true */ - 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(enclosing); } - - /** - * Filter through the suggestions removing all terms already tagged and - * display the available terms to the user. - * - * @param {Object} suggestions - */ - function showSuggestions(suggestions) { - var tagged = autocomplete.splitValues(request.term); - for (var i = 0, il = tagged.length; 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 - */ - 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); - /*jshint validthis:true */ - $.ajax(this.element.attr('data-autocomplete-path'), options); - } + return result.map($.trim); } /** - * Handles an autocompletefocus event. - * - * @return {Boolean} + * 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 {Object} event - * @param {Object} ui - * - * @return {Boolean} - */ - 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 {Object} ul - * @param {Object} item - * - * @return {Object} - */ - function renderItem(ul, item) { - return $("