forkjo/web_src/js/semanticDropdown.js

4027 lines
141 KiB
JavaScript
Raw Normal View History

/* This is a patched version of semantic.dropdown which includes a11y changes, see
https://github.com/go-gitea/gitea/pull/8638#issuecomment-549175290 */
a11y: Improve accessibility of dropdown menus (#8638) * js: Import Semantic-UI's dropdown.js (version 2.3.1) * js: Set tabindex=-1 on dropdown items Setting tabindex=-1 on focusable elements within dropdown menus allows the user to treat dropdown menus as a single focusable item with its own internal navigation using arrow keys. * js: Don't use jQuery to click menu items Menu items are often <a> elements, which jQuery refuses to trigger click events on. Instead it just bubbles up to the menu. Using HTMLElement's click method fixes this and makes menu items clickable from the keyboard using dropdown menus. * js: Set correct ARIA 1.1 roles on dropdown menus Setting role= makes assistive technology aware there is a widget here. In this case, Orca will now exit browse mode and allow us to capture keydown events when focused on a dropdown menu. It will also inform the user that there's a menu focused. Since dropdowns can be used in multiple elements each with different ARIA roles, a guessRole method is used to find the correct role. All roles I consider possible are listed, but only menu is implemented. * js: Set aria-expanded when dropdown menus show and hide This is deliberately done before the transition finishes so that screen readers get immediate feedback. * js: Set aria-label or aria-labelledby on dropdown menus This makes dropdown menu buttons screen reader accessible. aria-labelledby refers to an element using an ID, so the chosen labels are now assigned a unique ID- This ID is not stable, do not refer to it with user scripts. * js: Set aria-activedescendant on dropdown menus As the menus grab focus and navigate by tracking a 'selected' div class, assistive technology has no idea that what the current selection is. Assign IDs to each menu item and set aria-activedescendant to the ID of the currently selected menu item. When the menu is unfocused, remove aria-activedescendant- This isn't neccessary but in my experience it triggers Orca to remind the user of their current selection when re-focusing the menu. * Makefile: Make eslint ignore semantic.dropdown.js This file is taken from Semantic UI which isn't linted upstream. Ignore it as we won't fix these issues. * js: Add version note to semantic.dropdown.js * Add Md5 AppVer to templates/base/footer.tmpl Co-Authored-By: guillep2k <18600385+guillep2k@users.noreply.github.com> * Add Md5 AppVer to templates/pwa/serviceworker_js.tmpl Co-Authored-By: guillep2k <18600385+guillep2k@users.noreply.github.com> * semantic.dropdown.js -> semantic.dropdown.custom.js * Use eslintignore * remove bogus submodule
2019-11-08 18:33:21 +01:00
/*!
* # Semantic UI 2.3.1 - Dropdown
* http://github.com/semantic-org/semantic-ui/
*
*
* Released under the MIT license
* http://opensource.org/licenses/MIT
*
*/
/*
* Copyright 2019 The Gitea Authors
* Released under the MIT license
* http://opensource.org/licenses/MIT
* This version has been modified by Gitea to improve accessibility.
*/
;(function ($, window, document, undefined) {
'use strict';
window = (typeof window != 'undefined' && window.Math == Math)
? window
: (typeof self != 'undefined' && self.Math == Math)
? self
: Function('return this')()
;
$.fn.dropdown = function(parameters) {
var
$allModules = $(this),
$document = $(document),
moduleSelector = $allModules.selector || '',
hasTouch = ('ontouchstart' in document.documentElement),
time = new Date().getTime(),
performance = [],
query = arguments[0],
methodInvoked = (typeof query == 'string'),
queryArguments = [].slice.call(arguments, 1),
lastAriaID = 1,
returnedValue
;
$allModules
.each(function(elementIndex) {
var
settings = ( $.isPlainObject(parameters) )
? $.extend(true, {}, $.fn.dropdown.settings, parameters)
: $.extend({}, $.fn.dropdown.settings),
className = settings.className,
message = settings.message,
fields = settings.fields,
keys = settings.keys,
metadata = settings.metadata,
namespace = settings.namespace,
regExp = settings.regExp,
selector = settings.selector,
error = settings.error,
templates = settings.templates,
eventNamespace = '.' + namespace,
moduleNamespace = 'module-' + namespace,
$module = $(this),
$context = $(settings.context),
$text = $module.find(selector.text),
$search = $module.find(selector.search),
$sizer = $module.find(selector.sizer),
$input = $module.find(selector.input),
$icon = $module.find(selector.icon),
$combo = ($module.prev().find(selector.text).length > 0)
? $module.prev().find(selector.text)
: $module.prev(),
$menu = $module.children(selector.menu),
$item = $menu.find(selector.item),
activated = false,
itemActivated = false,
internalChange = false,
element = this,
instance = $module.data(moduleNamespace),
initialLoad,
pageLostFocus,
willRefocus,
elementNamespace,
id,
selectObserver,
menuObserver,
module
;
module = {
initialize: function() {
module.debug('Initializing dropdown', settings);
if( module.is.alreadySetup() ) {
module.setup.reference();
}
else {
module.setup.layout();
if(settings.values) {
module.change.values(settings.values);
}
module.refreshData();
module.save.defaults();
module.restore.selected();
module.create.id();
module.bind.events();
module.observeChanges();
module.instantiate();
module.aria.setup();
}
},
instantiate: function() {
module.verbose('Storing instance of dropdown', module);
instance = module;
$module
.data(moduleNamespace, module)
;
},
destroy: function() {
module.verbose('Destroying previous dropdown', $module);
module.remove.tabbable();
$module
.off(eventNamespace)
.removeData(moduleNamespace)
;
$menu
.off(eventNamespace)
;
$document
.off(elementNamespace)
;
module.disconnect.menuObserver();
module.disconnect.selectObserver();
},
observeChanges: function() {
if('MutationObserver' in window) {
selectObserver = new MutationObserver(module.event.select.mutation);
menuObserver = new MutationObserver(module.event.menu.mutation);
module.debug('Setting up mutation observer', selectObserver, menuObserver);
module.observe.select();
module.observe.menu();
}
},
disconnect: {
menuObserver: function() {
if(menuObserver) {
menuObserver.disconnect();
}
},
selectObserver: function() {
if(selectObserver) {
selectObserver.disconnect();
}
}
},
observe: {
select: function() {
if(module.has.input()) {
selectObserver.observe($module[0], {
childList : true,
subtree : true
});
}
},
menu: function() {
if(module.has.menu()) {
menuObserver.observe($menu[0], {
childList : true,
subtree : true
});
}
}
},
create: {
id: function() {
id = (Math.random().toString(16) + '000000000').substr(2, 8);
elementNamespace = '.' + id;
module.verbose('Creating unique id for element', id);
},
userChoice: function(values) {
var
$userChoices,
$userChoice,
isUserValue,
html
;
values = values || module.get.userValues();
if(!values) {
return false;
}
values = $.isArray(values)
? values
: [values]
;
$.each(values, function(index, value) {
if(module.get.item(value) === false) {
html = settings.templates.addition( module.add.variables(message.addResult, value) );
$userChoice = $('<div />')
.html(html)
.attr('data-' + metadata.value, value)
.attr('data-' + metadata.text, value)
.addClass(className.addition)
.addClass(className.item)
;
if(settings.hideAdditions) {
$userChoice.addClass(className.hidden);
}
$userChoices = ($userChoices === undefined)
? $userChoice
: $userChoices.add($userChoice)
;
module.verbose('Creating user choices for value', value, $userChoice);
}
});
return $userChoices;
},
userLabels: function(value) {
var
userValues = module.get.userValues()
;
if(userValues) {
module.debug('Adding user labels', userValues);
$.each(userValues, function(index, value) {
module.verbose('Adding custom user value');
module.add.label(value, value);
});
}
},
menu: function() {
$menu = $('<div />')
.addClass(className.menu)
.appendTo($module)
;
},
sizer: function() {
$sizer = $('<span />')
.addClass(className.sizer)
.insertAfter($search)
;
}
},
search: function(query) {
query = (query !== undefined)
? query
: module.get.query()
;
module.verbose('Searching for query', query);
if(module.has.minCharacters(query)) {
module.filter(query);
}
else {
module.hide();
}
},
select: {
firstUnfiltered: function() {
module.verbose('Selecting first non-filtered element');
module.remove.selectedItem();
$item
.not(selector.unselectable)
.not(selector.addition + selector.hidden)
.eq(0)
.addClass(className.selected)
;
},
nextAvailable: function($selected) {
$selected = $selected.eq(0);
var
$nextAvailable = $selected.nextAll(selector.item).not(selector.unselectable).eq(0),
$prevAvailable = $selected.prevAll(selector.item).not(selector.unselectable).eq(0),
hasNext = ($nextAvailable.length > 0)
;
if(hasNext) {
module.verbose('Moving selection to', $nextAvailable);
$nextAvailable.addClass(className.selected);
}
else {
module.verbose('Moving selection to', $prevAvailable);
$prevAvailable.addClass(className.selected);
}
}
},
aria: {
setup: function() {
var role = module.aria.guessRole();
if( role !== 'menu' ) {
return;
}
$module.attr('aria-busy', 'true');
$module.attr('role', 'menu');
$module.attr('aria-haspopup', 'menu');
$module.attr('aria-expanded', 'false');
$menu.find('.divider').attr('role', 'separator');
$item.attr('role', 'menuitem');
$item.each(function (index, item) {
if( !item.id ) {
item.id = module.aria.nextID('menuitem');
}
});
$text = $module
.find('> .text')
.eq(0)
;
if( $module.data('content') ) {
$text.attr('aria-hidden');
$module.attr('aria-label', $module.data('content'));
}
else {
$text.attr('id', module.aria.nextID('menutext'));
$module.attr('aria-labelledby', $text.attr('id'));
}
$module.attr('aria-busy', 'false');
},
nextID: function(prefix) {
var nextID;
do {
nextID = prefix + '_' + lastAriaID++;
} while( document.getElementById(nextID) );
return nextID;
},
setExpanded: function(expanded) {
if( $module.attr('aria-haspopup') ) {
$module.attr('aria-expanded', expanded);
}
},
refreshDescendant: function() {
if( $module.attr('aria-haspopup') !== 'menu' ) {
return;
}
var
$currentlySelected = $item.not(selector.unselectable).filter('.' + className.selected).eq(0),
$activeItem = $menu.children('.' + className.active).eq(0),
$selectedItem = ($currentlySelected.length > 0)
? $currentlySelected
: $activeItem
;
if( $selectedItem ) {
$module.attr('aria-activedescendant', $selectedItem.attr('id'));
}
else {
module.aria.removeDescendant();
}
},
removeDescendant: function() {
if( $module.attr('aria-haspopup') == 'menu' ) {
$module.removeAttr('aria-activedescendant');
}
},
guessRole: function() {
var
isIcon = $module.hasClass('icon'),
hasSearch = module.has.search(),
hasInput = ($input.length > 0),
isMultiple = module.is.multiple()
;
if ( !isIcon && !hasSearch && !hasInput && !isMultiple ) {
return 'menu';
}
return 'unknown';
}
},
setup: {
api: function() {
var
apiSettings = {
debug : settings.debug,
urlData : {
value : module.get.value(),
query : module.get.query()
},
on : false
}
;
module.verbose('First request, initializing API');
$module
.api(apiSettings)
;
},
layout: function() {
if( $module.is('select') ) {
module.setup.select();
module.setup.returnedObject();
}
if( !module.has.menu() ) {
module.create.menu();
}
if( module.is.search() && !module.has.search() ) {
module.verbose('Adding search input');
$search = $('<input />')
.addClass(className.search)
.prop('autocomplete', 'off')
.insertBefore($text)
;
}
if( module.is.multiple() && module.is.searchSelection() && !module.has.sizer()) {
module.create.sizer();
}
if(settings.allowTab) {
module.set.tabbable();
}
$item.attr('tabindex', '-1');
},
select: function() {
var
selectValues = module.get.selectValues()
;
module.debug('Dropdown initialized on a select', selectValues);
if( $module.is('select') ) {
$input = $module;
}
// see if select is placed correctly already
if($input.parent(selector.dropdown).length > 0) {
module.debug('UI dropdown already exists. Creating dropdown menu only');
$module = $input.closest(selector.dropdown);
if( !module.has.menu() ) {
module.create.menu();
}
$menu = $module.children(selector.menu);
module.setup.menu(selectValues);
}
else {
module.debug('Creating entire dropdown from select');
$module = $('<div />')
.attr('class', $input.attr('class') )
.addClass(className.selection)
.addClass(className.dropdown)
.html( templates.dropdown(selectValues) )
.insertBefore($input)
;
if($input.hasClass(className.multiple) && $input.prop('multiple') === false) {
module.error(error.missingMultiple);
$input.prop('multiple', true);
}
if($input.is('[multiple]')) {
module.set.multiple();
}
if ($input.prop('disabled')) {
module.debug('Disabling dropdown');
$module.addClass(className.disabled);
}
$input
.removeAttr('class')
.detach()
.prependTo($module)
;
}
module.refresh();
},
menu: function(values) {
$menu.html( templates.menu(values, fields));
$item = $menu.find(selector.item);
},
reference: function() {
module.debug('Dropdown behavior was called on select, replacing with closest dropdown');
// replace module reference
$module = $module.parent(selector.dropdown);
instance = $module.data(moduleNamespace);
element = $module.get(0);
module.refresh();
module.setup.returnedObject();
},
returnedObject: function() {
var
$firstModules = $allModules.slice(0, elementIndex),
$lastModules = $allModules.slice(elementIndex + 1)
;
// adjust all modules to use correct reference
$allModules = $firstModules.add($module).add($lastModules);
}
},
refresh: function() {
module.refreshSelectors();
module.refreshData();
},
refreshItems: function() {
$item = $menu.find(selector.item);
},
refreshSelectors: function() {
module.verbose('Refreshing selector cache');
$text = $module.find(selector.text);
$search = $module.find(selector.search);
$input = $module.find(selector.input);
$icon = $module.find(selector.icon);
$combo = ($module.prev().find(selector.text).length > 0)
? $module.prev().find(selector.text)
: $module.prev()
;
$menu = $module.children(selector.menu);
$item = $menu.find(selector.item);
},
refreshData: function() {
module.verbose('Refreshing cached metadata');
$item
.removeData(metadata.text)
.removeData(metadata.value)
;
},
clearData: function() {
module.verbose('Clearing metadata');
$item
.removeData(metadata.text)
.removeData(metadata.value)
;
$module
.removeData(metadata.defaultText)
.removeData(metadata.defaultValue)
.removeData(metadata.placeholderText)
;
},
toggle: function() {
module.verbose('Toggling menu visibility');
if( !module.is.active() ) {
module.show();
}
else {
module.hide();
}
},
show: function(callback) {
callback = $.isFunction(callback)
? callback
: function(){}
;
if(!module.can.show() && module.is.remote()) {
module.debug('No API results retrieved, searching before show');
module.queryRemote(module.get.query(), module.show);
}
if( module.can.show() && !module.is.active() ) {
module.debug('Showing dropdown');
if(module.has.message() && !(module.has.maxSelections() || module.has.allResultsFiltered()) ) {
module.remove.message();
}
if(module.is.allFiltered()) {
return true;
}
if(settings.onShow.call(element) !== false) {
module.aria.setExpanded(true);
module.aria.refreshDescendant();
module.animate.show(function() {
if( module.can.click() ) {
module.bind.intent();
}
if(module.has.menuSearch()) {
module.focusSearch();
}
module.set.visible();
callback.call(element);
});
}
}
},
hide: function(callback) {
callback = $.isFunction(callback)
? callback
: function(){}
;
if( module.is.active() && !module.is.animatingOutward() ) {
module.debug('Hiding dropdown');
if(settings.onHide.call(element) !== false) {
module.aria.setExpanded(false);
module.aria.removeDescendant();
module.animate.hide(function() {
module.remove.visible();
callback.call(element);
});
}
}
},
hideOthers: function() {
module.verbose('Finding other dropdowns to hide');
$allModules
.not($module)
.has(selector.menu + '.' + className.visible)
.dropdown('hide')
;
},
hideMenu: function() {
module.verbose('Hiding menu instantaneously');
module.remove.active();
module.remove.visible();
$menu.transition('hide');
},
hideSubMenus: function() {
var
$subMenus = $menu.children(selector.item).find(selector.menu)
;
module.verbose('Hiding sub menus', $subMenus);
$subMenus.transition('hide');
},
bind: {
events: function() {
if(hasTouch) {
module.bind.touchEvents();
}
module.bind.keyboardEvents();
module.bind.inputEvents();
module.bind.mouseEvents();
},
touchEvents: function() {
module.debug('Touch device detected binding additional touch events');
if( module.is.searchSelection() ) {
// do nothing special yet
}
else if( module.is.single() ) {
$module
.on('touchstart' + eventNamespace, module.event.test.toggle)
;
}
$menu
.on('touchstart' + eventNamespace, selector.item, module.event.item.mouseenter)
;
},
keyboardEvents: function() {
module.verbose('Binding keyboard events');
$module
.on('keydown' + eventNamespace, module.event.keydown)
;
if( module.has.search() ) {
$module
.on(module.get.inputEvent() + eventNamespace, selector.search, module.event.input)
;
}
if( module.is.multiple() ) {
$document
.on('keydown' + elementNamespace, module.event.document.keydown)
;
}
},
inputEvents: function() {
module.verbose('Binding input change events');
$module
.on('change' + eventNamespace, selector.input, module.event.change)
;
},
mouseEvents: function() {
module.verbose('Binding mouse events');
if(module.is.multiple()) {
$module
.on('click' + eventNamespace, selector.label, module.event.label.click)
.on('click' + eventNamespace, selector.remove, module.event.remove.click)
;
}
if( module.is.searchSelection() ) {
$module
.on('mousedown' + eventNamespace, module.event.mousedown)
.on('mouseup' + eventNamespace, module.event.mouseup)
.on('mousedown' + eventNamespace, selector.menu, module.event.menu.mousedown)
.on('mouseup' + eventNamespace, selector.menu, module.event.menu.mouseup)
.on('click' + eventNamespace, selector.icon, module.event.icon.click)
.on('focus' + eventNamespace, selector.search, module.event.search.focus)
.on('click' + eventNamespace, selector.search, module.event.search.focus)
.on('blur' + eventNamespace, selector.search, module.event.search.blur)
.on('click' + eventNamespace, selector.text, module.event.text.focus)
;
if(module.is.multiple()) {
$module
.on('click' + eventNamespace, module.event.click)
;
}
}
else {
if(settings.on == 'click') {
$module
.on('click' + eventNamespace, selector.icon, module.event.icon.click)
.on('click' + eventNamespace, module.event.test.toggle)
;
}
else if(settings.on == 'hover') {
$module
.on('mouseenter' + eventNamespace, module.delay.show)
.on('mouseleave' + eventNamespace, module.delay.hide)
;
}
else {
$module
.on(settings.on + eventNamespace, module.toggle)
;
}
$module
.on('mousedown' + eventNamespace, module.event.mousedown)
.on('mouseup' + eventNamespace, module.event.mouseup)
.on('focus' + eventNamespace, module.event.focus)
;
if(module.has.menuSearch() ) {
$module
.on('blur' + eventNamespace, selector.search, module.event.search.blur)
;
}
else {
$module
.on('blur' + eventNamespace, module.event.blur)
;
}
}
$menu
.on('mouseenter' + eventNamespace, selector.item, module.event.item.mouseenter)
.on('mouseleave' + eventNamespace, selector.item, module.event.item.mouseleave)
.on('click' + eventNamespace, selector.item, module.event.item.click)
;
},
intent: function() {
module.verbose('Binding hide intent event to document');
if(hasTouch) {
$document
.on('touchstart' + elementNamespace, module.event.test.touch)
.on('touchmove' + elementNamespace, module.event.test.touch)
;
}
$document
.on('click' + elementNamespace, module.event.test.hide)
;
}
},
unbind: {
intent: function() {
module.verbose('Removing hide intent event from document');
if(hasTouch) {
$document
.off('touchstart' + elementNamespace)
.off('touchmove' + elementNamespace)
;
}
$document
.off('click' + elementNamespace)
;
}
},
filter: function(query) {
var
searchTerm = (query !== undefined)
? query
: module.get.query(),
afterFiltered = function() {
if(module.is.multiple()) {
module.filterActive();
}
if(query || (!query && module.get.activeItem().length == 0)) {
module.select.firstUnfiltered();
}
if( module.has.allResultsFiltered() ) {
if( settings.onNoResults.call(element, searchTerm) ) {
if(settings.allowAdditions) {
if(settings.hideAdditions) {
module.verbose('User addition with no menu, setting empty style');
module.set.empty();
module.hideMenu();
}
}
else {
module.verbose('All items filtered, showing message', searchTerm);
module.add.message(message.noResults);
}
}
else {
module.verbose('All items filtered, hiding dropdown', searchTerm);
module.hideMenu();
}
}
else {
module.remove.empty();
module.remove.message();
}
if(settings.allowAdditions) {
module.add.userSuggestion(query);
}
if(module.is.searchSelection() && module.can.show() && module.is.focusedOnSearch() ) {
module.show();
}
}
;
if(settings.useLabels && module.has.maxSelections()) {
return;
}
if(settings.apiSettings) {
if( module.can.useAPI() ) {
module.queryRemote(searchTerm, function() {
if(settings.filterRemoteData) {
module.filterItems(searchTerm);
}
afterFiltered();
});
}
else {
module.error(error.noAPI);
}
}
else {
module.filterItems(searchTerm);
afterFiltered();
}
},
queryRemote: function(query, callback) {
var
apiSettings = {
errorDuration : false,
cache : 'local',
throttle : settings.throttle,
urlData : {
query: query
},
onError: function() {
module.add.message(message.serverError);
callback();
},
onFailure: function() {
module.add.message(message.serverError);
callback();
},
onSuccess : function(response) {
module.remove.message();
module.setup.menu({
values: response[fields.remoteValues]
});
callback();
}
}
;
if( !$module.api('get request') ) {
module.setup.api();
}
apiSettings = $.extend(true, {}, apiSettings, settings.apiSettings);
$module
.api('setting', apiSettings)
.api('query')
;
},
filterItems: function(query) {
var
searchTerm = (query !== undefined)
? query
: module.get.query(),
results = null,
escapedTerm = module.escape.string(searchTerm),
beginsWithRegExp = new RegExp('^' + escapedTerm, 'igm')
;
// avoid loop if we're matching nothing
if( module.has.query() ) {
results = [];
module.verbose('Searching for matching values', searchTerm);
$item
.each(function(){
var
$choice = $(this),
text,
value
;
if(settings.match == 'both' || settings.match == 'text') {
text = String(module.get.choiceText($choice, false));
if(text.search(beginsWithRegExp) !== -1) {
results.push(this);
return true;
}
else if (settings.fullTextSearch === 'exact' && module.exactSearch(searchTerm, text)) {
results.push(this);
return true;
}
else if (settings.fullTextSearch === true && module.fuzzySearch(searchTerm, text)) {
results.push(this);
return true;
}
}
if(settings.match == 'both' || settings.match == 'value') {
value = String(module.get.choiceValue($choice, text));
if(value.search(beginsWithRegExp) !== -1) {
results.push(this);
return true;
}
else if (settings.fullTextSearch === 'exact' && module.exactSearch(searchTerm, value)) {
results.push(this);
return true;
}
else if (settings.fullTextSearch === true && module.fuzzySearch(searchTerm, value)) {
results.push(this);
return true;
}
}
})
;
}
module.debug('Showing only matched items', searchTerm);
module.remove.filteredItem();
if(results) {
$item
.not(results)
.addClass(className.filtered)
;
}
},
fuzzySearch: function(query, term) {
var
termLength = term.length,
queryLength = query.length
;
query = query.toLowerCase();
term = term.toLowerCase();
if(queryLength > termLength) {
return false;
}
if(queryLength === termLength) {
return (query === term);
}
search: for (var characterIndex = 0, nextCharacterIndex = 0; characterIndex < queryLength; characterIndex++) {
var
queryCharacter = query.charCodeAt(characterIndex)
;
while(nextCharacterIndex < termLength) {
if(term.charCodeAt(nextCharacterIndex++) === queryCharacter) {
continue search;
}
}
return false;
}
return true;
},
exactSearch: function (query, term) {
query = query.toLowerCase();
term = term.toLowerCase();
if(term.indexOf(query) > -1) {
return true;
}
return false;
},
filterActive: function() {
if(settings.useLabels) {
$item.filter('.' + className.active)
.addClass(className.filtered)
;
}
},
focusSearch: function(skipHandler) {
if( module.has.search() && !module.is.focusedOnSearch() ) {
if(skipHandler) {
$module.off('focus' + eventNamespace, selector.search);
$search.focus();
$module.on('focus' + eventNamespace, selector.search, module.event.search.focus);
}
else {
$search.focus();
}
}
},
forceSelection: function() {
var
$currentlySelected = $item.not(className.filtered).filter('.' + className.selected).eq(0),
$activeItem = $item.not(className.filtered).filter('.' + className.active).eq(0),
$selectedItem = ($currentlySelected.length > 0)
? $currentlySelected
: $activeItem,
hasSelected = ($selectedItem.length > 0)
;
if(hasSelected && !module.is.multiple()) {
module.debug('Forcing partial selection to selected item', $selectedItem);
module.event.item.click.call($selectedItem, {}, true);
a11y: Improve accessibility of dropdown menus (#8638) * js: Import Semantic-UI's dropdown.js (version 2.3.1) * js: Set tabindex=-1 on dropdown items Setting tabindex=-1 on focusable elements within dropdown menus allows the user to treat dropdown menus as a single focusable item with its own internal navigation using arrow keys. * js: Don't use jQuery to click menu items Menu items are often <a> elements, which jQuery refuses to trigger click events on. Instead it just bubbles up to the menu. Using HTMLElement's click method fixes this and makes menu items clickable from the keyboard using dropdown menus. * js: Set correct ARIA 1.1 roles on dropdown menus Setting role= makes assistive technology aware there is a widget here. In this case, Orca will now exit browse mode and allow us to capture keydown events when focused on a dropdown menu. It will also inform the user that there's a menu focused. Since dropdowns can be used in multiple elements each with different ARIA roles, a guessRole method is used to find the correct role. All roles I consider possible are listed, but only menu is implemented. * js: Set aria-expanded when dropdown menus show and hide This is deliberately done before the transition finishes so that screen readers get immediate feedback. * js: Set aria-label or aria-labelledby on dropdown menus This makes dropdown menu buttons screen reader accessible. aria-labelledby refers to an element using an ID, so the chosen labels are now assigned a unique ID- This ID is not stable, do not refer to it with user scripts. * js: Set aria-activedescendant on dropdown menus As the menus grab focus and navigate by tracking a 'selected' div class, assistive technology has no idea that what the current selection is. Assign IDs to each menu item and set aria-activedescendant to the ID of the currently selected menu item. When the menu is unfocused, remove aria-activedescendant- This isn't neccessary but in my experience it triggers Orca to remind the user of their current selection when re-focusing the menu. * Makefile: Make eslint ignore semantic.dropdown.js This file is taken from Semantic UI which isn't linted upstream. Ignore it as we won't fix these issues. * js: Add version note to semantic.dropdown.js * Add Md5 AppVer to templates/base/footer.tmpl Co-Authored-By: guillep2k <18600385+guillep2k@users.noreply.github.com> * Add Md5 AppVer to templates/pwa/serviceworker_js.tmpl Co-Authored-By: guillep2k <18600385+guillep2k@users.noreply.github.com> * semantic.dropdown.js -> semantic.dropdown.custom.js * Use eslintignore * remove bogus submodule
2019-11-08 18:33:21 +01:00
return;
}
else {
if(settings.allowAdditions) {
module.set.selected(module.get.query());
module.remove.searchTerm();
}
else {
module.remove.searchTerm();
}
}
},
change: {
values: function(values) {
if(!settings.allowAdditions) {
module.clear();
}
module.debug('Creating dropdown with specified values', values);
module.setup.menu({values: values});
$.each(values, function(index, item) {
if(item.selected == true) {
module.debug('Setting initial selection to', item.value);
module.set.selected(item.value);
return true;
}
});
}
},
event: {
change: function() {
if(!internalChange) {
module.debug('Input changed, updating selection');
module.set.selected();
}
},
focus: function() {
if(settings.showOnFocus && !activated && module.is.hidden() && !pageLostFocus) {
module.show();
}
},
blur: function(event) {
pageLostFocus = (document.activeElement === this);
if(!activated && !pageLostFocus) {
module.remove.activeLabel();
module.hide();
}
},
mousedown: function() {
if(module.is.searchSelection()) {
// prevent menu hiding on immediate re-focus
willRefocus = true;
}
else {
// prevents focus callback from occurring on mousedown
activated = true;
}
},
mouseup: function() {
if(module.is.searchSelection()) {
// prevent menu hiding on immediate re-focus
willRefocus = false;
}
else {
activated = false;
}
},
click: function(event) {
var
$target = $(event.target)
;
// focus search
if($target.is($module)) {
if(!module.is.focusedOnSearch()) {
module.focusSearch();
}
else {
module.show();
}
}
},
search: {
focus: function() {
activated = true;
if(module.is.multiple()) {
module.remove.activeLabel();
}
if(settings.showOnFocus) {
module.search();
}
},
blur: function(event) {
pageLostFocus = (document.activeElement === this);
if(module.is.searchSelection() && !willRefocus) {
if(!itemActivated && !pageLostFocus) {
if(settings.forceSelection) {
module.forceSelection();
}
module.hide();
}
}
willRefocus = false;
}
},
icon: {
click: function(event) {
module.toggle();
}
},
text: {
focus: function(event) {
activated = true;
module.focusSearch();
}
},
input: function(event) {
if(module.is.multiple() || module.is.searchSelection()) {
module.set.filtered();
}
clearTimeout(module.timer);
module.timer = setTimeout(module.search, settings.delay.search);
},
label: {
click: function(event) {
var
$label = $(this),
$labels = $module.find(selector.label),
$activeLabels = $labels.filter('.' + className.active),
$nextActive = $label.nextAll('.' + className.active),
$prevActive = $label.prevAll('.' + className.active),
$range = ($nextActive.length > 0)
? $label.nextUntil($nextActive).add($activeLabels).add($label)
: $label.prevUntil($prevActive).add($activeLabels).add($label)
;
if(event.shiftKey) {
$activeLabels.removeClass(className.active);
$range.addClass(className.active);
}
else if(event.ctrlKey) {
$label.toggleClass(className.active);
}
else {
$activeLabels.removeClass(className.active);
$label.addClass(className.active);
}
settings.onLabelSelect.apply(this, $labels.filter('.' + className.active));
}
},
remove: {
click: function() {
var
$label = $(this).parent()
;
if( $label.hasClass(className.active) ) {
// remove all selected labels
module.remove.activeLabels();
}
else {
// remove this label only
module.remove.activeLabels( $label );
}
}
},
test: {
toggle: function(event) {
var
toggleBehavior = (module.is.multiple())
? module.show
: module.toggle
;
if(module.is.bubbledLabelClick(event) || module.is.bubbledIconClick(event)) {
return;
}
if( module.determine.eventOnElement(event, toggleBehavior) ) {
event.preventDefault();
}
},
touch: function(event) {
module.determine.eventOnElement(event, function() {
if(event.type == 'touchstart') {
module.timer = setTimeout(function() {
module.hide();
}, settings.delay.touch);
}
else if(event.type == 'touchmove') {
clearTimeout(module.timer);
}
});
event.stopPropagation();
},
hide: function(event) {
module.determine.eventInModule(event, module.hide);
}
},
select: {
mutation: function(mutations) {
module.debug('<select> modified, recreating menu');
var
isSelectMutation = false
;
$.each(mutations, function(index, mutation) {
if($(mutation.target).is('select') || $(mutation.addedNodes).is('select')) {
isSelectMutation = true;
return true;
}
});
if(isSelectMutation) {
module.disconnect.selectObserver();
module.refresh();
module.setup.select();
module.set.selected();
module.observe.select();
}
}
},
menu: {
mutation: function(mutations) {
var
mutation = mutations[0],
$addedNode = mutation.addedNodes
? $(mutation.addedNodes[0])
: $(false),
$removedNode = mutation.removedNodes
? $(mutation.removedNodes[0])
: $(false),
$changedNodes = $addedNode.add($removedNode),
isUserAddition = $changedNodes.is(selector.addition) || $changedNodes.closest(selector.addition).length > 0,
isMessage = $changedNodes.is(selector.message) || $changedNodes.closest(selector.message).length > 0
;
if(isUserAddition || isMessage) {
module.debug('Updating item selector cache');
module.refreshItems();
}
else {
module.debug('Menu modified, updating selector cache');
module.refresh();
}
},
mousedown: function() {
itemActivated = true;
},
mouseup: function() {
itemActivated = false;
}
},
item: {
mouseenter: function(event) {
var
$target = $(event.target),
$item = $(this),
$subMenu = $item.children(selector.menu),
$otherMenus = $item.siblings(selector.item).children(selector.menu),
hasSubMenu = ($subMenu.length > 0),
isBubbledEvent = ($subMenu.find($target).length > 0)
;
if( !isBubbledEvent && hasSubMenu ) {
clearTimeout(module.itemTimer);
module.itemTimer = setTimeout(function() {
module.verbose('Showing sub-menu', $subMenu);
$.each($otherMenus, function() {
module.animate.hide(false, $(this));
});
module.animate.show(false, $subMenu);
}, settings.delay.show);
event.preventDefault();
}
},
mouseleave: function(event) {
var
$subMenu = $(this).children(selector.menu)
;
if($subMenu.length > 0) {
clearTimeout(module.itemTimer);
module.itemTimer = setTimeout(function() {
module.verbose('Hiding sub-menu', $subMenu);
module.animate.hide(false, $subMenu);
}, settings.delay.hide);
}
},
click: function (event, skipRefocus) {
var
$choice = $(this),
$target = (event)
? $(event.target)
: $(''),
$subMenu = $choice.find(selector.menu),
text = module.get.choiceText($choice),
value = module.get.choiceValue($choice, text),
hasSubMenu = ($subMenu.length > 0),
isBubbledEvent = ($subMenu.find($target).length > 0)
;
// prevents IE11 bug where menu receives focus even though `tabindex=-1`
if(module.has.menuSearch()) {
$(document.activeElement).blur();
}
if(!isBubbledEvent && (!hasSubMenu || settings.allowCategorySelection)) {
if(module.is.searchSelection()) {
if(settings.allowAdditions) {
module.remove.userAddition();
}
module.remove.searchTerm();
if(!module.is.focusedOnSearch() && !(skipRefocus == true)) {
module.focusSearch(true);
}
}
if(!settings.useLabels) {
module.remove.filteredItem();
module.set.scrollPosition($choice);
}
module.determine.selectAction.call(this, text, value);
}
}
},
document: {
// label selection should occur even when element has no focus
keydown: function(event) {
var
pressedKey = event.which,
isShortcutKey = module.is.inObject(pressedKey, keys)
;
if(isShortcutKey) {
var
$label = $module.find(selector.label),
$activeLabel = $label.filter('.' + className.active),
activeValue = $activeLabel.data(metadata.value),
labelIndex = $label.index($activeLabel),
labelCount = $label.length,
hasActiveLabel = ($activeLabel.length > 0),
hasMultipleActive = ($activeLabel.length > 1),
isFirstLabel = (labelIndex === 0),
isLastLabel = (labelIndex + 1 == labelCount),
isSearch = module.is.searchSelection(),
isFocusedOnSearch = module.is.focusedOnSearch(),
isFocused = module.is.focused(),
caretAtStart = (isFocusedOnSearch && module.get.caretPosition() === 0),
$nextLabel
;
if(isSearch && !hasActiveLabel && !isFocusedOnSearch) {
return;
}
if(pressedKey == keys.leftArrow) {
// activate previous label
if((isFocused || caretAtStart) && !hasActiveLabel) {
module.verbose('Selecting previous label');
$label.last().addClass(className.active);
}
else if(hasActiveLabel) {
if(!event.shiftKey) {
module.verbose('Selecting previous label');
$label.removeClass(className.active);
}
else {
module.verbose('Adding previous label to selection');
}
if(isFirstLabel && !hasMultipleActive) {
$activeLabel.addClass(className.active);
}
else {
$activeLabel.prev(selector.siblingLabel)
.addClass(className.active)
.end()
;
}
event.preventDefault();
}
}
else if(pressedKey == keys.rightArrow) {
// activate first label
if(isFocused && !hasActiveLabel) {
$label.first().addClass(className.active);
}
// activate next label
if(hasActiveLabel) {
if(!event.shiftKey) {
module.verbose('Selecting next label');
$label.removeClass(className.active);
}
else {
module.verbose('Adding next label to selection');
}
if(isLastLabel) {
if(isSearch) {
if(!isFocusedOnSearch) {
module.focusSearch();
}
else {
$label.removeClass(className.active);
}
}
else if(hasMultipleActive) {
$activeLabel.next(selector.siblingLabel).addClass(className.active);
}
else {
$activeLabel.addClass(className.active);
}
}
else {
$activeLabel.next(selector.siblingLabel).addClass(className.active);
}
event.preventDefault();
}
}
else if(pressedKey == keys.deleteKey || pressedKey == keys.backspace) {
if(hasActiveLabel) {
module.verbose('Removing active labels');
if(isLastLabel) {
if(isSearch && !isFocusedOnSearch) {
module.focusSearch();
}
}
$activeLabel.last().next(selector.siblingLabel).addClass(className.active);
module.remove.activeLabels($activeLabel);
event.preventDefault();
}
else if(caretAtStart && !hasActiveLabel && pressedKey == keys.backspace) {
module.verbose('Removing last label on input backspace');
$activeLabel = $label.last().addClass(className.active);
module.remove.activeLabels($activeLabel);
}
}
else {
$activeLabel.removeClass(className.active);
}
}
}
},
keydown: function(event) {
var
pressedKey = event.which,
isShortcutKey = module.is.inObject(pressedKey, keys)
;
if(isShortcutKey) {
var
$currentlySelected = $item.not(selector.unselectable).filter('.' + className.selected).eq(0),
$activeItem = $menu.children('.' + className.active).eq(0),
$selectedItem = ($currentlySelected.length > 0)
? $currentlySelected
: $activeItem,
$visibleItems = ($selectedItem.length > 0)
? $selectedItem.siblings(':not(.' + className.filtered +')').addBack()
: $menu.children(':not(.' + className.filtered +')'),
$subMenu = $selectedItem.children(selector.menu),
$parentMenu = $selectedItem.closest(selector.menu),
inVisibleMenu = ($parentMenu.hasClass(className.visible) || $parentMenu.hasClass(className.animating) || $parentMenu.parent(selector.menu).length > 0),
hasSubMenu = ($subMenu.length> 0),
hasSelectedItem = ($selectedItem.length > 0),
selectedIsSelectable = ($selectedItem.not(selector.unselectable).length > 0),
delimiterPressed = (pressedKey == keys.delimiter && settings.allowAdditions && module.is.multiple()),
isAdditionWithoutMenu = (settings.allowAdditions && settings.hideAdditions && (pressedKey == keys.enter || delimiterPressed) && selectedIsSelectable),
$nextItem,
isSubMenuItem,
newIndex
;
// allow selection with menu closed
if(isAdditionWithoutMenu) {
module.verbose('Selecting item from keyboard shortcut', $selectedItem);
$selectedItem[0].click();
if(module.is.searchSelection()) {
module.remove.searchTerm();
}
}
// visible menu keyboard shortcuts
if( module.is.visible() ) {
// enter (select or open sub-menu)
if(pressedKey == keys.enter || delimiterPressed) {
if(pressedKey == keys.enter && hasSelectedItem && hasSubMenu && !settings.allowCategorySelection) {
module.verbose('Pressed enter on unselectable category, opening sub menu');
pressedKey = keys.rightArrow;
}
else if(selectedIsSelectable) {
module.verbose('Selecting item from keyboard shortcut', $selectedItem);
$selectedItem[0].click();
if(module.is.searchSelection()) {
module.remove.searchTerm();
}
}
event.preventDefault();
}
// sub-menu actions
if(hasSelectedItem) {
if(pressedKey == keys.leftArrow) {
isSubMenuItem = ($parentMenu[0] !== $menu[0]);
if(isSubMenuItem) {
module.verbose('Left key pressed, closing sub-menu');
module.animate.hide(false, $parentMenu);
$selectedItem
.removeClass(className.selected)
;
$parentMenu
.closest(selector.item)
.addClass(className.selected)
;
module.aria.refreshDescendant();
event.preventDefault();
}
}
// right arrow (show sub-menu)
if(pressedKey == keys.rightArrow) {
if(hasSubMenu) {
module.verbose('Right key pressed, opening sub-menu');
module.animate.show(false, $subMenu);
$selectedItem
.removeClass(className.selected)
;
$subMenu
.find(selector.item).eq(0)
.addClass(className.selected)
;
module.aria.refreshDescendant();
event.preventDefault();
}
}
}
// up arrow (traverse menu up)
if(pressedKey == keys.upArrow) {
$nextItem = (hasSelectedItem && inVisibleMenu)
? $selectedItem.prevAll(selector.item + ':not(' + selector.unselectable + ')').eq(0)
: $item.eq(0)
;
if($visibleItems.index( $nextItem ) < 0) {
module.verbose('Up key pressed but reached top of current menu');
event.preventDefault();
return;
}
else {
module.verbose('Up key pressed, changing active item');
$selectedItem
.removeClass(className.selected)
;
$nextItem
.addClass(className.selected)
;
module.aria.refreshDescendant();
module.set.scrollPosition($nextItem);
if(settings.selectOnKeydown && module.is.single()) {
module.set.selectedItem($nextItem);
}
}
event.preventDefault();
}
// down arrow (traverse menu down)
if(pressedKey == keys.downArrow) {
$nextItem = (hasSelectedItem && inVisibleMenu)
? $nextItem = $selectedItem.nextAll(selector.item + ':not(' + selector.unselectable + ')').eq(0)
: $item.eq(0)
;
if($nextItem.length === 0) {
module.verbose('Down key pressed but reached bottom of current menu');
event.preventDefault();
return;
}
else {
module.verbose('Down key pressed, changing active item');
$item
.removeClass(className.selected)
;
$nextItem
.addClass(className.selected)
;
module.aria.refreshDescendant();
module.set.scrollPosition($nextItem);
if(settings.selectOnKeydown && module.is.single()) {
module.set.selectedItem($nextItem);
}
}
event.preventDefault();
}
// page down (show next page)
if(pressedKey == keys.pageUp) {
module.scrollPage('up');
event.preventDefault();
}
if(pressedKey == keys.pageDown) {
module.scrollPage('down');
event.preventDefault();
}
// escape (close menu)
if(pressedKey == keys.escape) {
module.verbose('Escape key pressed, closing dropdown');
module.hide();
}
}
else {
// delimiter key
if(delimiterPressed) {
event.preventDefault();
}
// down arrow (open menu)
if(pressedKey == keys.downArrow && !module.is.visible()) {
module.verbose('Down key pressed, showing dropdown');
module.show();
event.preventDefault();
}
}
}
else {
if( !module.has.search() ) {
module.set.selectedLetter( String.fromCharCode(pressedKey) );
}
}
}
},
trigger: {
change: function() {
var
events = document.createEvent('HTMLEvents'),
inputElement = $input[0]
;
if(inputElement) {
module.verbose('Triggering native change event');
events.initEvent('change', true, false);
inputElement.dispatchEvent(events);
}
}
},
determine: {
selectAction: function(text, value) {
module.verbose('Determining action', settings.action);
if( $.isFunction( module.action[settings.action] ) ) {
module.verbose('Triggering preset action', settings.action, text, value);
module.action[ settings.action ].call(element, text, value, this);
}
else if( $.isFunction(settings.action) ) {
module.verbose('Triggering user action', settings.action, text, value);
settings.action.call(element, text, value, this);
}
else {
module.error(error.action, settings.action);
}
},
eventInModule: function(event, callback) {
var
$target = $(event.target),
inDocument = ($target.closest(document.documentElement).length > 0),
inModule = ($target.closest($module).length > 0)
;
callback = $.isFunction(callback)
? callback
: function(){}
;
if(inDocument && !inModule) {
module.verbose('Triggering event', callback);
callback();
return true;
}
else {
module.verbose('Event occurred in dropdown, canceling callback');
return false;
}
},
eventOnElement: function(event, callback) {
var
$target = $(event.target),
$label = $target.closest(selector.siblingLabel),
inVisibleDOM = document.body.contains(event.target),
notOnLabel = ($module.find($label).length === 0),
notInMenu = ($target.closest($menu).length === 0)
;
callback = $.isFunction(callback)
? callback
: function(){}
;
if(inVisibleDOM && notOnLabel && notInMenu) {
module.verbose('Triggering event', callback);
callback();
return true;
}
else {
module.verbose('Event occurred in dropdown menu, canceling callback');
return false;
}
}
},
action: {
nothing: function() {},
activate: function(text, value, element) {
value = (value !== undefined)
? value
: text
;
if( module.can.activate( $(element) ) ) {
module.set.selected(value, $(element));
if(module.is.multiple() && !module.is.allFiltered()) {
return;
}
else {
module.hideAndClear();
}
}
},
select: function(text, value, element) {
value = (value !== undefined)
? value
: text
;
if( module.can.activate( $(element) ) ) {
module.set.value(value, text, $(element));
if(module.is.multiple() && !module.is.allFiltered()) {
return;
}
else {
module.hideAndClear();
}
}
},
combo: function(text, value, element) {
value = (value !== undefined)
? value
: text
;
module.set.selected(value, $(element));
module.hideAndClear();
},
hide: function(text, value, element) {
module.set.value(value, text);
module.hideAndClear();
}
},
get: {
id: function() {
return id;
},
defaultText: function() {
return $module.data(metadata.defaultText);
},
defaultValue: function() {
return $module.data(metadata.defaultValue);
},
placeholderText: function() {
if(settings.placeholder != 'auto' && typeof settings.placeholder == 'string') {
return settings.placeholder;
}
return $module.data(metadata.placeholderText) || '';
},
text: function() {
return $text.text();
},
query: function() {
return $.trim($search.val());
},
searchWidth: function(value) {
value = (value !== undefined)
? value
: $search.val()
;
$sizer.text(value);
// prevent rounding issues
return Math.ceil( $sizer.width() + 1);
},
selectionCount: function() {
var
values = module.get.values(),
count
;
count = ( module.is.multiple() )
? $.isArray(values)
? values.length
: 0
: (module.get.value() !== '')
? 1
: 0
;
return count;
},
transition: function($subMenu) {
return (settings.transition == 'auto')
? module.is.upward($subMenu)
? 'slide up'
: 'slide down'
: settings.transition
;
},
userValues: function() {
var
values = module.get.values()
;
if(!values) {
return false;
}
values = $.isArray(values)
? values
: [values]
;
return $.grep(values, function(value) {
return (module.get.item(value) === false);
});
},
uniqueArray: function(array) {
return $.grep(array, function (value, index) {
return $.inArray(value, array) === index;
});
},
caretPosition: function() {
var
input = $search.get(0),
range,
rangeLength
;
if('selectionStart' in input) {
return input.selectionStart;
}
else if (document.selection) {
input.focus();
range = document.selection.createRange();
rangeLength = range.text.length;
range.moveStart('character', -input.value.length);
return range.text.length - rangeLength;
}
},
value: function() {
var
value = ($input.length > 0)
? $input.val()
: $module.data(metadata.value),
isEmptyMultiselect = ($.isArray(value) && value.length === 1 && value[0] === '')
;
// prevents placeholder element from being selected when multiple
return (value === undefined || isEmptyMultiselect)
? ''
: value
;
},
values: function() {
var
value = module.get.value()
;
if(value === '') {
return '';
}
return ( !module.has.selectInput() && module.is.multiple() )
? (typeof value == 'string') // delimited string
? value.split(settings.delimiter)
: ''
: value
;
},
remoteValues: function() {
var
values = module.get.values(),
remoteValues = false
;
if(values) {
if(typeof values == 'string') {
values = [values];
}
$.each(values, function(index, value) {
var
name = module.read.remoteData(value)
;
module.verbose('Restoring value from session data', name, value);
if(name) {
if(!remoteValues) {
remoteValues = {};
}
remoteValues[value] = name;
}
});
}
return remoteValues;
},
choiceText: function($choice, preserveHTML) {
preserveHTML = (preserveHTML !== undefined)
? preserveHTML
: settings.preserveHTML
;
if($choice) {
if($choice.find(selector.menu).length > 0) {
module.verbose('Retrieving text of element with sub-menu');
$choice = $choice.clone();
$choice.find(selector.menu).remove();
$choice.find(selector.menuIcon).remove();
}
return ($choice.data(metadata.text) !== undefined)
? $choice.data(metadata.text)
: (preserveHTML)
? $.trim($choice.html())
: $.trim($choice.text())
;
}
},
choiceValue: function($choice, choiceText) {
choiceText = choiceText || module.get.choiceText($choice);
if(!$choice) {
return false;
}
return ($choice.data(metadata.value) !== undefined)
? String( $choice.data(metadata.value) )
: (typeof choiceText === 'string')
? $.trim(choiceText.toLowerCase())
: String(choiceText)
;
},
inputEvent: function() {
var
input = $search[0]
;
if(input) {
return (input.oninput !== undefined)
? 'input'
: (input.onpropertychange !== undefined)
? 'propertychange'
: 'keyup'
;
}
return false;
},
selectValues: function() {
var
select = {}
;
select.values = [];
$module
.find('option')
.each(function() {
var
$option = $(this),
name = $option.html(),
disabled = $option.attr('disabled'),
value = ( $option.attr('value') !== undefined )
? $option.attr('value')
: name
;
if(settings.placeholder === 'auto' && value === '') {
select.placeholder = name;
}
else {
select.values.push({
name : name,
value : value,
disabled : disabled
});
}
})
;
if(settings.placeholder && settings.placeholder !== 'auto') {
module.debug('Setting placeholder value to', settings.placeholder);
select.placeholder = settings.placeholder;
}
if(settings.sortSelect) {
select.values.sort(function(a, b) {
return (a.name > b.name)
? 1
: -1
;
});
module.debug('Retrieved and sorted values from select', select);
}
else {
module.debug('Retrieved values from select', select);
}
return select;
},
activeItem: function() {
return $item.filter('.' + className.active);
},
selectedItem: function() {
var
$selectedItem = $item.not(selector.unselectable).filter('.' + className.selected)
;
return ($selectedItem.length > 0)
? $selectedItem
: $item.eq(0)
;
},
itemWithAdditions: function(value) {
var
$items = module.get.item(value),
$userItems = module.create.userChoice(value),
hasUserItems = ($userItems && $userItems.length > 0)
;
if(hasUserItems) {
$items = ($items.length > 0)
? $items.add($userItems)
: $userItems
;
}
return $items;
},
item: function(value, strict) {
var
$selectedItem = false,
shouldSearch,
isMultiple
;
value = (value !== undefined)
? value
: ( module.get.values() !== undefined)
? module.get.values()
: module.get.text()
;
shouldSearch = (isMultiple)
? (value.length > 0)
: (value !== undefined && value !== null)
;
isMultiple = (module.is.multiple() && $.isArray(value));
strict = (value === '' || value === 0)
? true
: strict || false
;
if(shouldSearch) {
$item
.each(function() {
var
$choice = $(this),
optionText = module.get.choiceText($choice),
optionValue = module.get.choiceValue($choice, optionText)
;
// safe early exit
if(optionValue === null || optionValue === undefined) {
return;
}
if(isMultiple) {
if($.inArray( String(optionValue), value) !== -1 || $.inArray(optionText, value) !== -1) {
$selectedItem = ($selectedItem)
? $selectedItem.add($choice)
: $choice
;
}
}
else if(strict) {
module.verbose('Ambiguous dropdown value using strict type check', $choice, value);
if( optionValue === value || optionText === value) {
$selectedItem = $choice;
return true;
}
}
else {
if( String(optionValue) == String(value) || optionText == value) {
module.verbose('Found select item by value', optionValue, value);
$selectedItem = $choice;
return true;
}
}
})
;
}
return $selectedItem;
}
},
check: {
maxSelections: function(selectionCount) {
if(settings.maxSelections) {
selectionCount = (selectionCount !== undefined)
? selectionCount
: module.get.selectionCount()
;
if(selectionCount >= settings.maxSelections) {
module.debug('Maximum selection count reached');
if(settings.useLabels) {
$item.addClass(className.filtered);
module.add.message(message.maxSelections);
}
return true;
}
else {
module.verbose('No longer at maximum selection count');
module.remove.message();
module.remove.filteredItem();
if(module.is.searchSelection()) {
module.filterItems();
}
return false;
}
}
return true;
}
},
restore: {
defaults: function() {
module.clear();
module.restore.defaultText();
module.restore.defaultValue();
},
defaultText: function() {
var
defaultText = module.get.defaultText(),
placeholderText = module.get.placeholderText
;
if(defaultText === placeholderText) {
module.debug('Restoring default placeholder text', defaultText);
module.set.placeholderText(defaultText);
}
else {
module.debug('Restoring default text', defaultText);
module.set.text(defaultText);
}
},
placeholderText: function() {
module.set.placeholderText();
},
defaultValue: function() {
var
defaultValue = module.get.defaultValue()
;
if(defaultValue !== undefined) {
module.debug('Restoring default value', defaultValue);
if(defaultValue !== '') {
module.set.value(defaultValue);
module.set.selected();
}
else {
module.remove.activeItem();
module.remove.selectedItem();
}
}
},
labels: function() {
if(settings.allowAdditions) {
if(!settings.useLabels) {
module.error(error.labels);
settings.useLabels = true;
}
module.debug('Restoring selected values');
module.create.userLabels();
}
module.check.maxSelections();
},
selected: function() {
module.restore.values();
if(module.is.multiple()) {
module.debug('Restoring previously selected values and labels');
module.restore.labels();
}
else {
module.debug('Restoring previously selected values');
}
},
values: function() {
// prevents callbacks from occurring on initial load
module.set.initialLoad();
if(settings.apiSettings && settings.saveRemoteData && module.get.remoteValues()) {
module.restore.remoteValues();
}
else {
module.set.selected();
}
module.remove.initialLoad();
},
remoteValues: function() {
var
values = module.get.remoteValues()
;
module.debug('Recreating selected from session data', values);
if(values) {
if( module.is.single() ) {
$.each(values, function(value, name) {
module.set.text(name);
});
}
else {
$.each(values, function(value, name) {
module.add.label(value, name);
});
}
}
}
},
read: {
remoteData: function(value) {
var
name
;
if(window.Storage === undefined) {
module.error(error.noStorage);
return;
}
name = sessionStorage.getItem(value);
return (name !== undefined)
? name
: false
;
}
},
save: {
defaults: function() {
module.save.defaultText();
module.save.placeholderText();
module.save.defaultValue();
},
defaultValue: function() {
var
value = module.get.value()
;
module.verbose('Saving default value as', value);
$module.data(metadata.defaultValue, value);
},
defaultText: function() {
var
text = module.get.text()
;
module.verbose('Saving default text as', text);
$module.data(metadata.defaultText, text);
},
placeholderText: function() {
var
text
;
if(settings.placeholder !== false && $text.hasClass(className.placeholder)) {
text = module.get.text();
module.verbose('Saving placeholder text as', text);
$module.data(metadata.placeholderText, text);
}
},
remoteData: function(name, value) {
if(window.Storage === undefined) {
module.error(error.noStorage);
return;
}
module.verbose('Saving remote data to session storage', value, name);
sessionStorage.setItem(value, name);
}
},
clear: function() {
if(module.is.multiple() && settings.useLabels) {
module.remove.labels();
}
else {
module.remove.activeItem();
module.remove.selectedItem();
}
module.set.placeholderText();
module.clearValue();
},
clearValue: function() {
module.set.value('');
},
scrollPage: function(direction, $selectedItem) {
var
$currentItem = $selectedItem || module.get.selectedItem(),
$menu = $currentItem.closest(selector.menu),
menuHeight = $menu.outerHeight(),
currentScroll = $menu.scrollTop(),
itemHeight = $item.eq(0).outerHeight(),
itemsPerPage = Math.floor(menuHeight / itemHeight),
maxScroll = $menu.prop('scrollHeight'),
newScroll = (direction == 'up')
? currentScroll - (itemHeight * itemsPerPage)
: currentScroll + (itemHeight * itemsPerPage),
$selectableItem = $item.not(selector.unselectable),
isWithinRange,
$nextSelectedItem,
elementIndex
;
elementIndex = (direction == 'up')
? $selectableItem.index($currentItem) - itemsPerPage
: $selectableItem.index($currentItem) + itemsPerPage
;
isWithinRange = (direction == 'up')
? (elementIndex >= 0)
: (elementIndex < $selectableItem.length)
;
$nextSelectedItem = (isWithinRange)
? $selectableItem.eq(elementIndex)
: (direction == 'up')
? $selectableItem.first()
: $selectableItem.last()
;
if($nextSelectedItem.length > 0) {
module.debug('Scrolling page', direction, $nextSelectedItem);
$currentItem
.removeClass(className.selected)
;
$nextSelectedItem
.addClass(className.selected)
;
if(settings.selectOnKeydown && module.is.single()) {
module.set.selectedItem($nextSelectedItem);
}
$menu
.scrollTop(newScroll)
;
}
},
set: {
filtered: function() {
var
isMultiple = module.is.multiple(),
isSearch = module.is.searchSelection(),
isSearchMultiple = (isMultiple && isSearch),
searchValue = (isSearch)
? module.get.query()
: '',
hasSearchValue = (typeof searchValue === 'string' && searchValue.length > 0),
searchWidth = module.get.searchWidth(),
valueIsSet = searchValue !== ''
;
if(isMultiple && hasSearchValue) {
module.verbose('Adjusting input width', searchWidth, settings.glyphWidth);
$search.css('width', searchWidth);
}
if(hasSearchValue || (isSearchMultiple && valueIsSet)) {
module.verbose('Hiding placeholder text');
$text.addClass(className.filtered);
}
else if(!isMultiple || (isSearchMultiple && !valueIsSet)) {
module.verbose('Showing placeholder text');
$text.removeClass(className.filtered);
}
},
empty: function() {
$module.addClass(className.empty);
},
loading: function() {
$module.addClass(className.loading);
},
placeholderText: function(text) {
text = text || module.get.placeholderText();
module.debug('Setting placeholder text', text);
module.set.text(text);
$text.addClass(className.placeholder);
},
tabbable: function() {
if( module.is.searchSelection() ) {
module.debug('Added tabindex to searchable dropdown');
$search
.val('')
.attr('tabindex', 0)
;
$menu
.attr('tabindex', -1)
;
}
else {
module.debug('Added tabindex to dropdown');
if( $module.attr('tabindex') === undefined) {
$module
.attr('tabindex', 0)
;
$menu
.attr('tabindex', -1)
;
}
}
},
initialLoad: function() {
module.verbose('Setting initial load');
initialLoad = true;
},
activeItem: function($item) {
if( settings.allowAdditions && $item.filter(selector.addition).length > 0 ) {
$item.addClass(className.filtered);
}
else {
$item.addClass(className.active);
}
},
partialSearch: function(text) {
var
length = module.get.query().length
;
$search.val( text.substr(0, length));
},
scrollPosition: function($item, forceScroll) {
var
edgeTolerance = 5,
$menu,
hasActive,
offset,
itemHeight,
itemOffset,
menuOffset,
menuScroll,
menuHeight,
abovePage,
belowPage
;
$item = $item || module.get.selectedItem();
$menu = $item.closest(selector.menu);
hasActive = ($item && $item.length > 0);
forceScroll = (forceScroll !== undefined)
? forceScroll
: false
;
if($item && $menu.length > 0 && hasActive) {
itemOffset = $item.position().top;
$menu.addClass(className.loading);
menuScroll = $menu.scrollTop();
menuOffset = $menu.offset().top;
itemOffset = $item.offset().top;
offset = menuScroll - menuOffset + itemOffset;
if(!forceScroll) {
menuHeight = $menu.height();
belowPage = menuScroll + menuHeight < (offset + edgeTolerance);
abovePage = ((offset - edgeTolerance) < menuScroll);
}
module.debug('Scrolling to active item', offset);
if(forceScroll || abovePage || belowPage) {
$menu.scrollTop(offset);
}
$menu.removeClass(className.loading);
}
},
text: function(text) {
if(settings.action !== 'select') {
if(settings.action == 'combo') {
module.debug('Changing combo button text', text, $combo);
if(settings.preserveHTML) {
$combo.html(text);
}
else {
$combo.text(text);
}
}
else {
if(text !== module.get.placeholderText()) {
$text.removeClass(className.placeholder);
}
module.debug('Changing text', text, $text);
$text
.removeClass(className.filtered)
;
if(settings.preserveHTML) {
$text.html(text);
}
else {
$text.text(text);
}
}
}
},
selectedItem: function($item) {
var
value = module.get.choiceValue($item),
searchText = module.get.choiceText($item, false),
text = module.get.choiceText($item, true)
;
module.debug('Setting user selection to item', $item);
module.remove.activeItem();
module.set.partialSearch(searchText);
module.set.activeItem($item);
module.set.selected(value, $item);
module.set.text(text);
},
selectedLetter: function(letter) {
var
$selectedItem = $item.filter('.' + className.selected),
alreadySelectedLetter = $selectedItem.length > 0 && module.has.firstLetter($selectedItem, letter),
$nextValue = false,
$nextItem
;
// check next of same letter
if(alreadySelectedLetter) {
$nextItem = $selectedItem.nextAll($item).eq(0);
if( module.has.firstLetter($nextItem, letter) ) {
$nextValue = $nextItem;
}
}
// check all values
if(!$nextValue) {
$item
.each(function(){
if(module.has.firstLetter($(this), letter)) {
$nextValue = $(this);
return false;
}
})
;
}
// set next value
if($nextValue) {
module.verbose('Scrolling to next value with letter', letter);
module.set.scrollPosition($nextValue);
$selectedItem.removeClass(className.selected);
$nextValue.addClass(className.selected);
module.aria.refreshDescendant();
if(settings.selectOnKeydown && module.is.single()) {
module.set.selectedItem($nextValue);
}
}
},
direction: function($menu) {
if(settings.direction == 'auto') {
// reset position
module.remove.upward();
if(module.can.openDownward($menu)) {
module.remove.upward($menu);
}
else {
module.set.upward($menu);
}
if(!module.is.leftward($menu) && !module.can.openRightward($menu)) {
module.set.leftward($menu);
}
}
else if(settings.direction == 'upward') {
module.set.upward($menu);
}
},
upward: function($currentMenu) {
var $element = $currentMenu || $module;
$element.addClass(className.upward);
},
leftward: function($currentMenu) {
var $element = $currentMenu || $menu;
$element.addClass(className.leftward);
},
value: function(value, text, $selected) {
var
escapedValue = module.escape.value(value),
hasInput = ($input.length > 0),
currentValue = module.get.values(),
stringValue = (value !== undefined)
? String(value)
: value,
newValue
;
if(hasInput) {
if(!settings.allowReselection && stringValue == currentValue) {
module.verbose('Skipping value update already same value', value, currentValue);
if(!module.is.initialLoad()) {
return;
}
}
if( module.is.single() && module.has.selectInput() && module.can.extendSelect() ) {
module.debug('Adding user option', value);
module.add.optionValue(value);
}
module.debug('Updating input value', escapedValue, currentValue);
internalChange = true;
$input
.val(escapedValue)
;
if(settings.fireOnInit === false && module.is.initialLoad()) {
module.debug('Input native change event ignored on initial load');
}
else {
module.trigger.change();
}
internalChange = false;
}
else {
module.verbose('Storing value in metadata', escapedValue, $input);
if(escapedValue !== currentValue) {
$module.data(metadata.value, stringValue);
}
}
if(settings.fireOnInit === false && module.is.initialLoad()) {
module.verbose('No callback on initial load', settings.onChange);
}
else {
settings.onChange.call(element, value, text, $selected);
}
},
active: function() {
$module
.addClass(className.active)
;
},
multiple: function() {
$module.addClass(className.multiple);
},
visible: function() {
$module.addClass(className.visible);
},
exactly: function(value, $selectedItem) {
module.debug('Setting selected to exact values');
module.clear();
module.set.selected(value, $selectedItem);
},
selected: function(value, $selectedItem) {
var
isMultiple = module.is.multiple(),
$userSelectedItem
;
$selectedItem = (settings.allowAdditions)
? $selectedItem || module.get.itemWithAdditions(value)
: $selectedItem || module.get.item(value)
;
if(!$selectedItem) {
return;
}
module.debug('Setting selected menu item to', $selectedItem);
if(module.is.multiple()) {
module.remove.searchWidth();
}
if(module.is.single()) {
module.remove.activeItem();
module.remove.selectedItem();
}
else if(settings.useLabels) {
module.remove.selectedItem();
}
// select each item
$selectedItem
.each(function() {
var
$selected = $(this),
selectedText = module.get.choiceText($selected),
selectedValue = module.get.choiceValue($selected, selectedText),
isFiltered = $selected.hasClass(className.filtered),
isActive = $selected.hasClass(className.active),
isUserValue = $selected.hasClass(className.addition),
shouldAnimate = (isMultiple && $selectedItem.length == 1)
;
if(isMultiple) {
if(!isActive || isUserValue) {
if(settings.apiSettings && settings.saveRemoteData) {
module.save.remoteData(selectedText, selectedValue);
}
if(settings.useLabels) {
module.add.label(selectedValue, selectedText, shouldAnimate);
module.add.value(selectedValue, selectedText, $selected);
module.set.activeItem($selected);
module.filterActive();
module.select.nextAvailable($selectedItem);
}
else {
module.add.value(selectedValue, selectedText, $selected);
module.set.text(module.add.variables(message.count));
module.set.activeItem($selected);
}
}
else if(!isFiltered) {
module.debug('Selected active value, removing label');
module.remove.selected(selectedValue);
}
}
else {
if(settings.apiSettings && settings.saveRemoteData) {
module.save.remoteData(selectedText, selectedValue);
}
module.set.text(selectedText);
module.set.value(selectedValue, selectedText, $selected);
$selected
.addClass(className.active)
.addClass(className.selected)
;
}
})
;
}
},
add: {
label: function(value, text, shouldAnimate) {
var
$next = module.is.searchSelection()
? $search
: $text,
escapedValue = module.escape.value(value),
$label
;
if(settings.ignoreCase) {
escapedValue = escapedValue.toLowerCase();
}
$label = $('<a />')
.addClass(className.label)
.attr('data-' + metadata.value, escapedValue)
.html(templates.label(escapedValue, text))
;
$label = settings.onLabelCreate.call($label, escapedValue, text);
if(module.has.label(value)) {
module.debug('User selection already exists, skipping', escapedValue);
return;
}
if(settings.label.variation) {
$label.addClass(settings.label.variation);
}
if(shouldAnimate === true) {
module.debug('Animating in label', $label);
$label
.addClass(className.hidden)
.insertBefore($next)
.transition(settings.label.transition, settings.label.duration)
;
}
else {
module.debug('Adding selection label', $label);
$label
.insertBefore($next)
;
}
},
message: function(message) {
var
$message = $menu.children(selector.message),
html = settings.templates.message(module.add.variables(message))
;
if($message.length > 0) {
$message
.html(html)
;
}
else {
$message = $('<div/>')
.html(html)
.addClass(className.message)
.appendTo($menu)
;
}
},
optionValue: function(value) {
var
escapedValue = module.escape.value(value),
$option = $input.find('option[value="' + module.escape.string(escapedValue) + '"]'),
hasOption = ($option.length > 0)
;
if(hasOption) {
return;
}
// temporarily disconnect observer
module.disconnect.selectObserver();
if( module.is.single() ) {
module.verbose('Removing previous user addition');
$input.find('option.' + className.addition).remove();
}
$('<option/>')
.prop('value', escapedValue)
.addClass(className.addition)
.html(value)
.appendTo($input)
;
module.verbose('Adding user addition as an <option>', value);
module.observe.select();
},
userSuggestion: function(value) {
var
$addition = $menu.children(selector.addition),
$existingItem = module.get.item(value),
alreadyHasValue = $existingItem && $existingItem.not(selector.addition).length,
hasUserSuggestion = $addition.length > 0,
html
;
if(settings.useLabels && module.has.maxSelections()) {
return;
}
if(value === '' || alreadyHasValue) {
$addition.remove();
return;
}
if(hasUserSuggestion) {
$addition
.data(metadata.value, value)
.data(metadata.text, value)
.attr('data-' + metadata.value, value)
.attr('data-' + metadata.text, value)
.removeClass(className.filtered)
;
if(!settings.hideAdditions) {
html = settings.templates.addition( module.add.variables(message.addResult, value) );
$addition
.html(html)
;
}
module.verbose('Replacing user suggestion with new value', $addition);
}
else {
$addition = module.create.userChoice(value);
$addition
.prependTo($menu)
;
module.verbose('Adding item choice to menu corresponding with user choice addition', $addition);
}
if(!settings.hideAdditions || module.is.allFiltered()) {
$addition
.addClass(className.selected)
.siblings()
.removeClass(className.selected)
;
}
module.refreshItems();
},
variables: function(message, term) {
var
hasCount = (message.search('{count}') !== -1),
hasMaxCount = (message.search('{maxCount}') !== -1),
hasTerm = (message.search('{term}') !== -1),
values,
count,
query
;
module.verbose('Adding templated variables to message', message);
if(hasCount) {
count = module.get.selectionCount();
message = message.replace('{count}', count);
}
if(hasMaxCount) {
count = module.get.selectionCount();
message = message.replace('{maxCount}', settings.maxSelections);
}
if(hasTerm) {
query = term || module.get.query();
message = message.replace('{term}', query);
}
return message;
},
value: function(addedValue, addedText, $selectedItem) {
var
currentValue = module.get.values(),
newValue
;
if(module.has.value(addedValue)) {
module.debug('Value already selected');
return;
}
if(addedValue === '') {
module.debug('Cannot select blank values from multiselect');
return;
}
// extend current array
if($.isArray(currentValue)) {
newValue = currentValue.concat([addedValue]);
newValue = module.get.uniqueArray(newValue);
}
else {
newValue = [addedValue];
}
// add values
if( module.has.selectInput() ) {
if(module.can.extendSelect()) {
module.debug('Adding value to select', addedValue, newValue, $input);
module.add.optionValue(addedValue);
}
}
else {
newValue = newValue.join(settings.delimiter);
module.debug('Setting hidden input to delimited value', newValue, $input);
}
if(settings.fireOnInit === false && module.is.initialLoad()) {
module.verbose('Skipping onadd callback on initial load', settings.onAdd);
}
else {
settings.onAdd.call(element, addedValue, addedText, $selectedItem);
}
module.set.value(newValue, addedValue, addedText, $selectedItem);
module.check.maxSelections();
}
},
remove: {
active: function() {
$module.removeClass(className.active);
},
activeLabel: function() {
$module.find(selector.label).removeClass(className.active);
},
empty: function() {
$module.removeClass(className.empty);
},
loading: function() {
$module.removeClass(className.loading);
},
initialLoad: function() {
initialLoad = false;
},
upward: function($currentMenu) {
var $element = $currentMenu || $module;
$element.removeClass(className.upward);
},
leftward: function($currentMenu) {
var $element = $currentMenu || $menu;
$element.removeClass(className.leftward);
},
visible: function() {
$module.removeClass(className.visible);
},
activeItem: function() {
$item.removeClass(className.active);
},
filteredItem: function() {
if(settings.useLabels && module.has.maxSelections() ) {
return;
}
if(settings.useLabels && module.is.multiple()) {
$item.not('.' + className.active).removeClass(className.filtered);
}
else {
$item.removeClass(className.filtered);
}
module.remove.empty();
},
optionValue: function(value) {
var
escapedValue = module.escape.value(value),
$option = $input.find('option[value="' + module.escape.string(escapedValue) + '"]'),
hasOption = ($option.length > 0)
;
if(!hasOption || !$option.hasClass(className.addition)) {
return;
}
// temporarily disconnect observer
if(selectObserver) {
selectObserver.disconnect();
module.verbose('Temporarily disconnecting mutation observer');
}
$option.remove();
module.verbose('Removing user addition as an <option>', escapedValue);
if(selectObserver) {
selectObserver.observe($input[0], {
childList : true,
subtree : true
});
}
},
message: function() {
$menu.children(selector.message).remove();
},
searchWidth: function() {
$search.css('width', '');
},
searchTerm: function() {
module.verbose('Cleared search term');
$search.val('');
module.set.filtered();
},
userAddition: function() {
$item.filter(selector.addition).remove();
},
selected: function(value, $selectedItem) {
$selectedItem = (settings.allowAdditions)
? $selectedItem || module.get.itemWithAdditions(value)
: $selectedItem || module.get.item(value)
;
if(!$selectedItem) {
return false;
}
$selectedItem
.each(function() {
var
$selected = $(this),
selectedText = module.get.choiceText($selected),
selectedValue = module.get.choiceValue($selected, selectedText)
;
if(module.is.multiple()) {
if(settings.useLabels) {
module.remove.value(selectedValue, selectedText, $selected);
module.remove.label(selectedValue);
}
else {
module.remove.value(selectedValue, selectedText, $selected);
if(module.get.selectionCount() === 0) {
module.set.placeholderText();
}
else {
module.set.text(module.add.variables(message.count));
}
}
}
else {
module.remove.value(selectedValue, selectedText, $selected);
}
$selected
.removeClass(className.filtered)
.removeClass(className.active)
;
if(settings.useLabels) {
$selected.removeClass(className.selected);
}
})
;
},
selectedItem: function() {
$item.removeClass(className.selected);
},
value: function(removedValue, removedText, $removedItem) {
var
values = module.get.values(),
newValue
;
if( module.has.selectInput() ) {
module.verbose('Input is <select> removing selected option', removedValue);
newValue = module.remove.arrayValue(removedValue, values);
module.remove.optionValue(removedValue);
}
else {
module.verbose('Removing from delimited values', removedValue);
newValue = module.remove.arrayValue(removedValue, values);
newValue = newValue.join(settings.delimiter);
}
if(settings.fireOnInit === false && module.is.initialLoad()) {
module.verbose('No callback on initial load', settings.onRemove);
}
else {
settings.onRemove.call(element, removedValue, removedText, $removedItem);
}
module.set.value(newValue, removedText, $removedItem);
module.check.maxSelections();
},
arrayValue: function(removedValue, values) {
if( !$.isArray(values) ) {
values = [values];
}
values = $.grep(values, function(value){
return (removedValue != value);
});
module.verbose('Removed value from delimited string', removedValue, values);
return values;
},
label: function(value, shouldAnimate) {
var
$labels = $module.find(selector.label),
$removedLabel = $labels.filter('[data-' + metadata.value + '="' + module.escape.string(value) +'"]')
;
module.verbose('Removing label', $removedLabel);
$removedLabel.remove();
},
activeLabels: function($activeLabels) {
$activeLabels = $activeLabels || $module.find(selector.label).filter('.' + className.active);
module.verbose('Removing active label selections', $activeLabels);
module.remove.labels($activeLabels);
},
labels: function($labels) {
$labels = $labels || $module.find(selector.label);
module.verbose('Removing labels', $labels);
$labels
.each(function(){
var
$label = $(this),
value = $label.data(metadata.value),
stringValue = (value !== undefined)
? String(value)
: value,
isUserValue = module.is.userValue(stringValue)
;
if(settings.onLabelRemove.call($label, value) === false) {
module.debug('Label remove callback cancelled removal');
return;
}
module.remove.message();
if(isUserValue) {
module.remove.value(stringValue);
module.remove.label(stringValue);
}
else {
// selected will also remove label
module.remove.selected(stringValue);
}
})
;
},
tabbable: function() {
if( module.is.searchSelection() ) {
module.debug('Searchable dropdown initialized');
$search
.removeAttr('tabindex')
;
$menu
.removeAttr('tabindex')
;
}
else {
module.debug('Simple selection dropdown initialized');
$module
.removeAttr('tabindex')
;
$menu
.removeAttr('tabindex')
;
}
}
},
has: {
menuSearch: function() {
return (module.has.search() && $search.closest($menu).length > 0);
},
search: function() {
return ($search.length > 0);
},
sizer: function() {
return ($sizer.length > 0);
},
selectInput: function() {
return ( $input.is('select') );
},
minCharacters: function(searchTerm) {
if(settings.minCharacters) {
searchTerm = (searchTerm !== undefined)
? String(searchTerm)
: String(module.get.query())
;
return (searchTerm.length >= settings.minCharacters);
}
return true;
},
firstLetter: function($item, letter) {
var
text,
firstLetter
;
if(!$item || $item.length === 0 || typeof letter !== 'string') {
return false;
}
text = module.get.choiceText($item, false);
letter = letter.toLowerCase();
firstLetter = String(text).charAt(0).toLowerCase();
return (letter == firstLetter);
},
input: function() {
return ($input.length > 0);
},
items: function() {
return ($item.length > 0);
},
menu: function() {
return ($menu.length > 0);
},
message: function() {
return ($menu.children(selector.message).length !== 0);
},
label: function(value) {
var
escapedValue = module.escape.value(value),
$labels = $module.find(selector.label)
;
if(settings.ignoreCase) {
escapedValue = escapedValue.toLowerCase();
}
return ($labels.filter('[data-' + metadata.value + '="' + module.escape.string(escapedValue) +'"]').length > 0);
},
maxSelections: function() {
return (settings.maxSelections && module.get.selectionCount() >= settings.maxSelections);
},
allResultsFiltered: function() {
var
$normalResults = $item.not(selector.addition)
;
return ($normalResults.filter(selector.unselectable).length === $normalResults.length);
},
userSuggestion: function() {
return ($menu.children(selector.addition).length > 0);
},
query: function() {
return (module.get.query() !== '');
},
value: function(value) {
return (settings.ignoreCase)
? module.has.valueIgnoringCase(value)
: module.has.valueMatchingCase(value)
;
},
valueMatchingCase: function(value) {
var
values = module.get.values(),
hasValue = $.isArray(values)
? values && ($.inArray(value, values) !== -1)
: (values == value)
;
return (hasValue)
? true
: false
;
},
valueIgnoringCase: function(value) {
var
values = module.get.values(),
hasValue = false
;
if(!$.isArray(values)) {
values = [values];
}
$.each(values, function(index, existingValue) {
if(String(value).toLowerCase() == String(existingValue).toLowerCase()) {
hasValue = true;
return false;
}
});
return hasValue;
}
},
is: {
active: function() {
return $module.hasClass(className.active);
},
animatingInward: function() {
return $menu.transition('is inward');
},
animatingOutward: function() {
return $menu.transition('is outward');
},
bubbledLabelClick: function(event) {
return $(event.target).is('select, input') && $module.closest('label').length > 0;
},
bubbledIconClick: function(event) {
return $(event.target).closest($icon).length > 0;
},
alreadySetup: function() {
return ($module.is('select') && $module.parent(selector.dropdown).data(moduleNamespace) !== undefined && $module.prev().length === 0);
},
animating: function($subMenu) {
return ($subMenu)
? $subMenu.transition && $subMenu.transition('is animating')
: $menu.transition && $menu.transition('is animating')
;
},
leftward: function($subMenu) {
var $selectedMenu = $subMenu || $menu;
return $selectedMenu.hasClass(className.leftward);
},
disabled: function() {
return $module.hasClass(className.disabled);
},
focused: function() {
return (document.activeElement === $module[0]);
},
focusedOnSearch: function() {
return (document.activeElement === $search[0]);
},
allFiltered: function() {
return( (module.is.multiple() || module.has.search()) && !(settings.hideAdditions == false && module.has.userSuggestion()) && !module.has.message() && module.has.allResultsFiltered() );
},
hidden: function($subMenu) {
return !module.is.visible($subMenu);
},
initialLoad: function() {
return initialLoad;
},
inObject: function(needle, object) {
var
found = false
;
$.each(object, function(index, property) {
if(property == needle) {
found = true;
return true;
}
});
return found;
},
multiple: function() {
return $module.hasClass(className.multiple);
},
remote: function() {
return settings.apiSettings && module.can.useAPI();
},
single: function() {
return !module.is.multiple();
},
selectMutation: function(mutations) {
var
selectChanged = false
;
$.each(mutations, function(index, mutation) {
if(mutation.target && $(mutation.target).is('select')) {
selectChanged = true;
return true;
}
});
return selectChanged;
},
search: function() {
return $module.hasClass(className.search);
},
searchSelection: function() {
return ( module.has.search() && $search.parent(selector.dropdown).length === 1 );
},
selection: function() {
return $module.hasClass(className.selection);
},
userValue: function(value) {
return ($.inArray(value, module.get.userValues()) !== -1);
},
upward: function($menu) {
var $element = $menu || $module;
return $element.hasClass(className.upward);
},
visible: function($subMenu) {
return ($subMenu)
? $subMenu.hasClass(className.visible)
: $menu.hasClass(className.visible)
;
},
verticallyScrollableContext: function() {
var
overflowY = ($context.get(0) !== window)
? $context.css('overflow-y')
: false
;
return (overflowY == 'auto' || overflowY == 'scroll');
},
horizontallyScrollableContext: function() {
var
overflowX = ($context.get(0) !== window)
? $context.css('overflow-X')
: false
;
return (overflowX == 'auto' || overflowX == 'scroll');
}
},
can: {
activate: function($item) {
if(settings.useLabels) {
return true;
}
if(!module.has.maxSelections()) {
return true;
}
if(module.has.maxSelections() && $item.hasClass(className.active)) {
return true;
}
return false;
},
openDownward: function($subMenu) {
var
$currentMenu = $subMenu || $menu,
canOpenDownward = true,
onScreen = {},
calculations
;
$currentMenu
.addClass(className.loading)
;
calculations = {
context: {
offset : ($context.get(0) === window)
? { top: 0, left: 0}
: $context.offset(),
scrollTop : $context.scrollTop(),
height : $context.outerHeight()
},
menu : {
offset: $currentMenu.offset(),
height: $currentMenu.outerHeight()
}
};
if(module.is.verticallyScrollableContext()) {
calculations.menu.offset.top += calculations.context.scrollTop;
}
onScreen = {
above : (calculations.context.scrollTop) <= calculations.menu.offset.top - calculations.context.offset.top - calculations.menu.height,
below : (calculations.context.scrollTop + calculations.context.height) >= calculations.menu.offset.top - calculations.context.offset.top + calculations.menu.height
};
if(onScreen.below) {
module.verbose('Dropdown can fit in context downward', onScreen);
canOpenDownward = true;
}
else if(!onScreen.below && !onScreen.above) {
module.verbose('Dropdown cannot fit in either direction, favoring downward', onScreen);
canOpenDownward = true;
}
else {
module.verbose('Dropdown cannot fit below, opening upward', onScreen);
canOpenDownward = false;
}
$currentMenu.removeClass(className.loading);
return canOpenDownward;
},
openRightward: function($subMenu) {
var
$currentMenu = $subMenu || $menu,
canOpenRightward = true,
isOffscreenRight = false,
calculations
;
$currentMenu
.addClass(className.loading)
;
calculations = {
context: {
offset : ($context.get(0) === window)
? { top: 0, left: 0}
: $context.offset(),
scrollLeft : $context.scrollLeft(),
width : $context.outerWidth()
},
menu: {
offset : $currentMenu.offset(),
width : $currentMenu.outerWidth()
}
};
if(module.is.horizontallyScrollableContext()) {
calculations.menu.offset.left += calculations.context.scrollLeft;
}
isOffscreenRight = (calculations.menu.offset.left - calculations.context.offset.left + calculations.menu.width >= calculations.context.scrollLeft + calculations.context.width);
if(isOffscreenRight) {
module.verbose('Dropdown cannot fit in context rightward', isOffscreenRight);
canOpenRightward = false;
}
$currentMenu.removeClass(className.loading);
return canOpenRightward;
},
click: function() {
return (hasTouch || settings.on == 'click');
},
extendSelect: function() {
return settings.allowAdditions || settings.apiSettings;
},
show: function() {
return !module.is.disabled() && (module.has.items() || module.has.message());
},
useAPI: function() {
return $.fn.api !== undefined;
}
},
animate: {
show: function(callback, $subMenu) {
var
$currentMenu = $subMenu || $menu,
start = ($subMenu)
? function() {}
: function() {
module.hideSubMenus();
module.hideOthers();
module.set.active();
},
transition
;
callback = $.isFunction(callback)
? callback
: function(){}
;
module.verbose('Doing menu show animation', $currentMenu);
module.set.direction($subMenu);
transition = module.get.transition($subMenu);
if( module.is.selection() ) {
module.set.scrollPosition(module.get.selectedItem(), true);
}
if( module.is.hidden($currentMenu) || module.is.animating($currentMenu) ) {
if(transition == 'none') {
start();
$currentMenu.transition('show');
callback.call(element);
}
else if($.fn.transition !== undefined && $module.transition('is supported')) {
$currentMenu
.transition({
animation : transition + ' in',
debug : settings.debug,
verbose : settings.verbose,
duration : settings.duration,
queue : true,
onStart : start,
onComplete : function() {
callback.call(element);
}
})
;
}
else {
module.error(error.noTransition, transition);
}
}
},
hide: function(callback, $subMenu) {
var
$currentMenu = $subMenu || $menu,
duration = ($subMenu)
? (settings.duration * 0.9)
: settings.duration,
start = ($subMenu)
? function() {}
: function() {
if( module.can.click() ) {
module.unbind.intent();
}
module.remove.active();
},
transition = module.get.transition($subMenu)
;
callback = $.isFunction(callback)
? callback
: function(){}
;
if( module.is.visible($currentMenu) || module.is.animating($currentMenu) ) {
module.verbose('Doing menu hide animation', $currentMenu);
if(transition == 'none') {
start();
$currentMenu.transition('hide');
callback.call(element);
}
else if($.fn.transition !== undefined && $module.transition('is supported')) {
$currentMenu
.transition({
animation : transition + ' out',
duration : settings.duration,
debug : settings.debug,
verbose : settings.verbose,
queue : false,
onStart : start,
onComplete : function() {
callback.call(element);
}
})
;
}
else {
module.error(error.transition);
}
}
}
},
hideAndClear: function() {
module.remove.searchTerm();
if( module.has.maxSelections() ) {
return;
}
if(module.has.search()) {
module.hide(function() {
module.remove.filteredItem();
});
}
else {
module.hide();
}
},
delay: {
show: function() {
module.verbose('Delaying show event to ensure user intent');
clearTimeout(module.timer);
module.timer = setTimeout(module.show, settings.delay.show);
},
hide: function() {
module.verbose('Delaying hide event to ensure user intent');
clearTimeout(module.timer);
module.timer = setTimeout(module.hide, settings.delay.hide);
}
},
escape: {
value: function(value) {
var
multipleValues = $.isArray(value),
stringValue = (typeof value === 'string'),
isUnparsable = (!stringValue && !multipleValues),
hasQuotes = (stringValue && value.search(regExp.quote) !== -1),
values = []
;
if(isUnparsable || !hasQuotes) {
return value;
}
module.debug('Encoding quote values for use in select', value);
if(multipleValues) {
$.each(value, function(index, value){
values.push(value.replace(regExp.quote, '&quot;'));
});
return values;
}
return value.replace(regExp.quote, '&quot;');
},
string: function(text) {
text = String(text);
return text.replace(regExp.escape, '\\$&');
}
},
setting: function(name, value) {
module.debug('Changing setting', name, value);
if( $.isPlainObject(name) ) {
$.extend(true, settings, name);
}
else if(value !== undefined) {
if($.isPlainObject(settings[name])) {
$.extend(true, settings[name], value);
}
else {
settings[name] = value;
}
}
else {
return settings[name];
}
},
internal: function(name, value) {
if( $.isPlainObject(name) ) {
$.extend(true, module, name);
}
else if(value !== undefined) {
module[name] = value;
}
else {
return module[name];
}
},
debug: function() {
if(!settings.silent && settings.debug) {
if(settings.performance) {
module.performance.log(arguments);
}
else {
module.debug = Function.prototype.bind.call(console.info, console, settings.name + ':');
module.debug.apply(console, arguments);
}
}
},
verbose: function() {
if(!settings.silent && settings.verbose && settings.debug) {
if(settings.performance) {
module.performance.log(arguments);
}
else {
module.verbose = Function.prototype.bind.call(console.info, console, settings.name + ':');
module.verbose.apply(console, arguments);
}
}
},
error: function() {
if(!settings.silent) {
module.error = Function.prototype.bind.call(console.error, console, settings.name + ':');
module.error.apply(console, arguments);
}
},
performance: {
log: function(message) {
var
currentTime,
executionTime,
previousTime
;
if(settings.performance) {
currentTime = new Date().getTime();
previousTime = time || currentTime;
executionTime = currentTime - previousTime;
time = currentTime;
performance.push({
'Name' : message[0],
'Arguments' : [].slice.call(message, 1) || '',
'Element' : element,
'Execution Time' : executionTime
});
}
clearTimeout(module.performance.timer);
module.performance.timer = setTimeout(module.performance.display, 500);
},
display: function() {
var
title = settings.name + ':',
totalTime = 0
;
time = false;
clearTimeout(module.performance.timer);
$.each(performance, function(index, data) {
totalTime += data['Execution Time'];
});
title += ' ' + totalTime + 'ms';
if(moduleSelector) {
title += ' \'' + moduleSelector + '\'';
}
if( (console.group !== undefined || console.table !== undefined) && performance.length > 0) {
console.groupCollapsed(title);
if(console.table) {
console.table(performance);
}
else {
$.each(performance, function(index, data) {
console.log(data['Name'] + ': ' + data['Execution Time']+'ms');
});
}
console.groupEnd();
}
performance = [];
}
},
invoke: function(query, passedArguments, context) {
var
object = instance,
maxDepth,
found,
response
;
passedArguments = passedArguments || queryArguments;
context = element || context;
if(typeof query == 'string' && object !== undefined) {
query = query.split(/[\. ]/);
maxDepth = query.length - 1;
$.each(query, function(depth, value) {
var camelCaseValue = (depth != maxDepth)
? value + query[depth + 1].charAt(0).toUpperCase() + query[depth + 1].slice(1)
: query
;
if( $.isPlainObject( object[camelCaseValue] ) && (depth != maxDepth) ) {
object = object[camelCaseValue];
}
else if( object[camelCaseValue] !== undefined ) {
found = object[camelCaseValue];
return false;
}
else if( $.isPlainObject( object[value] ) && (depth != maxDepth) ) {
object = object[value];
}
else if( object[value] !== undefined ) {
found = object[value];
return false;
}
else {
module.error(error.method, query);
return false;
}
});
}
if ( $.isFunction( found ) ) {
response = found.apply(context, passedArguments);
}
else if(found !== undefined) {
response = found;
}
if($.isArray(returnedValue)) {
returnedValue.push(response);
}
else if(returnedValue !== undefined) {
returnedValue = [returnedValue, response];
}
else if(response !== undefined) {
returnedValue = response;
}
return found;
}
};
if(methodInvoked) {
if(instance === undefined) {
module.initialize();
}
module.invoke(query);
}
else {
if(instance !== undefined) {
instance.invoke('destroy');
}
module.initialize();
}
})
;
return (returnedValue !== undefined)
? returnedValue
: $allModules
;
};
$.fn.dropdown.settings = {
silent : false,
debug : false,
verbose : false,
performance : true,
on : 'click', // what event should show menu action on item selection
action : 'activate', // action on item selection (nothing, activate, select, combo, hide, function(){})
values : false, // specify values to use for dropdown
apiSettings : false,
selectOnKeydown : true, // Whether selection should occur automatically when keyboard shortcuts used
minCharacters : 0, // Minimum characters required to trigger API call
filterRemoteData : false, // Whether API results should be filtered after being returned for query term
saveRemoteData : true, // Whether remote name/value pairs should be stored in sessionStorage to allow remote data to be restored on page refresh
throttle : 200, // How long to wait after last user input to search remotely
context : window, // Context to use when determining if on screen
direction : 'auto', // Whether dropdown should always open in one direction
keepOnScreen : true, // Whether dropdown should check whether it is on screen before showing
match : 'both', // what to match against with search selection (both, text, or label)
fullTextSearch : false, // search anywhere in value (set to 'exact' to require exact matches)
placeholder : 'auto', // whether to convert blank <select> values to placeholder text
preserveHTML : true, // preserve html when selecting value
sortSelect : false, // sort selection on init
forceSelection : true, // force a choice on blur with search selection
allowAdditions : false, // whether multiple select should allow user added values
ignoreCase : false, // whether to consider values not matching in case to be the same
hideAdditions : true, // whether or not to hide special message prompting a user they can enter a value
maxSelections : false, // When set to a number limits the number of selections to this count
useLabels : true, // whether multiple select should filter currently active selections from choices
delimiter : ',', // when multiselect uses normal <input> the values will be delimited with this character
showOnFocus : true, // show menu on focus
allowReselection : false, // whether current value should trigger callbacks when reselected
allowTab : true, // add tabindex to element
allowCategorySelection : false, // allow elements with sub-menus to be selected
fireOnInit : false, // Whether callbacks should fire when initializing dropdown values
transition : 'auto', // auto transition will slide down or up based on direction
duration : 200, // duration of transition
glyphWidth : 1.037, // widest glyph width in em (W is 1.037 em) used to calculate multiselect input width
// label settings on multi-select
label: {
transition : 'scale',
duration : 200,
variation : false
},
// delay before event
delay : {
hide : 300,
show : 200,
search : 20,
touch : 50
},
/* Callbacks */
onChange : function(value, text, $selected){},
onAdd : function(value, text, $selected){},
onRemove : function(value, text, $selected){},
onLabelSelect : function($selectedLabels){},
onLabelCreate : function(value, text) { return $(this); },
onLabelRemove : function(value) { return true; },
onNoResults : function(searchTerm) { return true; },
onShow : function(){},
onHide : function(){},
/* Component */
name : 'Dropdown',
namespace : 'dropdown',
message: {
addResult : 'Add <b>{term}</b>',
count : '{count} selected',
maxSelections : 'Max {maxCount} selections',
noResults : 'No results found.',
serverError : 'There was an error contacting the server'
},
error : {
action : 'You called a dropdown action that was not defined',
alreadySetup : 'Once a select has been initialized behaviors must be called on the created ui dropdown',
labels : 'Allowing user additions currently requires the use of labels.',
missingMultiple : '<select> requires multiple property to be set to correctly preserve multiple values',
method : 'The method you called is not defined.',
noAPI : 'The API module is required to load resources remotely',
noStorage : 'Saving remote data requires session storage',
noTransition : 'This module requires ui transitions <https://github.com/Semantic-Org/UI-Transition>'
},
regExp : {
escape : /[-[\]{}()*+?.,\\^$|#\s]/g,
quote : /"/g
},
metadata : {
defaultText : 'defaultText',
defaultValue : 'defaultValue',
placeholderText : 'placeholder',
text : 'text',
value : 'value'
},
// property names for remote query
fields: {
remoteValues : 'results', // grouping for api results
values : 'values', // grouping for all dropdown values
disabled : 'disabled', // whether value should be disabled
name : 'name', // displayed dropdown text
value : 'value', // actual dropdown value
text : 'text' // displayed text when selected
},
keys : {
backspace : 8,
delimiter : 188, // comma
deleteKey : 46,
enter : 13,
escape : 27,
pageUp : 33,
pageDown : 34,
leftArrow : 37,
upArrow : 38,
rightArrow : 39,
downArrow : 40
},
selector : {
addition : '.addition',
dropdown : '.ui.dropdown',
hidden : '.hidden',
icon : '> .dropdown.icon',
input : '> input[type="hidden"], > select',
item : '.item',
label : '> .label',
remove : '> .label > .delete.icon',
siblingLabel : '.label',
menu : '.menu',
message : '.message',
menuIcon : '.dropdown.icon',
search : 'input.search, .menu > .search > input, .menu input.search',
sizer : '> input.sizer',
text : '> .text:not(.icon)',
unselectable : '.disabled, .filtered'
},
className : {
active : 'active',
addition : 'addition',
animating : 'animating',
disabled : 'disabled',
empty : 'empty',
dropdown : 'ui dropdown',
filtered : 'filtered',
hidden : 'hidden transition',
item : 'item',
label : 'ui label',
loading : 'loading',
menu : 'menu',
message : 'message',
multiple : 'multiple',
placeholder : 'default',
sizer : 'sizer',
search : 'search',
selected : 'selected',
selection : 'selection',
upward : 'upward',
leftward : 'left',
visible : 'visible'
}
};
/* Templates */
$.fn.dropdown.settings.templates = {
// generates dropdown from select values
dropdown: function(select) {
var
placeholder = select.placeholder || false,
values = select.values || {},
html = ''
;
html += '<i class="dropdown icon"></i>';
if(select.placeholder) {
html += '<div class="default text">' + placeholder + '</div>';
}
else {
html += '<div class="text"></div>';
}
html += '<div class="menu">';
$.each(select.values, function(index, option) {
html += (option.disabled)
? '<div class="disabled item" data-value="' + option.value + '">' + option.name + '</div>'
: '<div class="item" data-value="' + option.value + '">' + option.name + '</div>'
;
});
html += '</div>';
return html;
},
// generates just menu from select
menu: function(response, fields) {
var
values = response[fields.values] || {},
html = ''
;
$.each(values, function(index, option) {
var
maybeText = (option[fields.text])
? 'data-text="' + option[fields.text] + '"'
: '',
maybeDisabled = (option[fields.disabled])
? 'disabled '
: ''
;
html += '<div class="'+ maybeDisabled +'item" data-value="' + option[fields.value] + '"' + maybeText + '>';
html += option[fields.name];
html += '</div>';
});
return html;
},
// generates label for multiselect
label: function(value, text) {
return text + '<i class="delete icon"></i>';
},
// generates messages like "No results"
message: function(message) {
return message;
},
// generates user addition to selection menu
addition: function(choice) {
return choice;
}
};
})( jQuery, window, document );