diff --git a/Makefile b/Makefile
index fed225b166a3c..8ce7a0aa63f07 100644
--- a/Makefile
+++ b/Makefile
@@ -703,7 +703,6 @@ fomantic:
cd $(FOMANTIC_WORK_DIR) && npm install --no-save
cp -f $(FOMANTIC_WORK_DIR)/theme.config.less $(FOMANTIC_WORK_DIR)/node_modules/fomantic-ui/src/theme.config
cp -rf $(FOMANTIC_WORK_DIR)/_site $(FOMANTIC_WORK_DIR)/node_modules/fomantic-ui/src/
- cp -f web_src/js/vendor/dropdown.js $(FOMANTIC_WORK_DIR)/node_modules/fomantic-ui/src/definitions/modules
cd $(FOMANTIC_WORK_DIR) && npx gulp -f node_modules/fomantic-ui/gulpfile.js build
rm -f $(FOMANTIC_WORK_DIR)/build/*.min.*
diff --git a/web_src/fomantic/build/semantic.js b/web_src/fomantic/build/semantic.js
index 2222cade65335..dcf99410c29ff 100644
--- a/web_src/fomantic/build/semantic.js
+++ b/web_src/fomantic/build/semantic.js
@@ -2827,13 +2827,6 @@ $.fn.dimmer.settings = {
*
*/
-/*
- * 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';
@@ -2867,7 +2860,6 @@ $.fn.dropdown = function(parameters) {
query = arguments[0],
methodInvoked = (typeof query == 'string'),
queryArguments = [].slice.call(arguments, 1),
- lastAriaID = 1,
returnedValue
;
@@ -2960,8 +2952,6 @@ $.fn.dropdown = function(parameters) {
module.observeChanges();
module.instantiate();
-
- module.aria.setup();
}
},
@@ -3162,86 +3152,6 @@ $.fn.dropdown = function(parameters) {
}
},
- 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
@@ -3288,7 +3198,6 @@ $.fn.dropdown = function(parameters) {
if(settings.allowTab) {
module.set.tabbable();
}
- $item.attr('tabindex', '-1');
},
select: function() {
var
@@ -3435,8 +3344,6 @@ $.fn.dropdown = function(parameters) {
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();
@@ -3459,8 +3366,6 @@ $.fn.dropdown = function(parameters) {
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();
// hidding search focus
@@ -4414,7 +4319,7 @@ $.fn.dropdown = function(parameters) {
// allow selection with menu closed
if(isAdditionWithoutMenu) {
module.verbose('Selecting item from keyboard shortcut', $selectedItem);
- $selectedItem[0].click();
+ module.event.item.click.call($selectedItem, event);
if(module.is.searchSelection()) {
module.remove.searchTerm();
}
@@ -4434,7 +4339,7 @@ $.fn.dropdown = function(parameters) {
}
else if(selectedIsSelectable) {
module.verbose('Selecting item from keyboard shortcut', $selectedItem);
- $selectedItem[0].click();
+ module.event.item.click.call($selectedItem, event);
if(module.is.searchSelection()) {
module.remove.searchTerm();
if(module.is.multiple()) {
@@ -4462,7 +4367,6 @@ $.fn.dropdown = function(parameters) {
.closest(selector.item)
.addClass(className.selected)
;
- module.aria.refreshDescendant();
event.preventDefault();
}
}
@@ -4479,7 +4383,6 @@ $.fn.dropdown = function(parameters) {
.find(selector.item).eq(0)
.addClass(className.selected)
;
- module.aria.refreshDescendant();
event.preventDefault();
}
}
@@ -4504,7 +4407,6 @@ $.fn.dropdown = function(parameters) {
$nextItem
.addClass(className.selected)
;
- module.aria.refreshDescendant();
module.set.scrollPosition($nextItem);
if(settings.selectOnKeydown && module.is.single()) {
module.set.selectedItem($nextItem);
@@ -4532,7 +4434,6 @@ $.fn.dropdown = function(parameters) {
$nextItem
.addClass(className.selected)
;
- module.aria.refreshDescendant();
module.set.scrollPosition($nextItem);
if(settings.selectOnKeydown && module.is.single()) {
module.set.selectedItem($nextItem);
@@ -5502,7 +5403,6 @@ $.fn.dropdown = function(parameters) {
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);
}
diff --git a/web_src/js/features/aria.js b/web_src/js/features/aria.js
new file mode 100644
index 0000000000000..aa7315a0f49ea
--- /dev/null
+++ b/web_src/js/features/aria.js
@@ -0,0 +1,84 @@
+import $ from 'jquery';
+
+let ariaIdCounter = 0;
+
+function generateAriaId() {
+ return `_aria_auto_id_${ariaIdCounter++}`;
+}
+
+// make the item has role=option, and add an id if there wasn't one yet.
+function prepareMenuItem($item) {
+ $item.attr({'role': 'option'});
+ if (!$item.attr('id')) $item.attr('id', generateAriaId());
+}
+
+// when the menu items are loaded from AJAX requests, the items are created dynamically
+const defaultCreateDynamicMenu = $.fn.dropdown.settings.templates.menu;
+$.fn.dropdown.settings.templates.menu = function(response, fields, preserveHTML, className) {
+ const ret = defaultCreateDynamicMenu(response, fields, preserveHTML, className);
+ const $wrapper = $('
').append(ret);
+ const $items = $wrapper.find('> .item');
+ $items.each((_, item) => {
+ prepareMenuItem($(item));
+ });
+ return $wrapper.html();
+};
+
+function attachOneDropdownAria($dropdown) {
+ const $textSearch = $dropdown.find('input.search').eq(0);
+ const $focusable = $textSearch.length ? $textSearch : $dropdown; // see comment below
+ if (!$focusable.length) return;
+
+ // prepare menu list
+ const $menu = $dropdown.find('> .menu');
+ if (!$menu.attr('id')) $menu.attr('id', generateAriaId());
+ $menu.attr('role', 'listbox');
+
+ // dropdown has 2 different focusing behaviors
+ // * with search input: the input is focused, and it works perfectly with aria-activedescendant pointing another sibling element.
+ // * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown,
+ // which make the UI flicking when navigating between list options, that's the best effect at the moment.
+
+ $focusable.attr({
+ 'role': 'combobox',
+ 'aria-controls': $menu.attr('id'),
+ 'aria-expanded': 'false',
+ });
+
+ $menu.find('> .item').each((_, item) => {
+ prepareMenuItem($(item));
+ });
+
+ // update aria attributes according current active/selected item
+ const refreshAria = () => {
+ const isMenuVisible = !$menu.is('.hidden') && !$menu.is('.animating.out');
+ $focusable.attr('aria-expanded', isMenuVisible ? 'true' : 'false');
+
+ let $active = $menu.find('> .item.active');
+ if (!$active.length) $active = $menu.find('> .item.selected'); // it's strange that we need this fallback at the moment
+
+ // if there is an active item, use its id. if no active item, then the empty string is set
+ $focusable.attr('aria-activedescendant', $active.attr('id'));
+ };
+
+ // use setTimeout to run the refreshAria in next tick
+ $focusable.on('focus', () => {
+ setTimeout(refreshAria, 0);
+ });
+ $focusable.on('mouseup', () => {
+ setTimeout(refreshAria, 0);
+ });
+ $focusable.on('blur', () => {
+ setTimeout(refreshAria, 0);
+ });
+ $dropdown.on('keyup', (e) => {
+ const key = e.key;
+ if (key === 'Tab' || key === 'Space' || key === 'Enter' || key.startsWith('Arrow')) {
+ setTimeout(refreshAria, 0);
+ }
+ });
+}
+
+export function attachDropdownAria($dropdowns) {
+ $dropdowns.each((_, e) => attachOneDropdownAria($(e)));
+}
diff --git a/web_src/js/features/aria.md b/web_src/js/features/aria.md
new file mode 100644
index 0000000000000..be86428b6242c
--- /dev/null
+++ b/web_src/js/features/aria.md
@@ -0,0 +1,40 @@
+**This document is used as aria/a11y reference for future developers**
+
+ARIA Dropdown:
+
+```html
+
+
+
+
+```
+
+
+Fomantic UI Dropdown:
+
+```html
+
+
+
+
+
+```
diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js
index 241a357703b74..eb59bcbe38f7f 100644
--- a/web_src/js/features/common-global.js
+++ b/web_src/js/features/common-global.js
@@ -4,6 +4,7 @@ import {mqBinarySearch} from '../utils.js';
import createDropzone from './dropzone.js';
import {initCompColorPicker} from './comp/ColorPicker.js';
import {showGlobalErrorMessage} from '../bootstrap.js';
+import {attachDropdownAria} from './aria.js';
const {appUrl, csrfToken} = window.config;
@@ -97,24 +98,27 @@ export function initGlobalCommon() {
}
// Semantic UI modules.
- $('.dropdown:not(.custom)').dropdown({
+ const $uiDropdowns = $('.ui.dropdown');
+ $uiDropdowns.filter(':not(.custom)').dropdown({
fullTextSearch: 'exact'
});
- $('.jump.dropdown').dropdown({
+ $uiDropdowns.filter('.jump').dropdown({
action: 'hide',
onShow() {
$('.tooltip').popup('hide');
},
fullTextSearch: 'exact'
});
- $('.slide.up.dropdown').dropdown({
+ $uiDropdowns.filter('.slide.up').dropdown({
transition: 'slide up',
fullTextSearch: 'exact'
});
- $('.upward.dropdown').dropdown({
+ $uiDropdowns.filter('.upward').dropdown({
direction: 'upward',
fullTextSearch: 'exact'
});
+ attachDropdownAria($uiDropdowns);
+
$('.ui.checkbox').checkbox();
// init popups
diff --git a/web_src/js/vendor/dropdown.js b/web_src/js/vendor/dropdown.js
deleted file mode 100644
index 3d4cfec27a147..0000000000000
--- a/web_src/js/vendor/dropdown.js
+++ /dev/null
@@ -1,4338 +0,0 @@
-/*!
- * # Fomantic-UI - Dropdown
- * http://github.com/fomantic/Fomantic-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';
-
-$.isFunction = $.isFunction || function(obj) {
- return typeof obj === "function" && typeof obj.nodeType !== "number";
-};
-
-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),
- clickEvent = hasTouch
- ? 'touchstart'
- : 'click',
-
- 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),
- $clear = $module.find(selector.clearIcon),
-
- $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),
- $divider = settings.hideDividers ? $item.parent().children(selector.divider) : $(),
-
- activated = false,
- itemActivated = false,
- internalChange = false,
- iconClicked = false,
- element = this,
- instance = $module.data(moduleNamespace),
-
- selectActionActive,
- initialLoad,
- pageLostFocus,
- willRefocus,
- elementNamespace,
- id,
- selectObserver,
- menuObserver,
- classObserver,
- module
- ;
-
- module = {
-
- initialize: function() {
- module.debug('Initializing dropdown', settings);
-
- if( module.is.alreadySetup() ) {
- module.setup.reference();
- }
- else {
- if (settings.ignoreDiacritics && !String.prototype.normalize) {
- settings.ignoreDiacritics = false;
- module.error(error.noNormalize, element);
- }
-
- module.setup.layout();
-
- if(settings.values) {
- module.set.initialLoad();
- module.change.values(settings.values);
- module.remove.initialLoad();
- }
-
- 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.remove.active();
- $menu.transition('stop all');
- $menu.removeClass(className.visible).addClass(className.hidden);
- $module
- .off(eventNamespace)
- .removeData(moduleNamespace)
- ;
- $menu
- .off(eventNamespace)
- ;
- $document
- .off(elementNamespace)
- ;
- module.disconnect.menuObserver();
- module.disconnect.selectObserver();
- module.disconnect.classObserver();
- },
-
- observeChanges: function() {
- if('MutationObserver' in window) {
- selectObserver = new MutationObserver(module.event.select.mutation);
- menuObserver = new MutationObserver(module.event.menu.mutation);
- classObserver = new MutationObserver(module.event.class.mutation);
- module.debug('Setting up mutation observer', selectObserver, menuObserver, classObserver);
- module.observe.select();
- module.observe.menu();
- module.observe.class();
- }
- },
-
- disconnect: {
- menuObserver: function() {
- if(menuObserver) {
- menuObserver.disconnect();
- }
- },
- selectObserver: function() {
- if(selectObserver) {
- selectObserver.disconnect();
- }
- },
- classObserver: function() {
- if(classObserver) {
- classObserver.disconnect();
- }
- }
- },
- observe: {
- select: function() {
- if(module.has.input() && selectObserver) {
- selectObserver.observe($module[0], {
- childList : true,
- subtree : true
- });
- }
- },
- menu: function() {
- if(module.has.menu() && menuObserver) {
- menuObserver.observe($menu[0], {
- childList : true,
- subtree : true
- });
- }
- },
- class: function() {
- if(module.has.search() && classObserver) {
- classObserver.observe($module[0], {
- attributes : 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 = Array.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 = $('
')
- .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 = $('
')
- .addClass(className.menu)
- .appendTo($module)
- ;
- },
- sizer: function() {
- $sizer = $('
')
- .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(null,true);
- }
- },
-
- 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.selection() && module.is.clearable() && !module.has.clearItem() ) {
- module.verbose('Adding clear icon');
- $clear = $('
')
- .addClass('remove icon')
- .insertBefore($text)
- ;
- }
- if( module.is.search() && !module.has.search() ) {
- module.verbose('Adding search input');
- $search = $('
')
- .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 = $('
')
- .attr('class', $input.attr('class') )
- .addClass(className.selection)
- .addClass(className.dropdown)
- .html( templates.dropdown(selectValues, fields, settings.preserveHTML, settings.className) )
- .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('required')
- .removeAttr('class')
- .detach()
- .prependTo($module)
- ;
- }
- module.refresh();
- },
- menu: function(values) {
- $menu.html( templates.menu(values, fields,settings.preserveHTML,settings.className));
- $item = $menu.find(selector.item);
- $divider = settings.hideDividers ? $item.parent().children(selector.divider) : $();
- },
- 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);
- $divider = settings.hideDividers ? $item.parent().children(selector.divider) : $();
- },
-
- 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);
- $divider = settings.hideDividers ? $item.parent().children(selector.divider) : $();
- },
-
- 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, preventFocus) {
- 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.search() && !preventFocus) {
- module.focusSearch();
- }
- module.set.visible();
- callback.call(element);
- });
- }
- }
- },
-
- hide: function(callback, preventBlur) {
- 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();
- // hidding search focus
- if ( module.is.focusedOnSearch() && preventBlur !== true ) {
- $search.blur();
- }
- callback.call(element);
- });
- }
- } else if( module.can.click() ) {
- module.unbind.intent();
- }
- iconClicked = false;
- },
-
- 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() {
- module.bind.keyboardEvents();
- module.bind.inputEvents();
- module.bind.mouseEvents();
- },
- 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(clickEvent + eventNamespace, selector.label, module.event.label.click)
- .on(clickEvent + 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(clickEvent + eventNamespace, selector.icon, module.event.icon.click)
- .on(clickEvent + eventNamespace, selector.clearIcon, module.event.clearIcon.click)
- .on('focus' + eventNamespace, selector.search, module.event.search.focus)
- .on(clickEvent + eventNamespace, selector.search, module.event.search.focus)
- .on('blur' + eventNamespace, selector.search, module.event.search.blur)
- .on(clickEvent + eventNamespace, selector.text, module.event.text.focus)
- ;
- if(module.is.multiple()) {
- $module
- .on(clickEvent + eventNamespace, module.event.click)
- ;
- }
- }
- else {
- if(settings.on == 'click') {
- $module
- .on(clickEvent + eventNamespace, selector.icon, module.event.icon.click)
- .on(clickEvent + 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)
- .on(clickEvent + eventNamespace, selector.clearIcon, module.event.clearIcon.click)
- ;
- if(module.has.menuSearch() ) {
- $module
- .on('blur' + eventNamespace, selector.search, module.event.search.blur)
- ;
- }
- else {
- $module
- .on('blur' + eventNamespace, module.event.blur)
- ;
- }
- }
- $menu
- .on((hasTouch ? 'touchstart' : '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(clickEvent + 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(clickEvent + 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(module.escape.htmlEntities(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);
- }
- var preSelected = $input.val();
- if(!Array.isArray(preSelected)) {
- preSelected = preSelected && preSelected!=="" ? preSelected.split(settings.delimiter) : [];
- }
- $.each(preSelected,function(index,value){
- $item.filter('[data-value="'+value+'"]')
- .addClass(className.filtered)
- ;
- });
- 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) {
- var
- values = response[fields.remoteValues]
- ;
- if (!Array.isArray(values)){
- values = [];
- }
- module.remove.message();
- var menuConfig = {};
- menuConfig[fields.values] = values;
- module.setup.menu(menuConfig);
-
- if(values.length===0 && !settings.allowAdditions) {
- module.add.message(message.noResults);
- }
- 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 = module.remove.diacritics(query !== undefined
- ? query
- : module.get.query()
- ),
- results = null,
- escapedTerm = module.escape.string(searchTerm),
- regExpFlags = (settings.ignoreSearchCase ? 'i' : '') + 'gm',
- beginsWithRegExp = new RegExp('^' + escapedTerm, regExpFlags)
- ;
- // 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($choice.hasClass(className.unfilterable)) {
- results.push(this);
- return true;
- }
- if(settings.match === 'both' || settings.match === 'text') {
- text = module.remove.diacritics(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 = module.remove.diacritics(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)
- ;
- }
-
- if(!module.has.query()) {
- $divider
- .removeClass(className.hidden);
- } else if(settings.hideDividers === true) {
- $divider
- .addClass(className.hidden);
- } else if(settings.hideDividers === 'empty') {
- $divider
- .removeClass(className.hidden)
- .filter(function() {
- // First find the last divider in this divider group
- // Dividers which are direct siblings are considered a group
- var lastDivider = $(this).nextUntil(selector.item);
-
- return (lastDivider.length ? lastDivider : $(this))
- // Count all non-filtered items until the next divider (or end of the dropdown)
- .nextUntil(selector.divider)
- .filter(selector.item + ":not(." + className.filtered + ")")
- // Hide divider if no items are found
- .length === 0;
- })
- .addClass(className.hidden);
- }
- },
-
- fuzzySearch: function(query, term) {
- var
- termLength = term.length,
- queryLength = query.length
- ;
- query = (settings.ignoreSearchCase ? query.toLowerCase() : query);
- term = (settings.ignoreSearchCase ? term.toLowerCase() : term);
- 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 = (settings.ignoreSearchCase ? query.toLowerCase() : query);
- term = (settings.ignoreSearchCase ? term.toLowerCase() : term);
- return term.indexOf(query) > -1;
-
- },
- 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();
- }
- }
- },
-
- blurSearch: function() {
- if( module.has.search() ) {
- $search.blur();
- }
- },
-
- 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(settings.allowAdditions || (hasSelected && !module.is.multiple())) {
- module.debug('Forcing partial selection to selected item', $selectedItem);
- module.event.item.click.call($selectedItem, {}, true);
- }
- else {
- module.remove.searchTerm();
- }
- },
-
- change: {
- values: function(values) {
- if(!settings.allowAdditions) {
- module.clear();
- }
- module.debug('Creating dropdown with specified values', values);
- var menuConfig = {};
- menuConfig[fields.values] = values;
- module.setup.menu(menuConfig);
- $.each(values, function(index, item) {
- if(item.selected == true) {
- module.debug('Setting initial selection to', item[fields.value]);
- module.set.selected(item[fields.value]);
- if(!module.is.multiple()) {
- return false;
- }
- }
- });
-
- if(module.has.selectInput()) {
- module.disconnect.selectObserver();
- $input.html('');
- $input.append('
');
- $.each(values, function(index, item) {
- var
- value = settings.templates.deQuote(item[fields.value]),
- name = settings.templates.escape(
- item[fields.name] || '',
- settings.preserveHTML
- )
- ;
- $input.append('
');
- });
- module.observe.select();
- }
- }
- },
-
- 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(event) {
- activated = true;
- if(module.is.multiple()) {
- module.remove.activeLabel();
- }
- if(settings.showOnFocus || (event.type !== 'focus' && event.type !== 'focusin')) {
- module.search();
- }
- },
- blur: function(event) {
- pageLostFocus = (document.activeElement === this);
- if(module.is.searchSelection() && !willRefocus) {
- if(!itemActivated && !pageLostFocus) {
- if(settings.forceSelection) {
- module.forceSelection();
- } else if(!settings.allowAdditions){
- module.remove.searchTerm();
- }
- module.hide();
- }
- }
- willRefocus = false;
- }
- },
- clearIcon: {
- click: function(event) {
- module.clear();
- if(module.is.searchSelection()) {
- module.remove.searchTerm();
- }
- module.hide();
- event.stopPropagation();
- }
- },
- icon: {
- click: function(event) {
- iconClicked=true;
- if(module.has.search()) {
- if(!module.is.active()) {
- if(settings.showOnFocus){
- module.focusSearch();
- } else {
- module.toggle();
- }
- } else {
- module.blurSearch();
- }
- } else {
- 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) {
- if(module.determine.eventInModule(event, module.hide)){
- if(element.id && $(event.target).attr('for') === element.id){
- event.preventDefault();
- }
- }
- }
- },
- class: {
- mutation: function(mutations) {
- mutations.forEach(function(mutation) {
- if(mutation.attributeName === "class") {
- module.check.disabled();
- }
- });
- }
- },
- select: {
- mutation: function(mutations) {
- module.debug('