MDL-68167 lib: autocomplete focos should not be removed on blur
[moodle.git] / lib / amd / src / form-autocomplete.js
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
12 //
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * Autocomplete wrapper for select2 library.
18  *
19  * @module     core/form-autocomplete
20  * @class      autocomplete
21  * @package    core
22  * @copyright  2015 Damyon Wiese <damyon@moodle.com>
23  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24  * @since      3.0
25  */
26 /* globals require: false */
27 define(
28     ['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification', 'core/loadingicon', 'core/aria'],
29 function($, log, str, templates, notification, LoadingIcon, Aria) {
31     // Private functions and variables.
32     /** @var {Object} KEYS - List of keycode constants. */
33     var KEYS = {
34         DOWN: 40,
35         ENTER: 13,
36         SPACE: 32,
37         ESCAPE: 27,
38         COMMA: 44,
39         UP: 38,
40         LEFT: 37,
41         RIGHT: 39
42     };
44     var uniqueId = Date.now();
46     /**
47      * Make an item in the selection list "active".
48      *
49      * @method activateSelection
50      * @private
51      * @param {Number} index The index in the current (visible) list of selection.
52      * @param {Object} state State variables for this autocomplete element.
53      * @return {Promise}
54      */
55     var activateSelection = function(index, state) {
56         // Find the elements in the DOM.
57         var selectionElement = $(document.getElementById(state.selectionId));
59         // Count the visible items.
60         var length = selectionElement.children('[aria-selected=true]').length;
61         // Limit the index to the upper/lower bounds of the list (wrap in both directions).
62         index = index % length;
63         while (index < 0) {
64             index += length;
65         }
66         // Find the specified element.
67         var element = $(selectionElement.children('[aria-selected=true]').get(index));
68         // Create an id we can assign to this element.
69         var itemId = state.selectionId + '-' + index;
71         // Deselect all the selections.
72         selectionElement.children().attr('data-active-selection', false).attr('id', '');
73         // Select only this suggestion and assign it the id.
74         element.attr('data-active-selection', true).attr('id', itemId);
75         // Tell the input field it has a new active descendant so the item is announced.
76         selectionElement.attr('aria-activedescendant', itemId);
78         return $.Deferred().resolve();
79     };
81     /**
82      * Update the element that shows the currently selected items.
83      *
84      * @method updateSelectionList
85      * @private
86      * @param {Object} options Original options for this autocomplete element.
87      * @param {Object} state State variables for this autocomplete element.
88      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
89      * @return {Promise}
90      */
91     var updateSelectionList = function(options, state, originalSelect) {
92         var pendingKey = 'form-autocomplete-updateSelectionList-' + state.inputId;
93         M.util.js_pending(pendingKey);
95         // Build up a valid context to re-render the template.
96         var items = [];
97         var newSelection = $(document.getElementById(state.selectionId));
98         var activeId = newSelection.attr('aria-activedescendant');
99         var activeValue = false;
101         if (activeId) {
102             activeValue = $(document.getElementById(activeId)).attr('data-value');
103         }
104         originalSelect.children('option').each(function(index, ele) {
105             if ($(ele).prop('selected')) {
106                 var label;
107                 if ($(ele).data('html')) {
108                     label = $(ele).data('html');
109                 } else {
110                     label = $(ele).html();
111                 }
112                 if (label !== '') {
113                     items.push({label: label, value: $(ele).attr('value')});
114                 }
115             }
116         });
117         var context = $.extend({items: items}, options, state);
118         // Render the template.
119         return templates.render(options.templates.items, context)
120         .then(function(html, js) {
121             // Add it to the page.
122             templates.replaceNodeContents(newSelection, html, js);
124             if (activeValue !== false) {
125                 // Reselect any previously selected item.
126                 newSelection.children('[aria-selected=true]').each(function(index, ele) {
127                     if ($(ele).attr('data-value') === activeValue) {
128                         activateSelection(index, state);
129                     }
130                 });
131             }
133             return activeValue;
134         })
135         .then(function() {
136             return M.util.js_complete(pendingKey);
137         })
138         .catch(notification.exception);
139     };
141     /**
142      * Notify of a change in the selection.
143      *
144      * @param {jQuery} originalSelect The jQuery object matching the hidden select list.
145      */
146     var notifyChange = function(originalSelect) {
147         if (typeof M.core_formchangechecker !== 'undefined') {
148             M.core_formchangechecker.set_form_changed();
149         }
151         // Note, jQuery .change() was not working here. Better to
152         // use plain JavaScript anyway.
153         originalSelect[0].dispatchEvent(new Event('change'));
154     };
156     /**
157      * Remove the given item from the list of selected things.
158      *
159      * @method deselectItem
160      * @private
161      * @param {Object} options Original options for this autocomplete element.
162      * @param {Object} state State variables for this autocomplete element.
163      * @param {Element} item The item to be deselected.
164      * @param {Element} originalSelect The original select list.
165      * @return {Promise}
166      */
167     var deselectItem = function(options, state, item, originalSelect) {
168         var selectedItemValue = $(item).attr('data-value');
170         // Look for a match, and toggle the selected property if there is a match.
171         originalSelect.children('option').each(function(index, ele) {
172             if ($(ele).attr('value') == selectedItemValue) {
173                 $(ele).prop('selected', false);
174                 // We remove newly created custom tags from the suggestions list when they are deselected.
175                 if ($(ele).attr('data-iscustom')) {
176                     $(ele).remove();
177                 }
178             }
179         });
180         // Rerender the selection list.
181         return updateSelectionList(options, state, originalSelect)
182         .then(function() {
183             // Notify that the selection changed.
184             notifyChange(originalSelect);
186             return;
187         });
188     };
190     /**
191      * Make an item in the suggestions "active" (about to be selected).
192      *
193      * @method activateItem
194      * @private
195      * @param {Number} index The index in the current (visible) list of suggestions.
196      * @param {Object} state State variables for this instance of autocomplete.
197      * @return {Promise}
198      */
199     var activateItem = function(index, state) {
200         // Find the elements in the DOM.
201         var inputElement = $(document.getElementById(state.inputId));
202         var suggestionsElement = $(document.getElementById(state.suggestionsId));
204         // Count the visible items.
205         var length = suggestionsElement.children(':not([aria-hidden])').length;
206         // Limit the index to the upper/lower bounds of the list (wrap in both directions).
207         index = index % length;
208         while (index < 0) {
209             index += length;
210         }
211         // Find the specified element.
212         var element = $(suggestionsElement.children(':not([aria-hidden])').get(index));
213         // Find the index of this item in the full list of suggestions (including hidden).
214         var globalIndex = $(suggestionsElement.children('[role=option]')).index(element);
215         // Create an id we can assign to this element.
216         var itemId = state.suggestionsId + '-' + globalIndex;
218         // Deselect all the suggestions.
219         suggestionsElement.children().attr('aria-selected', false).attr('id', '');
220         // Select only this suggestion and assign it the id.
221         element.attr('aria-selected', true).attr('id', itemId);
222         // Tell the input field it has a new active descendant so the item is announced.
223         inputElement.attr('aria-activedescendant', itemId);
225         // Scroll it into view.
226         var scrollPos = element.offset().top
227                        - suggestionsElement.offset().top
228                        + suggestionsElement.scrollTop()
229                        - (suggestionsElement.height() / 2);
230         return suggestionsElement.animate({
231             scrollTop: scrollPos
232         }, 100).promise();
233     };
235     /**
236      * Find the index of the current active suggestion, and activate the next one.
237      *
238      * @method activateNextItem
239      * @private
240      * @param {Object} state State variable for this auto complete element.
241      * @return {Promise}
242      */
243     var activateNextItem = function(state) {
244         // Find the list of suggestions.
245         var suggestionsElement = $(document.getElementById(state.suggestionsId));
246         // Find the active one.
247         var element = suggestionsElement.children('[aria-selected=true]');
248         // Find it's index.
249         var current = suggestionsElement.children(':not([aria-hidden])').index(element);
250         // Activate the next one.
251         return activateItem(current + 1, state);
252     };
254     /**
255      * Find the index of the current active selection, and activate the previous one.
256      *
257      * @method activatePreviousSelection
258      * @private
259      * @param {Object} state State variables for this instance of autocomplete.
260      * @return {Promise}
261      */
262     var activatePreviousSelection = function(state) {
263         // Find the list of selections.
264         var selectionsElement = $(document.getElementById(state.selectionId));
265         // Find the active one.
266         var element = selectionsElement.children('[data-active-selection=true]');
267         if (!element) {
268             return activateSelection(0, state);
269         }
270         // Find it's index.
271         var current = selectionsElement.children('[aria-selected=true]').index(element);
272         // Activate the next one.
273         return activateSelection(current - 1, state);
274     };
276     /**
277      * Find the index of the current active selection, and activate the next one.
278      *
279      * @method activateNextSelection
280      * @private
281      * @param {Object} state State variables for this instance of autocomplete.
282      * @return {Promise}
283      */
284     var activateNextSelection = function(state) {
285         // Find the list of selections.
286         var selectionsElement = $(document.getElementById(state.selectionId));
288         // Find the active one.
289         var element = selectionsElement.children('[data-active-selection=true]');
290         var current = 0;
292         if (element) {
293             // The element was found. Determine the index and move to the next one.
294             current = selectionsElement.children('[aria-selected=true]').index(element);
295             current = current + 1;
296         } else {
297             // No selected item found. Move to the first.
298             current = 0;
299         }
301         return activateSelection(current, state);
302     };
304     /**
305      * Find the index of the current active suggestion, and activate the previous one.
306      *
307      * @method activatePreviousItem
308      * @private
309      * @param {Object} state State variables for this autocomplete element.
310      * @return {Promise}
311      */
312     var activatePreviousItem = function(state) {
313         // Find the list of suggestions.
314         var suggestionsElement = $(document.getElementById(state.suggestionsId));
316         // Find the active one.
317         var element = suggestionsElement.children('[aria-selected=true]');
319         // Find it's index.
320         var current = suggestionsElement.children(':not([aria-hidden])').index(element);
322         // Activate the previous one.
323         return activateItem(current - 1, state);
324     };
326     /**
327      * Close the list of suggestions.
328      *
329      * @method closeSuggestions
330      * @private
331      * @param {Object} state State variables for this autocomplete element.
332      * @return {Promise}
333      */
334     var closeSuggestions = function(state) {
335         // Find the elements in the DOM.
336         var inputElement = $(document.getElementById(state.inputId));
337         var suggestionsElement = $(document.getElementById(state.suggestionsId));
339         if (inputElement.attr('aria-expanded') === "true") {
340             // Announce the list of suggestions was closed.
341             inputElement.attr('aria-expanded', false);
342         }
343         // Read the current list of selections.
344         inputElement.attr('aria-activedescendant', state.selectionId);
346         // Hide the suggestions list (from screen readers too).
347         Aria.hide(suggestionsElement.get());
348         suggestionsElement.hide();
350         return $.Deferred().resolve();
351     };
353     /**
354      * Rebuild the list of suggestions based on the current values in the select list, and the query.
355      *
356      * @method updateSuggestions
357      * @private
358      * @param {Object} options The original options for this autocomplete.
359      * @param {Object} state The state variables for this autocomplete.
360      * @param {String} query The current text for the search string.
361      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
362      * @return {Promise}
363      */
364     var updateSuggestions = function(options, state, query, originalSelect) {
365         var pendingKey = 'form-autocomplete-updateSuggestions-' + state.inputId;
366         M.util.js_pending(pendingKey);
368         // Find the elements in the DOM.
369         var inputElement = $(document.getElementById(state.inputId));
370         var suggestionsElement = $(document.getElementById(state.suggestionsId));
372         // Used to track if we found any visible suggestions.
373         var matchingElements = false;
374         // Options is used by the context when rendering the suggestions from a template.
375         var suggestions = [];
376         originalSelect.children('option').each(function(index, option) {
377             if ($(option).prop('selected') !== true) {
378                 suggestions[suggestions.length] = {label: option.innerHTML, value: $(option).attr('value')};
379             }
380         });
382         // Re-render the list of suggestions.
383         var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
384         var context = $.extend({options: suggestions}, options, state);
385         var returnVal = templates.render(
386             'core/form_autocomplete_suggestions',
387             context
388         )
389         .then(function(html, js) {
390             // We have the new template, insert it in the page.
391             templates.replaceNode(suggestionsElement, html, js);
393             // Get the element again.
394             suggestionsElement = $(document.getElementById(state.suggestionsId));
396             // Show it if it is hidden.
397             Aria.unhide(suggestionsElement.get());
398             suggestionsElement.show();
400             // For each option in the list, hide it if it doesn't match the query.
401             suggestionsElement.children().each(function(index, node) {
402                 node = $(node);
403                 if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) ||
404                         (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) {
405                     Aria.unhide(node.get());
406                     node.show();
407                     matchingElements = true;
408                 } else {
409                     node.hide();
410                     Aria.hide(node.get());
411                 }
412             });
413             // If we found any matches, show the list.
414             inputElement.attr('aria-expanded', true);
415             if (originalSelect.attr('data-notice')) {
416                 // Display a notice rather than actual suggestions.
417                 suggestionsElement.html(originalSelect.attr('data-notice'));
418             } else if (matchingElements) {
419                 // We only activate the first item in the list if tags is false,
420                 // because otherwise "Enter" would select the first item, instead of
421                 // creating a new tag.
422                 if (!options.tags) {
423                     activateItem(0, state);
424                 }
425             } else {
426                 // Nothing matches. Tell them that.
427                 str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) {
428                     suggestionsElement.html(nosuggestionsstr);
429                 });
430             }
432             return suggestionsElement;
433         })
434         .then(function() {
435             return M.util.js_complete(pendingKey);
436         })
437         .catch(notification.exception);
439         return returnVal;
440     };
442     /**
443      * Create a new item for the list (a tag).
444      *
445      * @method createItem
446      * @private
447      * @param {Object} options The original options for the autocomplete.
448      * @param {Object} state State variables for the autocomplete.
449      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
450      * @return {Promise}
451      */
452     var createItem = function(options, state, originalSelect) {
453         // Find the element in the DOM.
454         var inputElement = $(document.getElementById(state.inputId));
455         // Get the current text in the input field.
456         var query = inputElement.val();
457         var tags = query.split(',');
458         var found = false;
460         $.each(tags, function(tagindex, tag) {
461             // If we can only select one at a time, deselect any current value.
462             tag = tag.trim();
463             if (tag !== '') {
464                 if (!options.multiple) {
465                     originalSelect.children('option').prop('selected', false);
466                 }
467                 // Look for an existing option in the select list that matches this new tag.
468                 originalSelect.children('option').each(function(index, ele) {
469                     if ($(ele).attr('value') == tag) {
470                         found = true;
471                         $(ele).prop('selected', true);
472                     }
473                 });
474                 // Only create the item if it's new.
475                 if (!found) {
476                     var option = $('<option>');
477                     option.append(document.createTextNode(tag));
478                     option.attr('value', tag);
479                     originalSelect.append(option);
480                     option.prop('selected', true);
481                     // We mark newly created custom options as we handle them differently if they are "deselected".
482                     option.attr('data-iscustom', true);
483                 }
484             }
485         });
487         return updateSelectionList(options, state, originalSelect)
488         .then(function() {
489             // Notify that the selection changed.
490             notifyChange(originalSelect);
492             return;
493         })
494         .then(function() {
495             // Clear the input field.
496             inputElement.val('');
498             return;
499         })
500         .then(function() {
501             // Close the suggestions list.
502             return closeSuggestions(state);
503         });
504     };
506     /**
507      * Select the currently active item from the suggestions list.
508      *
509      * @method selectCurrentItem
510      * @private
511      * @param {Object} options The original options for the autocomplete.
512      * @param {Object} state State variables for the autocomplete.
513      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
514      * @return {Promise}
515      */
516     var selectCurrentItem = function(options, state, originalSelect) {
517         // Find the elements in the page.
518         var inputElement = $(document.getElementById(state.inputId));
519         var suggestionsElement = $(document.getElementById(state.suggestionsId));
520         // Here loop through suggestions and set val to join of all selected items.
522         var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value');
523         // The select will either be a single or multi select, so the following will either
524         // select one or more items correctly.
525         // Take care to use 'prop' and not 'attr' for selected properties.
526         // If only one can be selected at a time, start by deselecting everything.
527         if (!options.multiple) {
528             originalSelect.children('option').prop('selected', false);
529         }
530         // Look for a match, and toggle the selected property if there is a match.
531         originalSelect.children('option').each(function(index, ele) {
532             if ($(ele).attr('value') == selectedItemValue) {
533                 $(ele).prop('selected', true);
534             }
535         });
537         return updateSelectionList(options, state, originalSelect)
538         .then(function() {
539             // Notify that the selection changed.
540             notifyChange(originalSelect);
542             return;
543         })
544         .then(function() {
545             if (options.closeSuggestionsOnSelect) {
546                 // Clear the input element.
547                 inputElement.val('');
548                 // Close the list of suggestions.
549                 return closeSuggestions(state);
550             } else {
551                 // Focus on the input element so the suggestions does not auto-close.
552                 inputElement.focus();
553                 // Remove the last selected item from the suggestions list.
554                 return updateSuggestions(options, state, inputElement.val(), originalSelect);
555             }
556         });
557     };
559     /**
560      * Fetch a new list of options via ajax.
561      *
562      * @method updateAjax
563      * @private
564      * @param {Event} e The event that triggered this update.
565      * @param {Object} options The original options for the autocomplete.
566      * @param {Object} state The state variables for the autocomplete.
567      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
568      * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
569      * @return {Promise}
570      */
571     var updateAjax = function(e, options, state, originalSelect, ajaxHandler) {
572         var pendingPromise = addPendingJSPromise('updateAjax');
573         // We need to show the indicator outside of the hidden select list.
574         // So we get the parent id of the hidden select list.
575         var parentElement = $(document.getElementById(state.selectId)).parent();
576         LoadingIcon.addIconToContainerRemoveOnCompletion(parentElement, pendingPromise);
578         // Get the query to pass to the ajax function.
579         var query = $(e.currentTarget).val();
580         // Call the transport function to do the ajax (name taken from Select2).
581         ajaxHandler.transport(options.selector, query, function(results) {
582             // We got a result - pass it through the translator before using it.
583             var processedResults = ajaxHandler.processResults(options.selector, results);
584             var existingValues = [];
586             // Now destroy all options that are not currently selected.
587             if (!options.multiple) {
588                 originalSelect.children('option').remove();
589             }
590             originalSelect.children('option').each(function(optionIndex, option) {
591                 option = $(option);
592                 if (!option.prop('selected')) {
593                     option.remove();
594                 } else {
595                     existingValues.push(String(option.attr('value')));
596                 }
597             });
599             if (!options.multiple && originalSelect.children('option').length === 0) {
600                 // If this is a single select - and there are no current options
601                 // the first option added will be selected by the browser. This causes a bug!
602                 // We need to insert an empty option so that none of the real options are selected.
603                 var option = $('<option>');
604                 originalSelect.append(option);
605             }
606             if ($.isArray(processedResults)) {
607                 // Add all the new ones returned from ajax.
608                 $.each(processedResults, function(resultIndex, result) {
609                     if (existingValues.indexOf(String(result.value)) === -1) {
610                         var option = $('<option>');
611                         option.append(result.label);
612                         option.attr('value', result.value);
613                         originalSelect.append(option);
614                     }
615                 });
616                 originalSelect.attr('data-notice', '');
617             } else {
618                 // The AJAX handler returned a string instead of the array.
619                 originalSelect.attr('data-notice', processedResults);
620             }
621             // Update the list of suggestions now from the new values in the select list.
622             pendingPromise.resolve(updateSuggestions(options, state, '', originalSelect));
623         }, function(error) {
624             pendingPromise.reject(error);
625         });
627         return pendingPromise;
628     };
630     /**
631      * Add all the event listeners required for keyboard nav, blur clicks etc.
632      *
633      * @method addNavigation
634      * @private
635      * @param {Object} options The options used to create this autocomplete element.
636      * @param {Object} state State variables for this autocomplete element.
637      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
638      */
639     var addNavigation = function(options, state, originalSelect) {
640         // Start with the input element.
641         var inputElement = $(document.getElementById(state.inputId));
642         // Add keyboard nav with keydown.
643         inputElement.on('keydown', function(e) {
644             var pendingJsPromise = addPendingJSPromise('addNavigation-' + state.inputId + '-' + e.keyCode);
646             switch (e.keyCode) {
647                 case KEYS.DOWN:
648                     // If the suggestion list is open, move to the next item.
649                     if (!options.showSuggestions) {
650                         // Do not consume this event.
651                         pendingJsPromise.resolve();
652                         return true;
653                     } else if (inputElement.attr('aria-expanded') === "true") {
654                         pendingJsPromise.resolve(activateNextItem(state));
655                     } else {
656                         // Handle ajax population of suggestions.
657                         if (!inputElement.val() && options.ajax) {
658                             require([options.ajax], function(ajaxHandler) {
659                                 pendingJsPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
660                             });
661                         } else {
662                             // Open the suggestions list.
663                             pendingJsPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
664                         }
665                     }
666                     // We handled this event, so prevent it.
667                     e.preventDefault();
668                     return false;
669                 case KEYS.UP:
670                     // Choose the previous active item.
671                     pendingJsPromise.resolve(activatePreviousItem(state));
673                     // We handled this event, so prevent it.
674                     e.preventDefault();
675                     return false;
676                 case KEYS.ENTER:
677                     var suggestionsElement = $(document.getElementById(state.suggestionsId));
678                     if ((inputElement.attr('aria-expanded') === "true") &&
679                             (suggestionsElement.children('[aria-selected=true]').length > 0)) {
680                         // If the suggestion list has an active item, select it.
681                         pendingJsPromise.resolve(selectCurrentItem(options, state, originalSelect));
682                     } else if (options.tags) {
683                         // If tags are enabled, create a tag.
684                         pendingJsPromise.resolve(createItem(options, state, originalSelect));
685                     } else {
686                         pendingJsPromise.resolve();
687                     }
689                     // We handled this event, so prevent it.
690                     e.preventDefault();
691                     return false;
692                 case KEYS.ESCAPE:
693                     if (inputElement.attr('aria-expanded') === "true") {
694                         // If the suggestion list is open, close it.
695                         pendingJsPromise.resolve(closeSuggestions(state));
696                     } else {
697                         pendingJsPromise.resolve();
698                     }
699                     // We handled this event, so prevent it.
700                     e.preventDefault();
701                     return false;
702             }
703             pendingJsPromise.resolve();
704             return true;
705         });
706         // Support multi lingual COMMA keycode (44).
707         inputElement.on('keypress', function(e) {
709             if (e.keyCode === KEYS.COMMA) {
710                 if (options.tags) {
711                     // If we are allowing tags, comma should create a tag (or enter).
712                     addPendingJSPromise('keypress-' + e.keyCode)
713                     .resolve(createItem(options, state, originalSelect));
714                 }
715                 // We handled this event, so prevent it.
716                 e.preventDefault();
717                 return false;
718             }
719             return true;
720         });
721         // Support submitting the form without leaving the autocomplete element,
722         // or submitting too quick before the blur handler action is completed.
723         inputElement.closest('form').on('submit', function() {
724             if (options.tags) {
725                 // If tags are enabled, create a tag.
726                 addPendingJSPromise('form-autocomplete-submit')
727                 .resolve(createItem(options, state, originalSelect));
728             }
730             return true;
731         });
732         inputElement.on('blur', function() {
733             var pendingPromise = addPendingJSPromise('form-autocomplete-blur');
734             window.setTimeout(function() {
735                 // Get the current element with focus.
736                 var focusElement = $(document.activeElement);
737                 var timeoutPromise = $.Deferred();
739                 // Only close the menu if the input hasn't regained focus and if the element still exists,
740                 // and regain focus if the scrollbar is clicked.
741                 // Due to the half a second delay, it is possible that the input element no longer exist
742                 // by the time this code is being executed.
743                 if (focusElement.is(document.getElementById(state.suggestionsId))) {
744                     inputElement.focus(); // Probably the scrollbar is clicked. Regain focus.
745                 } else if (!focusElement.is(inputElement) && $(document.getElementById(state.inputId)).length) {
746                     if (options.tags) {
747                         timeoutPromise.then(function() {
748                             return createItem(options, state, originalSelect);
749                         })
750                         .catch();
751                     }
752                     timeoutPromise.then(function() {
753                         return closeSuggestions(state);
754                     })
755                     .catch();
756                 }
758                 timeoutPromise.then(function() {
759                     return pendingPromise.resolve();
760                 })
761                 .catch();
762                 timeoutPromise.resolve();
763             }, 500);
764         });
765         if (options.showSuggestions) {
766             var arrowElement = $(document.getElementById(state.downArrowId));
767             arrowElement.on('click', function(e) {
768                 var pendingPromise = addPendingJSPromise('form-autocomplete-show-suggestions');
770                 // Prevent the close timer, or we will open, then close the suggestions.
771                 inputElement.focus();
773                 // Handle ajax population of suggestions.
774                 if (!inputElement.val() && options.ajax) {
775                     require([options.ajax], function(ajaxHandler) {
776                         pendingPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
777                     });
778                 } else {
779                     // Else - open the suggestions list.
780                     pendingPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
781                 }
782             });
783         }
785         var suggestionsElement = $(document.getElementById(state.suggestionsId));
786         // Remove any click handler first.
787         suggestionsElement.parent().prop("onclick", null).off("click");
788         suggestionsElement.parent().on('click', `#${state.suggestionsId} [role=option]`, function(e) {
789             var pendingPromise = addPendingJSPromise('form-autocomplete-parent');
790             // Handle clicks on suggestions.
791             var element = $(e.currentTarget).closest('[role=option]');
792             var suggestionsElement = $(document.getElementById(state.suggestionsId));
793             // Find the index of the clicked on suggestion.
794             var current = suggestionsElement.children(':not([aria-hidden])').index(element);
796             // Activate it.
797             activateItem(current, state)
798             .then(function() {
799                 // And select it.
800                 return selectCurrentItem(options, state, originalSelect);
801             })
802             .then(function() {
803                 return pendingPromise.resolve();
804             })
805             .catch();
806         });
807         var selectionElement = $(document.getElementById(state.selectionId));
808         // Handle clicks on the selected items (will unselect an item).
809         selectionElement.on('click', '[role=option]', function(e) {
810             var pendingPromise = addPendingJSPromise('form-autocomplete-clicks');
812             // Remove it from the selection.
813             pendingPromise.resolve(deselectItem(options, state, $(e.currentTarget), originalSelect));
814         });
815         // When listbox is focused, focus on the first option if there is no focused option.
816         selectionElement.on('focus', function() {
817             // Find the list of selections.
818             var selectionsElement = $(document.getElementById(state.selectionId));
819             // Find the active one.
820             var element = selectionsElement.children('[data-active-selection=true]');
821             if (!element.length) {
822                 return activateSelection(0, state);
823             }
824         });
825         // Keyboard navigation for the selection list.
826         selectionElement.on('keydown', function(e) {
827             var pendingPromise = addPendingJSPromise('form-autocomplete-keydown-' + e.keyCode);
828             switch (e.keyCode) {
829                 case KEYS.RIGHT:
830                 case KEYS.DOWN:
831                     // We handled this event, so prevent it.
832                     e.preventDefault();
834                     // Choose the next selection item.
835                     pendingPromise.resolve(activateNextSelection(state));
836                     return false;
837                 case KEYS.LEFT:
838                 case KEYS.UP:
839                     // We handled this event, so prevent it.
840                     e.preventDefault();
842                     // Choose the previous selection item.
843                     pendingPromise.resolve(activatePreviousSelection(state));
844                     return false;
845                 case KEYS.SPACE:
846                 case KEYS.ENTER:
847                     // Get the item that is currently selected.
848                     var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection=true]');
849                     if (selectedItem) {
850                         e.preventDefault();
852                         // Unselect this item.
853                         pendingPromise.resolve(deselectItem(options, state, selectedItem, originalSelect));
854                     }
855                     return false;
856             }
858             // Not handled. Resolve the promise.
859             pendingPromise.resolve();
860             return true;
861         });
862         // Whenever the input field changes, update the suggestion list.
863         if (options.showSuggestions) {
864             // Store the value of the field as its last value, when the field gains focus.
865             inputElement.on('focus', function(e) {
866                 var query = $(e.currentTarget).val();
867                 $(e.currentTarget).data('last-value', query);
868             });
870             // If this field uses ajax, set it up.
871             if (options.ajax) {
872                 require([options.ajax], function(ajaxHandler) {
873                     // Creating throttled handlers free of race conditions, and accurate.
874                     // This code keeps track of a throttleTimeout, which is periodically polled.
875                     // Once the throttled function is executed, the fact that it is running is noted.
876                     // If a subsequent request comes in whilst it is running, this request is re-applied.
877                     var throttleTimeout = null;
878                     var inProgress = false;
879                     var pendingKey = 'autocomplete-throttledhandler';
880                     var handler = function(e) {
881                         // Empty the current timeout.
882                         throttleTimeout = null;
884                         // Mark this request as in-progress.
885                         inProgress = true;
887                         // Process the request.
888                         updateAjax(e, options, state, originalSelect, ajaxHandler)
889                         .then(function() {
890                             // Check if the throttleTimeout is still empty.
891                             // There's a potential condition whereby the JS request takes long enough to complete that
892                             // another task has been queued.
893                             // In this case another task will be kicked off and we must wait for that before marking htis as
894                             // complete.
895                             if (null === throttleTimeout) {
896                                 // Mark this task as complete.
897                                 M.util.js_complete(pendingKey);
898                             }
899                             inProgress = false;
901                             return arguments[0];
902                         })
903                         .catch(notification.exception);
904                     };
906                     // For input events, we do not want to trigger many, many updates.
907                     var throttledHandler = function(e) {
908                         window.clearTimeout(throttleTimeout);
909                         if (inProgress) {
910                             // A request is currently ongoing.
911                             // Delay this request another 100ms.
912                             throttleTimeout = window.setTimeout(throttledHandler.bind(this, e), 100);
913                             return;
914                         }
916                         if (throttleTimeout === null) {
917                             // There is currently no existing timeout handler, and it has not been recently cleared, so
918                             // this is the start of a throttling check.
919                             M.util.js_pending(pendingKey);
920                         }
922                         // There is currently no existing timeout handler, and it has not been recently cleared, so this
923                         // is the start of a throttling check.
924                         // Queue a call to the handler.
925                         throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
926                     };
928                     // Trigger an ajax update after the text field value changes.
929                     inputElement.on('input', function(e) {
930                         var query = $(e.currentTarget).val();
931                         var last = $(e.currentTarget).data('last-value');
932                         // IE11 fires many more input events than required - even when the value has not changed.
933                         if (last !== query) {
934                             throttledHandler(e);
935                         }
936                         $(e.currentTarget).data('last-value', query);
937                     });
938                 });
939             } else {
940                 inputElement.on('input', function(e) {
941                     var query = $(e.currentTarget).val();
942                     var last = $(e.currentTarget).data('last-value');
943                     // IE11 fires many more input events than required - even when the value has not changed.
944                     // We need to only do this for real value changed events or the suggestions will be
945                     // unclickable on IE11 (because they will be rebuilt before the click event fires).
946                     // Note - because of this we cannot close the list when the query is empty or it will break
947                     // on IE11.
948                     if (last !== query) {
949                         updateSuggestions(options, state, query, originalSelect);
950                     }
951                     $(e.currentTarget).data('last-value', query);
952                 });
953             }
954         }
955     };
957     /**
958      * Create and return an unresolved Promise for some pending JS.
959      *
960      * @param   {String} key The unique identifier for this promise
961      * @return  {Promise}
962      */
963     var addPendingJSPromise = function(key) {
964             var pendingKey = 'form-autocomplete:' + key;
966             M.util.js_pending(pendingKey);
968             var pendingPromise = $.Deferred();
970             pendingPromise
971             .then(function() {
972                 M.util.js_complete(pendingKey);
974                 return arguments[0];
975             })
976             .catch(notification.exception);
978             return pendingPromise;
979     };
981     return /** @alias module:core/form-autocomplete */ {
982         // Public variables and functions.
983         /**
984          * Turn a boring select box into an auto-complete beast.
985          *
986          * @method enhance
987          * @param {string} selector The selector that identifies the select box.
988          * @param {boolean} tags Whether to allow support for tags (can define new entries).
989          * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
990          *                      module must expose 2 functions "transport" and "processResults".
991          *                      These are modeled on Select2 see: https://select2.github.io/options.html#ajax
992          * @param {String} placeholder - The text to display before a selection is made.
993          * @param {Boolean} caseSensitive - If search has to be made case sensitive.
994          * @param {Boolean} showSuggestions - If suggestions should be shown
995          * @param {String} noSelectionString - Text to display when there is no selection
996          * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection.
997          * @param {Object} templateOverrides A set of templates to use instead of the standard templates
998          * @return {Promise}
999          */
1000         enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString,
1001                           closeSuggestionsOnSelect, templateOverrides) {
1002             // Set some default values.
1003             var options = {
1004                 selector: selector,
1005                 tags: false,
1006                 ajax: false,
1007                 placeholder: placeholder,
1008                 caseSensitive: false,
1009                 showSuggestions: true,
1010                 noSelectionString: noSelectionString,
1011                 templates: $.extend({
1012                         input: 'core/form_autocomplete_input',
1013                         items: 'core/form_autocomplete_selection_items',
1014                         layout: 'core/form_autocomplete_layout',
1015                         selection: 'core/form_autocomplete_selection',
1016                         suggestions: 'core/form_autocomplete_suggestions',
1017                     }, templateOverrides),
1018             };
1019             var pendingKey = 'autocomplete-setup-' + selector;
1020             M.util.js_pending(pendingKey);
1021             if (typeof tags !== "undefined") {
1022                 options.tags = tags;
1023             }
1024             if (typeof ajax !== "undefined") {
1025                 options.ajax = ajax;
1026             }
1027             if (typeof caseSensitive !== "undefined") {
1028                 options.caseSensitive = caseSensitive;
1029             }
1030             if (typeof showSuggestions !== "undefined") {
1031                 options.showSuggestions = showSuggestions;
1032             }
1033             if (typeof noSelectionString === "undefined") {
1034                 str.get_string('noselection', 'form').done(function(result) {
1035                     options.noSelectionString = result;
1036                 }).fail(notification.exception);
1037             }
1039             // Look for the select element.
1040             var originalSelect = $(selector);
1041             if (!originalSelect) {
1042                 log.debug('Selector not found: ' + selector);
1043                 M.util.js_complete(pendingKey);
1044                 return false;
1045             }
1047             Aria.hide(originalSelect.get());
1048             originalSelect.css('visibility', 'hidden');
1050             // Hide the original select.
1052             // Find or generate some ids.
1053             var state = {
1054                 selectId: originalSelect.attr('id'),
1055                 inputId: 'form_autocomplete_input-' + uniqueId,
1056                 suggestionsId: 'form_autocomplete_suggestions-' + uniqueId,
1057                 selectionId: 'form_autocomplete_selection-' + uniqueId,
1058                 downArrowId: 'form_autocomplete_downarrow-' + uniqueId
1059             };
1061             // Increment the unique counter so we don't get duplicates ever.
1062             uniqueId++;
1064             options.multiple = originalSelect.attr('multiple');
1065             if (!options.multiple) {
1066                 // If this is a single select then there is no way to de-select the current value -
1067                 // unless we add a bogus blank option to be selected when nothing else is.
1068                 // This matches similar code in updateAjax above.
1069                 originalSelect.prepend('<option>');
1070             }
1072             if (typeof closeSuggestionsOnSelect !== "undefined") {
1073                 options.closeSuggestionsOnSelect = closeSuggestionsOnSelect;
1074             } else {
1075                 // If not specified, this will close suggestions by default for single-select elements only.
1076                 options.closeSuggestionsOnSelect = !options.multiple;
1077             }
1079             var originalLabel = $('[for=' + state.selectId + ']');
1080             // Create the new markup and insert it after the select.
1081             var suggestions = [];
1082             originalSelect.children('option').each(function(index, option) {
1083                 suggestions[index] = {label: option.innerHTML, value: $(option).attr('value')};
1084             });
1086             // Render all the parts of our UI.
1087             var context = $.extend({}, options, state);
1088             context.options = suggestions;
1089             context.items = [];
1091             // Collect rendered inline JS to be executed once the HTML is shown.
1092             var collectedjs = '';
1094             var renderLayout = templates.render(options.templates.layout, {})
1095             .then(function(html) {
1096                 return $(html);
1097             });
1099             var renderInput = templates.render(options.templates.input, context).then(function(html, js) {
1100                 collectedjs += js;
1101                 return $(html);
1102             });
1104             var renderDatalist = templates.render(options.templates.suggestions, context).then(function(html, js) {
1105                 collectedjs += js;
1106                 return $(html);
1107             });
1109             var renderSelection = templates.render(options.templates.selection, context).then(function(html, js) {
1110                 collectedjs += js;
1111                 return $(html);
1112             });
1114             return $.when(renderLayout, renderInput, renderDatalist, renderSelection)
1115             .then(function(layout, input, suggestions, selection) {
1116                 originalSelect.hide();
1117                 var container = originalSelect.parent();
1119                 container.append(layout);
1120                 container.find('[data-region="form_autocomplete-input"]').replaceWith(input);
1121                 container.find('[data-region="form_autocomplete-suggestions"]').replaceWith(suggestions);
1122                 container.find('[data-region="form_autocomplete-selection"]').replaceWith(selection);
1124                 templates.runTemplateJS(collectedjs);
1126                 // Update the form label to point to the text input.
1127                 originalLabel.attr('for', state.inputId);
1128                 // Add the event handlers.
1129                 addNavigation(options, state, originalSelect);
1131                 var suggestionsElement = $(document.getElementById(state.suggestionsId));
1132                 // Hide the suggestions by default.
1133                 suggestionsElement.hide();
1134                 Aria.hide(suggestionsElement.get());
1136                 return;
1137             })
1138             .then(function() {
1139                 // Show the current values in the selection list.
1140                 return updateSelectionList(options, state, originalSelect);
1141             })
1142             .then(function() {
1143                 return M.util.js_complete(pendingKey);
1144             })
1145             .catch(function(error) {
1146                 M.util.js_complete(pendingKey);
1147                 notification.exception(error);
1148             });
1149         }
1150     };
1151 });