1 // This file is part of Moodle - http://moodle.org/
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.
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.
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/>.
17 * Autocomplete wrapper for select2 library.
19 * @module core/form-autocomplete
22 * @copyright 2015 Damyon Wiese <damyon@moodle.com>
23 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26 /* globals require: false */
27 define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification'], function($, log, str, templates, notification) {
29 // Private functions and variables.
30 /** @var {Object} KEYS - List of keycode constants. */
41 * Make an item in the selection list "active".
43 * @method activateSelection
45 * @param {Number} index The index in the current (visible) list of selection.
46 * @param {Object} state State variables for this autocomplete element.
48 var activateSelection = function(index, state) {
49 // Find the elements in the DOM.
50 var selectionElement = $(document.getElementById(state.selectionId));
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;
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.
62 var itemId = state.selectionId + '-' + index;
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);
73 * Update the element that shows the currently selected items.
75 * @method updateSelectionList
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.
81 var updateSelectionList = function(options, state, originalSelect) {
82 // Build up a valid context to re-render the template.
84 var newSelection = $(document.getElementById(state.selectionId));
85 var activeId = newSelection.attr('aria-activedescendant');
86 var activeValue = false;
89 activeValue = $(document.getElementById(activeId)).attr('data-value');
91 originalSelect.children('option').each(function(index, ele) {
92 if ($(ele).prop('selected')) {
93 items.push( { label: $(ele).html(), value: $(ele).attr('value') } );
96 var context = $.extend({ items: items }, options, state);
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());
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);
111 }).fail(notification.exception);
112 // Because this function get's called after changing the selection, this is a good place
113 // to trigger a change notification.
114 originalSelect.change();
119 * Remove the given item from the list of selected things.
121 * @method deselectItem
123 * @param {Object} options Original options for this autocomplete element.
124 * @param {Object} state State variables for this autocomplete element.
125 * @param {Element} The item to be deselected.
126 * @param {Element} originalSelect The original select list.
128 var deselectItem = function(options, state, item, originalSelect) {
129 var selectedItemValue = $(item).attr('data-value');
131 // We can only deselect items if this is a multi-select field.
132 if (options.multiple) {
133 // Look for a match, and toggle the selected property if there is a match.
134 originalSelect.children('option').each(function(index, ele) {
135 if ($(ele).attr('value') == selectedItemValue) {
136 $(ele).prop('selected', false);
137 // We remove newly created custom tags from the suggestions list when they are deselected.
138 if ($(ele).attr('data-iscustom')) {
144 // Rerender the selection list.
145 updateSelectionList(options, state, originalSelect);
149 * Make an item in the suggestions "active" (about to be selected).
151 * @method activateItem
153 * @param {Number} index The index in the current (visible) list of suggestions.
154 * @param {Object} state State variables for this instance of autocomplete.
156 var activateItem = function(index, state) {
157 // Find the elements in the DOM.
158 var inputElement = $(document.getElementById(state.inputId));
159 var suggestionsElement = $(document.getElementById(state.suggestionsId));
161 // Count the visible items.
162 var length = suggestionsElement.children('[aria-hidden=false]').length;
163 // Limit the index to the upper/lower bounds of the list (wrap in both directions).
164 index = index % length;
168 // Find the specified element.
169 var element = $(suggestionsElement.children('[aria-hidden=false]').get(index));
170 // Find the index of this item in the full list of suggestions (including hidden).
171 var globalIndex = $(suggestionsElement.children('[role=option]')).index(element);
172 // Create an id we can assign to this element.
173 var itemId = state.suggestionsId + '-' + globalIndex;
175 // Deselect all the suggestions.
176 suggestionsElement.children().attr('aria-selected', false).attr('id', '');
177 // Select only this suggestion and assign it the id.
178 element.attr('aria-selected', true).attr('id', itemId);
179 // Tell the input field it has a new active descendant so the item is announced.
180 inputElement.attr('aria-activedescendant', itemId);
182 // Scroll it into view.
183 var scrollPos = element.offset().top
184 - suggestionsElement.offset().top
185 + suggestionsElement.scrollTop()
186 - (suggestionsElement.height() / 2);
187 suggestionsElement.animate({
193 * Find the index of the current active suggestion, and activate the next one.
195 * @method activateNextItem
197 * @param {Object} state State variable for this auto complete element.
199 var activateNextItem = function(state) {
200 // Find the list of suggestions.
201 var suggestionsElement = $(document.getElementById(state.suggestionsId));
202 // Find the active one.
203 var element = suggestionsElement.children('[aria-selected=true]');
205 var current = suggestionsElement.children('[aria-hidden=false]').index(element);
206 // Activate the next one.
207 activateItem(current+1, state);
211 * Find the index of the current active selection, and activate the previous one.
213 * @method activatePreviousSelection
215 * @param {Object} state State variables for this instance of autocomplete.
217 var activatePreviousSelection = function(state) {
218 // Find the list of selections.
219 var selectionsElement = $(document.getElementById(state.selectionId));
220 // Find the active one.
221 var element = selectionsElement.children('[data-active-selection=true]');
223 activateSelection(0, state);
227 var current = selectionsElement.children('[aria-selected=true]').index(element);
228 // Activate the next one.
229 activateSelection(current-1, state);
232 * Find the index of the current active selection, and activate the next one.
234 * @method activateNextSelection
236 * @param {Object} state State variables for this instance of autocomplete.
238 var activateNextSelection = function(state) {
239 // Find the list of selections.
240 var selectionsElement = $(document.getElementById(state.selectionId));
241 // Find the active one.
242 var element = selectionsElement.children('[data-active-selection=true]');
244 activateSelection(0, state);
248 var current = selectionsElement.children('[aria-selected=true]').index(element);
249 // Activate the next one.
250 activateSelection(current+1, state);
254 * Find the index of the current active suggestion, and activate the previous one.
256 * @method activatePreviousItem
258 * @param {Object} state State variables for this autocomplete element.
260 var activatePreviousItem = function(state) {
261 // Find the list of suggestions.
262 var suggestionsElement = $(document.getElementById(state.suggestionsId));
263 // Find the active one.
264 var element = suggestionsElement.children('[aria-selected=true]');
266 var current = suggestionsElement.children('[aria-hidden=false]').index(element);
267 // Activate the next one.
268 activateItem(current-1, state);
272 * Close the list of suggestions.
274 * @method closeSuggestions
276 * @param {Object} state State variables for this autocomplete element.
278 var closeSuggestions = function(state) {
279 // Find the elements in the DOM.
280 var inputElement = $(document.getElementById(state.inputId));
281 var suggestionsElement = $(document.getElementById(state.suggestionsId));
283 // Announce the list of suggestions was closed, and read the current list of selections.
284 inputElement.attr('aria-expanded', false).attr('aria-activedescendant', state.selectionId);
285 // Hide the suggestions list (from screen readers too).
286 suggestionsElement.hide().attr('aria-hidden', true);
290 * Rebuild the list of suggestions based on the current values in the select list, and the query.
292 * @method updateSuggestions
294 * @param {Object} options The original options for this autocomplete.
295 * @param {Object} state The state variables for this autocomplete.
296 * @param {String} query The current text for the search string.
297 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
299 var updateSuggestions = function(options, state, query, originalSelect) {
300 // Find the elements in the DOM.
301 var inputElement = $(document.getElementById(state.inputId));
302 var suggestionsElement = $(document.getElementById(state.suggestionsId));
304 // Used to track if we found any visible suggestions.
305 var matchingElements = false;
306 // Options is used by the context when rendering the suggestions from a template.
307 var suggestions = [];
308 originalSelect.children('option').each(function(index, option) {
309 if ($(option).prop('selected') !== true) {
310 suggestions[suggestions.length] = { label: option.innerHTML, value: $(option).attr('value') };
314 // Re-render the list of suggestions.
315 var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
316 var context = $.extend({ options: suggestions}, options, state);
318 'core/form_autocomplete_suggestions',
320 ).done(function(newHTML) {
321 // We have the new template, insert it in the page.
322 suggestionsElement.replaceWith(newHTML);
323 // Get the element again.
324 suggestionsElement = $(document.getElementById(state.suggestionsId));
325 // Show it if it is hidden.
326 suggestionsElement.show().attr('aria-hidden', false);
327 // For each option in the list, hide it if it doesn't match the query.
328 suggestionsElement.children().each(function(index, node) {
330 if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) ||
331 (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) {
332 node.show().attr('aria-hidden', false);
333 matchingElements = true;
335 node.hide().attr('aria-hidden', true);
338 // If we found any matches, show the list.
339 inputElement.attr('aria-expanded', true);
340 if (matchingElements) {
341 // We only activate the first item in the list if tags is false,
342 // because otherwise "Enter" would select the first item, instead of
343 // creating a new tag.
345 activateItem(0, state);
348 // Nothing matches. Tell them that.
349 str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) {
350 suggestionsElement.html(nosuggestionsstr);
353 }).fail(notification.exception);
358 * Create a new item for the list (a tag).
362 * @param {Object} options The original options for the autocomplete.
363 * @param {Object} state State variables for the autocomplete.
364 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
366 var createItem = function(options, state, originalSelect) {
367 // Find the element in the DOM.
368 var inputElement = $(document.getElementById(state.inputId));
369 // Get the current text in the input field.
370 var query = inputElement.val();
371 var tags = query.split(',');
374 $.each(tags, function(tagindex, tag) {
375 // If we can only select one at a time, deselect any current value.
378 if (!options.multiple) {
379 originalSelect.children('option').prop('selected', false);
381 // Look for an existing option in the select list that matches this new tag.
382 originalSelect.children('option').each(function(index, ele) {
383 if ($(ele).attr('value') == tag) {
385 $(ele).prop('selected', true);
388 // Only create the item if it's new.
390 var option = $('<option>');
392 option.attr('value', tag);
393 originalSelect.append(option);
394 option.prop('selected', true);
395 // We mark newly created custom options as we handle them differently if they are "deselected".
396 option.attr('data-iscustom', true);
401 updateSelectionList(options, state, originalSelect);
403 // Clear the input field.
404 inputElement.val('');
405 // Close the suggestions list.
406 closeSuggestions(state);
410 * Select the currently active item from the suggestions list.
412 * @method selectCurrentItem
414 * @param {Object} options The original options for the autocomplete.
415 * @param {Object} state State variables for the autocomplete.
416 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
418 var selectCurrentItem = function(options, state, originalSelect) {
419 // Find the elements in the page.
420 var inputElement = $(document.getElementById(state.inputId));
421 var suggestionsElement = $(document.getElementById(state.suggestionsId));
422 // Here loop through suggestions and set val to join of all selected items.
424 var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value');
425 // The select will either be a single or multi select, so the following will either
426 // select one or more items correctly.
427 // Take care to use 'prop' and not 'attr' for selected properties.
428 // If only one can be selected at a time, start by deselecting everything.
429 if (!options.multiple) {
430 originalSelect.children('option').prop('selected', false);
432 // Look for a match, and toggle the selected property if there is a match.
433 originalSelect.children('option').each(function(index, ele) {
434 if ($(ele).attr('value') == selectedItemValue) {
435 $(ele).prop('selected', true);
438 // Rerender the selection list.
439 updateSelectionList(options, state, originalSelect);
440 // Clear the input element.
441 inputElement.val('');
442 // Close the list of suggestions.
443 closeSuggestions(state);
447 * Fetch a new list of options via ajax.
451 * @param {Event} e The event that triggered this update.
452 * @param {Object} options The original options for the autocomplete.
453 * @param {Object} state The state variables for the autocomplete.
454 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
455 * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
457 var updateAjax = function(e, options, state, originalSelect, ajaxHandler) {
458 // Get the query to pass to the ajax function.
459 var query = $(e.currentTarget).val();
460 // Call the transport function to do the ajax (name taken from Select2).
461 ajaxHandler.transport(options.selector, query, function(results) {
462 // We got a result - pass it through the translator before using it.
463 var processedResults = ajaxHandler.processResults(options.selector, results);
464 var existingValues = [];
466 // Now destroy all options that are not currently selected.
467 originalSelect.children('option').each(function(optionIndex, option) {
469 if (!option.prop('selected')) {
472 existingValues.push(option.attr('value'));
475 // And add all the new ones returned from ajax.
476 $.each(processedResults, function(resultIndex, result) {
477 if (existingValues.indexOf(result.value) === -1) {
478 var option = $('<option>');
479 option.append(result.label);
480 option.attr('value', result.value);
481 originalSelect.append(option);
484 // Update the list of suggestions now from the new values in the select list.
485 updateSuggestions(options, state, '', originalSelect);
486 }, notification.exception);
490 * Add all the event listeners required for keyboard nav, blur clicks etc.
492 * @method addNavigation
494 * @param {Object} options The options used to create this autocomplete element.
495 * @param {Object} state State variables for this autocomplete element.
496 * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
498 var addNavigation = function(options, state, originalSelect) {
499 // Start with the input element.
500 var inputElement = $(document.getElementById(state.inputId));
501 // Add keyboard nav with keydown.
502 inputElement.on('keydown', function(e) {
505 // If the suggestion list is open, move to the next item.
506 if (!options.showSuggestions) {
507 // Do not consume this event.
509 } else if (inputElement.attr('aria-expanded') === "true") {
510 activateNextItem(state);
512 // Handle ajax population of suggestions.
513 if (!inputElement.val() && options.ajax) {
514 require([options.ajax], function(ajaxHandler) {
515 updateAjax(e, options, state, originalSelect, ajaxHandler);
518 // Else - open the suggestions list.
519 updateSuggestions(options, state, inputElement.val(), originalSelect);
522 // We handled this event, so prevent it.
527 // If we are allowing tags, comma should create a tag (or enter).
528 createItem(options, state, originalSelect);
530 // We handled this event, so prevent it.
534 // Choose the previous active item.
535 activatePreviousItem(state);
536 // We handled this event, so prevent it.
540 var suggestionsElement = $(document.getElementById(state.suggestionsId));
541 if ((inputElement.attr('aria-expanded') === "true") &&
542 (suggestionsElement.children('[aria-selected=true]').length > 0)) {
543 // If the suggestion list has an active item, select it.
544 selectCurrentItem(options, state, originalSelect);
545 } else if (options.tags) {
546 // If tags are enabled, create a tag.
547 createItem(options, state, originalSelect);
549 // We handled this event, so prevent it.
553 if (inputElement.attr('aria-expanded') === "true") {
554 // If the suggestion list is open, close it.
555 closeSuggestions(state);
557 // We handled this event, so prevent it.
563 // Handler used to force set the value from behat.
564 inputElement.on('behat:set-value', function() {
566 createItem(options, state, originalSelect);
569 inputElement.on('blur', function() {
570 window.setTimeout(function() {
571 // Get the current element with focus.
572 var focusElement = $(document.activeElement);
573 // Only close the menu if the input hasn't regained focus.
574 if (focusElement.attr('id') != inputElement.attr('id')) {
576 createItem(options, state, originalSelect);
578 closeSuggestions(state);
582 if (options.showSuggestions) {
583 var arrowElement = $(document.getElementById(state.downArrowId));
584 arrowElement.on('click', function() {
585 // Prevent the close timer, or we will open, then close the suggestions.
586 inputElement.focus();
587 // Show the suggestions list.
588 updateSuggestions(options, state, inputElement.val(), originalSelect);
592 var suggestionsElement = $(document.getElementById(state.suggestionsId));
593 suggestionsElement.parent().on('click', '[role=option]', function(e) {
594 // Handle clicks on suggestions.
595 var element = $(e.currentTarget).closest('[role=option]');
596 var suggestionsElement = $(document.getElementById(state.suggestionsId));
597 // Find the index of the clicked on suggestion.
598 var current = suggestionsElement.children('[aria-hidden=false]').index(element);
600 activateItem(current, state);
602 selectCurrentItem(options, state, originalSelect);
604 var selectionElement = $(document.getElementById(state.selectionId));
605 // Handle clicks on the selected items (will unselect an item).
606 selectionElement.on('click', '[role=listitem]', function(e) {
607 // Get the item that was clicked.
608 var item = $(e.currentTarget);
609 // Remove it from the selection.
610 deselectItem(options, state, item, originalSelect);
612 // Keyboard navigation for the selection list.
613 selectionElement.on('keydown', function(e) {
616 // Choose the next selection item.
617 activateNextSelection(state);
618 // We handled this event, so prevent it.
622 // Choose the previous selection item.
623 activatePreviousSelection(state);
624 // We handled this event, so prevent it.
629 // Get the item that is currently selected.
630 var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection=true]');
632 // Unselect this item.
633 deselectItem(options, state, selectedItem, originalSelect);
634 // We handled this event, so prevent it.
641 // Whenever the input field changes, update the suggestion list.
642 if (options.showSuggestions) {
643 inputElement.on('input', function(e) {
644 var query = $(e.currentTarget).val();
645 var last = $(e.currentTarget).data('last-value');
646 // IE11 fires many more input events than required - even when the value has not changed.
647 // We need to only do this for real value changed events or the suggestions will be
648 // unclickable on IE11 (because they will be rebuilt before the click event fires).
649 // Note - because of this we cannot close the list when the query is empty or it will break
651 if (last !== query) {
652 updateSuggestions(options, state, query, originalSelect);
654 $(e.currentTarget).data('last-value', query);
659 return /** @alias module:core/form-autocomplete */ {
660 // Public variables and functions.
662 * Turn a boring select box into an auto-complete beast.
665 * @param {string} select The selector that identifies the select box.
666 * @param {boolean} tags Whether to allow support for tags (can define new entries).
667 * @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
668 * module must expose 2 functions "transport" and "processResults".
669 * These are modeled on Select2 see: https://select2.github.io/options.html#ajax
670 * @param {String} placeholder - The text to display before a selection is made.
671 * @param {Boolean} caseSensitive - If search has to be made case sensitive.
673 enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions) {
674 // Set some default values.
679 placeholder: placeholder,
680 caseSensitive: false,
681 showSuggestions: true
683 if (typeof tags !== "undefined") {
686 if (typeof ajax !== "undefined") {
689 if (typeof caseSensitive !== "undefined") {
690 options.caseSensitive = caseSensitive;
692 if (typeof showSuggestions !== "undefined") {
693 options.showSuggestions = showSuggestions;
696 // Look for the select element.
697 var originalSelect = $(selector);
698 if (!originalSelect) {
699 log.debug('Selector not found: ' + selector);
703 // Hide the original select.
704 originalSelect.hide().attr('aria-hidden', true);
706 // Find or generate some ids.
708 selectId: originalSelect.attr('id'),
709 inputId: 'form_autocomplete_input-' + $.now(),
710 suggestionsId: 'form_autocomplete_suggestions-' + $.now(),
711 selectionId: 'form_autocomplete_selection-' + $.now(),
712 downArrowId: 'form_autocomplete_downarrow-' + $.now()
714 options.multiple = originalSelect.attr('multiple');
716 var originalLabel = $('[for=' + state.selectId + ']');
717 // Create the new markup and insert it after the select.
718 var suggestions = [];
719 originalSelect.children('option').each(function(index, option) {
720 suggestions[index] = { label: option.innerHTML, value: $(option).attr('value') };
723 // Render all the parts of our UI.
724 var context = $.extend({}, options, state);
725 context.options = suggestions;
728 var renderInput = templates.render('core/form_autocomplete_input', context);
729 var renderDatalist = templates.render('core/form_autocomplete_suggestions', context);
730 var renderSelection = templates.render('core/form_autocomplete_selection', context);
732 $.when(renderInput, renderDatalist, renderSelection).done(function(input, suggestions, selection) {
733 // Add our new UI elements to the page.
734 originalSelect.after(suggestions);
735 originalSelect.after(input);
736 originalSelect.after(selection);
737 // Update the form label to point to the text input.
738 originalLabel.attr('for', state.inputId);
739 // Add the event handlers.
740 addNavigation(options, state, originalSelect);
742 var inputElement = $(document.getElementById(state.inputId));
743 var suggestionsElement = $(document.getElementById(state.suggestionsId));
744 // Hide the suggestions by default.
745 suggestionsElement.hide().attr('aria-hidden', true);
747 // If this field uses ajax, set it up.
749 require([options.ajax], function(ajaxHandler) {
750 var throttleTimeout = null;
751 var handler = function(e) {
752 updateAjax(e, options, state, originalSelect, ajaxHandler);
755 // For input events, we do not want to trigger many, many updates.
756 var throttledHandler = function(e) {
757 if (throttleTimeout !== null) {
758 window.clearTimeout(throttleTimeout);
759 throttleTimeout = null;
761 throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
763 // Trigger an ajax update after the text field value changes.
764 inputElement.on("input keypress", throttledHandler);
766 var arrowElement = $(document.getElementById(state.downArrowId));
767 arrowElement.on("click", handler);
770 // Show the current values in the selection list.
771 updateSelectionList(options, state, originalSelect);