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