MDL-53281 search: Properly set timeout, and don't wait for optimize
[moodle.git] / lib / amd / src / form-autocomplete.js
CommitLineData
60a1ea56
DW
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/>.
15
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 */
27define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification'], function($, log, str, templates, notification) {
28
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 };
39
60a1ea56
DW
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.
273b2556 46 * @param {Object} state State variables for this autocomplete element.
60a1ea56 47 */
273b2556 48 var activateSelection = function(index, state) {
60a1ea56 49 // Find the elements in the DOM.
273b2556 50 var selectionElement = $(document.getElementById(state.selectionId));
60a1ea56
DW
51
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.
273b2556 62 var itemId = state.selectionId + '-' + index;
60a1ea56
DW
63
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 };
71
f992dcf6
DP
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;
87
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);
97
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());
102
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 };
116
117
60a1ea56 118 /**
563fe0a5 119 * Remove the given item from the list of selected things.
60a1ea56 120 *
563fe0a5 121 * @method deselectItem
60a1ea56 122 * @private
273b2556
DW
123 * @param {Object} options Original options for this autocomplete element.
124 * @param {Object} state State variables for this autocomplete element.
563fe0a5 125 * @param {Element} The item to be deselected.
60a1ea56 126 * @param {Element} originalSelect The original select list.
60a1ea56 127 */
273b2556 128 var deselectItem = function(options, state, item, originalSelect) {
563fe0a5
RW
129 var selectedItemValue = $(item).attr('data-value');
130
131 // We can only deselect items if this is a multi-select field.
273b2556 132 if (options.multiple) {
563fe0a5
RW
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);
bdd60287
DW
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 }
60a1ea56 141 }
563fe0a5
RW
142 });
143 }
60a1ea56 144 // Rerender the selection list.
273b2556 145 updateSelectionList(options, state, originalSelect);
60a1ea56
DW
146 };
147
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.
273b2556 154 * @param {Object} state State variables for this instance of autocomplete.
60a1ea56 155 */
273b2556 156 var activateItem = function(index, state) {
60a1ea56 157 // Find the elements in the DOM.
273b2556
DW
158 var inputElement = $(document.getElementById(state.inputId));
159 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
160
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.
273b2556 173 var itemId = state.suggestionsId + '-' + globalIndex;
60a1ea56
DW
174
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);
32f3de56
DW
181
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);
60a1ea56
DW
190 };
191
192 /**
193 * Find the index of the current active suggestion, and activate the next one.
194 *
195 * @method activateNextItem
196 * @private
273b2556 197 * @param {Object} state State variable for this auto complete element.
60a1ea56 198 */
273b2556 199 var activateNextItem = function(state) {
60a1ea56 200 // Find the list of suggestions.
273b2556 201 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
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.
273b2556 207 activateItem(current+1, state);
60a1ea56
DW
208 };
209
210 /**
211 * Find the index of the current active selection, and activate the previous one.
212 *
213 * @method activatePreviousSelection
214 * @private
273b2556 215 * @param {Object} state State variables for this instance of autocomplete.
60a1ea56 216 */
273b2556 217 var activatePreviousSelection = function(state) {
60a1ea56 218 // Find the list of selections.
273b2556 219 var selectionsElement = $(document.getElementById(state.selectionId));
60a1ea56
DW
220 // Find the active one.
221 var element = selectionsElement.children('[data-active-selection=true]');
222 if (!element) {
273b2556 223 activateSelection(0, state);
60a1ea56
DW
224 return;
225 }
226 // Find it's index.
227 var current = selectionsElement.children('[aria-selected=true]').index(element);
228 // Activate the next one.
273b2556 229 activateSelection(current-1, state);
60a1ea56
DW
230 };
231 /**
232 * Find the index of the current active selection, and activate the next one.
233 *
234 * @method activateNextSelection
235 * @private
273b2556 236 * @param {Object} state State variables for this instance of autocomplete.
60a1ea56 237 */
273b2556 238 var activateNextSelection = function(state) {
60a1ea56 239 // Find the list of selections.
273b2556 240 var selectionsElement = $(document.getElementById(state.selectionId));
60a1ea56
DW
241 // Find the active one.
242 var element = selectionsElement.children('[data-active-selection=true]');
243 if (!element) {
273b2556 244 activateSelection(0, state);
60a1ea56
DW
245 return;
246 }
247 // Find it's index.
248 var current = selectionsElement.children('[aria-selected=true]').index(element);
249 // Activate the next one.
273b2556 250 activateSelection(current+1, state);
60a1ea56
DW
251 };
252
253 /**
254 * Find the index of the current active suggestion, and activate the previous one.
255 *
256 * @method activatePreviousItem
257 * @private
273b2556 258 * @param {Object} state State variables for this autocomplete element.
60a1ea56 259 */
273b2556 260 var activatePreviousItem = function(state) {
60a1ea56 261 // Find the list of suggestions.
273b2556 262 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
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.
273b2556 268 activateItem(current-1, state);
60a1ea56
DW
269 };
270
271 /**
272 * Close the list of suggestions.
273 *
274 * @method closeSuggestions
275 * @private
273b2556 276 * @param {Object} state State variables for this autocomplete element.
60a1ea56 277 */
273b2556 278 var closeSuggestions = function(state) {
60a1ea56 279 // Find the elements in the DOM.
273b2556
DW
280 var inputElement = $(document.getElementById(state.inputId));
281 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
282
283 // Announce the list of suggestions was closed, and read the current list of selections.
273b2556 284 inputElement.attr('aria-expanded', false).attr('aria-activedescendant', state.selectionId);
60a1ea56
DW
285 // Hide the suggestions list (from screen readers too).
286 suggestionsElement.hide().attr('aria-hidden', true);
287 };
288
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
273b2556
DW
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.
60a1ea56 297 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
60a1ea56 298 */
273b2556 299 var updateSuggestions = function(options, state, query, originalSelect) {
60a1ea56 300 // Find the elements in the DOM.
273b2556
DW
301 var inputElement = $(document.getElementById(state.inputId));
302 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
303
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.
273b2556 307 var suggestions = [];
60a1ea56
DW
308 originalSelect.children('option').each(function(index, option) {
309 if ($(option).prop('selected') !== true) {
273b2556 310 suggestions[suggestions.length] = { label: option.innerHTML, value: $(option).attr('value') };
60a1ea56
DW
311 }
312 });
313
314 // Re-render the list of suggestions.
273b2556
DW
315 var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
316 var context = $.extend({ options: suggestions}, options, state);
60a1ea56
DW
317 templates.render(
318 'core/form_autocomplete_suggestions',
273b2556 319 context
60a1ea56
DW
320 ).done(function(newHTML) {
321 // We have the new template, insert it in the page.
322 suggestionsElement.replaceWith(newHTML);
323 // Get the element again.
273b2556 324 suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
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);
273b2556
DW
330 if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) ||
331 (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) {
60a1ea56
DW
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.
81c471e2 339 inputElement.attr('aria-expanded', true);
60a1ea56 340 if (matchingElements) {
60a1ea56
DW
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.
273b2556
DW
344 if (!options.tags) {
345 activateItem(0, state);
60a1ea56
DW
346 }
347 } else {
81c471e2
DW
348 // Nothing matches. Tell them that.
349 str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) {
350 suggestionsElement.html(nosuggestionsstr);
351 });
60a1ea56
DW
352 }
353 }).fail(notification.exception);
354
355 };
356
357 /**
358 * Create a new item for the list (a tag).
359 *
360 * @method createItem
361 * @private
273b2556
DW
362 * @param {Object} options The original options for the autocomplete.
363 * @param {Object} state State variables for the autocomplete.
60a1ea56
DW
364 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
365 */
273b2556 366 var createItem = function(options, state, originalSelect) {
60a1ea56 367 // Find the element in the DOM.
273b2556 368 var inputElement = $(document.getElementById(state.inputId));
60a1ea56
DW
369 // Get the current text in the input field.
370 var query = inputElement.val();
371 var tags = query.split(',');
372 var found = false;
373
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 !== '') {
273b2556 378 if (!options.multiple) {
60a1ea56
DW
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);
bdd60287
DW
395 // We mark newly created custom options as we handle them differently if they are "deselected".
396 option.attr('data-iscustom', true);
60a1ea56
DW
397 }
398 }
399 });
bdd60287 400
273b2556 401 updateSelectionList(options, state, originalSelect);
bdd60287 402
60a1ea56
DW
403 // Clear the input field.
404 inputElement.val('');
405 // Close the suggestions list.
273b2556 406 closeSuggestions(state);
60a1ea56
DW
407 };
408
60a1ea56
DW
409 /**
410 * Select the currently active item from the suggestions list.
411 *
412 * @method selectCurrentItem
413 * @private
273b2556
DW
414 * @param {Object} options The original options for the autocomplete.
415 * @param {Object} state State variables for the autocomplete.
60a1ea56
DW
416 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
417 */
273b2556 418 var selectCurrentItem = function(options, state, originalSelect) {
60a1ea56 419 // Find the elements in the page.
273b2556
DW
420 var inputElement = $(document.getElementById(state.inputId));
421 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
422 // Here loop through suggestions and set val to join of all selected items.
423
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.
273b2556 429 if (!options.multiple) {
60a1ea56
DW
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.
273b2556 439 updateSelectionList(options, state, originalSelect);
60a1ea56
DW
440 // Clear the input element.
441 inputElement.val('');
442 // Close the list of suggestions.
273b2556 443 closeSuggestions(state);
60a1ea56
DW
444 };
445
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.
273b2556
DW
452 * @param {Object} options The original options for the autocomplete.
453 * @param {Object} state The state variables for the autocomplete.
60a1ea56 454 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
60a1ea56
DW
455 * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
456 */
273b2556 457 var updateAjax = function(e, options, state, originalSelect, ajaxHandler) {
60a1ea56
DW
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).
273b2556 461 ajaxHandler.transport(options.selector, query, function(results) {
60a1ea56 462 // We got a result - pass it through the translator before using it.
273b2556 463 var processedResults = ajaxHandler.processResults(options.selector, results);
60a1ea56
DW
464 var existingValues = [];
465
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.
273b2556 485 updateSuggestions(options, state, '', originalSelect);
60a1ea56
DW
486 }, notification.exception);
487 };
488
489 /**
490 * Add all the event listeners required for keyboard nav, blur clicks etc.
491 *
492 * @method addNavigation
493 * @private
273b2556
DW
494 * @param {Object} options The options used to create this autocomplete element.
495 * @param {Object} state State variables for this autocomplete element.
60a1ea56 496 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
60a1ea56 497 */
273b2556 498 var addNavigation = function(options, state, originalSelect) {
60a1ea56 499 // Start with the input element.
273b2556 500 var inputElement = $(document.getElementById(state.inputId));
60a1ea56
DW
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.
273b2556 506 if (!options.showSuggestions) {
97d2ea7f
DW
507 // Do not consume this event.
508 return true;
509 } else if (inputElement.attr('aria-expanded') === "true") {
273b2556 510 activateNextItem(state);
60a1ea56 511 } else {
d304952b 512 // Handle ajax population of suggestions.
273b2556
DW
513 if (!inputElement.val() && options.ajax) {
514 require([options.ajax], function(ajaxHandler) {
515 updateAjax(e, options, state, originalSelect, ajaxHandler);
d304952b
AG
516 });
517 } else {
518 // Else - open the suggestions list.
273b2556 519 updateSuggestions(options, state, inputElement.val(), originalSelect);
d304952b 520 }
60a1ea56
DW
521 }
522 // We handled this event, so prevent it.
523 e.preventDefault();
524 return false;
525 case KEYS.COMMA:
273b2556 526 if (options.tags) {
60a1ea56 527 // If we are allowing tags, comma should create a tag (or enter).
273b2556 528 createItem(options, state, originalSelect);
60a1ea56
DW
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.
273b2556 535 activatePreviousItem(state);
60a1ea56
DW
536 // We handled this event, so prevent it.
537 e.preventDefault();
538 return false;
539 case KEYS.ENTER:
273b2556 540 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
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.
273b2556
DW
544 selectCurrentItem(options, state, originalSelect);
545 } else if (options.tags) {
60a1ea56 546 // If tags are enabled, create a tag.
273b2556 547 createItem(options, state, originalSelect);
60a1ea56
DW
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.
273b2556 555 closeSuggestions(state);
60a1ea56
DW
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() {
273b2556
DW
565 if (options.tags) {
566 createItem(options, state, originalSelect);
60a1ea56
DW
567 }
568 });
e1db2b41
RW
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')) {
273b2556
DW
575 if (options.tags) {
576 createItem(options, state, originalSelect);
527bde6e 577 }
273b2556 578 closeSuggestions(state);
60a1ea56 579 }
60a1ea56
DW
580 }, 500);
581 });
273b2556
DW
582 if (options.showSuggestions) {
583 var arrowElement = $(document.getElementById(state.downArrowId));
97d2ea7f
DW
584 arrowElement.on('click', function() {
585 // Prevent the close timer, or we will open, then close the suggestions.
586 inputElement.focus();
97d2ea7f 587 // Show the suggestions list.
273b2556 588 updateSuggestions(options, state, inputElement.val(), originalSelect);
97d2ea7f
DW
589 });
590 }
60a1ea56 591
273b2556 592 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
593 suggestionsElement.parent().on('click', '[role=option]', function(e) {
594 // Handle clicks on suggestions.
595 var element = $(e.currentTarget).closest('[role=option]');
273b2556 596 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
597 // Find the index of the clicked on suggestion.
598 var current = suggestionsElement.children('[aria-hidden=false]').index(element);
599 // Activate it.
273b2556 600 activateItem(current, state);
60a1ea56 601 // And select it.
273b2556 602 selectCurrentItem(options, state, originalSelect);
60a1ea56 603 });
273b2556 604 var selectionElement = $(document.getElementById(state.selectionId));
60a1ea56 605 // Handle clicks on the selected items (will unselect an item).
adebc069 606 selectionElement.on('click', '[role=listitem]', function(e) {
563fe0a5
RW
607 // Get the item that was clicked.
608 var item = $(e.currentTarget);
609 // Remove it from the selection.
273b2556 610 deselectItem(options, state, item, originalSelect);
60a1ea56
DW
611 });
612 // Keyboard navigation for the selection list.
adebc069 613 selectionElement.on('keydown', function(e) {
60a1ea56
DW
614 switch (e.keyCode) {
615 case KEYS.DOWN:
616 // Choose the next selection item.
273b2556 617 activateNextSelection(state);
60a1ea56
DW
618 // We handled this event, so prevent it.
619 e.preventDefault();
620 return false;
621 case KEYS.UP:
622 // Choose the previous selection item.
273b2556 623 activatePreviousSelection(state);
60a1ea56
DW
624 // We handled this event, so prevent it.
625 e.preventDefault();
626 return false;
627 case KEYS.SPACE:
628 case KEYS.ENTER:
563fe0a5 629 // Get the item that is currently selected.
273b2556 630 var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection=true]');
563fe0a5
RW
631 if (selectedItem) {
632 // Unselect this item.
273b2556 633 deselectItem(options, state, selectedItem, originalSelect);
563fe0a5
RW
634 // We handled this event, so prevent it.
635 e.preventDefault();
636 }
60a1ea56
DW
637 return false;
638 }
639 return true;
640 });
641 // Whenever the input field changes, update the suggestion list.
273b2556 642 if (options.showSuggestions) {
97d2ea7f
DW
643 inputElement.on('input', function(e) {
644 var query = $(e.currentTarget).val();
645 var last = $(e.currentTarget).data('last-value');
97d2ea7f
DW
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).
861c1dea
DW
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) {
273b2556 652 updateSuggestions(options, state, query, originalSelect);
97d2ea7f 653 }
861c1dea 654 $(e.currentTarget).data('last-value', query);
97d2ea7f
DW
655 });
656 }
60a1ea56
DW
657 };
658
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.
ecbc2a2f 671 * @param {Boolean} caseSensitive - If search has to be made case sensitive.
60a1ea56 672 */
97d2ea7f 673 enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions) {
60a1ea56 674 // Set some default values.
273b2556
DW
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;
60a1ea56 685 }
273b2556
DW
686 if (typeof ajax !== "undefined") {
687 options.ajax = ajax;
60a1ea56 688 }
273b2556
DW
689 if (typeof caseSensitive !== "undefined") {
690 options.caseSensitive = caseSensitive;
ecbc2a2f 691 }
273b2556
DW
692 if (typeof showSuggestions !== "undefined") {
693 options.showSuggestions = showSuggestions;
97d2ea7f 694 }
60a1ea56
DW
695
696 // Look for the select element.
697 var originalSelect = $(selector);
698 if (!originalSelect) {
699 log.debug('Selector not found: ' + selector);
700 return false;
701 }
702
703 // Hide the original select.
704 originalSelect.hide().attr('aria-hidden', true);
705
706 // Find or generate some ids.
273b2556
DW
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');
715
716 var originalLabel = $('[for=' + state.selectId + ']');
60a1ea56 717 // Create the new markup and insert it after the select.
273b2556 718 var suggestions = [];
60a1ea56 719 originalSelect.children('option').each(function(index, option) {
273b2556 720 suggestions[index] = { label: option.innerHTML, value: $(option).attr('value') };
60a1ea56
DW
721 });
722
723 // Render all the parts of our UI.
273b2556
DW
724 var context = $.extend({}, options, state);
725 context.options = suggestions;
726 context.items = [];
727
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);
60a1ea56
DW
731
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.
273b2556 738 originalLabel.attr('for', state.inputId);
60a1ea56 739 // Add the event handlers.
273b2556
DW
740 addNavigation(options, state, originalSelect);
741
742 var inputElement = $(document.getElementById(state.inputId));
743 var suggestionsElement = $(document.getElementById(state.suggestionsId));
60a1ea56
DW
744 // Hide the suggestions by default.
745 suggestionsElement.hide().attr('aria-hidden', true);
746
747 // If this field uses ajax, set it up.
273b2556
DW
748 if (options.ajax) {
749 require([options.ajax], function(ajaxHandler) {
60a1ea56 750 var handler = function(e) {
273b2556 751 updateAjax(e, options, state, originalSelect, ajaxHandler);
60a1ea56
DW
752 };
753 // Trigger an ajax update after the text field value changes.
754 inputElement.on("input keypress", handler);
273b2556 755 var arrowElement = $(document.getElementById(state.downArrowId));
60a1ea56
DW
756 arrowElement.on("click", handler);
757 });
758 }
759 // Show the current values in the selection list.
273b2556 760 updateSelectionList(options, state, originalSelect);
60a1ea56
DW
761 });
762 }
763 };
764});