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