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