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