MDL-51247 forms: All new aria-pimped autocomplete mform element.

Supports static list of options - or options fetched via ajax.
Has options for single,multi and tags support.
This commit is contained in:
Damyon Wiese 2015-09-18 10:48:19 +08:00 committed by Dan Poltawski
parent cd83fae089
commit 60a1ea56d9
15 changed files with 1320 additions and 4 deletions

View File

@ -49,6 +49,7 @@ $string['month'] = 'Month';
$string['mustbeoverriden'] = 'Abstract form_definition() method in class {$a} must be overridden, please fix the code.';
$string['nomethodforaddinghelpbutton'] = 'There is no method for adding a help button to form element {$a->name} (class {$a->classname})';
$string['nonexistentformelements'] = 'Trying to add help buttons to non-existent form elements : {$a}';
$string['noselection'] = 'No selection';
$string['optional'] = 'Optional';
$string['othersettings'] = 'Other settings';
$string['requiredelement'] = 'Required field';
@ -56,6 +57,7 @@ $string['revealpassword'] = 'Reveal';
$string['security'] = 'Security';
$string['selectallornone'] = 'Select all/none';
$string['selected'] = 'Selected';
$string['selecteditems'] = 'Selected items:';
$string['showadvanced'] = 'Show advanced';
$string['showless'] = 'Show less...';
$string['showmore'] = 'Show more...';

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,760 @@
// 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 <http://www.gnu.org/licenses/>.
/**
* Autocomplete wrapper for select2 library.
*
* @module core/form-autocomplete
* @class autocomplete
* @package core
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @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
};
/** @var {Number} closeSuggestionsTimer - integer used to cancel window.setTimeout. */
var closeSuggestionsTimer = null;
/**
* Make an item in the selection list "active".
*
* @method activateSelection
* @private
* @param {Number} index The index in the current (visible) list of selection.
* @param {String} selectionId The id of the selection element for this instance of the autocomplete.
*/
var activateSelection = function(index, selectionId) {
// Find the elements in the DOM.
var selectionElement = $(document.getElementById(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 = 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);
};
/**
* Remove the current item from the list of selected things.
*
* @method deselectCurrentSelection
* @private
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
* @param {String} selectionId The id of the selection element for this instance of the autocomplete.
* @param {Element} originalSelect The original select list.
* @param {Boolean} multiple Is this a multi select.
* @param {Boolean} tags Is this a tags select.
*/
var deselectCurrentSelection = function(inputId, suggestionsId, selectionId, originalSelect, multiple, tags) {
var selectionElement = $(document.getElementById(selectionId));
var selectedItemValue = selectionElement.children('[data-active-selection=true]').attr('data-value');
// The select will either be a single or multi select, so the following will either
// select one or more items correctly.
// Take care to use 'prop' and not 'attr' for selected properties.
// If only one can be selected at a time, start by deselecting everything.
if (!multiple) {
originalSelect.children('option').prop('selected', false);
}
// 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);
if (tags) {
$(ele).remove();
}
}
});
// Rerender the selection list.
updateSelectionList(selectionId, inputId, originalSelect, multiple);
};
/**
* 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 {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
*/
var activateItem = function(index, inputId, suggestionsId) {
// Find the elements in the DOM.
var inputElement = $(document.getElementById(inputId));
var suggestionsElement = $(document.getElementById(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 = 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);
};
/**
* Find the index of the current active suggestion, and activate the next one.
*
* @method activateNextItem
* @private
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
*/
var activateNextItem = function(inputId, suggestionsId) {
// Find the list of suggestions.
var suggestionsElement = $(document.getElementById(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, inputId, suggestionsId);
};
/**
* Find the index of the current active selection, and activate the previous one.
*
* @method activatePreviousSelection
* @private
* @param {String} selectionId The id of the selection element for this instance of the autocomplete.
*/
var activatePreviousSelection = function(selectionId) {
// Find the list of selections.
var selectionsElement = $(document.getElementById(selectionId));
// Find the active one.
var element = selectionsElement.children('[data-active-selection=true]');
if (!element) {
activateSelection(0, selectionId);
return;
}
// Find it's index.
var current = selectionsElement.children('[aria-selected=true]').index(element);
// Activate the next one.
activateSelection(current-1, selectionId);
};
/**
* Find the index of the current active selection, and activate the next one.
*
* @method activateNextSelection
* @private
* @param {String} selectionId The id of the selection element for this instance of the autocomplete.
*/
var activateNextSelection = function(selectionId) {
// Find the list of selections.
var selectionsElement = $(document.getElementById(selectionId));
// Find the active one.
var element = selectionsElement.children('[data-active-selection=true]');
if (!element) {
activateSelection(0, selectionId);
return;
}
// Find it's index.
var current = selectionsElement.children('[aria-selected=true]').index(element);
// Activate the next one.
activateSelection(current+1, selectionId);
};
/**
* Find the index of the current active suggestion, and activate the previous one.
*
* @method activatePreviousItem
* @private
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
*/
var activatePreviousItem = function(inputId, suggestionsId) {
// Find the list of suggestions.
var suggestionsElement = $(document.getElementById(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, inputId, suggestionsId);
};
/**
* Close the list of suggestions.
*
* @method closeSuggestions
* @private
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
*/
var closeSuggestions = function(inputId, suggestionsId, selectionId) {
// Find the elements in the DOM.
var inputElement = $(document.getElementById(inputId));
var suggestionsElement = $(document.getElementById(suggestionsId));
// Announce the list of suggestions was closed, and read the current list of selections.
inputElement.attr('aria-expanded', false).attr('aria-activedescendant', 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 {String} query The current query typed in the input field.
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
* @param {JQuery} originalSelect The JQuery object matching the hidden select list.
* @param {Boolean} multiple Are multiple items allowed to be selected?
* @param {Boolean} tags Are we allowed to create new items on the fly?
*/
var updateSuggestions = function(query, inputId, suggestionsId, originalSelect, multiple, tags) {
// Find the elements in the DOM.
var inputElement = $(document.getElementById(inputId));
var suggestionsElement = $(document.getElementById(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 options = [];
originalSelect.children('option').each(function(index, option) {
if ($(option).prop('selected') !== true) {
options[options.length] = { label: option.innerHTML, value: $(option).attr('value') };
}
});
// Re-render the list of suggestions.
templates.render(
'core/form_autocomplete_suggestions',
{ inputId: inputId, suggestionsId: suggestionsId, options: options, multiple: multiple}
).done(function(newHTML) {
// We have the new template, insert it in the page.
suggestionsElement.replaceWith(newHTML);
// Get the element again.
suggestionsElement = $(document.getElementById(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 (node.text().indexOf(query) > -1) {
node.show().attr('aria-hidden', false);
matchingElements = true;
} else {
node.hide().attr('aria-hidden', true);
}
});
// If we found any matches, show the list.
if (matchingElements) {
inputElement.attr('aria-expanded', true);
// 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 (!tags) {
activateItem(0, inputId, suggestionsId);
}
} else {
// Abort - nothing matches. Hide the suggestions properly.
suggestionsElement.hide();
suggestionsElement.attr('aria-hidden', true);
inputElement.attr('aria-expanded', false);
}
}).fail(notification.exception);
};
/**
* Create a new item for the list (a tag).
*
* @method createItem
* @private
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
* @param {Boolean} multiple Are multiple items allowed to be selected?
* @param {JQuery} originalSelect The JQuery object matching the hidden select list.
*/
var createItem = function(inputId, suggestionsId, selectionId, multiple, originalSelect) {
// Find the element in the DOM.
var inputElement = $(document.getElementById(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 (!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 = $('<option>');
option.append(tag);
option.attr('value', tag);
originalSelect.append(option);
option.prop('selected', true);
}
}
});
// Get the selection element.
var newSelection = $(document.getElementById(selectionId));
// Build up a valid context to re-render the selection.
var items = [];
originalSelect.children('option').each(function(index, ele) {
if ($(ele).prop('selected')) {
items.push( { label: $(ele).html(), value: $(ele).attr('value') } );
}
});
var context = {
selectionId: selectionId,
items: items,
multiple: multiple
};
// Re-render the selection.
templates.render('core/form_autocomplete_selection', context).done(function(newHTML) {
// Update the page.
newSelection.empty().append($(newHTML).html());
}).fail(notification.exception);
// Clear the input field.
inputElement.val('');
// Close the suggestions list.
closeSuggestions(inputId, suggestionsId, selectionId);
// Trigger a change event so that the mforms javascript can check for required fields etc.
originalSelect.change();
};
/**
* Update the element that shows the currently selected items.
*
* @method updateSelectionList
* @private
* @param {String} selectionId The id of the selections element for this instance of the autocomplete.
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {JQuery} originalSelect The JQuery object matching the hidden select list.
* @param {Boolean} multiple Does this element support multiple selections.
*/
var updateSelectionList = function(selectionId, inputId, originalSelect, multiple) {
// Build up a valid context to re-render the template.
var items = [];
var newSelection = $(document.getElementById(selectionId));
originalSelect.children('option').each(function(index, ele) {
if ($(ele).prop('selected')) {
items.push( { label: $(ele).html(), value: $(ele).attr('value') } );
}
});
var context = {
selectionId: selectionId,
items: items,
multiple: multiple
};
// Render the template.
templates.render('core/form_autocomplete_selection', context).done(function(newHTML) {
// Add it to the page.
newSelection.empty().append($(newHTML).html());
}).fail(notification.exception);
// Because this function get's called after changing the selection, this is a good place
// to trigger a change notification.
originalSelect.change();
};
/**
* Select the currently active item from the suggestions list.
*
* @method selectCurrentItem
* @private
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
* @param {String} selectionId The id of the selection element for this instance of the autocomplete.
* @param {Boolean} multiple Are multiple items allowed to be selected?
* @param {JQuery} originalSelect The JQuery object matching the hidden select list.
*/
var selectCurrentItem = function(inputId, suggestionsId, selectionId, multiple, originalSelect) {
// Find the elements in the page.
var inputElement = $(document.getElementById(inputId));
var suggestionsElement = $(document.getElementById(suggestionsId));
// Here loop through suggestions and set val to join of all selected items.
var selectedItemValue = suggestionsElement.children('[aria-selected=true]').attr('data-value');
// The select will either be a single or multi select, so the following will either
// select one or more items correctly.
// Take care to use 'prop' and not 'attr' for selected properties.
// If only one can be selected at a time, start by deselecting everything.
if (!multiple) {
originalSelect.children('option').prop('selected', false);
}
// 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', true);
}
});
// Rerender the selection list.
updateSelectionList(selectionId, inputId, originalSelect, multiple);
// Clear the input element.
inputElement.val('');
// Close the list of suggestions.
closeSuggestions(inputId, suggestionsId, selectionId);
};
/**
* Fetch a new list of options via ajax.
*
* @method updateAjax
* @private
* @param {Event} e The event that triggered this update.
* @param {String} selector The selector pointing to the original select.
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
* @param {JQuery} originalSelect The JQuery object matching the hidden select list.
* @param {Boolean} multiple Are multiple items allowed to be selected?
* @param {Boolean} tags Are we allowed to create new items on the fly?
* @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
*/
var updateAjax = function(e, selector, inputId, suggestionsId, originalSelect, multiple, tags, ajaxHandler) {
// Get the query to pass to the ajax function.
var query = $(e.currentTarget).val();
// Call the transport function to do the ajax (name taken from Select2).
ajaxHandler.transport(selector, query, function(results) {
// We got a result - pass it through the translator before using it.
var processedResults = ajaxHandler.processResults(selector, results);
var existingValues = [];
// Now destroy all options that are not currently selected.
originalSelect.children('option').each(function(optionIndex, option) {
option = $(option);
if (!option.prop('selected')) {
option.remove();
} else {
existingValues.push(option.attr('value'));
}
});
// And add all the new ones returned from ajax.
$.each(processedResults, function(resultIndex, result) {
if (existingValues.indexOf(result.value) === -1) {
var option = $('<option>');
option.append(result.label);
option.attr('value', result.value);
originalSelect.append(option);
}
});
// Update the list of suggestions now from the new values in the select list.
updateSuggestions('', inputId, suggestionsId, originalSelect, multiple, tags);
}, notification.exception);
};
/**
* Add all the event listeners required for keyboard nav, blur clicks etc.
*
* @method addNavigation
* @private
* @param {String} inputId The id of the input element for this instance of the autocomplete.
* @param {String} suggestionsId The id of the suggestions element for this instance of the autocomplete.
* @param {String} downArrowId The id of arrow to open the suggestions list.
* @param {String} selectionId The id of element that shows the current selections.
* @param {JQuery} originalSelect The JQuery object matching the hidden select list.
* @param {Boolean} multiple Are multiple items allowed to be selected?
* @param {Boolean} tags Are we allowed to create new items on the fly?
*/
var addNavigation = function(inputId, suggestionsId, downArrowId, selectionId, originalSelect, multiple, tags) {
// Start with the input element.
var inputElement = $(document.getElementById(inputId));
// Add keyboard nav with keydown.
inputElement.on('keydown', function(e) {
switch (e.keyCode) {
case KEYS.DOWN:
// If the suggestion list is open, move to the next item.
if (inputElement.attr('aria-expanded') === "true") {
activateNextItem(inputId, suggestionsId);
} else {
// Else - open the suggestions list.
updateSuggestions(inputElement.val(), inputId, suggestionsId, originalSelect, multiple, tags);
}
// We handled this event, so prevent it.
e.preventDefault();
return false;
case KEYS.COMMA:
if (tags) {
// If we are allowing tags, comma should create a tag (or enter).
createItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
}
// We handled this event, so prevent it.
e.preventDefault();
return false;
case KEYS.UP:
// Choose the previous active item.
activatePreviousItem(inputId, suggestionsId);
// We handled this event, so prevent it.
e.preventDefault();
return false;
case KEYS.ENTER:
var suggestionsElement = $(document.getElementById(suggestionsId));
if ((inputElement.attr('aria-expanded') === "true") &&
(suggestionsElement.children('[aria-selected=true]').length > 0)) {
// If the suggestion list has an active item, select it.
selectCurrentItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
} else if (tags) {
// If tags are enabled, create a tag.
createItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
}
// We handled this event, so prevent it.
e.preventDefault();
return false;
case KEYS.ESCAPE:
if (inputElement.attr('aria-expanded') === "true") {
// If the suggestion list is open, close it.
closeSuggestions(inputId, suggestionsId, selectionId);
}
// We handled this event, so prevent it.
e.preventDefault();
return false;
}
return true;
});
// Handler used to force set the value from behat.
inputElement.on('behat:set-value', function() {
if (tags) {
createItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
}
});
inputElement.on('blur focus', function(e) {
// We may be blurring because we have clicked on the suggestion list. We
// dont want to close the selection list before the click event fires, so
// we have to delay.
if (closeSuggestionsTimer) {
window.clearTimeout(closeSuggestionsTimer);
}
closeSuggestionsTimer = window.setTimeout(function() {
if ((e.type == 'blur') && tags) {
createItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
}
closeSuggestions(inputId, suggestionsId, selectionId);
}, 500);
});
var arrowElement = $(document.getElementById(downArrowId));
arrowElement.on('click', function() {
// Prevent the close timer, or we will open, then close the suggestions.
inputElement.focus();
if (closeSuggestionsTimer) {
window.clearTimeout(closeSuggestionsTimer);
}
// Show the suggestions list.
updateSuggestions(inputElement.val(), inputId, suggestionsId, originalSelect, multiple, tags);
});
var suggestionsElement = $(document.getElementById(suggestionsId));
suggestionsElement.parent().on('click', '[role=option]', function(e) {
// Handle clicks on suggestions.
var element = $(e.currentTarget).closest('[role=option]');
var suggestionsElement = $(document.getElementById(suggestionsId));
// Find the index of the clicked on suggestion.
var current = suggestionsElement.children('[aria-hidden=false]').index(element);
// Activate it.
activateItem(current, inputId, suggestionsId);
// And select it.
selectCurrentItem(inputId, suggestionsId, selectionId, multiple, originalSelect);
});
var selectionElement = $(document.getElementById(selectionId));
// Handle clicks on the selected items (will unselect an item).
selectionElement.parent().on('click', '[role=listitem]', function(e) {
var value = $(e.currentTarget).attr('data-value');
// Only allow deselect if we allow multiple selections.
if (multiple) {
// Find the matching element and deselect it.
originalSelect.children('option').each(function(index, ele) {
if ($(ele).attr('value') == value) {
$(ele).prop('selected', !$(ele).prop('selected'));
}
});
}
// Re-render the selection list.
updateSelectionList(selectionId, inputId, originalSelect, multiple);
});
// Keyboard navigation for the selection list.
selectionElement.parent().on('keydown', function(e) {
switch (e.keyCode) {
case KEYS.DOWN:
// Choose the next selection item.
activateNextSelection(selectionId);
// We handled this event, so prevent it.
e.preventDefault();
return false;
case KEYS.UP:
// Choose the previous selection item.
activatePreviousSelection(selectionId);
// We handled this event, so prevent it.
e.preventDefault();
return false;
case KEYS.SPACE:
case KEYS.ENTER:
// Unselect this item.
deselectCurrentSelection(inputId, suggestionsId, selectionId, originalSelect, multiple, tags);
// We handled this event, so prevent it.
e.preventDefault();
return false;
}
return true;
});
// Whenever the input field changes, update the suggestion list.
inputElement.on('input', function(e) {
var query = $(e.currentTarget).val();
updateSuggestions(query, inputId, suggestionsId, originalSelect, multiple, tags);
});
};
return /** @alias module:core/form-autocomplete */ {
// Public variables and functions.
/**
* Turn a boring select box into an auto-complete beast.
*
* @method enhance
* @param {string} select The selector that identifies the select box.
* @param {boolean} tags Whether to allow support for tags (can define new entries).
* @param {string} ajax Name of an AMD module to handle ajax requests. If specified, the AMD
* module must expose 2 functions "transport" and "processResults".
* These are modeled on Select2 see: https://select2.github.io/options.html#ajax
* @param {String} placeholder - The text to display before a selection is made.
*/
enhance: function(selector, tags, ajax, placeholder) {
// Set some default values.
if (typeof tags === "undefined") {
tags = false;
}
if (typeof ajax === "undefined") {
ajax = false;
}
// Look for the select element.
var originalSelect = $(selector);
if (!originalSelect) {
log.debug('Selector not found: ' + selector);
return false;
}
// Hide the original select.
originalSelect.hide().attr('aria-hidden', true);
// Find or generate some ids.
var selectId = originalSelect.attr('id');
var multiple = originalSelect.attr('multiple');
var inputId = 'form_autocomplete_input-' + $.now();
var suggestionsId = 'form_autocomplete_suggestions-' + $.now();
var selectionId = 'form_autocomplete_selection-' + $.now();
var downArrowId = 'form_autocomplete_downarrow-' + $.now();
var originalLabel = $('[for=' + selectId + ']');
// Create the new markup and insert it after the select.
var options = [];
originalSelect.children('option').each(function(index, option) {
options[index] = { label: option.innerHTML, value: $(option).attr('value') };
});
// Render all the parts of our UI.
var renderInput = templates.render(
'core/form_autocomplete_input',
{ downArrowId: downArrowId,
inputId: inputId,
suggestionsId: suggestionsId,
selectionId: selectionId,
placeholder: placeholder,
multiple: multiple }
);
var renderDatalist = templates.render(
'core/form_autocomplete_suggestions',
{ inputId: inputId, suggestionsId: suggestionsId, options: options, multiple: multiple}
);
var renderSelection = templates.render(
'core/form_autocomplete_selection',
{ selectionId: selectionId, items: [], multiple: multiple}
);
$.when(renderInput, renderDatalist, renderSelection).done(function(input, suggestions, selection) {
// Add our new UI elements to the page.
originalSelect.after(suggestions);
originalSelect.after(input);
originalSelect.after(selection);
// Update the form label to point to the text input.
originalLabel.attr('for', inputId);
// Add the event handlers.
addNavigation(inputId, suggestionsId, downArrowId, selectionId, originalSelect, multiple, tags);
var inputElement = $(document.getElementById(inputId));
var suggestionsElement = $(document.getElementById(suggestionsId));
// Hide the suggestions by default.
suggestionsElement.hide().attr('aria-hidden', true);
// If this field uses ajax, set it up.
if (ajax) {
require([ajax], function(ajaxHandler) {
var handler = function(e) {
updateAjax(e, selector, inputId, suggestionsId, originalSelect, multiple, tags, ajaxHandler);
};
// Trigger an ajax update after the text field value changes.
inputElement.on("input keypress", handler);
var arrowElement = $(document.getElementById(downArrowId));
arrowElement.on("click", handler);
});
}
// Show the current values in the selection list.
updateSelectionList(selectionId, inputId, originalSelect, multiple);
});
}
};
});

View File

@ -0,0 +1,55 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Auto complete form field class.
*
* @package core_form
* @category test
* @copyright 2015 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
require_once(__DIR__ . '/behat_form_text.php');
/**
* Auto complete form field.
*
* @package core_form
* @category test
* @copyright 2015 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class behat_form_autocomplete extends behat_form_text {
/**
* Sets the value to a field.
*
* @param string $value
* @return void
*/
public function set_value($value) {
if (!$this->running_javascript()) {
throw new coding_exception('Setting the valid of an autocomplete field requires javascript.');
}
$this->field->setValue($value);
$id = $this->field->getAttribute('id');
$js = ' require(["jquery"], function($) { $(document.getElementById("'.$id.'")).trigger("behat:set-value"); }); ';
$this->session->executeScript($js);
}
}

176
lib/form/autocomplete.php Normal file
View File

@ -0,0 +1,176 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* autocomplete type form element
*
* Contains HTML class for a autocomplete type element
*
* @package core_form
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
global $CFG;
require_once($CFG->libdir . '/form/select.php');
/**
* Autocomplete as you type form element
*
* HTML class for a autocomplete type element
*
* @package core_form
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class MoodleQuickForm_autocomplete extends MoodleQuickForm_select {
/** @var boolean $tags Should we allow typing new entries to the field? */
protected $tags = false;
/** @var string $ajax Name of an AMD module to send/process ajax requests. */
protected $ajax = '';
/** @var string $placeholder Placeholder text for an empty list. */
protected $placeholder = '';
/**
* constructor
*
* @param string $elementName Select name attribute
* @param mixed $elementLabel Label(s) for the select
* @param mixed $options Data to be used to populate options
* @param mixed $attributes Either a typical HTML attribute string or an associative array. Special options
* "tags", "placeholder", "ajax", "multiple" are supported.
*/
function MoodleQuickForm_autocomplete($elementName=null, $elementLabel=null, $options=null, $attributes=null) {
// Even if the constructor gets called twice we do not really want 2x options (crazy forms!).
$this->_options = array();
if ($attributes === null) {
$attributes = array();
}
if (isset($attributes['tags'])) {
$this->tags = $attributes['tags'];
unset($attributes['tags']);
}
$this->placeholder = get_string('search');
if (isset($attributes['placeholder'])) {
$this->placeholder = $attributes['placeholder'];
unset($attributes['placeholder']);
}
if (isset($attributes['ajax'])) {
$this->ajax = $attributes['ajax'];
unset($attributes['ajax']);
}
parent::HTML_QuickForm_select($elementName, $elementLabel, $options, $attributes);
$this->_type = 'autocomplete';
}
/**
* Returns HTML for select form element.
*
* @return string
*/
function toHtml(){
global $PAGE;
// Enhance the select with javascript.
$this->_generateId();
$id = $this->getAttribute('id');
$PAGE->requires->js_call_amd('core/form-autocomplete', 'enhance', $params = array('#' . $id, $this->tags, $this->ajax, $this->placeholder));
return parent::toHTML();
}
/**
* Search the current list of options to see if there are any options with this value.
* @param string $value to search
* @return boolean
*/
function optionExists($value) {
foreach ($this->_options as $option) {
if (isset($option['attr']['value']) && ($option['attr']['value'] == $value)) {
return true;
}
}
return false;
}
/**
* Set the value of this element. If values can be added or are unknown, we will
* make sure they exist in the options array.
* @param mixed string|array $value The value to set.
* @return boolean
*/
function setValue($value) {
$values = (array) $value;
foreach ($values as $onevalue) {
if (($this->tags || $this->ajax) &&
(!$this->optionExists($onevalue)) &&
($onevalue !== '_qf__force_multiselect_submission')) {
$this->addOption($onevalue, $onevalue);
}
}
return parent::setValue($value);
}
/**
* Returns a 'safe' element's value
*
* @param array array of submitted values to search
* @param bool whether to return the value as associative array
* @access public
* @return mixed
*/
function exportValue(&$submitValues, $assoc = false) {
if ($this->ajax || $this->tags) {
// When this was an ajax request, we do not know the allowed list of values.
$value = $this->_findValue($submitValues);
if (null === $value) {
$value = $this->getValue();
}
// Quickforms inserts a duplicate element in the form with
// this value so that a value is always submitted for this form element.
// Normally this is cleaned as a side effect of it not being a valid option,
// but in this case we need to detect and skip it manually.
if ($value === '_qf__force_multiselect_submission' || $value === null) {
$value = '';
}
return $this->_prepareValue($value, $assoc);
} else {
return parent::exportValue($submitValues, $assoc);
}
}
/**
* Called by HTML_QuickForm whenever form event is made on this element
*
* @param string $event Name of event
* @param mixed $arg event arguments
* @param object $caller calling object
* @return bool
*/
function onQuickFormEvent($event, $arg, &$caller)
{
switch ($event) {
case 'createElement':
$caller->setType($arg[0], PARAM_TAGLIST);
break;
}
return parent::onQuickFormEvent($event, $arg, $caller);
}
}

View File

@ -0,0 +1,66 @@
<?php
// 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 <http://www.gnu.org/licenses/>.
/**
* Unit tests for autocomplete forms element.
*
* This file contains all unit test related to autocomplete forms element.
*
* @package core_form
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
defined('MOODLE_INTERNAL') || die();
global $CFG;
require_once($CFG->libdir . '/form/autocomplete.php');
/**
* Unit tests for MoodleQuickForm_autocomplete
*
* Contains test cases for testing MoodleQuickForm_autocomplete
*
* @package core_form
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class core_form_autocomplete_testcase extends basic_testcase {
/**
* Testcase for validation
*/
public function test_validation() {
// A default select with single values validates the data.
$options = array('1' => 'One', 2 => 'Two');
$element = new MoodleQuickForm_autocomplete('testel', null, $options);
$submission = array('testel' => 2);
$this->assertEquals($element->exportValue($submission), 2);
$submission = array('testel' => 3);
$this->assertNull($element->exportValue($submission));
// A select with multiple values validates the data.
$options = array('1' => 'One', 2 => 'Two');
$element = new MoodleQuickForm_autocomplete('testel', null, $options, array('multiple'=>'multiple'));
$submission = array('testel' => array(2, 3));
$this->assertEquals($element->exportValue($submission), array(2));
// A select where the values are fetched via ajax does not validate the data.
$element = new MoodleQuickForm_autocomplete('testel', null, array(), array('multiple'=>'multiple', 'ajax'=>'anything'));
$submission = array('testel' => array(2, 3));
$this->assertEquals($element->exportValue($submission), array(2, 3));
}
}

View File

@ -2931,6 +2931,7 @@ $GLOBALS['_HTML_QuickForm_default_renderer'] = new MoodleQuickForm_Renderer();
/** Please keep this list in alphabetical order. */
MoodleQuickForm::registerElementType('advcheckbox', "$CFG->libdir/form/advcheckbox.php", 'MoodleQuickForm_advcheckbox');
MoodleQuickForm::registerElementType('autocomplete', "$CFG->libdir/form/autocomplete.php", 'MoodleQuickForm_autocomplete');
MoodleQuickForm::registerElementType('button', "$CFG->libdir/form/button.php", 'MoodleQuickForm_button');
MoodleQuickForm::registerElementType('cancel', "$CFG->libdir/form/cancel.php", 'MoodleQuickForm_cancel');
MoodleQuickForm::registerElementType('searchableselector', "$CFG->libdir/form/searchableselector.php", 'MoodleQuickForm_searchableselector');

View File

@ -218,7 +218,7 @@ class HTML_QuickForm_RuleRegistry
$value = " _qfGroups['{$elementName}'] = {";
$elements =& $element->getElements();
for ($i = 0, $count = count($elements); $i < $count; $i++) {
$append = ($elements[$i]->getType() == 'select' && $elements[$i]->getMultiple())? '[]': '';
$append = (($elements[$i]->getType() == 'select' || $element->getType() == 'autocomplete') && $elements[$i]->getMultiple())? '[]': '';
$value .= "'" . $element->getElementName($i) . $append . "': true" .
($i < $count - 1? ', ': '');
}
@ -281,7 +281,7 @@ class HTML_QuickForm_RuleRegistry
" }\n";
}
} elseif ($element->getType() == 'select') {
} elseif ($element->getType() == 'select' || $element->getType() == 'autocomplete') {
if ($element->getMultiple()) {
$elementName .= '[]';
$value =

View File

@ -0,0 +1,38 @@
{{!
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 <http://www.gnu.org/licenses/>.
}}
{{!
@template core/form_autocomplete_input
Moodle template for the input field in an autocomplate form element.
Classes required for JS:
* none
Data attributes required for JS:
* none
Context variables required for this template:
* inputId The dom id of this input field.
* suggestionsId The dom id of the suggestions list.
* selectionId The dom id of the current selection list.
* downArrowId The dom id of the down arrow to open the suggestions.
* placeholder The place holder text when the field is empty
Example context (json):
{ "inputID": 1, "suggestionsId": 2, "selectionId": 3, "downArrowId": 4, "placeholder": "Select something" }
}}
<input type="text" id="{{inputId}}" list="{{suggestionsId}}" placeholder="{{placeholder}}" role="combobox" aria-expanded="false" autocomplete="off" autocorrect="off" autocapitalize="off" aria-autocomplete="list" aria-owns="{{suggestionsId}} {{selectionId}}"/><span class="form-autocomplete-downarrow" id="{{downArrowId}}">&#x25BC;</span>

View File

@ -0,0 +1,48 @@
{{!
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 <http://www.gnu.org/licenses/>.
}}
{{!
@template core/form_autocomplete_selection
Moodle template for the currently selected items in an autocomplate form element.
Classes required for JS:
* none
Data attributes required for JS:
* none
Context variables required for this template:
* multiple True if this field allows multiple selections
* selectionId The dom id of the current selection list.
* items List of items with label and value fields.
Example context (json):
{ "multiple": true, "selectionId": 1, "items": [
{ "label": "Item label with <strong>tags</strong>", "value": "5" },
{ "label": "Another item label with <strong>tags</strong>", "value": "4" }
]}
}}
<div class="form-autocomplete-selection {{#multiple}}form-autocomplete-multiple{{/multiple}}" id="{{selectionId}}" role="list" aria-atomic="true" tabindex="0" aria-multiselectable="true">
<span class="accesshide">{{#str}}selecteditems, form{{/str}}</span>
{{#items}}
<span role="listitem" data-value="{{value}}" aria-selected="true" class="label label-info"><span aria-hidden="true">×</span> {{{label}}}</span>
{{/items}}
{{^items}}
<span>{{#str}}noselection,form{{/str}}</span>
{{/items}}
</div>
</div>

View File

@ -0,0 +1,42 @@
{{!
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 <http://www.gnu.org/licenses/>.
}}
{{!
@template core/form_autocomplete_suggestions
Moodle template for the list of valid options in an autocomplate form element.
Classes required for JS:
* none
Data attributes required for JS:
* none
Context variables required for this template:
* suggestionsId The dom id of the current suggestions list.
* options List of options with label and value fields.
Example context (json):
{ "suggestionsId": 1, "options": [
{ "label": "Item label with <strong>tags</strong>", "value": "5" },
{ "label": "Another item label with <strong>tags</strong>", "value": "4" }
]}
}}
<ul class="form-autocomplete-suggestions" id="{{suggestionsId}}" role="listbox" aria-hidden="true">
{{#options}}
<li role="option" data-value="{{value}}">{{{label}}}</li>
{{/options}}
</ul>

View File

@ -50,7 +50,8 @@ $THEME->sheets = array(
'user',
'tabs',
'filemanager',
'templates'
'templates',
'autocomplete'
);
$THEME->editor_sheets = array('editor');

View File

@ -0,0 +1,68 @@
/* Custom styles for autocomplete form element */
.form-autocomplete-selection {
margin: 0.2em;
min-height: 21px;
}
.form-autocomplete-multiple [role=listitem].label {
cursor: pointer;
}
.form-autocomplete-selection [role=listitem].label {
color: #333;
background-color: #00E;
border: 4px solid #00E;
color: #FFF;
font-weight: bold;
border-radius: 3px;
display: inline-block;
margin-bottom: 3px;
}
.form-autocomplete-suggestions {
position: absolute;
background-color: white;
border: 2px solid #EEE;
border-radius: 3px;
min-width: 206px;
max-height: 20em;
overflow: auto;
margin: 0px;
padding: 0px;
margin-top: -0.2em;
z-index: 1;
}
.form-autocomplete-suggestions li {
list-style-type: none;
padding: 0.2em;
margin: 0;
cursor: pointer;
color: #333;
}
.form-autocomplete-suggestions li:hover {
background-color: #00E;
color: #FFF;
}
.form-autocomplete-suggestions li[aria-selected=true] {
background-color: #555;
color: #FFF;
}
.form-autocomplete-downarrow {
position: relative;
top: -0.1em;
left: -1.5em;
cursor: pointer;
color: #000;
}
.dir-rtl .form-autocomplete-downarrow {
right: -1.5em;
left: inherit;
}
.form-autocomplete-selection:focus {
outline: none;
}
.form-autocomplete-selection [data-active-selection=true] {
padding: 0.5em;
font-size: large;
}

View File

@ -657,3 +657,61 @@ textarea[cols],
input[size] {
width: auto;
}
/* Custom styles for autocomplete form element */
.form-autocomplete-selection {
margin: 0.2em;
min-height: 21px;
}
.form-autocomplete-multiple [role=listitem] {
cursor: pointer;
}
.form-autocomplete-suggestions {
position: absolute;
background-color: white;
border: 2px solid @grayLighter;
border-radius: 3px;
min-width: 206px;
max-height: 20em;
overflow: auto;
margin: 0px;
padding: 0px;
margin-top: -0.2em;
z-index: 1;
}
.form-autocomplete-suggestions li {
list-style-type: none;
padding: 0.2em;
margin: 0;
cursor: pointer;
color: @textColor;
}
.form-autocomplete-suggestions li:hover {
background-color: lighten(@dropdownLinkBackgroundActive, 15%);
color: @dropdownLinkColorActive;
}
.form-autocomplete-suggestions li[aria-selected=true] {
background-color: darken(@navbarBackground, 5%);
color: @gray;
}
.form-autocomplete-downarrow {
color: @textColor;
position: relative;
top: -0.3em;
left: -1.5em;
cursor: pointer;
}
.dir-rtl .form-autocomplete-downarrow {
right: -1.5em;
left: inherit;
}
.form-autocomplete-selection:focus {
outline: none;
}
.form-autocomplete-selection [data-active-selection=true] {
padding: 0.5em;
font-size: large;
}

File diff suppressed because one or more lines are too long