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