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