MDL-59366 core_amd: Modifications for form-autocomplete
[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(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification'], function($, log, str, templates, notification) {
29     // Private functions and variables.
30     /** @var {Object} KEYS - List of keycode constants. */
31     var KEYS = {
32         DOWN: 40,
33         ENTER: 13,
34         SPACE: 32,
35         ESCAPE: 27,
36         COMMA: 44,
37         UP: 38
38     };
40     /**
41      * Make an item in the selection list "active".
42      *
43      * @method activateSelection
44      * @private
45      * @param {Number} index The index in the current (visible) list of selection.
46      * @param {Object} state State variables for this autocomplete element.
47      */
48     var activateSelection = function(index, state) {
49         // Find the elements in the DOM.
50         var selectionElement = $(document.getElementById(state.selectionId));
52         // Count the visible items.
53         var length = selectionElement.children('[aria-selected=true]').length;
54         // Limit the index to the upper/lower bounds of the list (wrap in both directions).
55         index = index % length;
56         while (index < 0) {
57             index += length;
58         }
59         // Find the specified element.
60         var element = $(selectionElement.children('[aria-selected=true]').get(index));
61         // Create an id we can assign to this element.
62         var itemId = state.selectionId + '-' + index;
64         // Deselect all the selections.
65         selectionElement.children().attr('data-active-selection', false).attr('id', '');
66         // Select only this suggestion and assign it the id.
67         element.attr('data-active-selection', true).attr('id', itemId);
68         // Tell the input field it has a new active descendant so the item is announced.
69         selectionElement.attr('aria-activedescendant', itemId);
70     };
72     /**
73      * Update the element that shows the currently selected items.
74      *
75      * @method updateSelectionList
76      * @private
77      * @param {Object} options Original options for this autocomplete element.
78      * @param {Object} state State variables for this autocomplete element.
79      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
80      */
81     var updateSelectionList = function(options, state, originalSelect) {
82         // Build up a valid context to re-render the template.
83         var items = [];
84         var newSelection = $(document.getElementById(state.selectionId));
85         var activeId = newSelection.attr('aria-activedescendant');
86         var activeValue = false;
88         if (activeId) {
89             activeValue = $(document.getElementById(activeId)).attr('data-value');
90         }
91         originalSelect.children('option').each(function(index, ele) {
92             if ($(ele).prop('selected')) {
93                 items.push({label: $(ele).html(), value: $(ele).attr('value')});
94             }
95         });
96         var context = $.extend({items: items}, options, state);
98         // Render the template.
99         templates.render('core/form_autocomplete_selection', context).done(function(newHTML) {
100             // Add it to the page.
101             newSelection.empty().append($(newHTML).html());
103             if (activeValue !== false) {
104                 // Reselect any previously selected item.
105                 newSelection.children('[aria-selected=true]').each(function(index, ele) {
106                     if ($(ele).attr('data-value') === activeValue) {
107                         activateSelection(index, state);
108                     }
109                 });
110             }
111         }).fail(notification.exception);
112     };
114     /**
115      * Notify of a change in the selection.
116      *
117      * @param {jQuery} originalSelect The jQuery object matching the hidden select list.
118      */
119     var notifyChange = function(originalSelect) {
120         if (typeof M.core_formchangechecker !== 'undefined') {
121             M.core_formchangechecker.set_form_changed();
122         }
123         originalSelect.change();
124     };
126     /**
127      * Remove the given item from the list of selected things.
128      *
129      * @method deselectItem
130      * @private
131      * @param {Object} options Original options for this autocomplete element.
132      * @param {Object} state State variables for this autocomplete element.
133      * @param {Element} item The item to be deselected.
134      * @param {Element} originalSelect The original select list.
135      */
136     var deselectItem = function(options, state, item, originalSelect) {
137         var selectedItemValue = $(item).attr('data-value');
139         // We can only deselect items if this is a multi-select field.
140         if (options.multiple) {
141             // Look for a match, and toggle the selected property if there is a match.
142             originalSelect.children('option').each(function(index, ele) {
143                 if ($(ele).attr('value') == selectedItemValue) {
144                     $(ele).prop('selected', false);
145                     // We remove newly created custom tags from the suggestions list when they are deselected.
146                     if ($(ele).attr('data-iscustom')) {
147                         $(ele).remove();
148                     }
149                 }
150             });
151         }
152         // Rerender the selection list.
153         updateSelectionList(options, state, originalSelect);
154         // Notifiy that the selection changed.
155         notifyChange(originalSelect);
156     };
158     /**
159      * Make an item in the suggestions "active" (about to be selected).
160      *
161      * @method activateItem
162      * @private
163      * @param {Number} index The index in the current (visible) list of suggestions.
164      * @param {Object} state State variables for this instance of autocomplete.
165      */
166     var activateItem = function(index, state) {
167         // Find the elements in the DOM.
168         var inputElement = $(document.getElementById(state.inputId));
169         var suggestionsElement = $(document.getElementById(state.suggestionsId));
171         // Count the visible items.
172         var length = suggestionsElement.children('[aria-hidden=false]').length;
173         // Limit the index to the upper/lower bounds of the list (wrap in both directions).
174         index = index % length;
175         while (index < 0) {
176             index += length;
177         }
178         // Find the specified element.
179         var element = $(suggestionsElement.children('[aria-hidden=false]').get(index));
180         // Find the index of this item in the full list of suggestions (including hidden).
181         var globalIndex = $(suggestionsElement.children('[role=option]')).index(element);
182         // Create an id we can assign to this element.
183         var itemId = state.suggestionsId + '-' + globalIndex;
185         // Deselect all the suggestions.
186         suggestionsElement.children().attr('aria-selected', false).attr('id', '');
187         // Select only this suggestion and assign it the id.
188         element.attr('aria-selected', true).attr('id', itemId);
189         // Tell the input field it has a new active descendant so the item is announced.
190         inputElement.attr('aria-activedescendant', itemId);
192         // Scroll it into view.
193         var scrollPos = element.offset().top
194                        - suggestionsElement.offset().top
195                        + suggestionsElement.scrollTop()
196                        - (suggestionsElement.height() / 2);
197         suggestionsElement.animate({
198             scrollTop: scrollPos
199         }, 100);
200     };
202     /**
203      * Find the index of the current active suggestion, and activate the next one.
204      *
205      * @method activateNextItem
206      * @private
207      * @param {Object} state State variable for this auto complete element.
208      */
209     var activateNextItem = function(state) {
210         // Find the list of suggestions.
211         var suggestionsElement = $(document.getElementById(state.suggestionsId));
212         // Find the active one.
213         var element = suggestionsElement.children('[aria-selected=true]');
214         // Find it's index.
215         var current = suggestionsElement.children('[aria-hidden=false]').index(element);
216         // Activate the next one.
217         activateItem(current + 1, state);
218     };
220     /**
221      * Find the index of the current active selection, and activate the previous one.
222      *
223      * @method activatePreviousSelection
224      * @private
225      * @param {Object} state State variables for this instance of autocomplete.
226      */
227     var activatePreviousSelection = function(state) {
228         // Find the list of selections.
229         var selectionsElement = $(document.getElementById(state.selectionId));
230         // Find the active one.
231         var element = selectionsElement.children('[data-active-selection=true]');
232         if (!element) {
233             activateSelection(0, state);
234             return;
235         }
236         // Find it's index.
237         var current = selectionsElement.children('[aria-selected=true]').index(element);
238         // Activate the next one.
239         activateSelection(current - 1, state);
240     };
241     /**
242      * Find the index of the current active selection, and activate the next one.
243      *
244      * @method activateNextSelection
245      * @private
246      * @param {Object} state State variables for this instance of autocomplete.
247      */
248     var activateNextSelection = function(state) {
249         // Find the list of selections.
250         var selectionsElement = $(document.getElementById(state.selectionId));
251         // Find the active one.
252         var element = selectionsElement.children('[data-active-selection=true]');
253         if (!element) {
254             activateSelection(0, state);
255             return;
256         }
257         // Find it's index.
258         var current = selectionsElement.children('[aria-selected=true]').index(element);
259         // Activate the next one.
260         activateSelection(current + 1, state);
261     };
263     /**
264      * Find the index of the current active suggestion, and activate the previous one.
265      *
266      * @method activatePreviousItem
267      * @private
268      * @param {Object} state State variables for this autocomplete element.
269      */
270     var activatePreviousItem = function(state) {
271         // Find the list of suggestions.
272         var suggestionsElement = $(document.getElementById(state.suggestionsId));
273         // Find the active one.
274         var element = suggestionsElement.children('[aria-selected=true]');
275         // Find it's index.
276         var current = suggestionsElement.children('[aria-hidden=false]').index(element);
277         // Activate the next one.
278         activateItem(current - 1, state);
279     };
281     /**
282      * Close the list of suggestions.
283      *
284      * @method closeSuggestions
285      * @private
286      * @param {Object} state State variables for this autocomplete element.
287      */
288     var closeSuggestions = function(state) {
289         // Find the elements in the DOM.
290         var inputElement = $(document.getElementById(state.inputId));
291         var suggestionsElement = $(document.getElementById(state.suggestionsId));
293         // Announce the list of suggestions was closed, and read the current list of selections.
294         inputElement.attr('aria-expanded', false).attr('aria-activedescendant', state.selectionId);
295         // Hide the suggestions list (from screen readers too).
296         suggestionsElement.hide().attr('aria-hidden', true);
297     };
299     /**
300      * Rebuild the list of suggestions based on the current values in the select list, and the query.
301      *
302      * @method updateSuggestions
303      * @private
304      * @param {Object} options The original options for this autocomplete.
305      * @param {Object} state The state variables for this autocomplete.
306      * @param {String} query The current text for the search string.
307      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
308      */
309     var updateSuggestions = function(options, state, query, originalSelect) {
310         // Find the elements in the DOM.
311         var inputElement = $(document.getElementById(state.inputId));
312         var suggestionsElement = $(document.getElementById(state.suggestionsId));
314         // Used to track if we found any visible suggestions.
315         var matchingElements = false;
316         // Options is used by the context when rendering the suggestions from a template.
317         var suggestions = [];
318         originalSelect.children('option').each(function(index, option) {
319             if ($(option).prop('selected') !== true) {
320                 suggestions[suggestions.length] = {label: option.innerHTML, value: $(option).attr('value')};
321             }
322         });
324         // Re-render the list of suggestions.
325         var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
326         var context = $.extend({options: suggestions}, options, state);
327         templates.render(
328             'core/form_autocomplete_suggestions',
329             context
330         ).done(function(newHTML) {
331             // We have the new template, insert it in the page.
332             suggestionsElement.replaceWith(newHTML);
333             // Get the element again.
334             suggestionsElement = $(document.getElementById(state.suggestionsId));
335             // Show it if it is hidden.
336             suggestionsElement.show().attr('aria-hidden', false);
337             // For each option in the list, hide it if it doesn't match the query.
338             suggestionsElement.children().each(function(index, node) {
339                 node = $(node);
340                 if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) ||
341                         (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) {
342                     node.show().attr('aria-hidden', false);
343                     matchingElements = true;
344                 } else {
345                     node.hide().attr('aria-hidden', true);
346                 }
347             });
348             // If we found any matches, show the list.
349             inputElement.attr('aria-expanded', true);
350             if (matchingElements) {
351                 // We only activate the first item in the list if tags is false,
352                 // because otherwise "Enter" would select the first item, instead of
353                 // creating a new tag.
354                 if (!options.tags) {
355                     activateItem(0, state);
356                 }
357             } else {
358                 // Nothing matches. Tell them that.
359                 str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) {
360                     suggestionsElement.html(nosuggestionsstr);
361                 });
362             }
363         }).fail(notification.exception);
365     };
367     /**
368      * Create a new item for the list (a tag).
369      *
370      * @method createItem
371      * @private
372      * @param {Object} options The original options for the autocomplete.
373      * @param {Object} state State variables for the autocomplete.
374      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
375      */
376     var createItem = function(options, state, originalSelect) {
377         // Find the element in the DOM.
378         var inputElement = $(document.getElementById(state.inputId));
379         // Get the current text in the input field.
380         var query = inputElement.val();
381         var tags = query.split(',');
382         var found = false;
384         $.each(tags, function(tagindex, tag) {
385             // If we can only select one at a time, deselect any current value.
386             tag = tag.trim();
387             if (tag !== '') {
388                 if (!options.multiple) {
389                     originalSelect.children('option').prop('selected', false);
390                 }
391                 // Look for an existing option in the select list that matches this new tag.
392                 originalSelect.children('option').each(function(index, ele) {
393                     if ($(ele).attr('value') == tag) {
394                         found = true;
395                         $(ele).prop('selected', true);
396                     }
397                 });
398                 // Only create the item if it's new.
399                 if (!found) {
400                     var option = $('<option>');
401                     option.append(tag);
402                     option.attr('value', tag);
403                     originalSelect.append(option);
404                     option.prop('selected', true);
405                     // We mark newly created custom options as we handle them differently if they are "deselected".
406                     option.attr('data-iscustom', true);
407                 }
408             }
409         });
411         updateSelectionList(options, state, originalSelect);
412         // Notifiy that the selection changed.
413         notifyChange(originalSelect);
414         // Clear the input field.
415         inputElement.val('');
416         // Close the suggestions list.
417         closeSuggestions(state);
418     };
420     /**
421      * Select the currently active item from the suggestions list.
422      *
423      * @method selectCurrentItem
424      * @private
425      * @param {Object} options The original options for the autocomplete.
426      * @param {Object} state State variables for the autocomplete.
427      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
428      */
429     var selectCurrentItem = function(options, state, originalSelect) {
430         // Find the elements in the page.
431         var inputElement = $(document.getElementById(state.inputId));
432         var suggestionsElement = $(document.getElementById(state.suggestionsId));
433         // Here loop through suggestions and set val to join of all selected items.
435         var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value');
436         // The select will either be a single or multi select, so the following will either
437         // select one or more items correctly.
438         // Take care to use 'prop' and not 'attr' for selected properties.
439         // If only one can be selected at a time, start by deselecting everything.
440         if (!options.multiple) {
441             originalSelect.children('option').prop('selected', false);
442         }
443         // Look for a match, and toggle the selected property if there is a match.
444         originalSelect.children('option').each(function(index, ele) {
445             if ($(ele).attr('value') == selectedItemValue) {
446                 $(ele).prop('selected', true);
447             }
448         });
449         // Rerender the selection list.
450         updateSelectionList(options, state, originalSelect);
451         // Notifiy that the selection changed.
452         notifyChange(originalSelect);
454         if (options.closeSuggestionsOnSelect) {
455             // Clear the input element.
456             inputElement.val('');
457             // Close the list of suggestions.
458             closeSuggestions(state);
459         } else {
460             // Focus on the input element so the suggestions does not auto-close.
461             inputElement.focus();
462             // Remove the last selected item from the suggestions list.
463             updateSuggestions(options, state, inputElement.val(), originalSelect);
464         }
465     };
467     /**
468      * Fetch a new list of options via ajax.
469      *
470      * @method updateAjax
471      * @private
472      * @param {Event} e The event that triggered this update.
473      * @param {Object} options The original options for the autocomplete.
474      * @param {Object} state The state variables for the autocomplete.
475      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
476      * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
477      */
478     var updateAjax = function(e, options, state, originalSelect, ajaxHandler) {
479         // Get the query to pass to the ajax function.
480         var query = $(e.currentTarget).val();
481         // Call the transport function to do the ajax (name taken from Select2).
482         ajaxHandler.transport(options.selector, query, function(results) {
483             // We got a result - pass it through the translator before using it.
484             var processedResults = ajaxHandler.processResults(options.selector, results);
485             var existingValues = [];
487             // Now destroy all options that are not currently selected.
488             originalSelect.children('option').each(function(optionIndex, option) {
489                 option = $(option);
490                 if (!option.prop('selected')) {
491                     option.remove();
492                 } else {
493                     existingValues.push(String(option.attr('value')));
494                 }
495             });
497             if (!options.multiple && originalSelect.children('option').length === 0) {
498                 // If this is a single select - and there are no current options
499                 // the first option added will be selected by the browser. This causes a bug!
500                 // We need to insert an empty option so that none of the real options are selected.
501                 var option = $('<option>');
502                 originalSelect.append(option);
503             }
504             // And add all the new ones returned from ajax.
505             $.each(processedResults, function(resultIndex, result) {
506                 if (existingValues.indexOf(String(result.value)) === -1) {
507                     var option = $('<option>');
508                     option.append(result.label);
509                     option.attr('value', result.value);
510                     originalSelect.append(option);
511                 }
512             });
513             // Update the list of suggestions now from the new values in the select list.
514             updateSuggestions(options, state, '', originalSelect);
515         }, notification.exception);
516     };
518     /**
519      * Add all the event listeners required for keyboard nav, blur clicks etc.
520      *
521      * @method addNavigation
522      * @private
523      * @param {Object} options The options used to create this autocomplete element.
524      * @param {Object} state State variables for this autocomplete element.
525      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
526      */
527     var addNavigation = function(options, state, originalSelect) {
528         // Start with the input element.
529         var inputElement = $(document.getElementById(state.inputId));
530         // Add keyboard nav with keydown.
531         inputElement.on('keydown', function(e) {
532             switch (e.keyCode) {
533                 case KEYS.DOWN:
534                     // If the suggestion list is open, move to the next item.
535                     if (!options.showSuggestions) {
536                         // Do not consume this event.
537                         return true;
538                     } else if (inputElement.attr('aria-expanded') === "true") {
539                         activateNextItem(state);
540                     } else {
541                         // Handle ajax population of suggestions.
542                         if (!inputElement.val() && options.ajax) {
543                             require([options.ajax], function(ajaxHandler) {
544                                 updateAjax(e, options, state, originalSelect, ajaxHandler);
545                             });
546                         } else {
547                             // Open the suggestions list.
548                             updateSuggestions(options, state, inputElement.val(), originalSelect);
549                         }
550                     }
551                     // We handled this event, so prevent it.
552                     e.preventDefault();
553                     return false;
554                 case KEYS.UP:
555                     // Choose the previous active item.
556                     activatePreviousItem(state);
557                     // We handled this event, so prevent it.
558                     e.preventDefault();
559                     return false;
560                 case KEYS.ENTER:
561                     var suggestionsElement = $(document.getElementById(state.suggestionsId));
562                     if ((inputElement.attr('aria-expanded') === "true") &&
563                             (suggestionsElement.children('[aria-selected=true]').length > 0)) {
564                         // If the suggestion list has an active item, select it.
565                         selectCurrentItem(options, state, originalSelect);
566                     } else if (options.tags) {
567                         // If tags are enabled, create a tag.
568                         createItem(options, state, originalSelect);
569                     }
570                     // We handled this event, so prevent it.
571                     e.preventDefault();
572                     return false;
573                 case KEYS.ESCAPE:
574                     if (inputElement.attr('aria-expanded') === "true") {
575                         // If the suggestion list is open, close it.
576                         closeSuggestions(state);
577                     }
578                     // We handled this event, so prevent it.
579                     e.preventDefault();
580                     return false;
581             }
582             return true;
583         });
584         // Support multi lingual COMMA keycode (44).
585         inputElement.on('keypress', function(e) {
586             if (e.keyCode === KEYS.COMMA) {
587                 if (options.tags) {
588                     // If we are allowing tags, comma should create a tag (or enter).
589                     createItem(options, state, originalSelect);
590                 }
591                 // We handled this event, so prevent it.
592                 e.preventDefault();
593                 return false;
594             }
595             return true;
596         });
597         // Handler used to force set the value from behat.
598         inputElement.on('behat:set-value', function() {
599             var suggestionsElement = $(document.getElementById(state.suggestionsId));
600             if ((inputElement.attr('aria-expanded') === "true") &&
601                     (suggestionsElement.children('[aria-selected=true]').length > 0)) {
602                 // If the suggestion list has an active item, select it.
603                 selectCurrentItem(options, state, originalSelect);
604             } else if (options.tags) {
605                 // If tags are enabled, create a tag.
606                 createItem(options, state, originalSelect);
607             }
608         });
609         inputElement.on('blur', function() {
610             window.setTimeout(function() {
611                 // Get the current element with focus.
612                 var focusElement = $(document.activeElement);
613                 // Only close the menu if the input hasn't regained focus.
614                 if (focusElement.attr('id') != inputElement.attr('id')) {
615                     if (options.tags) {
616                         createItem(options, state, originalSelect);
617                     }
618                     closeSuggestions(state);
619                 }
620             }, 500);
621         });
622         if (options.showSuggestions) {
623             var arrowElement = $(document.getElementById(state.downArrowId));
624             arrowElement.on('click', function(e) {
625                 // Prevent the close timer, or we will open, then close the suggestions.
626                 inputElement.focus();
627                 // Handle ajax population of suggestions.
628                 if (!inputElement.val() && options.ajax) {
629                     require([options.ajax], function(ajaxHandler) {
630                         updateAjax(e, options, state, originalSelect, ajaxHandler);
631                     });
632                 } else {
633                     // Else - open the suggestions list.
634                     updateSuggestions(options, state, inputElement.val(), originalSelect);
635                 }
636             });
637         }
639         var suggestionsElement = $(document.getElementById(state.suggestionsId));
640         suggestionsElement.parent().on('click', '[role=option]', function(e) {
641             // Handle clicks on suggestions.
642             var element = $(e.currentTarget).closest('[role=option]');
643             var suggestionsElement = $(document.getElementById(state.suggestionsId));
644             // Find the index of the clicked on suggestion.
645             var current = suggestionsElement.children('[aria-hidden=false]').index(element);
646             // Activate it.
647             activateItem(current, state);
648             // And select it.
649             selectCurrentItem(options, state, originalSelect);
650         });
651         var selectionElement = $(document.getElementById(state.selectionId));
652         // Handle clicks on the selected items (will unselect an item).
653         selectionElement.on('click', '[role=listitem]', function(e) {
654             // Get the item that was clicked.
655             var item = $(e.currentTarget);
656             // Remove it from the selection.
657             deselectItem(options, state, item, originalSelect);
658         });
659         // Keyboard navigation for the selection list.
660         selectionElement.on('keydown', function(e) {
661             switch (e.keyCode) {
662                 case KEYS.DOWN:
663                     // Choose the next selection item.
664                     activateNextSelection(state);
665                     // We handled this event, so prevent it.
666                     e.preventDefault();
667                     return false;
668                 case KEYS.UP:
669                     // Choose the previous selection item.
670                     activatePreviousSelection(state);
671                     // We handled this event, so prevent it.
672                     e.preventDefault();
673                     return false;
674                 case KEYS.SPACE:
675                 case KEYS.ENTER:
676                     // Get the item that is currently selected.
677                     var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection=true]');
678                     if (selectedItem) {
679                         // Unselect this item.
680                         deselectItem(options, state, selectedItem, originalSelect);
681                         // We handled this event, so prevent it.
682                         e.preventDefault();
683                     }
684                     return false;
685             }
686             return true;
687         });
688         // Whenever the input field changes, update the suggestion list.
689         if (options.showSuggestions) {
690             inputElement.on('input', function(e) {
691                 var query = $(e.currentTarget).val();
692                 var last = $(e.currentTarget).data('last-value');
693                 // IE11 fires many more input events than required - even when the value has not changed.
694                 // We need to only do this for real value changed events or the suggestions will be
695                 // unclickable on IE11 (because they will be rebuilt before the click event fires).
696                 // Note - because of this we cannot close the list when the query is empty or it will break
697                 // on IE11.
698                 if (last !== query) {
699                     updateSuggestions(options, state, query, originalSelect);
700                 }
701                 $(e.currentTarget).data('last-value', query);
702             });
703         }
704     };
706     return /** @alias module:core/form-autocomplete */ {
707         // Public variables and functions.
708         /**
709          * Turn a boring select box into an auto-complete beast.
710          *
711          * @method enhance
712          * @param {string} selector The selector that identifies the select box.
713          * @param {boolean} tags Whether to allow support for tags (can define new entries).
714          * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
715          *                      module must expose 2 functions "transport" and "processResults".
716          *                      These are modeled on Select2 see: https://select2.github.io/options.html#ajax
717          * @param {String} placeholder - The text to display before a selection is made.
718          * @param {Boolean} caseSensitive - If search has to be made case sensitive.
719          * @param {Boolean} showSuggestions - If suggestions should be shown
720          * @param {String} noSelectionString - Text to display when there is no selection
721          * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection.
722          * @return {Promise}
723          */
724         enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString,
725                           closeSuggestionsOnSelect) {
726             // Set some default values.
727             var options = {
728                 selector: selector,
729                 tags: false,
730                 ajax: false,
731                 placeholder: placeholder,
732                 caseSensitive: false,
733                 showSuggestions: true,
734                 noSelectionString: noSelectionString
735             };
736             if (typeof tags !== "undefined") {
737                 options.tags = tags;
738             }
739             if (typeof ajax !== "undefined") {
740                 options.ajax = ajax;
741             }
742             if (typeof caseSensitive !== "undefined") {
743                 options.caseSensitive = caseSensitive;
744             }
745             if (typeof showSuggestions !== "undefined") {
746                 options.showSuggestions = showSuggestions;
747             }
748             if (typeof noSelectionString === "undefined") {
749                 str.get_string('noselection', 'form').done(function(result) {
750                     options.noSelectionString = result;
751                 }).fail(notification.exception);
752             }
754             // Look for the select element.
755             var originalSelect = $(selector);
756             if (!originalSelect) {
757                 log.debug('Selector not found: ' + selector);
758                 return false;
759             }
761             // Hide the original select.
762             originalSelect.hide().attr('aria-hidden', true);
764             // Find or generate some ids.
765             var state = {
766                 selectId: originalSelect.attr('id'),
767                 inputId: 'form_autocomplete_input-' + $.now(),
768                 suggestionsId: 'form_autocomplete_suggestions-' + $.now(),
769                 selectionId: 'form_autocomplete_selection-' + $.now(),
770                 downArrowId: 'form_autocomplete_downarrow-' + $.now()
771             };
772             options.multiple = originalSelect.attr('multiple');
774             if (typeof closeSuggestionsOnSelect !== "undefined") {
775                 options.closeSuggestionsOnSelect = closeSuggestionsOnSelect;
776             } else {
777                 // If not specified, this will close suggestions by default for single-select elements only.
778                 options.closeSuggestionsOnSelect = !options.multiple;
779             }
781             var originalLabel = $('[for=' + state.selectId + ']');
782             // Create the new markup and insert it after the select.
783             var suggestions = [];
784             originalSelect.children('option').each(function(index, option) {
785                 suggestions[index] = {label: option.innerHTML, value: $(option).attr('value')};
786             });
788             // Render all the parts of our UI.
789             var context = $.extend({}, options, state);
790             context.options = suggestions;
791             context.items = [];
793             var renderInput = templates.render('core/form_autocomplete_input', context);
794             var renderDatalist = templates.render('core/form_autocomplete_suggestions', context);
795             var renderSelection = templates.render('core/form_autocomplete_selection', context);
797             return $.when(renderInput, renderDatalist, renderSelection).then(function(input, suggestions, selection) {
798                 // Add our new UI elements to the page.
799                 originalSelect.after(suggestions);
800                 originalSelect.after(input);
801                 originalSelect.after(selection);
802                 // Update the form label to point to the text input.
803                 originalLabel.attr('for', state.inputId);
804                 // Add the event handlers.
805                 addNavigation(options, state, originalSelect);
807                 var inputElement = $(document.getElementById(state.inputId));
808                 var suggestionsElement = $(document.getElementById(state.suggestionsId));
809                 // Hide the suggestions by default.
810                 suggestionsElement.hide().attr('aria-hidden', true);
812                 // If this field uses ajax, set it up.
813                 if (options.ajax) {
814                     require([options.ajax], function(ajaxHandler) {
815                         var throttleTimeout = null;
816                         var handler = function(e) {
817                             updateAjax(e, options, state, originalSelect, ajaxHandler);
818                         };
820                         // For input events, we do not want to trigger many, many updates.
821                         var throttledHandler = function(e) {
822                             if (throttleTimeout !== null) {
823                                 window.clearTimeout(throttleTimeout);
824                                 throttleTimeout = null;
825                             }
826                             throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
827                         };
828                         // Trigger an ajax update after the text field value changes.
829                         inputElement.on("input", throttledHandler);
830                     });
831                 }
832                 // Show the current values in the selection list.
833                 updateSelectionList(options, state, originalSelect);
834                 return true;
835             });
836         }
837     };
838 });