2cffd175e611beb75e7038622e08f12a84437503
[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'],
29 function($, log, str, templates, notification, LoadingIcon) {
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     };
42     var uniqueId = Date.now();
44     /**
45      * Make an item in the selection list "active".
46      *
47      * @method activateSelection
48      * @private
49      * @param {Number} index The index in the current (visible) list of selection.
50      * @param {Object} state State variables for this autocomplete element.
51      * @return {Promise}
52      */
53     var activateSelection = function(index, state) {
54         // Find the elements in the DOM.
55         var selectionElement = $(document.getElementById(state.selectionId));
57         // Count the visible items.
58         var length = selectionElement.children('[aria-selected=true]').length;
59         // Limit the index to the upper/lower bounds of the list (wrap in both directions).
60         index = index % length;
61         while (index < 0) {
62             index += length;
63         }
64         // Find the specified element.
65         var element = $(selectionElement.children('[aria-selected=true]').get(index));
66         // Create an id we can assign to this element.
67         var itemId = state.selectionId + '-' + index;
69         // Deselect all the selections.
70         selectionElement.children().attr('data-active-selection', false).attr('id', '');
71         // Select only this suggestion and assign it the id.
72         element.attr('data-active-selection', true).attr('id', itemId);
73         // Tell the input field it has a new active descendant so the item is announced.
74         selectionElement.attr('aria-activedescendant', itemId);
76         return $.Deferred().resolve();
77     };
79     /**
80      * Update the element that shows the currently selected items.
81      *
82      * @method updateSelectionList
83      * @private
84      * @param {Object} options Original options for this autocomplete element.
85      * @param {Object} state State variables for this autocomplete element.
86      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
87      * @return {Promise}
88      */
89     var updateSelectionList = function(options, state, originalSelect) {
90         var pendingKey = 'form-autocomplete-updateSelectionList-' + state.inputId;
91         M.util.js_pending(pendingKey);
93         // Build up a valid context to re-render the template.
94         var items = [];
95         var newSelection = $(document.getElementById(state.selectionId));
96         var activeId = newSelection.attr('aria-activedescendant');
97         var activeValue = false;
99         if (activeId) {
100             activeValue = $(document.getElementById(activeId)).attr('data-value');
101         }
102         originalSelect.children('option').each(function(index, ele) {
103             if ($(ele).prop('selected')) {
104                 var label;
105                 if ($(ele).data('html')) {
106                     label = $(ele).data('html');
107                 } else {
108                     label = $(ele).html();
109                 }
110                 if (label !== '') {
111                     items.push({label: label, value: $(ele).attr('value')});
112                 }
113             }
114         });
115         var context = $.extend({items: items}, options, state);
116         // Render the template.
117         return templates.render('core/form_autocomplete_selection_items', context)
118         .then(function(html, js) {
119             // Add it to the page.
120             templates.replaceNodeContents(newSelection, html, js);
122             if (activeValue !== false) {
123                 // Reselect any previously selected item.
124                 newSelection.children('[aria-selected=true]').each(function(index, ele) {
125                     if ($(ele).attr('data-value') === activeValue) {
126                         activateSelection(index, state);
127                     }
128                 });
129             }
131             return activeValue;
132         })
133         .then(function() {
134             return M.util.js_complete(pendingKey);
135         })
136         .catch(notification.exception);
137     };
139     /**
140      * Notify of a change in the selection.
141      *
142      * @param {jQuery} originalSelect The jQuery object matching the hidden select list.
143      */
144     var notifyChange = function(originalSelect) {
145         if (typeof M.core_formchangechecker !== 'undefined') {
146             M.core_formchangechecker.set_form_changed();
147         }
148         originalSelect.change();
149     };
151     /**
152      * Remove the given item from the list of selected things.
153      *
154      * @method deselectItem
155      * @private
156      * @param {Object} options Original options for this autocomplete element.
157      * @param {Object} state State variables for this autocomplete element.
158      * @param {Element} item The item to be deselected.
159      * @param {Element} originalSelect The original select list.
160      * @return {Promise}
161      */
162     var deselectItem = function(options, state, item, originalSelect) {
163         var selectedItemValue = $(item).attr('data-value');
165         // Look for a match, and toggle the selected property if there is a match.
166         originalSelect.children('option').each(function(index, ele) {
167             if ($(ele).attr('value') == selectedItemValue) {
168                 $(ele).prop('selected', false);
169                 // We remove newly created custom tags from the suggestions list when they are deselected.
170                 if ($(ele).attr('data-iscustom')) {
171                     $(ele).remove();
172                 }
173             }
174         });
175         // Rerender the selection list.
176         return updateSelectionList(options, state, originalSelect)
177         .then(function() {
178             // Notify that the selection changed.
179             notifyChange(originalSelect);
181             return;
182         });
183     };
185     /**
186      * Make an item in the suggestions "active" (about to be selected).
187      *
188      * @method activateItem
189      * @private
190      * @param {Number} index The index in the current (visible) list of suggestions.
191      * @param {Object} state State variables for this instance of autocomplete.
192      * @return {Promise}
193      */
194     var activateItem = function(index, state) {
195         // Find the elements in the DOM.
196         var inputElement = $(document.getElementById(state.inputId));
197         var suggestionsElement = $(document.getElementById(state.suggestionsId));
199         // Count the visible items.
200         var length = suggestionsElement.children('[aria-hidden=false]').length;
201         // Limit the index to the upper/lower bounds of the list (wrap in both directions).
202         index = index % length;
203         while (index < 0) {
204             index += length;
205         }
206         // Find the specified element.
207         var element = $(suggestionsElement.children('[aria-hidden=false]').get(index));
208         // Find the index of this item in the full list of suggestions (including hidden).
209         var globalIndex = $(suggestionsElement.children('[role=option]')).index(element);
210         // Create an id we can assign to this element.
211         var itemId = state.suggestionsId + '-' + globalIndex;
213         // Deselect all the suggestions.
214         suggestionsElement.children().attr('aria-selected', false).attr('id', '');
215         // Select only this suggestion and assign it the id.
216         element.attr('aria-selected', true).attr('id', itemId);
217         // Tell the input field it has a new active descendant so the item is announced.
218         inputElement.attr('aria-activedescendant', itemId);
220         // Scroll it into view.
221         var scrollPos = element.offset().top
222                        - suggestionsElement.offset().top
223                        + suggestionsElement.scrollTop()
224                        - (suggestionsElement.height() / 2);
225         return suggestionsElement.animate({
226             scrollTop: scrollPos
227         }, 100).promise();
228     };
230     /**
231      * Find the index of the current active suggestion, and activate the next one.
232      *
233      * @method activateNextItem
234      * @private
235      * @param {Object} state State variable for this auto complete element.
236      * @return {Promise}
237      */
238     var activateNextItem = function(state) {
239         // Find the list of suggestions.
240         var suggestionsElement = $(document.getElementById(state.suggestionsId));
241         // Find the active one.
242         var element = suggestionsElement.children('[aria-selected=true]');
243         // Find it's index.
244         var current = suggestionsElement.children('[aria-hidden=false]').index(element);
245         // Activate the next one.
246         return activateItem(current + 1, state);
247     };
249     /**
250      * Find the index of the current active selection, and activate the previous one.
251      *
252      * @method activatePreviousSelection
253      * @private
254      * @param {Object} state State variables for this instance of autocomplete.
255      * @return {Promise}
256      */
257     var activatePreviousSelection = function(state) {
258         // Find the list of selections.
259         var selectionsElement = $(document.getElementById(state.selectionId));
260         // Find the active one.
261         var element = selectionsElement.children('[data-active-selection=true]');
262         if (!element) {
263             return activateSelection(0, state);
264         }
265         // Find it's index.
266         var current = selectionsElement.children('[aria-selected=true]').index(element);
267         // Activate the next one.
268         return activateSelection(current - 1, state);
269     };
271     /**
272      * Find the index of the current active selection, and activate the next one.
273      *
274      * @method activateNextSelection
275      * @private
276      * @param {Object} state State variables for this instance of autocomplete.
277      * @return {Promise}
278      */
279     var activateNextSelection = function(state) {
280         // Find the list of selections.
281         var selectionsElement = $(document.getElementById(state.selectionId));
283         // Find the active one.
284         var element = selectionsElement.children('[data-active-selection=true]');
285         var current = 0;
287         if (element) {
288             // The element was found. Determine the index and move to the next one.
289             current = selectionsElement.children('[aria-selected=true]').index(element);
290             current = current + 1;
291         } else {
292             // No selected item found. Move to the first.
293             current = 0;
294         }
296         return activateSelection(current, state);
297     };
299     /**
300      * Find the index of the current active suggestion, and activate the previous one.
301      *
302      * @method activatePreviousItem
303      * @private
304      * @param {Object} state State variables for this autocomplete element.
305      * @return {Promise}
306      */
307     var activatePreviousItem = function(state) {
308         // Find the list of suggestions.
309         var suggestionsElement = $(document.getElementById(state.suggestionsId));
311         // Find the active one.
312         var element = suggestionsElement.children('[aria-selected=true]');
314         // Find it's index.
315         var current = suggestionsElement.children('[aria-hidden=false]').index(element);
317         // Activate the previous one.
318         return activateItem(current - 1, state);
319     };
321     /**
322      * Close the list of suggestions.
323      *
324      * @method closeSuggestions
325      * @private
326      * @param {Object} state State variables for this autocomplete element.
327      * @return {Promise}
328      */
329     var closeSuggestions = function(state) {
330         // Find the elements in the DOM.
331         var inputElement = $(document.getElementById(state.inputId));
332         var suggestionsElement = $(document.getElementById(state.suggestionsId));
334         // Announce the list of suggestions was closed, and read the current list of selections.
335         inputElement.attr('aria-expanded', false).attr('aria-activedescendant', state.selectionId);
337         // Hide the suggestions list (from screen readers too).
338         suggestionsElement.hide().attr('aria-hidden', true);
340         return $.Deferred().resolve();
341     };
343     /**
344      * Rebuild the list of suggestions based on the current values in the select list, and the query.
345      *
346      * @method updateSuggestions
347      * @private
348      * @param {Object} options The original options for this autocomplete.
349      * @param {Object} state The state variables for this autocomplete.
350      * @param {String} query The current text for the search string.
351      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
352      * @return {Promise}
353      */
354     var updateSuggestions = function(options, state, query, originalSelect) {
355         var pendingKey = 'form-autocomplete-updateSuggestions-' + state.inputId;
356         M.util.js_pending(pendingKey);
358         // Find the elements in the DOM.
359         var inputElement = $(document.getElementById(state.inputId));
360         var suggestionsElement = $(document.getElementById(state.suggestionsId));
362         // Used to track if we found any visible suggestions.
363         var matchingElements = false;
364         // Options is used by the context when rendering the suggestions from a template.
365         var suggestions = [];
366         originalSelect.children('option').each(function(index, option) {
367             if ($(option).prop('selected') !== true) {
368                 suggestions[suggestions.length] = {label: option.innerHTML, value: $(option).attr('value')};
369             }
370         });
372         // Re-render the list of suggestions.
373         var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
374         var context = $.extend({options: suggestions}, options, state);
375         var returnVal = templates.render(
376             'core/form_autocomplete_suggestions',
377             context
378         )
379         .then(function(html, js) {
380             // We have the new template, insert it in the page.
381             templates.replaceNode(suggestionsElement, html, js);
383             // Get the element again.
384             suggestionsElement = $(document.getElementById(state.suggestionsId));
385             // Show it if it is hidden.
386             suggestionsElement.show().attr('aria-hidden', false);
387             // For each option in the list, hide it if it doesn't match the query.
388             suggestionsElement.children().each(function(index, node) {
389                 node = $(node);
390                 if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) ||
391                         (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) {
392                     node.show().attr('aria-hidden', false);
393                     matchingElements = true;
394                 } else {
395                     node.hide().attr('aria-hidden', true);
396                 }
397             });
398             // If we found any matches, show the list.
399             inputElement.attr('aria-expanded', true);
400             if (originalSelect.attr('data-notice')) {
401                 // Display a notice rather than actual suggestions.
402                 suggestionsElement.html(originalSelect.attr('data-notice'));
403             } else if (matchingElements) {
404                 // We only activate the first item in the list if tags is false,
405                 // because otherwise "Enter" would select the first item, instead of
406                 // creating a new tag.
407                 if (!options.tags) {
408                     activateItem(0, state);
409                 }
410             } else {
411                 // Nothing matches. Tell them that.
412                 str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) {
413                     suggestionsElement.html(nosuggestionsstr);
414                 });
415             }
417             return suggestionsElement;
418         })
419         .then(function() {
420             return M.util.js_complete(pendingKey);
421         })
422         .catch(notification.exception);
424         return returnVal;
425     };
427     /**
428      * Create a new item for the list (a tag).
429      *
430      * @method createItem
431      * @private
432      * @param {Object} options The original options for the autocomplete.
433      * @param {Object} state State variables for the autocomplete.
434      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
435      * @return {Promise}
436      */
437     var createItem = function(options, state, originalSelect) {
438         // Find the element in the DOM.
439         var inputElement = $(document.getElementById(state.inputId));
440         // Get the current text in the input field.
441         var query = inputElement.val();
442         var tags = query.split(',');
443         var found = false;
445         $.each(tags, function(tagindex, tag) {
446             // If we can only select one at a time, deselect any current value.
447             tag = tag.trim();
448             if (tag !== '') {
449                 if (!options.multiple) {
450                     originalSelect.children('option').prop('selected', false);
451                 }
452                 // Look for an existing option in the select list that matches this new tag.
453                 originalSelect.children('option').each(function(index, ele) {
454                     if ($(ele).attr('value') == tag) {
455                         found = true;
456                         $(ele).prop('selected', true);
457                     }
458                 });
459                 // Only create the item if it's new.
460                 if (!found) {
461                     var option = $('<option>');
462                     option.append(document.createTextNode(tag));
463                     option.attr('value', tag);
464                     originalSelect.append(option);
465                     option.prop('selected', true);
466                     // We mark newly created custom options as we handle them differently if they are "deselected".
467                     option.attr('data-iscustom', true);
468                 }
469             }
470         });
472         return updateSelectionList(options, state, originalSelect)
473         .then(function() {
474             // Notify that the selection changed.
475             notifyChange(originalSelect);
477             return;
478         })
479         .then(function() {
480             // Clear the input field.
481             inputElement.val('');
483             return;
484         })
485         .then(function() {
486             // Close the suggestions list.
487             return closeSuggestions(state);
488         });
489     };
491     /**
492      * Select the currently active item from the suggestions list.
493      *
494      * @method selectCurrentItem
495      * @private
496      * @param {Object} options The original options for the autocomplete.
497      * @param {Object} state State variables for the autocomplete.
498      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
499      * @return {Promise}
500      */
501     var selectCurrentItem = function(options, state, originalSelect) {
502         // Find the elements in the page.
503         var inputElement = $(document.getElementById(state.inputId));
504         var suggestionsElement = $(document.getElementById(state.suggestionsId));
505         // Here loop through suggestions and set val to join of all selected items.
507         var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value');
508         // The select will either be a single or multi select, so the following will either
509         // select one or more items correctly.
510         // Take care to use 'prop' and not 'attr' for selected properties.
511         // If only one can be selected at a time, start by deselecting everything.
512         if (!options.multiple) {
513             originalSelect.children('option').prop('selected', false);
514         }
515         // Look for a match, and toggle the selected property if there is a match.
516         originalSelect.children('option').each(function(index, ele) {
517             if ($(ele).attr('value') == selectedItemValue) {
518                 $(ele).prop('selected', true);
519             }
520         });
522         return updateSelectionList(options, state, originalSelect)
523         .then(function() {
524             // Notify that the selection changed.
525             notifyChange(originalSelect);
527             return;
528         })
529         .then(function() {
530             if (options.closeSuggestionsOnSelect) {
531                 // Clear the input element.
532                 inputElement.val('');
533                 // Close the list of suggestions.
534                 return closeSuggestions(state);
535             } else {
536                 // Focus on the input element so the suggestions does not auto-close.
537                 inputElement.focus();
538                 // Remove the last selected item from the suggestions list.
539                 return updateSuggestions(options, state, inputElement.val(), originalSelect);
540             }
541         });
542     };
544     /**
545      * Fetch a new list of options via ajax.
546      *
547      * @method updateAjax
548      * @private
549      * @param {Event} e The event that triggered this update.
550      * @param {Object} options The original options for the autocomplete.
551      * @param {Object} state The state variables for the autocomplete.
552      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
553      * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
554      * @return {Promise}
555      */
556     var updateAjax = function(e, options, state, originalSelect, ajaxHandler) {
557         var pendingPromise = addPendingJSPromise('updateAjax');
558         // We need to show the indicator outside of the hidden select list.
559         // So we get the parent id of the hidden select list.
560         var parentElement = $(document.getElementById(state.selectId)).parent();
561         LoadingIcon.addIconToContainerRemoveOnCompletion(parentElement, pendingPromise);
563         // Get the query to pass to the ajax function.
564         var query = $(e.currentTarget).val();
565         // Call the transport function to do the ajax (name taken from Select2).
566         ajaxHandler.transport(options.selector, query, function(results) {
567             // We got a result - pass it through the translator before using it.
568             var processedResults = ajaxHandler.processResults(options.selector, results);
569             var existingValues = [];
571             // Now destroy all options that are not currently selected.
572             if (!options.multiple) {
573                 originalSelect.children('option').remove();
574             }
575             originalSelect.children('option').each(function(optionIndex, option) {
576                 option = $(option);
577                 if (!option.prop('selected')) {
578                     option.remove();
579                 } else {
580                     existingValues.push(String(option.attr('value')));
581                 }
582             });
584             if (!options.multiple && originalSelect.children('option').length === 0) {
585                 // If this is a single select - and there are no current options
586                 // the first option added will be selected by the browser. This causes a bug!
587                 // We need to insert an empty option so that none of the real options are selected.
588                 var option = $('<option>');
589                 originalSelect.append(option);
590             }
591             if ($.isArray(processedResults)) {
592                 // Add all the new ones returned from ajax.
593                 $.each(processedResults, function(resultIndex, result) {
594                     if (existingValues.indexOf(String(result.value)) === -1) {
595                         var option = $('<option>');
596                         option.append(result.label);
597                         option.attr('value', result.value);
598                         originalSelect.append(option);
599                     }
600                 });
601                 originalSelect.attr('data-notice', '');
602             } else {
603                 // The AJAX handler returned a string instead of the array.
604                 originalSelect.attr('data-notice', processedResults);
605             }
606             // Update the list of suggestions now from the new values in the select list.
607             pendingPromise.resolve(updateSuggestions(options, state, '', originalSelect));
608         }, function(error) {
609             pendingPromise.reject(error);
610         });
612         return pendingPromise;
613     };
615     /**
616      * Add all the event listeners required for keyboard nav, blur clicks etc.
617      *
618      * @method addNavigation
619      * @private
620      * @param {Object} options The options used to create this autocomplete element.
621      * @param {Object} state State variables for this autocomplete element.
622      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
623      */
624     var addNavigation = function(options, state, originalSelect) {
625         // Start with the input element.
626         var inputElement = $(document.getElementById(state.inputId));
627         // Add keyboard nav with keydown.
628         inputElement.on('keydown', function(e) {
629             var pendingJsPromise = addPendingJSPromise('addNavigation-' + state.inputId + '-' + e.keyCode);
631             switch (e.keyCode) {
632                 case KEYS.DOWN:
633                     // If the suggestion list is open, move to the next item.
634                     if (!options.showSuggestions) {
635                         // Do not consume this event.
636                         pendingJsPromise.resolve();
637                         return true;
638                     } else if (inputElement.attr('aria-expanded') === "true") {
639                         pendingJsPromise.resolve(activateNextItem(state));
640                     } else {
641                         // Handle ajax population of suggestions.
642                         if (!inputElement.val() && options.ajax) {
643                             require([options.ajax], function(ajaxHandler) {
644                                 pendingJsPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
645                             });
646                         } else {
647                             // Open the suggestions list.
648                             pendingJsPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
649                         }
650                     }
651                     // We handled this event, so prevent it.
652                     e.preventDefault();
653                     return false;
654                 case KEYS.UP:
655                     // Choose the previous active item.
656                     pendingJsPromise.resolve(activatePreviousItem(state));
658                     // We handled this event, so prevent it.
659                     e.preventDefault();
660                     return false;
661                 case KEYS.ENTER:
662                     var suggestionsElement = $(document.getElementById(state.suggestionsId));
663                     if ((inputElement.attr('aria-expanded') === "true") &&
664                             (suggestionsElement.children('[aria-selected=true]').length > 0)) {
665                         // If the suggestion list has an active item, select it.
666                         pendingJsPromise.resolve(selectCurrentItem(options, state, originalSelect));
667                     } else if (options.tags) {
668                         // If tags are enabled, create a tag.
669                         pendingJsPromise.resolve(createItem(options, state, originalSelect));
670                     } else {
671                         pendingJsPromise.resolve();
672                     }
674                     // We handled this event, so prevent it.
675                     e.preventDefault();
676                     return false;
677                 case KEYS.ESCAPE:
678                     if (inputElement.attr('aria-expanded') === "true") {
679                         // If the suggestion list is open, close it.
680                         pendingJsPromise.resolve(closeSuggestions(state));
681                     } else {
682                         pendingJsPromise.resolve();
683                     }
684                     // We handled this event, so prevent it.
685                     e.preventDefault();
686                     return false;
687             }
688             pendingJsPromise.resolve();
689             return true;
690         });
691         // Support multi lingual COMMA keycode (44).
692         inputElement.on('keypress', function(e) {
694             if (e.keyCode === KEYS.COMMA) {
695                 if (options.tags) {
696                     // If we are allowing tags, comma should create a tag (or enter).
697                     addPendingJSPromise('keypress-' + e.keyCode)
698                     .resolve(createItem(options, state, originalSelect));
699                 }
700                 // We handled this event, so prevent it.
701                 e.preventDefault();
702                 return false;
703             }
704             return true;
705         });
706         // Support submitting the form without leaving the autocomplete element,
707         // or submitting too quick before the blur handler action is completed.
708         inputElement.closest('form').on('submit', function() {
709             if (options.tags) {
710                 // If tags are enabled, create a tag.
711                 addPendingJSPromise('form-autocomplete-submit')
712                 .resolve(createItem(options, state, originalSelect));
713             }
715             return true;
716         });
717         inputElement.on('blur', function() {
718             var pendingPromise = addPendingJSPromise('form-autocomplete-blur');
719             window.setTimeout(function() {
720                 // Get the current element with focus.
721                 var focusElement = $(document.activeElement);
722                 var timeoutPromise = $.Deferred();
724                 // Only close the menu if the input hasn't regained focus and if the element still exists,
725                 // and regain focus if the scrollbar is clicked.
726                 // Due to the half a second delay, it is possible that the input element no longer exist
727                 // by the time this code is being executed.
728                 if (focusElement.is(document.getElementById(state.suggestionsId))) {
729                     inputElement.focus(); // Probably the scrollbar is clicked. Regain focus.
730                 } else if (!focusElement.is(inputElement) && $(document.getElementById(state.inputId)).length) {
731                     if (options.tags) {
732                         timeoutPromise.then(function() {
733                             return createItem(options, state, originalSelect);
734                         })
735                         .catch();
736                     }
737                     timeoutPromise.then(function() {
738                         return closeSuggestions(state);
739                     })
740                     .catch();
741                 }
743                 timeoutPromise.then(function() {
744                     return pendingPromise.resolve();
745                 })
746                 .catch();
747                 timeoutPromise.resolve();
748             }, 500);
749         });
750         if (options.showSuggestions) {
751             var arrowElement = $(document.getElementById(state.downArrowId));
752             arrowElement.on('click', function(e) {
753                 var pendingPromise = addPendingJSPromise('form-autocomplete-show-suggestions');
755                 // Prevent the close timer, or we will open, then close the suggestions.
756                 inputElement.focus();
758                 // Handle ajax population of suggestions.
759                 if (!inputElement.val() && options.ajax) {
760                     require([options.ajax], function(ajaxHandler) {
761                         pendingPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
762                     });
763                 } else {
764                     // Else - open the suggestions list.
765                     pendingPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
766                 }
767             });
768         }
770         var suggestionsElement = $(document.getElementById(state.suggestionsId));
771         // Remove any click handler first.
772         suggestionsElement.parent().prop("onclick", null).off("click");
773         suggestionsElement.parent().on('click', '[role=option]', function(e) {
774             var pendingPromise = addPendingJSPromise('form-autocomplete-parent');
775             // Handle clicks on suggestions.
776             var element = $(e.currentTarget).closest('[role=option]');
777             var suggestionsElement = $(document.getElementById(state.suggestionsId));
778             // Find the index of the clicked on suggestion.
779             var current = suggestionsElement.children('[aria-hidden=false]').index(element);
781             // Activate it.
782             activateItem(current, state)
783             .then(function() {
784                 // And select it.
785                 return selectCurrentItem(options, state, originalSelect);
786             })
787             .then(function() {
788                 return pendingPromise.resolve();
789             })
790             .catch();
791         });
792         var selectionElement = $(document.getElementById(state.selectionId));
793         // Handle clicks on the selected items (will unselect an item).
794         selectionElement.on('click', '[role=listitem]', function(e) {
795             var pendingPromise = addPendingJSPromise('form-autocomplete-clicks');
797             // Remove it from the selection.
798             pendingPromise.resolve(deselectItem(options, state, $(e.currentTarget), originalSelect));
799         });
800         // Keyboard navigation for the selection list.
801         selectionElement.on('keydown', function(e) {
802             var pendingPromise = addPendingJSPromise('form-autocomplete-keydown-' + e.keyCode);
803             switch (e.keyCode) {
804                 case KEYS.DOWN:
805                     // We handled this event, so prevent it.
806                     e.preventDefault();
808                     // Choose the next selection item.
809                     pendingPromise.resolve(activateNextSelection(state));
810                     return false;
811                 case KEYS.UP:
812                     // We handled this event, so prevent it.
813                     e.preventDefault();
815                     // Choose the previous selection item.
816                     pendingPromise.resolve(activatePreviousSelection(state));
817                     return false;
818                 case KEYS.SPACE:
819                 case KEYS.ENTER:
820                     // Get the item that is currently selected.
821                     var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection=true]');
822                     if (selectedItem) {
823                         e.preventDefault();
825                         // Unselect this item.
826                         pendingPromise.resolve(deselectItem(options, state, selectedItem, originalSelect));
827                     }
828                     return false;
829             }
831             // Not handled. Resolve the promise.
832             pendingPromise.resolve();
833             return true;
834         });
835         // Whenever the input field changes, update the suggestion list.
836         if (options.showSuggestions) {
837             // If this field uses ajax, set it up.
838             if (options.ajax) {
839                 require([options.ajax], function(ajaxHandler) {
840                     // Creating throttled handlers free of race conditions, and accurate.
841                     // This code keeps track of a throttleTimeout, which is periodically polled.
842                     // Once the throttled function is executed, the fact that it is running is noted.
843                     // If a subsequent request comes in whilst it is running, this request is re-applied.
844                     var throttleTimeout = null;
845                     var inProgress = false;
846                     var pendingKey = 'autocomplete-throttledhandler';
847                     var handler = function(e) {
848                         // Empty the current timeout.
849                         throttleTimeout = null;
851                         // Mark this request as in-progress.
852                         inProgress = true;
854                         // Process the request.
855                         updateAjax(e, options, state, originalSelect, ajaxHandler)
856                         .then(function() {
857                             // Check if the throttleTimeout is still empty.
858                             // There's a potential condition whereby the JS request takes long enough to complete that
859                             // another task has been queued.
860                             // In this case another task will be kicked off and we must wait for that before marking htis as
861                             // complete.
862                             if (null === throttleTimeout) {
863                                 // Mark this task as complete.
864                                 M.util.js_complete(pendingKey);
865                             }
866                             inProgress = false;
868                             return arguments[0];
869                         })
870                         .catch(notification.exception);
871                     };
873                     // For input events, we do not want to trigger many, many updates.
874                     var throttledHandler = function(e) {
875                         window.clearTimeout(throttleTimeout);
876                         if (inProgress) {
877                             // A request is currently ongoing.
878                             // Delay this request another 100ms.
879                             throttleTimeout = window.setTimeout(throttledHandler.bind(this, e), 100);
880                             return;
881                         }
883                         if (throttleTimeout === null) {
884                             // There is currently no existing timeout handler, and it has not been recently cleared, so
885                             // this is the start of a throttling check.
886                             M.util.js_pending(pendingKey);
887                         }
889                         // There is currently no existing timeout handler, and it has not been recently cleared, so this
890                         // is the start of a throttling check.
891                         // Queue a call to the handler.
892                         throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
893                     };
895                     // Trigger an ajax update after the text field value changes.
896                     inputElement.on("input", throttledHandler);
897                 });
898             } else {
899                 inputElement.on('input', function(e) {
900                     var query = $(e.currentTarget).val();
901                     var last = $(e.currentTarget).data('last-value');
902                     // IE11 fires many more input events than required - even when the value has not changed.
903                     // We need to only do this for real value changed events or the suggestions will be
904                     // unclickable on IE11 (because they will be rebuilt before the click event fires).
905                     // Note - because of this we cannot close the list when the query is empty or it will break
906                     // on IE11.
907                     if (last !== query) {
908                         updateSuggestions(options, state, query, originalSelect);
909                     }
910                     $(e.currentTarget).data('last-value', query);
911                 });
912             }
913         }
914     };
916     /**
917      * Create and return an unresolved Promise for some pending JS.
918      *
919      * @param   {String} key The unique identifier for this promise
920      * @return  {Promise}
921      */
922     var addPendingJSPromise = function(key) {
923             var pendingKey = 'form-autocomplete:' + key;
925             M.util.js_pending(pendingKey);
927             var pendingPromise = $.Deferred();
929             pendingPromise
930             .then(function() {
931                 M.util.js_complete(pendingKey);
933                 return arguments[0];
934             })
935             .catch(notification.exception);
937             return pendingPromise;
938     };
940     return /** @alias module:core/form-autocomplete */ {
941         // Public variables and functions.
942         /**
943          * Turn a boring select box into an auto-complete beast.
944          *
945          * @method enhance
946          * @param {string} selector The selector that identifies the select box.
947          * @param {boolean} tags Whether to allow support for tags (can define new entries).
948          * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
949          *                      module must expose 2 functions "transport" and "processResults".
950          *                      These are modeled on Select2 see: https://select2.github.io/options.html#ajax
951          * @param {String} placeholder - The text to display before a selection is made.
952          * @param {Boolean} caseSensitive - If search has to be made case sensitive.
953          * @param {Boolean} showSuggestions - If suggestions should be shown
954          * @param {String} noSelectionString - Text to display when there is no selection
955          * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection.
956          * @return {Promise}
957          */
958         enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString,
959                           closeSuggestionsOnSelect) {
960             // Set some default values.
961             var options = {
962                 selector: selector,
963                 tags: false,
964                 ajax: false,
965                 placeholder: placeholder,
966                 caseSensitive: false,
967                 showSuggestions: true,
968                 noSelectionString: noSelectionString
969             };
970             var pendingKey = 'autocomplete-setup-' + selector;
971             M.util.js_pending(pendingKey);
972             if (typeof tags !== "undefined") {
973                 options.tags = tags;
974             }
975             if (typeof ajax !== "undefined") {
976                 options.ajax = ajax;
977             }
978             if (typeof caseSensitive !== "undefined") {
979                 options.caseSensitive = caseSensitive;
980             }
981             if (typeof showSuggestions !== "undefined") {
982                 options.showSuggestions = showSuggestions;
983             }
984             if (typeof noSelectionString === "undefined") {
985                 str.get_string('noselection', 'form').done(function(result) {
986                     options.noSelectionString = result;
987                 }).fail(notification.exception);
988             }
990             // Look for the select element.
991             var originalSelect = $(selector);
992             if (!originalSelect) {
993                 log.debug('Selector not found: ' + selector);
994                 M.util.js_complete(pendingKey);
995                 return false;
996             }
998             originalSelect.css('visibility', 'hidden').attr('aria-hidden', true);
1000             // Hide the original select.
1002             // Find or generate some ids.
1003             var state = {
1004                 selectId: originalSelect.attr('id'),
1005                 inputId: 'form_autocomplete_input-' + uniqueId,
1006                 suggestionsId: 'form_autocomplete_suggestions-' + uniqueId,
1007                 selectionId: 'form_autocomplete_selection-' + uniqueId,
1008                 downArrowId: 'form_autocomplete_downarrow-' + uniqueId
1009             };
1011             // Increment the unique counter so we don't get duplicates ever.
1012             uniqueId++;
1014             options.multiple = originalSelect.attr('multiple');
1016             if (typeof closeSuggestionsOnSelect !== "undefined") {
1017                 options.closeSuggestionsOnSelect = closeSuggestionsOnSelect;
1018             } else {
1019                 // If not specified, this will close suggestions by default for single-select elements only.
1020                 options.closeSuggestionsOnSelect = !options.multiple;
1021             }
1023             var originalLabel = $('[for=' + state.selectId + ']');
1024             // Create the new markup and insert it after the select.
1025             var suggestions = [];
1026             originalSelect.children('option').each(function(index, option) {
1027                 suggestions[index] = {label: option.innerHTML, value: $(option).attr('value')};
1028             });
1030             // Render all the parts of our UI.
1031             var context = $.extend({}, options, state);
1032             context.options = suggestions;
1033             context.items = [];
1035             // Collect rendered inline JS to be executed once the HTML is shown.
1036             var collectedjs = '';
1038             var renderInput = templates.render('core/form_autocomplete_input', context).then(function(html, js) {
1039                 collectedjs += js;
1040                 return html;
1041             });
1043             var renderDatalist = templates.render('core/form_autocomplete_suggestions', context).then(function(html, js) {
1044                 collectedjs += js;
1045                 return html;
1046             });
1048             var renderSelection = templates.render('core/form_autocomplete_selection', context).then(function(html, js) {
1049                 collectedjs += js;
1050                 return html;
1051             });
1053             return $.when(renderInput, renderDatalist, renderSelection)
1054             .then(function(input, suggestions, selection) {
1055                 originalSelect.hide();
1056                 originalSelect.after(suggestions);
1057                 originalSelect.after(input);
1058                 originalSelect.after(selection);
1060                 templates.runTemplateJS(collectedjs);
1062                 // Update the form label to point to the text input.
1063                 originalLabel.attr('for', state.inputId);
1064                 // Add the event handlers.
1065                 addNavigation(options, state, originalSelect);
1067                 var suggestionsElement = $(document.getElementById(state.suggestionsId));
1068                 // Hide the suggestions by default.
1069                 suggestionsElement.hide().attr('aria-hidden', true);
1071                 return;
1072             })
1073             .then(function() {
1074                 // Show the current values in the selection list.
1075                 return updateSelectionList(options, state, originalSelect);
1076             })
1077             .then(function() {
1078                 return M.util.js_complete(pendingKey);
1079             })
1080             .catch(function(error) {
1081                 M.util.js_complete(pendingKey);
1082                 notification.exception(error);
1083             });
1084         }
1085     };
1086 });