MDL-69107 form_autocomplete: Rewrite item selection
[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', null).attr('id', '');
74         // Select only this suggestion and assign it the id.
75         element.attr('data-active-selection', true).attr('id', itemId);
77         // Tell the input field it has a new active descendant so the item is announced.
78         selectionElement.attr('aria-activedescendant', itemId);
79         selectionElement.attr('data-active-value', element.attr('data-value'));
81         return $.Deferred().resolve();
82     };
84     /**
85      * Get the actively selected element from the state object.
86      *
87      * @param   {Object} state
88      * @returns {jQuery}
89      */
90     var getActiveElementFromState = function(state) {
91         var selectionRegion = $(document.getElementById(state.selectionId));
92         var activeId = selectionRegion.attr('aria-activedescendant');
94         if (activeId) {
95             var activeElement = $(document.getElementById(activeId));
96             if (activeElement.length) {
97                 // The active descendent still exists.
98                 return activeElement;
99             }
100         }
102         var activeValue = selectionRegion.attr('data-active-value');
103         return selectionRegion.find('[data-value="' + activeValue + '"]');
104     };
106     /**
107      * Update the active selection from the given state object.
108      *
109      * @param   {Object} state
110      */
111     var updateActiveSelectionFromState = function(state) {
112         var activeElement = getActiveElementFromState(state);
113         var activeValue = activeElement.attr('data-value');
115         var selectionRegion = $(document.getElementById(state.selectionId));
116         if (activeValue) {
117             // Find the index of the currently selected index.
118             var activeIndex = selectionRegion.find('[aria-selected=true]').index(activeElement);
120             if (activeIndex !== -1) {
121                 activateSelection(activeIndex, state);
122                 return;
123             }
124         }
126         // Either the active index was not set, or it could not be found.
127         // Select the first value instead.
128         activateSelection(0, state);
129     };
131     /**
132      * Update the element that shows the currently selected items.
133      *
134      * @method updateSelectionList
135      * @private
136      * @param {Object} options Original options for this autocomplete element.
137      * @param {Object} state State variables for this autocomplete element.
138      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
139      * @return {Promise}
140      */
141     var updateSelectionList = function(options, state, originalSelect) {
142         var pendingKey = 'form-autocomplete-updateSelectionList-' + state.inputId;
143         M.util.js_pending(pendingKey);
145         // Build up a valid context to re-render the template.
146         var items = [];
147         var newSelection = $(document.getElementById(state.selectionId));
148         originalSelect.children('option').each(function(index, ele) {
149             if ($(ele).prop('selected')) {
150                 var label;
151                 if ($(ele).data('html')) {
152                     label = $(ele).data('html');
153                 } else {
154                     label = $(ele).html();
155                 }
156                 if (label !== '') {
157                     items.push({label: label, value: $(ele).attr('value')});
158                 }
159             }
160         });
162         if (!hasItemListChanged(state, items)) {
163             M.util.js_complete(pendingKey);
164             return Promise.resolve();
165         }
167         state.items = items;
169         var context = $.extend(options, state);
170         // Render the template.
171         return templates.render(options.templates.items, context)
172         .then(function(html, js) {
173             // Add it to the page.
174             templates.replaceNodeContents(newSelection, html, js);
176             updateActiveSelectionFromState(state);
178             return;
179         })
180         .then(function() {
181             return M.util.js_complete(pendingKey);
182         })
183         .catch(notification.exception);
184     };
186     /**
187      * Check whether the list of items stored in the state has changed.
188      *
189      * @param   {Object} state
190      * @param   {Array} items
191      */
192     var hasItemListChanged = function(state, items) {
193         if (state.items.length !== items.length) {
194             return true;
195         }
197         // Check for any items in the state items which are not present in the new items list.
198         return state.items.filter(item => items.indexOf(item) === -1).length > 0;
199     };
201     /**
202      * Notify of a change in the selection.
203      *
204      * @param {jQuery} originalSelect The jQuery object matching the hidden select list.
205      */
206     var notifyChange = function(originalSelect) {
207         if (typeof M.core_formchangechecker !== 'undefined') {
208             M.core_formchangechecker.set_form_changed();
209         }
211         // Note, jQuery .change() was not working here. Better to
212         // use plain JavaScript anyway.
213         originalSelect[0].dispatchEvent(new Event('change'));
214     };
216     /**
217      * Remove the given item from the list of selected things.
218      *
219      * @method deselectItem
220      * @private
221      * @param {Object} options Original options for this autocomplete element.
222      * @param {Object} state State variables for this autocomplete element.
223      * @param {Element} item The item to be deselected.
224      * @param {Element} originalSelect The original select list.
225      * @return {Promise}
226      */
227     var deselectItem = function(options, state, item, originalSelect) {
228         var selectedItemValue = $(item).attr('data-value');
230         // Look for a match, and toggle the selected property if there is a match.
231         originalSelect.children('option').each(function(index, ele) {
232             if ($(ele).attr('value') == selectedItemValue) {
233                 $(ele).prop('selected', false);
234                 // We remove newly created custom tags from the suggestions list when they are deselected.
235                 if ($(ele).attr('data-iscustom')) {
236                     $(ele).remove();
237                 }
238             }
239         });
240         // Rerender the selection list.
241         return updateSelectionList(options, state, originalSelect)
242         .then(function() {
243             // Notify that the selection changed.
244             notifyChange(originalSelect);
246             return;
247         });
248     };
250     /**
251      * Make an item in the suggestions "active" (about to be selected).
252      *
253      * @method activateItem
254      * @private
255      * @param {Number} index The index in the current (visible) list of suggestions.
256      * @param {Object} state State variables for this instance of autocomplete.
257      * @return {Promise}
258      */
259     var activateItem = function(index, state) {
260         // Find the elements in the DOM.
261         var inputElement = $(document.getElementById(state.inputId));
262         var suggestionsElement = $(document.getElementById(state.suggestionsId));
264         // Count the visible items.
265         var length = suggestionsElement.children(':not([aria-hidden])').length;
266         // Limit the index to the upper/lower bounds of the list (wrap in both directions).
267         index = index % length;
268         while (index < 0) {
269             index += length;
270         }
271         // Find the specified element.
272         var element = $(suggestionsElement.children(':not([aria-hidden])').get(index));
273         // Find the index of this item in the full list of suggestions (including hidden).
274         var globalIndex = $(suggestionsElement.children('[role=option]')).index(element);
275         // Create an id we can assign to this element.
276         var itemId = state.suggestionsId + '-' + globalIndex;
278         // Deselect all the suggestions.
279         suggestionsElement.children().attr('aria-selected', false).attr('id', '');
280         // Select only this suggestion and assign it the id.
281         element.attr('aria-selected', true).attr('id', itemId);
282         // Tell the input field it has a new active descendant so the item is announced.
283         inputElement.attr('aria-activedescendant', itemId);
285         // Scroll it into view.
286         var scrollPos = element.offset().top
287                        - suggestionsElement.offset().top
288                        + suggestionsElement.scrollTop()
289                        - (suggestionsElement.height() / 2);
290         return suggestionsElement.animate({
291             scrollTop: scrollPos
292         }, 100).promise();
293     };
295     /**
296      * Find the index of the current active suggestion, and activate the next one.
297      *
298      * @method activateNextItem
299      * @private
300      * @param {Object} state State variable for this auto complete element.
301      * @return {Promise}
302      */
303     var activateNextItem = function(state) {
304         // Find the list of suggestions.
305         var suggestionsElement = $(document.getElementById(state.suggestionsId));
306         // Find the active one.
307         var element = suggestionsElement.children('[aria-selected=true]');
308         // Find it's index.
309         var current = suggestionsElement.children(':not([aria-hidden])').index(element);
310         // Activate the next one.
311         return activateItem(current + 1, state);
312     };
314     /**
315      * Find the index of the current active selection, and activate the previous one.
316      *
317      * @method activatePreviousSelection
318      * @private
319      * @param {Object} state State variables for this instance of autocomplete.
320      * @return {Promise}
321      */
322     var activatePreviousSelection = function(state) {
323         // Find the list of selections.
324         var selectionsElement = $(document.getElementById(state.selectionId));
325         // Find the active one.
326         var element = selectionsElement.children('[data-active-selection]');
327         if (!element) {
328             return activateSelection(0, state);
329         }
330         // Find it's index.
331         var current = selectionsElement.children('[aria-selected=true]').index(element);
332         // Activate the next one.
333         return activateSelection(current - 1, state);
334     };
336     /**
337      * Find the index of the current active selection, and activate the next one.
338      *
339      * @method activateNextSelection
340      * @private
341      * @param {Object} state State variables for this instance of autocomplete.
342      * @return {Promise}
343      */
344     var activateNextSelection = function(state) {
345         // Find the list of selections.
346         var selectionsElement = $(document.getElementById(state.selectionId));
348         // Find the active one.
349         var element = selectionsElement.children('[data-active-selection]');
350         var current = 0;
352         if (element) {
353             // The element was found. Determine the index and move to the next one.
354             current = selectionsElement.children('[aria-selected=true]').index(element);
355             current = current + 1;
356         } else {
357             // No selected item found. Move to the first.
358             current = 0;
359         }
361         return activateSelection(current, state);
362     };
364     /**
365      * Find the index of the current active suggestion, and activate the previous one.
366      *
367      * @method activatePreviousItem
368      * @private
369      * @param {Object} state State variables for this autocomplete element.
370      * @return {Promise}
371      */
372     var activatePreviousItem = function(state) {
373         // Find the list of suggestions.
374         var suggestionsElement = $(document.getElementById(state.suggestionsId));
376         // Find the active one.
377         var element = suggestionsElement.children('[aria-selected=true]');
379         // Find it's index.
380         var current = suggestionsElement.children(':not([aria-hidden])').index(element);
382         // Activate the previous one.
383         return activateItem(current - 1, state);
384     };
386     /**
387      * Close the list of suggestions.
388      *
389      * @method closeSuggestions
390      * @private
391      * @param {Object} state State variables for this autocomplete element.
392      * @return {Promise}
393      */
394     var closeSuggestions = function(state) {
395         // Find the elements in the DOM.
396         var inputElement = $(document.getElementById(state.inputId));
397         var suggestionsElement = $(document.getElementById(state.suggestionsId));
399         if (inputElement.attr('aria-expanded') === "true") {
400             // Announce the list of suggestions was closed.
401             inputElement.attr('aria-expanded', false);
402         }
403         // Read the current list of selections.
404         inputElement.attr('aria-activedescendant', state.selectionId);
406         // Hide the suggestions list (from screen readers too).
407         Aria.hide(suggestionsElement.get());
408         suggestionsElement.hide();
410         return $.Deferred().resolve();
411     };
413     /**
414      * Rebuild the list of suggestions based on the current values in the select list, and the query.
415      *
416      * @method updateSuggestions
417      * @private
418      * @param {Object} options The original options for this autocomplete.
419      * @param {Object} state The state variables for this autocomplete.
420      * @param {String} query The current text for the search string.
421      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
422      * @return {Promise}
423      */
424     var updateSuggestions = function(options, state, query, originalSelect) {
425         var pendingKey = 'form-autocomplete-updateSuggestions-' + state.inputId;
426         M.util.js_pending(pendingKey);
428         // Find the elements in the DOM.
429         var inputElement = $(document.getElementById(state.inputId));
430         var suggestionsElement = $(document.getElementById(state.suggestionsId));
432         // Used to track if we found any visible suggestions.
433         var matchingElements = false;
434         // Options is used by the context when rendering the suggestions from a template.
435         var suggestions = [];
436         originalSelect.children('option').each(function(index, option) {
437             if ($(option).prop('selected') !== true) {
438                 suggestions[suggestions.length] = {label: option.innerHTML, value: $(option).attr('value')};
439             }
440         });
442         // Re-render the list of suggestions.
443         var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
444         var context = $.extend({options: suggestions}, options, state);
445         var returnVal = templates.render(
446             'core/form_autocomplete_suggestions',
447             context
448         )
449         .then(function(html, js) {
450             // We have the new template, insert it in the page.
451             templates.replaceNode(suggestionsElement, html, js);
453             // Get the element again.
454             suggestionsElement = $(document.getElementById(state.suggestionsId));
456             // Show it if it is hidden.
457             Aria.unhide(suggestionsElement.get());
458             suggestionsElement.show();
460             // For each option in the list, hide it if it doesn't match the query.
461             suggestionsElement.children().each(function(index, node) {
462                 node = $(node);
463                 if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) ||
464                         (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) {
465                     Aria.unhide(node.get());
466                     node.show();
467                     matchingElements = true;
468                 } else {
469                     node.hide();
470                     Aria.hide(node.get());
471                 }
472             });
473             // If we found any matches, show the list.
474             inputElement.attr('aria-expanded', true);
475             if (originalSelect.attr('data-notice')) {
476                 // Display a notice rather than actual suggestions.
477                 suggestionsElement.html(originalSelect.attr('data-notice'));
478             } else if (matchingElements) {
479                 // We only activate the first item in the list if tags is false,
480                 // because otherwise "Enter" would select the first item, instead of
481                 // creating a new tag.
482                 if (!options.tags) {
483                     activateItem(0, state);
484                 }
485             } else {
486                 // Nothing matches. Tell them that.
487                 str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) {
488                     suggestionsElement.html(nosuggestionsstr);
489                 });
490             }
492             return suggestionsElement;
493         })
494         .then(function() {
495             return M.util.js_complete(pendingKey);
496         })
497         .catch(notification.exception);
499         return returnVal;
500     };
502     /**
503      * Create a new item for the list (a tag).
504      *
505      * @method createItem
506      * @private
507      * @param {Object} options The original options for the autocomplete.
508      * @param {Object} state State variables for the autocomplete.
509      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
510      * @return {Promise}
511      */
512     var createItem = function(options, state, originalSelect) {
513         // Find the element in the DOM.
514         var inputElement = $(document.getElementById(state.inputId));
515         // Get the current text in the input field.
516         var query = inputElement.val();
517         var tags = query.split(',');
518         var found = false;
520         $.each(tags, function(tagindex, tag) {
521             // If we can only select one at a time, deselect any current value.
522             tag = tag.trim();
523             if (tag !== '') {
524                 if (!options.multiple) {
525                     originalSelect.children('option').prop('selected', false);
526                 }
527                 // Look for an existing option in the select list that matches this new tag.
528                 originalSelect.children('option').each(function(index, ele) {
529                     if ($(ele).attr('value') == tag) {
530                         found = true;
531                         $(ele).prop('selected', true);
532                     }
533                 });
534                 // Only create the item if it's new.
535                 if (!found) {
536                     var option = $('<option>');
537                     option.append(document.createTextNode(tag));
538                     option.attr('value', tag);
539                     originalSelect.append(option);
540                     option.prop('selected', true);
541                     // We mark newly created custom options as we handle them differently if they are "deselected".
542                     option.attr('data-iscustom', true);
543                 }
544             }
545         });
547         return updateSelectionList(options, state, originalSelect)
548         .then(function() {
549             // Notify that the selection changed.
550             notifyChange(originalSelect);
552             return;
553         })
554         .then(function() {
555             // Clear the input field.
556             inputElement.val('');
558             return;
559         })
560         .then(function() {
561             // Close the suggestions list.
562             return closeSuggestions(state);
563         });
564     };
566     /**
567      * Select the currently active item from the suggestions list.
568      *
569      * @method selectCurrentItem
570      * @private
571      * @param {Object} options The original options for the autocomplete.
572      * @param {Object} state State variables for the autocomplete.
573      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
574      * @return {Promise}
575      */
576     var selectCurrentItem = function(options, state, originalSelect) {
577         // Find the elements in the page.
578         var inputElement = $(document.getElementById(state.inputId));
579         var suggestionsElement = $(document.getElementById(state.suggestionsId));
580         // Here loop through suggestions and set val to join of all selected items.
582         var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value');
583         // The select will either be a single or multi select, so the following will either
584         // select one or more items correctly.
585         // Take care to use 'prop' and not 'attr' for selected properties.
586         // If only one can be selected at a time, start by deselecting everything.
587         if (!options.multiple) {
588             originalSelect.children('option').prop('selected', false);
589         }
590         // Look for a match, and toggle the selected property if there is a match.
591         originalSelect.children('option').each(function(index, ele) {
592             if ($(ele).attr('value') == selectedItemValue) {
593                 $(ele).prop('selected', true);
594             }
595         });
597         return updateSelectionList(options, state, originalSelect)
598         .then(function() {
599             // Notify that the selection changed.
600             notifyChange(originalSelect);
602             return;
603         })
604         .then(function() {
605             if (options.closeSuggestionsOnSelect) {
606                 // Clear the input element.
607                 inputElement.val('');
608                 // Close the list of suggestions.
609                 return closeSuggestions(state);
610             } else {
611                 // Focus on the input element so the suggestions does not auto-close.
612                 inputElement.focus();
613                 // Remove the last selected item from the suggestions list.
614                 return updateSuggestions(options, state, inputElement.val(), originalSelect);
615             }
616         });
617     };
619     /**
620      * Fetch a new list of options via ajax.
621      *
622      * @method updateAjax
623      * @private
624      * @param {Event} e The event that triggered this update.
625      * @param {Object} options The original options for the autocomplete.
626      * @param {Object} state The state variables for the autocomplete.
627      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
628      * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
629      * @return {Promise}
630      */
631     var updateAjax = function(e, options, state, originalSelect, ajaxHandler) {
632         var pendingPromise = addPendingJSPromise('updateAjax');
633         // We need to show the indicator outside of the hidden select list.
634         // So we get the parent id of the hidden select list.
635         var parentElement = $(document.getElementById(state.selectId)).parent();
636         LoadingIcon.addIconToContainerRemoveOnCompletion(parentElement, pendingPromise);
638         // Get the query to pass to the ajax function.
639         var query = $(e.currentTarget).val();
640         // Call the transport function to do the ajax (name taken from Select2).
641         ajaxHandler.transport(options.selector, query, function(results) {
642             // We got a result - pass it through the translator before using it.
643             var processedResults = ajaxHandler.processResults(options.selector, results);
644             var existingValues = [];
646             // Now destroy all options that are not currently selected.
647             if (!options.multiple) {
648                 originalSelect.children('option').remove();
649             }
650             originalSelect.children('option').each(function(optionIndex, option) {
651                 option = $(option);
652                 if (!option.prop('selected')) {
653                     option.remove();
654                 } else {
655                     existingValues.push(String(option.attr('value')));
656                 }
657             });
659             if (!options.multiple && originalSelect.children('option').length === 0) {
660                 // If this is a single select - and there are no current options
661                 // the first option added will be selected by the browser. This causes a bug!
662                 // We need to insert an empty option so that none of the real options are selected.
663                 var option = $('<option>');
664                 originalSelect.append(option);
665             }
666             if ($.isArray(processedResults)) {
667                 // Add all the new ones returned from ajax.
668                 $.each(processedResults, function(resultIndex, result) {
669                     if (existingValues.indexOf(String(result.value)) === -1) {
670                         var option = $('<option>');
671                         option.append(result.label);
672                         option.attr('value', result.value);
673                         originalSelect.append(option);
674                     }
675                 });
676                 originalSelect.attr('data-notice', '');
677             } else {
678                 // The AJAX handler returned a string instead of the array.
679                 originalSelect.attr('data-notice', processedResults);
680             }
681             // Update the list of suggestions now from the new values in the select list.
682             pendingPromise.resolve(updateSuggestions(options, state, '', originalSelect));
683         }, function(error) {
684             pendingPromise.reject(error);
685         });
687         return pendingPromise;
688     };
690     /**
691      * Add all the event listeners required for keyboard nav, blur clicks etc.
692      *
693      * @method addNavigation
694      * @private
695      * @param {Object} options The options used to create this autocomplete element.
696      * @param {Object} state State variables for this autocomplete element.
697      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
698      */
699     var addNavigation = function(options, state, originalSelect) {
700         // Start with the input element.
701         var inputElement = $(document.getElementById(state.inputId));
702         // Add keyboard nav with keydown.
703         inputElement.on('keydown', function(e) {
704             var pendingJsPromise = addPendingJSPromise('addNavigation-' + state.inputId + '-' + e.keyCode);
706             switch (e.keyCode) {
707                 case KEYS.DOWN:
708                     // If the suggestion list is open, move to the next item.
709                     if (!options.showSuggestions) {
710                         // Do not consume this event.
711                         pendingJsPromise.resolve();
712                         return true;
713                     } else if (inputElement.attr('aria-expanded') === "true") {
714                         pendingJsPromise.resolve(activateNextItem(state));
715                     } else {
716                         // Handle ajax population of suggestions.
717                         if (!inputElement.val() && options.ajax) {
718                             require([options.ajax], function(ajaxHandler) {
719                                 pendingJsPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
720                             });
721                         } else {
722                             // Open the suggestions list.
723                             pendingJsPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
724                         }
725                     }
726                     // We handled this event, so prevent it.
727                     e.preventDefault();
728                     return false;
729                 case KEYS.UP:
730                     // Choose the previous active item.
731                     pendingJsPromise.resolve(activatePreviousItem(state));
733                     // We handled this event, so prevent it.
734                     e.preventDefault();
735                     return false;
736                 case KEYS.ENTER:
737                     var suggestionsElement = $(document.getElementById(state.suggestionsId));
738                     if ((inputElement.attr('aria-expanded') === "true") &&
739                             (suggestionsElement.children('[aria-selected=true]').length > 0)) {
740                         // If the suggestion list has an active item, select it.
741                         pendingJsPromise.resolve(selectCurrentItem(options, state, originalSelect));
742                     } else if (options.tags) {
743                         // If tags are enabled, create a tag.
744                         pendingJsPromise.resolve(createItem(options, state, originalSelect));
745                     } else {
746                         pendingJsPromise.resolve();
747                     }
749                     // We handled this event, so prevent it.
750                     e.preventDefault();
751                     return false;
752                 case KEYS.ESCAPE:
753                     if (inputElement.attr('aria-expanded') === "true") {
754                         // If the suggestion list is open, close it.
755                         pendingJsPromise.resolve(closeSuggestions(state));
756                     } else {
757                         pendingJsPromise.resolve();
758                     }
759                     // We handled this event, so prevent it.
760                     e.preventDefault();
761                     return false;
762             }
763             pendingJsPromise.resolve();
764             return true;
765         });
766         // Support multi lingual COMMA keycode (44).
767         inputElement.on('keypress', function(e) {
769             if (e.keyCode === KEYS.COMMA) {
770                 if (options.tags) {
771                     // If we are allowing tags, comma should create a tag (or enter).
772                     addPendingJSPromise('keypress-' + e.keyCode)
773                     .resolve(createItem(options, state, originalSelect));
774                 }
775                 // We handled this event, so prevent it.
776                 e.preventDefault();
777                 return false;
778             }
779             return true;
780         });
781         // Support submitting the form without leaving the autocomplete element,
782         // or submitting too quick before the blur handler action is completed.
783         inputElement.closest('form').on('submit', function() {
784             if (options.tags) {
785                 // If tags are enabled, create a tag.
786                 addPendingJSPromise('form-autocomplete-submit')
787                 .resolve(createItem(options, state, originalSelect));
788             }
790             return true;
791         });
792         inputElement.on('blur', function() {
793             var pendingPromise = addPendingJSPromise('form-autocomplete-blur');
794             window.setTimeout(function() {
795                 // Get the current element with focus.
796                 var focusElement = $(document.activeElement);
797                 var timeoutPromise = $.Deferred();
799                 // Only close the menu if the input hasn't regained focus and if the element still exists,
800                 // and regain focus if the scrollbar is clicked.
801                 // Due to the half a second delay, it is possible that the input element no longer exist
802                 // by the time this code is being executed.
803                 if (focusElement.is(document.getElementById(state.suggestionsId))) {
804                     inputElement.focus(); // Probably the scrollbar is clicked. Regain focus.
805                 } else if (!focusElement.is(inputElement) && $(document.getElementById(state.inputId)).length) {
806                     if (options.tags) {
807                         timeoutPromise.then(function() {
808                             return createItem(options, state, originalSelect);
809                         })
810                         .catch();
811                     }
812                     timeoutPromise.then(function() {
813                         return closeSuggestions(state);
814                     })
815                     .catch();
816                 }
818                 timeoutPromise.then(function() {
819                     return pendingPromise.resolve();
820                 })
821                 .catch();
822                 timeoutPromise.resolve();
823             }, 500);
824         });
825         if (options.showSuggestions) {
826             var arrowElement = $(document.getElementById(state.downArrowId));
827             arrowElement.on('click', function(e) {
828                 var pendingPromise = addPendingJSPromise('form-autocomplete-show-suggestions');
830                 // Prevent the close timer, or we will open, then close the suggestions.
831                 inputElement.focus();
833                 // Handle ajax population of suggestions.
834                 if (!inputElement.val() && options.ajax) {
835                     require([options.ajax], function(ajaxHandler) {
836                         pendingPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
837                     });
838                 } else {
839                     // Else - open the suggestions list.
840                     pendingPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
841                 }
842             });
843         }
845         var suggestionsElement = $(document.getElementById(state.suggestionsId));
846         // Remove any click handler first.
847         suggestionsElement.parent().prop("onclick", null).off("click");
848         suggestionsElement.parent().on('click', `#${state.suggestionsId} [role=option]`, function(e) {
849             var pendingPromise = addPendingJSPromise('form-autocomplete-parent');
850             // Handle clicks on suggestions.
851             var element = $(e.currentTarget).closest('[role=option]');
852             var suggestionsElement = $(document.getElementById(state.suggestionsId));
853             // Find the index of the clicked on suggestion.
854             var current = suggestionsElement.children(':not([aria-hidden])').index(element);
856             // Activate it.
857             activateItem(current, state)
858             .then(function() {
859                 // And select it.
860                 return selectCurrentItem(options, state, originalSelect);
861             })
862             .then(function() {
863                 return pendingPromise.resolve();
864             })
865             .catch();
866         });
867         var selectionElement = $(document.getElementById(state.selectionId));
869         // Handle clicks on the selected items (will unselect an item).
870         selectionElement.on('click', '[role=option]', function(e) {
871             var pendingPromise = addPendingJSPromise('form-autocomplete-clicks');
873             // Remove it from the selection.
874             pendingPromise.resolve(deselectItem(options, state, $(e.currentTarget), originalSelect));
875         });
877         // When listbox is focused, focus on the first option if there is no focused option.
878         selectionElement.on('focus', function() {
879             updateActiveSelectionFromState(state);
880         });
882         // Keyboard navigation for the selection list.
883         selectionElement.on('keydown', function(e) {
884             var pendingPromise = addPendingJSPromise('form-autocomplete-keydown-' + e.keyCode);
885             switch (e.keyCode) {
886                 case KEYS.RIGHT:
887                 case KEYS.DOWN:
888                     // We handled this event, so prevent it.
889                     e.preventDefault();
891                     // Choose the next selection item.
892                     pendingPromise.resolve(activateNextSelection(state));
893                     return;
894                 case KEYS.LEFT:
895                 case KEYS.UP:
896                     // We handled this event, so prevent it.
897                     e.preventDefault();
899                     // Choose the previous selection item.
900                     pendingPromise.resolve(activatePreviousSelection(state));
901                     return;
902                 case KEYS.SPACE:
903                 case KEYS.ENTER:
904                     // Get the item that is currently selected.
905                     var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection]');
906                     if (selectedItem) {
907                         e.preventDefault();
909                         // Unselect this item.
910                         pendingPromise.resolve(deselectItem(options, state, selectedItem, originalSelect));
911                     }
912                     return;
913             }
915             // Not handled. Resolve the promise.
916             pendingPromise.resolve();
917         });
918         // Whenever the input field changes, update the suggestion list.
919         if (options.showSuggestions) {
920             // Store the value of the field as its last value, when the field gains focus.
921             inputElement.on('focus', function(e) {
922                 var query = $(e.currentTarget).val();
923                 $(e.currentTarget).data('last-value', query);
924             });
926             // If this field uses ajax, set it up.
927             if (options.ajax) {
928                 require([options.ajax], function(ajaxHandler) {
929                     // Creating throttled handlers free of race conditions, and accurate.
930                     // This code keeps track of a throttleTimeout, which is periodically polled.
931                     // Once the throttled function is executed, the fact that it is running is noted.
932                     // If a subsequent request comes in whilst it is running, this request is re-applied.
933                     var throttleTimeout = null;
934                     var inProgress = false;
935                     var pendingKey = 'autocomplete-throttledhandler';
936                     var handler = function(e) {
937                         // Empty the current timeout.
938                         throttleTimeout = null;
940                         // Mark this request as in-progress.
941                         inProgress = true;
943                         // Process the request.
944                         updateAjax(e, options, state, originalSelect, ajaxHandler)
945                         .then(function() {
946                             // Check if the throttleTimeout is still empty.
947                             // There's a potential condition whereby the JS request takes long enough to complete that
948                             // another task has been queued.
949                             // In this case another task will be kicked off and we must wait for that before marking htis as
950                             // complete.
951                             if (null === throttleTimeout) {
952                                 // Mark this task as complete.
953                                 M.util.js_complete(pendingKey);
954                             }
955                             inProgress = false;
957                             return arguments[0];
958                         })
959                         .catch(notification.exception);
960                     };
962                     // For input events, we do not want to trigger many, many updates.
963                     var throttledHandler = function(e) {
964                         window.clearTimeout(throttleTimeout);
965                         if (inProgress) {
966                             // A request is currently ongoing.
967                             // Delay this request another 100ms.
968                             throttleTimeout = window.setTimeout(throttledHandler.bind(this, e), 100);
969                             return;
970                         }
972                         if (throttleTimeout === null) {
973                             // There is currently no existing timeout handler, and it has not been recently cleared, so
974                             // this is the start of a throttling check.
975                             M.util.js_pending(pendingKey);
976                         }
978                         // There is currently no existing timeout handler, and it has not been recently cleared, so this
979                         // is the start of a throttling check.
980                         // Queue a call to the handler.
981                         throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
982                     };
984                     // Trigger an ajax update after the text field value changes.
985                     inputElement.on('input', function(e) {
986                         var query = $(e.currentTarget).val();
987                         var last = $(e.currentTarget).data('last-value');
988                         // IE11 fires many more input events than required - even when the value has not changed.
989                         if (last !== query) {
990                             throttledHandler(e);
991                         }
992                         $(e.currentTarget).data('last-value', query);
993                     });
994                 });
995             } else {
996                 inputElement.on('input', function(e) {
997                     var query = $(e.currentTarget).val();
998                     var last = $(e.currentTarget).data('last-value');
999                     // IE11 fires many more input events than required - even when the value has not changed.
1000                     // We need to only do this for real value changed events or the suggestions will be
1001                     // unclickable on IE11 (because they will be rebuilt before the click event fires).
1002                     // Note - because of this we cannot close the list when the query is empty or it will break
1003                     // on IE11.
1004                     if (last !== query) {
1005                         updateSuggestions(options, state, query, originalSelect);
1006                     }
1007                     $(e.currentTarget).data('last-value', query);
1008                 });
1009             }
1010         }
1011     };
1013     /**
1014      * Create and return an unresolved Promise for some pending JS.
1015      *
1016      * @param   {String} key The unique identifier for this promise
1017      * @return  {Promise}
1018      */
1019     var addPendingJSPromise = function(key) {
1020             var pendingKey = 'form-autocomplete:' + key;
1022             M.util.js_pending(pendingKey);
1024             var pendingPromise = $.Deferred();
1026             pendingPromise
1027             .then(function() {
1028                 M.util.js_complete(pendingKey);
1030                 return arguments[0];
1031             })
1032             .catch(notification.exception);
1034             return pendingPromise;
1035     };
1037     return /** @alias module:core/form-autocomplete */ {
1038         // Public variables and functions.
1039         /**
1040          * Turn a boring select box into an auto-complete beast.
1041          *
1042          * @method enhance
1043          * @param {string} selector The selector that identifies the select box.
1044          * @param {boolean} tags Whether to allow support for tags (can define new entries).
1045          * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
1046          *                      module must expose 2 functions "transport" and "processResults".
1047          *                      These are modeled on Select2 see: https://select2.github.io/options.html#ajax
1048          * @param {String} placeholder - The text to display before a selection is made.
1049          * @param {Boolean} caseSensitive - If search has to be made case sensitive.
1050          * @param {Boolean} showSuggestions - If suggestions should be shown
1051          * @param {String} noSelectionString - Text to display when there is no selection
1052          * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection.
1053          * @param {Object} templateOverrides A set of templates to use instead of the standard templates
1054          * @return {Promise}
1055          */
1056         enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString,
1057                           closeSuggestionsOnSelect, templateOverrides) {
1058             // Set some default values.
1059             var options = {
1060                 selector: selector,
1061                 tags: false,
1062                 ajax: false,
1063                 placeholder: placeholder,
1064                 caseSensitive: false,
1065                 showSuggestions: true,
1066                 noSelectionString: noSelectionString,
1067                 templates: $.extend({
1068                         input: 'core/form_autocomplete_input',
1069                         items: 'core/form_autocomplete_selection_items',
1070                         layout: 'core/form_autocomplete_layout',
1071                         selection: 'core/form_autocomplete_selection',
1072                         suggestions: 'core/form_autocomplete_suggestions',
1073                     }, templateOverrides),
1074             };
1075             var pendingKey = 'autocomplete-setup-' + selector;
1076             M.util.js_pending(pendingKey);
1077             if (typeof tags !== "undefined") {
1078                 options.tags = tags;
1079             }
1080             if (typeof ajax !== "undefined") {
1081                 options.ajax = ajax;
1082             }
1083             if (typeof caseSensitive !== "undefined") {
1084                 options.caseSensitive = caseSensitive;
1085             }
1086             if (typeof showSuggestions !== "undefined") {
1087                 options.showSuggestions = showSuggestions;
1088             }
1089             if (typeof noSelectionString === "undefined") {
1090                 str.get_string('noselection', 'form').done(function(result) {
1091                     options.noSelectionString = result;
1092                 }).fail(notification.exception);
1093             }
1095             // Look for the select element.
1096             var originalSelect = $(selector);
1097             if (!originalSelect) {
1098                 log.debug('Selector not found: ' + selector);
1099                 M.util.js_complete(pendingKey);
1100                 return false;
1101             }
1103             Aria.hide(originalSelect.get());
1104             originalSelect.css('visibility', 'hidden');
1106             // Hide the original select.
1108             // Find or generate some ids.
1109             var state = {
1110                 selectId: originalSelect.attr('id'),
1111                 inputId: 'form_autocomplete_input-' + uniqueId,
1112                 suggestionsId: 'form_autocomplete_suggestions-' + uniqueId,
1113                 selectionId: 'form_autocomplete_selection-' + uniqueId,
1114                 downArrowId: 'form_autocomplete_downarrow-' + uniqueId,
1115                 items: [],
1116             };
1118             // Increment the unique counter so we don't get duplicates ever.
1119             uniqueId++;
1121             options.multiple = originalSelect.attr('multiple');
1122             if (!options.multiple) {
1123                 // If this is a single select then there is no way to de-select the current value -
1124                 // unless we add a bogus blank option to be selected when nothing else is.
1125                 // This matches similar code in updateAjax above.
1126                 originalSelect.prepend('<option>');
1127             }
1129             if (typeof closeSuggestionsOnSelect !== "undefined") {
1130                 options.closeSuggestionsOnSelect = closeSuggestionsOnSelect;
1131             } else {
1132                 // If not specified, this will close suggestions by default for single-select elements only.
1133                 options.closeSuggestionsOnSelect = !options.multiple;
1134             }
1136             var originalLabel = $('[for=' + state.selectId + ']');
1137             // Create the new markup and insert it after the select.
1138             var suggestions = [];
1139             originalSelect.children('option').each(function(index, option) {
1140                 suggestions[index] = {label: option.innerHTML, value: $(option).attr('value')};
1141             });
1143             // Render all the parts of our UI.
1144             var context = $.extend({}, options, state);
1145             context.options = suggestions;
1146             context.items = [];
1148             // Collect rendered inline JS to be executed once the HTML is shown.
1149             var collectedjs = '';
1151             var renderLayout = templates.render(options.templates.layout, {})
1152             .then(function(html) {
1153                 return $(html);
1154             });
1156             var renderInput = templates.render(options.templates.input, context).then(function(html, js) {
1157                 collectedjs += js;
1158                 return $(html);
1159             });
1161             var renderDatalist = templates.render(options.templates.suggestions, context).then(function(html, js) {
1162                 collectedjs += js;
1163                 return $(html);
1164             });
1166             var renderSelection = templates.render(options.templates.selection, context).then(function(html, js) {
1167                 collectedjs += js;
1168                 return $(html);
1169             });
1171             return $.when(renderLayout, renderInput, renderDatalist, renderSelection)
1172             .then(function(layout, input, suggestions, selection) {
1173                 originalSelect.hide();
1174                 var container = originalSelect.parent();
1176                 // Ensure that the data-fieldtype is set for behat.
1177                 $(input).find('input').attr('data-fieldtype', 'autocomplete');
1179                 container.append(layout);
1180                 container.find('[data-region="form_autocomplete-input"]').replaceWith(input);
1181                 container.find('[data-region="form_autocomplete-suggestions"]').replaceWith(suggestions);
1182                 container.find('[data-region="form_autocomplete-selection"]').replaceWith(selection);
1184                 templates.runTemplateJS(collectedjs);
1186                 // Update the form label to point to the text input.
1187                 originalLabel.attr('for', state.inputId);
1188                 // Add the event handlers.
1189                 addNavigation(options, state, originalSelect);
1191                 var suggestionsElement = $(document.getElementById(state.suggestionsId));
1192                 // Hide the suggestions by default.
1193                 suggestionsElement.hide();
1194                 Aria.hide(suggestionsElement.get());
1196                 return;
1197             })
1198             .then(function() {
1199                 // Show the current values in the selection list.
1200                 return updateSelectionList(options, state, originalSelect);
1201             })
1202             .then(function() {
1203                 return M.util.js_complete(pendingKey);
1204             })
1205             .catch(function(error) {
1206                 M.util.js_complete(pendingKey);
1207                 notification.exception(error);
1208             });
1209         }
1210     };
1211 });