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