// This file is part of Moodle - http://moodle.org/ // // Moodle is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // Moodle is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** * Autocomplete wrapper for select2 library. * * @module core/form-autocomplete * @class autocomplete * @package core * @copyright 2015 Damyon Wiese * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later * @since 3.0 */ /* globals require: false */ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification'], function($, log, str, templates, notification) { // Private functions and variables. /** @var {Object} KEYS - List of keycode constants. */ var KEYS = { DOWN: 40, ENTER: 13, SPACE: 32, ESCAPE: 27, COMMA: 188, UP: 38 }; /** * Make an item in the selection list "active". * * @method activateSelection * @private * @param {Number} index The index in the current (visible) list of selection. * @param {Object} state State variables for this autocomplete element. */ var activateSelection = function(index, state) { // Find the elements in the DOM. var selectionElement = $(document.getElementById(state.selectionId)); // Count the visible items. var length = selectionElement.children('[aria-selected=true]').length; // Limit the index to the upper/lower bounds of the list (wrap in both directions). index = index % length; while (index < 0) { index += length; } // Find the specified element. var element = $(selectionElement.children('[aria-selected=true]').get(index)); // Create an id we can assign to this element. var itemId = state.selectionId + '-' + index; // Deselect all the selections. selectionElement.children().attr('data-active-selection', false).attr('id', ''); // Select only this suggestion and assign it the id. element.attr('data-active-selection', true).attr('id', itemId); // Tell the input field it has a new active descendant so the item is announced. selectionElement.attr('aria-activedescendant', itemId); }; /** * Update the element that shows the currently selected items. * * @method updateSelectionList * @private * @param {Object} options Original options for this autocomplete element. * @param {Object} state State variables for this autocomplete element. * @param {JQuery} originalSelect The JQuery object matching the hidden select list. */ var updateSelectionList = function(options, state, originalSelect) { // Build up a valid context to re-render the template. var items = []; var newSelection = $(document.getElementById(state.selectionId)); var activeId = newSelection.attr('aria-activedescendant'); var activeValue = false; if (activeId) { activeValue = $(document.getElementById(activeId)).attr('data-value'); } originalSelect.children('option').each(function(index, ele) { if ($(ele).prop('selected')) { items.push({label: $(ele).html(), value: $(ele).attr('value')}); } }); var context = $.extend({items: items}, options, state); // Render the template. templates.render('core/form_autocomplete_selection', context).done(function(newHTML) { // Add it to the page. newSelection.empty().append($(newHTML).html()); if (activeValue !== false) { // Reselect any previously selected item. newSelection.children('[aria-selected=true]').each(function(index, ele) { if ($(ele).attr('data-value') === activeValue) { activateSelection(index, state); } }); } }).fail(notification.exception); }; /** * Notify of a change in the selection. * * @param {jQuery} originalSelect The jQuery object matching the hidden select list. */ var notifyChange = function(originalSelect) { if (typeof M.core_formchangechecker !== 'undefined') { M.core_formchangechecker.set_form_changed(); } originalSelect.change(); }; /** * Remove the given item from the list of selected things. * * @method deselectItem * @private * @param {Object} options Original options for this autocomplete element. * @param {Object} state State variables for this autocomplete element. * @param {Element} item The item to be deselected. * @param {Element} originalSelect The original select list. */ var deselectItem = function(options, state, item, originalSelect) { var selectedItemValue = $(item).attr('data-value'); // We can only deselect items if this is a multi-select field. if (options.multiple) { // Look for a match, and toggle the selected property if there is a match. originalSelect.children('option').each(function(index, ele) { if ($(ele).attr('value') == selectedItemValue) { $(ele).prop('selected', false); // We remove newly created custom tags from the suggestions list when they are deselected. if ($(ele).attr('data-iscustom')) { $(ele).remove(); } } }); } // Rerender the selection list. updateSelectionList(options, state, originalSelect); // Notifiy that the selection changed. notifyChange(originalSelect); }; /** * Make an item in the suggestions "active" (about to be selected). * * @method activateItem * @private * @param {Number} index The index in the current (visible) list of suggestions. * @param {Object} state State variables for this instance of autocomplete. */ var activateItem = function(index, state) { // Find the elements in the DOM. var inputElement = $(document.getElementById(state.inputId)); var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Count the visible items. var length = suggestionsElement.children('[aria-hidden=false]').length; // Limit the index to the upper/lower bounds of the list (wrap in both directions). index = index % length; while (index < 0) { index += length; } // Find the specified element. var element = $(suggestionsElement.children('[aria-hidden=false]').get(index)); // Find the index of this item in the full list of suggestions (including hidden). var globalIndex = $(suggestionsElement.children('[role=option]')).index(element); // Create an id we can assign to this element. var itemId = state.suggestionsId + '-' + globalIndex; // Deselect all the suggestions. suggestionsElement.children().attr('aria-selected', false).attr('id', ''); // Select only this suggestion and assign it the id. element.attr('aria-selected', true).attr('id', itemId); // Tell the input field it has a new active descendant so the item is announced. inputElement.attr('aria-activedescendant', itemId); // Scroll it into view. var scrollPos = element.offset().top - suggestionsElement.offset().top + suggestionsElement.scrollTop() - (suggestionsElement.height() / 2); suggestionsElement.animate({ scrollTop: scrollPos }, 100); }; /** * Find the index of the current active suggestion, and activate the next one. * * @method activateNextItem * @private * @param {Object} state State variable for this auto complete element. */ var activateNextItem = function(state) { // Find the list of suggestions. var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Find the active one. var element = suggestionsElement.children('[aria-selected=true]'); // Find it's index. var current = suggestionsElement.children('[aria-hidden=false]').index(element); // Activate the next one. activateItem(current + 1, state); }; /** * Find the index of the current active selection, and activate the previous one. * * @method activatePreviousSelection * @private * @param {Object} state State variables for this instance of autocomplete. */ var activatePreviousSelection = function(state) { // Find the list of selections. var selectionsElement = $(document.getElementById(state.selectionId)); // Find the active one. var element = selectionsElement.children('[data-active-selection=true]'); if (!element) { activateSelection(0, state); return; } // Find it's index. var current = selectionsElement.children('[aria-selected=true]').index(element); // Activate the next one. activateSelection(current - 1, state); }; /** * Find the index of the current active selection, and activate the next one. * * @method activateNextSelection * @private * @param {Object} state State variables for this instance of autocomplete. */ var activateNextSelection = function(state) { // Find the list of selections. var selectionsElement = $(document.getElementById(state.selectionId)); // Find the active one. var element = selectionsElement.children('[data-active-selection=true]'); if (!element) { activateSelection(0, state); return; } // Find it's index. var current = selectionsElement.children('[aria-selected=true]').index(element); // Activate the next one. activateSelection(current + 1, state); }; /** * Find the index of the current active suggestion, and activate the previous one. * * @method activatePreviousItem * @private * @param {Object} state State variables for this autocomplete element. */ var activatePreviousItem = function(state) { // Find the list of suggestions. var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Find the active one. var element = suggestionsElement.children('[aria-selected=true]'); // Find it's index. var current = suggestionsElement.children('[aria-hidden=false]').index(element); // Activate the next one. activateItem(current - 1, state); }; /** * Close the list of suggestions. * * @method closeSuggestions * @private * @param {Object} state State variables for this autocomplete element. */ var closeSuggestions = function(state) { // Find the elements in the DOM. var inputElement = $(document.getElementById(state.inputId)); var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Announce the list of suggestions was closed, and read the current list of selections. inputElement.attr('aria-expanded', false).attr('aria-activedescendant', state.selectionId); // Hide the suggestions list (from screen readers too). suggestionsElement.hide().attr('aria-hidden', true); }; /** * Rebuild the list of suggestions based on the current values in the select list, and the query. * * @method updateSuggestions * @private * @param {Object} options The original options for this autocomplete. * @param {Object} state The state variables for this autocomplete. * @param {String} query The current text for the search string. * @param {JQuery} originalSelect The JQuery object matching the hidden select list. */ var updateSuggestions = function(options, state, query, originalSelect) { // Find the elements in the DOM. var inputElement = $(document.getElementById(state.inputId)); var suggestionsElement = $(document.getElementById(state.suggestionsId)); // Used to track if we found any visible suggestions. var matchingElements = false; // Options is used by the context when rendering the suggestions from a template. var suggestions = []; originalSelect.children('option').each(function(index, option) { if ($(option).prop('selected') !== true) { suggestions[suggestions.length] = {label: option.innerHTML, value: $(option).attr('value')}; } }); // Re-render the list of suggestions. var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase(); var context = $.extend({options: suggestions}, options, state); templates.render( 'core/form_autocomplete_suggestions', context ).done(function(newHTML) { // We have the new template, insert it in the page. suggestionsElement.replaceWith(newHTML); // Get the element again. suggestionsElement = $(document.getElementById(state.suggestionsId)); // Show it if it is hidden. suggestionsElement.show().attr('aria-hidden', false); // For each option in the list, hide it if it doesn't match the query. suggestionsElement.children().each(function(index, node) { node = $(node); if ((options.caseSensitive && node.text().indexOf(searchquery) > -1) || (!options.caseSensitive && node.text().toLocaleLowerCase().indexOf(searchquery) > -1)) { node.show().attr('aria-hidden', false); matchingElements = true; } else { node.hide().attr('aria-hidden', true); } }); // If we found any matches, show the list. inputElement.attr('aria-expanded', true); if (matchingElements) { // We only activate the first item in the list if tags is false, // because otherwise "Enter" would select the first item, instead of // creating a new tag. if (!options.tags) { activateItem(0, state); } } else { // Nothing matches. Tell them that. str.get_string('nosuggestions', 'form').done(function(nosuggestionsstr) { suggestionsElement.html(nosuggestionsstr); }); } }).fail(notification.exception); }; /** * Create a new item for the list (a tag). * * @method createItem * @private * @param {Object} options The original options for the autocomplete. * @param {Object} state State variables for the autocomplete. * @param {JQuery} originalSelect The JQuery object matching the hidden select list. */ var createItem = function(options, state, originalSelect) { // Find the element in the DOM. var inputElement = $(document.getElementById(state.inputId)); // Get the current text in the input field. var query = inputElement.val(); var tags = query.split(','); var found = false; $.each(tags, function(tagindex, tag) { // If we can only select one at a time, deselect any current value. tag = tag.trim(); if (tag !== '') { if (!options.multiple) { originalSelect.children('option').prop('selected', false); } // Look for an existing option in the select list that matches this new tag. originalSelect.children('option').each(function(index, ele) { if ($(ele).attr('value') == tag) { found = true; $(ele).prop('selected', true); } }); // Only create the item if it's new. if (!found) { var option = $('