Merge branch 'MDL-51863' of git://github.com/stronk7/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);
140     };
142     /**
143      * Find the index of the current active suggestion, and activate the next one.
144      *
145      * @method activateNextItem
146      * @private
147      * @param {String} inputId The id of the input element for this instance of the autocomplete.
148      * @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
149      */
150     var activateNextItem = function(inputId, suggestionsId) {
151         // Find the list of suggestions.
152         var suggestionsElement = $(document.getElementById(suggestionsId));
153         // Find the active one.
154         var element = suggestionsElement.children('[aria-selected=true]');
155         // Find it's index.
156         var current = suggestionsElement.children('[aria-hidden=false]').index(element);
157         // Activate the next one.
158         activateItem(current+1, inputId, suggestionsId);
159     };
161     /**
162      * Find the index of the current active selection, and activate the previous one.
163      *
164      * @method activatePreviousSelection
165      * @private
166      * @param {String} selectionId The id of the selection element for this instance of the autocomplete.
167      */
168     var activatePreviousSelection = function(selectionId) {
169         // Find the list of selections.
170         var selectionsElement = $(document.getElementById(selectionId));
171         // Find the active one.
172         var element = selectionsElement.children('[data-active-selection=true]');
173         if (!element) {
174             activateSelection(0, selectionId);
175             return;
176         }
177         // Find it's index.
178         var current = selectionsElement.children('[aria-selected=true]').index(element);
179         // Activate the next one.
180         activateSelection(current-1, selectionId);
181     };
182     /**
183      * Find the index of the current active selection, and activate the next one.
184      *
185      * @method activateNextSelection
186      * @private
187      * @param {String} selectionId The id of the selection element for this instance of the autocomplete.
188      */
189     var activateNextSelection = function(selectionId) {
190         // Find the list of selections.
191         var selectionsElement = $(document.getElementById(selectionId));
192         // Find the active one.
193         var element = selectionsElement.children('[data-active-selection=true]');
194         if (!element) {
195             activateSelection(0, selectionId);
196             return;
197         }
198         // Find it's index.
199         var current = selectionsElement.children('[aria-selected=true]').index(element);
200         // Activate the next one.
201         activateSelection(current+1, selectionId);
202     };
204     /**
205      * Find the index of the current active suggestion, and activate the previous one.
206      *
207      * @method activatePreviousItem
208      * @private
209      * @param {String} inputId The id of the input element for this instance of the autocomplete.
210      * @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
211      */
212     var activatePreviousItem = function(inputId, suggestionsId) {
213         // Find the list of suggestions.
214         var suggestionsElement = $(document.getElementById(suggestionsId));
215         // Find the active one.
216         var element = suggestionsElement.children('[aria-selected=true]');
217         // Find it's index.
218         var current = suggestionsElement.children('[aria-hidden=false]').index(element);
219         // Activate the next one.
220         activateItem(current-1, inputId, suggestionsId);
221     };
223     /**
224      * Close the list of suggestions.
225      *
226      * @method closeSuggestions
227      * @private
228      * @param {String} inputId The id of the input element for this instance of the autocomplete.
229      * @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
230      */
231     var closeSuggestions = function(inputId, suggestionsId, selectionId) {
232         // Find the elements in the DOM.
233         var inputElement = $(document.getElementById(inputId));
234         var suggestionsElement = $(document.getElementById(suggestionsId));
236         // Announce the list of suggestions was closed, and read the current list of selections.
237         inputElement.attr('aria-expanded', false).attr('aria-activedescendant', selectionId);
238         // Hide the suggestions list (from screen readers too).
239         suggestionsElement.hide().attr('aria-hidden', true);
240     };
242     /**
243      * Rebuild the list of suggestions based on the current values in the select list, and the query.
244      *
245      * @method updateSuggestions
246      * @private
247      * @param {String} query The current query typed in the input field.
248      * @param {String} inputId The id of the input element for this instance of the autocomplete.
249      * @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
250      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
251      * @param {Boolean} multiple Are multiple items allowed to be selected?
252      * @param {Boolean} tags Are we allowed to create new items on the fly?
253      */
254     var updateSuggestions = function(query, inputId, suggestionsId, originalSelect, multiple, tags) {
255         // Find the elements in the DOM.
256         var inputElement = $(document.getElementById(inputId));
257         var suggestionsElement = $(document.getElementById(suggestionsId));
259         // Used to track if we found any visible suggestions.
260         var matchingElements = false;
261         // Options is used by the context when rendering the suggestions from a template.
262         var options = [];
263         originalSelect.children('option').each(function(index, option) {
264             if ($(option).prop('selected') !== true) {
265                 options[options.length] = { label: option.innerHTML, value: $(option).attr('value') };
266             }
267         });
269         // Re-render the list of suggestions.
270         templates.render(
271             'core/form_autocomplete_suggestions',
272             { inputId: inputId, suggestionsId: suggestionsId, options: options, multiple: multiple}
273         ).done(function(newHTML) {
274             // We have the new template, insert it in the page.
275             suggestionsElement.replaceWith(newHTML);
276             // Get the element again.
277             suggestionsElement = $(document.getElementById(suggestionsId));
278             // Show it if it is hidden.
279             suggestionsElement.show().attr('aria-hidden', false);
280             // For each option in the list, hide it if it doesn't match the query.
281             suggestionsElement.children().each(function(index, node) {
282                 node = $(node);
283                 if (node.text().indexOf(query) > -1) {
284                     node.show().attr('aria-hidden', false);
285                     matchingElements = true;
286                 } else {
287                     node.hide().attr('aria-hidden', true);
288                 }
289             });
290             // If we found any matches, show the list.
291             inputElement.attr('aria-expanded', true);
292             if (matchingElements) {
293                 // We only activate the first item in the list if tags is false,
294                 // because otherwise "Enter" would select the first item, instead of
295                 // creating a new tag.
296                 if (!tags) {
297                     activateItem(0, inputId, suggestionsId);
298                 }
299             } else {
300                 // Nothing matches. Tell them that.
301                 str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) {
302                     suggestionsElement.html(nosuggestionsstr);
303                 });
304             }
305         }).fail(notification.exception);
307     };
309     /**
310      * Create a new item for the list (a tag).
311      *
312      * @method createItem
313      * @private
314      * @param {String} inputId The id of the input element for this instance of the autocomplete.
315      * @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
316      * @param {Boolean} multiple Are multiple items allowed to be selected?
317      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
318      */
319     var createItem = function(inputId, suggestionsId, selectionId, multiple, originalSelect) {
320         // Find the element in the DOM.
321         var inputElement = $(document.getElementById(inputId));
322         // Get the current text in the input field.
323         var query = inputElement.val();
324         var tags = query.split(',');
325         var found = false;
327         $.each(tags, function(tagindex, tag) {
328             // If we can only select one at a time, deselect any current value.
329             tag = tag.trim();
330             if (tag !== '') {
331                 if (!multiple) {
332                     originalSelect.children('option').prop('selected', false);
333                 }
334                 // Look for an existing option in the select list that matches this new tag.
335                 originalSelect.children('option').each(function(index, ele) {
336                     if ($(ele).attr('value') == tag) {
337                         found = true;
338                         $(ele).prop('selected', true);
339                     }
340                 });
341                 // Only create the item if it's new.
342                 if (!found) {
343                     var option = $('<option>');
344                     option.append(tag);
345                     option.attr('value', tag);
346                     originalSelect.append(option);
347                     option.prop('selected', true);
348                     // We mark newly created custom options as we handle them differently if they are "deselected".
349                     option.attr('data-iscustom', true);
350                 }
351             }
352         });
354         updateSelectionList(selectionId, inputId, originalSelect, multiple);
356         // Clear the input field.
357         inputElement.val('');
358         // Close the suggestions list.
359         closeSuggestions(inputId, suggestionsId, selectionId);
360     };
362     /**
363      * Update the element that shows the currently selected items.
364      *
365      * @method updateSelectionList
366      * @private
367      * @param {String} selectionId The id of the selections element for this instance of the autocomplete.
368      * @param {String} inputId The id of the input element for this instance of the autocomplete.
369      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
370      * @param {Boolean} multiple Does this element support multiple selections.
371      */
372     var updateSelectionList = function(selectionId, inputId, originalSelect, multiple) {
373         // Build up a valid context to re-render the template.
374         var items = [];
375         var newSelection = $(document.getElementById(selectionId));
376         var activeId = newSelection.attr('aria-activedescendant');
377         var activeValue = false;
379         if (activeId) {
380             activeValue = $(document.getElementById(activeId)).attr('data-value');
381         }
382         originalSelect.children('option').each(function(index, ele) {
383             if ($(ele).prop('selected')) {
384                 items.push( { label: $(ele).html(), value: $(ele).attr('value') } );
385             }
386         });
387         var context = {
388             selectionId: selectionId,
389             items: items,
390             multiple: multiple
391         };
392         // Render the template.
393         templates.render('core/form_autocomplete_selection', context).done(function(newHTML) {
394             // Add it to the page.
395             newSelection.empty().append($(newHTML).html());
397             if (activeValue !== false) {
398                 // Reselect any previously selected item.
399                 newSelection.children('[aria-selected=true]').each(function(index, ele) {
400                     if ($(ele).attr('data-value') === activeValue) {
401                         activateSelection(index, selectionId);
402                     }
403                 });
404             }
405         }).fail(notification.exception);
406         // Because this function get's called after changing the selection, this is a good place
407         // to trigger a change notification.
408         originalSelect.change();
409     };
411     /**
412      * Select the currently active item from the suggestions list.
413      *
414      * @method selectCurrentItem
415      * @private
416      * @param {String} inputId The id of the input element for this instance of the autocomplete.
417      * @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
418      * @param {String} selectionId The id of the selection element for this instance of the autocomplete.
419      * @param {Boolean} multiple Are multiple items allowed to be selected?
420      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
421      */
422     var selectCurrentItem = function(inputId, suggestionsId, selectionId, multiple, originalSelect) {
423         // Find the elements in the page.
424         var inputElement = $(document.getElementById(inputId));
425         var suggestionsElement = $(document.getElementById(suggestionsId));
426         // Here loop through suggestions and set val to join of all selected items.
428         var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value');
429         // The select will either be a single or multi select, so the following will either
430         // select one or more items correctly.
431         // Take care to use 'prop' and not 'attr' for selected properties.
432         // If only one can be selected at a time, start by deselecting everything.
433         if (!multiple) {
434             originalSelect.children('option').prop('selected', false);
435         }
436         // Look for a match, and toggle the selected property if there is a match.
437         originalSelect.children('option').each(function(index, ele) {
438             if ($(ele).attr('value') == selectedItemValue) {
439                 $(ele).prop('selected', true);
440             }
441         });
442         // Rerender the selection list.
443         updateSelectionList(selectionId, inputId, originalSelect, multiple);
444         // Clear the input element.
445         inputElement.val('');
446         // Close the list of suggestions.
447         closeSuggestions(inputId, suggestionsId, selectionId);
448     };
450     /**
451      * Fetch a new list of options via ajax.
452      *
453      * @method updateAjax
454      * @private
455      * @param {Event} e The event that triggered this update.
456      * @param {String} selector The selector pointing to the original select.
457      * @param {String} inputId The id of the input element for this instance of the autocomplete.
458      * @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
459      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
460      * @param {Boolean} multiple Are multiple items allowed to be selected?
461      * @param {Boolean} tags Are we allowed to create new items on the fly?
462      * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
463      */
464     var updateAjax = function(e, selector, inputId, suggestionsId, originalSelect, multiple, tags, ajaxHandler) {
465         // Get the query to pass to the ajax function.
466         var query = $(e.currentTarget).val();
467         // Call the transport function to do the ajax (name taken from Select2).
468         ajaxHandler.transport(selector, query, function(results) {
469             // We got a result - pass it through the translator before using it.
470             var processedResults = ajaxHandler.processResults(selector, results);
471             var existingValues = [];
473             // Now destroy all options that are not currently selected.
474             originalSelect.children('option').each(function(optionIndex, option) {
475                 option = $(option);
476                 if (!option.prop('selected')) {
477                     option.remove();
478                 } else {
479                     existingValues.push(option.attr('value'));
480                 }
481             });
482             // And add all the new ones returned from ajax.
483             $.each(processedResults, function(resultIndex, result) {
484                 if (existingValues.indexOf(result.value) === -1) {
485                     var option = $('<option>');
486                     option.append(result.label);
487                     option.attr('value', result.value);
488                     originalSelect.append(option);
489                 }
490             });
491             // Update the list of suggestions now from the new values in the select list.
492             updateSuggestions('', inputId, suggestionsId, originalSelect, multiple, tags);
493         }, notification.exception);
494     };
496     /**
497      * Add all the event listeners required for keyboard nav, blur clicks etc.
498      *
499      * @method addNavigation
500      * @private
501      * @param {String} inputId The id of the input element for this instance of the autocomplete.
502      * @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
503      * @param {String} downArrowId The id of arrow to open the suggestions list.
504      * @param {String} selectionId The id of element that shows the current selections.
505      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
506      * @param {Boolean} multiple Are multiple items allowed to be selected?
507      * @param {Boolean} tags Are we allowed to create new items on the fly?
508      * @param {String} selector The selector for this select list.
509      * @param {String} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
510      *                      module must expose 2 functions "transport" and "processResults".
511      */
512     var addNavigation = function(inputId, suggestionsId, downArrowId, selectionId, originalSelect, multiple, tags, selector, ajax) {
513         // Start with the input element.
514         var inputElement = $(document.getElementById(inputId));
515         // Add keyboard nav with keydown.
516         inputElement.on('keydown', function(e) {
517             switch (e.keyCode) {
518                 case KEYS.DOWN:
519                     // If the suggestion list is open, move to the next item.
520                     if (inputElement.attr('aria-expanded') === "true") {
521                         activateNextItem(inputId, suggestionsId);
522                     } else {
523                         // Handle ajax population of suggestions.
524                         if (!inputElement.val() && ajax) {
525                             require([ajax], function(ajaxHandler) {
526                                 updateAjax(e, selector, inputId, suggestionsId, originalSelect, multiple, tags, ajaxHandler);
527                             });
528                         } else {
529                             // Else - open the suggestions list.
530                             updateSuggestions(inputElement.val(), inputId, suggestionsId, originalSelect, multiple, tags);
531                         }
532                     }
533                     // We handled this event, so prevent it.
534                     e.preventDefault();
535                     return false;
536                 case KEYS.COMMA:
537                     if (tags) {
538                         // If we are allowing tags, comma should create a tag (or enter).
539                         createItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
540                     }
541                     // We handled this event, so prevent it.
542                     e.preventDefault();
543                     return false;
544                 case KEYS.UP:
545                     // Choose the previous active item.
546                     activatePreviousItem(inputId, suggestionsId);
547                     // We handled this event, so prevent it.
548                     e.preventDefault();
549                     return false;
550                 case KEYS.ENTER:
551                     var suggestionsElement = $(document.getElementById(suggestionsId));
552                     if ((inputElement.attr('aria-expanded') === "true") &&
553                             (suggestionsElement.children('[aria-selected=true]').length > 0)) {
554                         // If the suggestion list has an active item, select it.
555                         selectCurrentItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
556                     } else if (tags) {
557                         // If tags are enabled, create a tag.
558                         createItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
559                     }
560                     // We handled this event, so prevent it.
561                     e.preventDefault();
562                     return false;
563                 case KEYS.ESCAPE:
564                     if (inputElement.attr('aria-expanded') === "true") {
565                         // If the suggestion list is open, close it.
566                         closeSuggestions(inputId, suggestionsId, selectionId);
567                     }
568                     // We handled this event, so prevent it.
569                     e.preventDefault();
570                     return false;
571             }
572             return true;
573         });
574         // Handler used to force set the value from behat.
575         inputElement.on('behat:set-value', function() {
576             if (tags) {
577                 createItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
578             }
579         });
580         inputElement.on('blur focus', function(e) {
581             // We may be blurring because we have clicked on the suggestion list. We
582             // dont want to close the selection list before the click event fires, so
583             // we have to delay.
584             if (closeSuggestionsTimer) {
585                 window.clearTimeout(closeSuggestionsTimer);
586             }
587             closeSuggestionsTimer = window.setTimeout(function() {
588                 if ((e.type == 'blur') && tags) {
589                     createItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
590                 }
591                 closeSuggestions(inputId, suggestionsId, selectionId);
592             }, 500);
593         });
594         var arrowElement = $(document.getElementById(downArrowId));
595         arrowElement.on('click', function() {
596             // Prevent the close timer, or we will open, then close the suggestions.
597             inputElement.focus();
598             if (closeSuggestionsTimer) {
599                 window.clearTimeout(closeSuggestionsTimer);
600             }
601             // Show the suggestions list.
602             updateSuggestions(inputElement.val(), inputId, suggestionsId, originalSelect, multiple, tags);
603         });
605         var suggestionsElement = $(document.getElementById(suggestionsId));
606         suggestionsElement.parent().on('click', '[role=option]', function(e) {
607             // Handle clicks on suggestions.
608             var element = $(e.currentTarget).closest('[role=option]');
609             var suggestionsElement = $(document.getElementById(suggestionsId));
610             // Find the index of the clicked on suggestion.
611             var current = suggestionsElement.children('[aria-hidden=false]').index(element);
612             // Activate it.
613             activateItem(current, inputId, suggestionsId);
614             // And select it.
615             selectCurrentItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
616         });
617         var selectionElement = $(document.getElementById(selectionId));
618         // Handle clicks on the selected items (will unselect an item).
619         selectionElement.on('click', '[role=listitem]', function(e) {
620             // Get the item that was clicked.
621             var item = $(e.currentTarget);
622             // Remove it from the selection.
623             deselectItem(inputId, selectionId, item, originalSelect, multiple);
624         });
625         // Keyboard navigation for the selection list.
626         selectionElement.on('keydown', function(e) {
627             switch (e.keyCode) {
628                 case KEYS.DOWN:
629                     // Choose the next selection item.
630                     activateNextSelection(selectionId);
631                     // We handled this event, so prevent it.
632                     e.preventDefault();
633                     return false;
634                 case KEYS.UP:
635                     // Choose the previous selection item.
636                     activatePreviousSelection(selectionId);
637                     // We handled this event, so prevent it.
638                     e.preventDefault();
639                     return false;
640                 case KEYS.SPACE:
641                 case KEYS.ENTER:
642                     // Get the item that is currently selected.
643                     var selectedItem = $(document.getElementById(selectionId)).children('[data-active-selection=true]');
644                     if (selectedItem) {
645                         // Unselect this item.
646                         deselectItem(inputId, selectionId, selectedItem, originalSelect, multiple);
647                         // We handled this event, so prevent it.
648                         e.preventDefault();
649                     }
650                     return false;
651             }
652             return true;
653         });
654         // Whenever the input field changes, update the suggestion list.
655         inputElement.on('input', function(e) {
656             var query = $(e.currentTarget).val();
657             updateSuggestions(query, inputId, suggestionsId, originalSelect, multiple, tags);
658         });
659     };
661     return /** @alias module:core/form-autocomplete */ {
662         // Public variables and functions.
663         /**
664          * Turn a boring select box into an auto-complete beast.
665          *
666          * @method enhance
667          * @param {string} select The selector that identifies the select box.
668          * @param {boolean} tags Whether to allow support for tags (can define new entries).
669          * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
670          *                      module must expose 2 functions "transport" and "processResults".
671          *                      These are modeled on Select2 see: https://select2.github.io/options.html#ajax
672          * @param {String} placeholder - The text to display before a selection is made.
673          */
674         enhance: function(selector, tags, ajax, placeholder) {
675             // Set some default values.
676             if (typeof tags === "undefined") {
677                 tags = false;
678             }
679             if (typeof ajax === "undefined") {
680                 ajax = false;
681             }
683             // Look for the select element.
684             var originalSelect = $(selector);
685             if (!originalSelect) {
686                 log.debug('Selector not found: ' + selector);
687                 return false;
688             }
690             // Hide the original select.
691             originalSelect.hide().attr('aria-hidden', true);
693             // Find or generate some ids.
694             var selectId = originalSelect.attr('id');
695             var multiple = originalSelect.attr('multiple');
696             var inputId = 'form_autocomplete_input-' + $.now();
697             var suggestionsId = 'form_autocomplete_suggestions-' + $.now();
698             var selectionId = 'form_autocomplete_selection-' + $.now();
699             var downArrowId = 'form_autocomplete_downarrow-' + $.now();
701             var originalLabel = $('[for=' + selectId + ']');
702             // Create the new markup and insert it after the select.
703             var options = [];
704             originalSelect.children('option').each(function(index, option) {
705                 options[index] = { label: option.innerHTML, value: $(option).attr('value') };
706             });
708             // Render all the parts of our UI.
709             var renderInput = templates.render(
710                 'core/form_autocomplete_input',
711                 { downArrowId: downArrowId,
712                   inputId: inputId,
713                   suggestionsId: suggestionsId,
714                   selectionId: selectionId,
715                   placeholder: placeholder,
716                   multiple: multiple }
717             );
718             var renderDatalist = templates.render(
719                 'core/form_autocomplete_suggestions',
720                 { inputId: inputId, suggestionsId: suggestionsId, options: options, multiple: multiple}
721             );
722             var renderSelection = templates.render(
723                 'core/form_autocomplete_selection',
724                 { selectionId: selectionId, items: [], multiple: multiple}
725             );
727             $.when(renderInput, renderDatalist, renderSelection).done(function(input, suggestions, selection) {
728                 // Add our new UI elements to the page.
729                 originalSelect.after(suggestions);
730                 originalSelect.after(input);
731                 originalSelect.after(selection);
732                 // Update the form label to point to the text input.
733                 originalLabel.attr('for', inputId);
734                 // Add the event handlers.
735                 addNavigation(inputId, suggestionsId, downArrowId, selectionId, originalSelect, multiple, tags, selector, ajax);
737                 var inputElement = $(document.getElementById(inputId));
738                 var suggestionsElement = $(document.getElementById(suggestionsId));
739                 // Hide the suggestions by default.
740                 suggestionsElement.hide().attr('aria-hidden', true);
742                 // If this field uses ajax, set it up.
743                 if (ajax) {
744                     require([ajax], function(ajaxHandler) {
745                         var handler = function(e) {
746                             updateAjax(e, selector, inputId, suggestionsId, originalSelect, multiple, tags, ajaxHandler);
747                         };
748                         // Trigger an ajax update after the text field value changes.
749                         inputElement.on("input keypress", handler);
750                         var arrowElement = $(document.getElementById(downArrowId));
751                         arrowElement.on("click", handler);
752                     });
753                 }
754                 // Show the current values in the selection list.
755                 updateSelectionList(selectionId, inputId, originalSelect, multiple);
756             });
757         }
758     };
759 });