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