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