Skip to content

Commit 0da4f05

Browse files
committed
Remove customized (unmaintained) dropdown, improve aria a11y for dropdown
1 parent 0e51694 commit 0da4f05

File tree

7 files changed

+137
-4448
lines changed

7 files changed

+137
-4448
lines changed

Makefile

-1
Original file line numberDiff line numberDiff line change
@@ -703,7 +703,6 @@ fomantic:
703703
cd $(FOMANTIC_WORK_DIR) && npm install --no-save
704704
cp -f $(FOMANTIC_WORK_DIR)/theme.config.less $(FOMANTIC_WORK_DIR)/node_modules/fomantic-ui/src/theme.config
705705
cp -rf $(FOMANTIC_WORK_DIR)/_site $(FOMANTIC_WORK_DIR)/node_modules/fomantic-ui/src/
706-
cp -f web_src/js/vendor/dropdown.js $(FOMANTIC_WORK_DIR)/node_modules/fomantic-ui/src/definitions/modules
707706
cd $(FOMANTIC_WORK_DIR) && npx gulp -f node_modules/fomantic-ui/gulpfile.js build
708707
rm -f $(FOMANTIC_WORK_DIR)/build/*.min.*
709708

web_src/fomantic/build/semantic.js

+2-102
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web_src/js/features/aria.js

+84
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import $ from 'jquery';
2+
3+
let ariaIdCounter = 0;
4+
5+
function generateAriaId() {
6+
return `_aria_auto_id_${ariaIdCounter++}`;
7+
}
8+
9+
// make the item has role=option, and add an id if there wasn't one yet.
10+
function prepareMenuItem($item) {
11+
$item.attr({'role': 'option'});
12+
if (!$item.attr('id')) $item.attr('id', generateAriaId());
13+
}
14+
15+
// when the menu items are loaded from AJAX requests, the items are created dynamically
16+
const defaultCreateDynamicMenu = $.fn.dropdown.settings.templates.menu;
17+
$.fn.dropdown.settings.templates.menu = function(response, fields, preserveHTML, className) {
18+
const ret = defaultCreateDynamicMenu(response, fields, preserveHTML, className);
19+
const $wrapper = $('<div>').append(ret);
20+
const $items = $wrapper.find('> .item');
21+
$items.each((_, item) => {
22+
prepareMenuItem($(item));
23+
});
24+
return $wrapper.html();
25+
};
26+
27+
function attachOneDropdownAria($dropdown) {
28+
const $textSearch = $dropdown.find('input.search').eq(0);
29+
const $focusable = $textSearch.length ? $textSearch : $dropdown; // see comment below
30+
if (!$focusable.length) return;
31+
32+
// prepare menu list
33+
const $menu = $dropdown.find('> .menu');
34+
if (!$menu.attr('id')) $menu.attr('id', generateAriaId());
35+
$menu.attr('role', 'listbox');
36+
37+
// dropdown has 2 different focusing behaviors
38+
// * with search input: the input is focused, and it works perfectly with aria-activedescendant pointing another sibling element.
39+
// * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown,
40+
// which make the UI flicking when navigating between list options, that's the best effect at the moment.
41+
42+
$focusable.attr({
43+
'role': 'combobox',
44+
'aria-controls': $menu.attr('id'),
45+
'aria-expanded': 'false',
46+
});
47+
48+
$menu.find('> .item').each((_, item) => {
49+
prepareMenuItem($(item));
50+
});
51+
52+
// update aria attributes according current active/selected item
53+
const refreshAria = () => {
54+
const isMenuVisible = !$menu.is('.hidden') && !$menu.is('.animating.out');
55+
$focusable.attr('aria-expanded', isMenuVisible ? 'true' : 'false');
56+
57+
let $active = $menu.find('> .item.active');
58+
if (!$active.length) $active = $menu.find('> .item.selected'); // it's strange that we need this fallback at the moment
59+
60+
// if there is an active item, use its id. if no active item, then the empty string is set
61+
$focusable.attr('aria-activedescendant', $active.attr('id'));
62+
};
63+
64+
// use setTimeout to run the refreshAria in next tick
65+
$focusable.on('focus', () => {
66+
setTimeout(refreshAria, 0);
67+
});
68+
$focusable.on('mouseup', () => {
69+
setTimeout(refreshAria, 0);
70+
});
71+
$focusable.on('blur', () => {
72+
setTimeout(refreshAria, 0);
73+
});
74+
$dropdown.on('keyup', (e) => {
75+
const key = e.key;
76+
if (key === 'Tab' || key === 'Space' || key === 'Enter' || key.startsWith('Arrow')) {
77+
setTimeout(refreshAria, 0);
78+
}
79+
});
80+
}
81+
82+
export function attachDropdownAria($dropdowns) {
83+
$dropdowns.each((_, e) => attachOneDropdownAria($(e)));
84+
}

web_src/js/features/aria.md

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
**This document is used as aria/a11y reference for future developers**
2+
3+
ARIA Dropdown:
4+
5+
```html
6+
<div>
7+
<input role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="the-menu-listbox" aria-activedescendant="item-id-123456">
8+
<ul id="the-menu-listbox" role="listbox">
9+
<li role="option" id="item-id-123456" aria-selected="true">
10+
<a tabindex="-1" href="....">....</a>
11+
</li>
12+
</ul>
13+
</div>
14+
```
15+
16+
17+
Fomantic UI Dropdown:
18+
19+
```html
20+
<!-- read-only dropdown -->
21+
<div class="ui dropdown"> <!-- focused here, then it's not perfect to use aria-activedescendant to point to the menu item -->
22+
<input type="hidden" ...>
23+
<div class="text">Default</div>
24+
<div class="menu transition hidden" tabindex="-1">
25+
<div class="item active selected">Default</div>
26+
<div class="item">...</div>
27+
</div>
28+
</div>
29+
30+
<!-- search input dropdown -->
31+
<div class="ui dropdown">
32+
<input type="hidden" ...>
33+
<input class="search" autocomplete="off" tabindex="0"> <!-- focused here -->
34+
<div class="text"></div>
35+
<div class="menu transition visible" tabindex="-1">
36+
<div class="item selected">...</div>
37+
<div class="item">...</div>
38+
</div>
39+
</div>
40+
```

web_src/js/features/common-global.js

+8-4
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {mqBinarySearch} from '../utils.js';
44
import createDropzone from './dropzone.js';
55
import {initCompColorPicker} from './comp/ColorPicker.js';
66
import {showGlobalErrorMessage} from '../bootstrap.js';
7+
import {attachDropdownAria} from './aria.js';
78

89
const {appUrl, csrfToken} = window.config;
910

@@ -97,24 +98,27 @@ export function initGlobalCommon() {
9798
}
9899

99100
// Semantic UI modules.
100-
$('.dropdown:not(.custom)').dropdown({
101+
const $uiDropdowns = $('.ui.dropdown');
102+
$uiDropdowns.filter(':not(.custom)').dropdown({
101103
fullTextSearch: 'exact'
102104
});
103-
$('.jump.dropdown').dropdown({
105+
$uiDropdowns.filter('.jump').dropdown({
104106
action: 'hide',
105107
onShow() {
106108
$('.tooltip').popup('hide');
107109
},
108110
fullTextSearch: 'exact'
109111
});
110-
$('.slide.up.dropdown').dropdown({
112+
$uiDropdowns.filter('.slide.up').dropdown({
111113
transition: 'slide up',
112114
fullTextSearch: 'exact'
113115
});
114-
$('.upward.dropdown').dropdown({
116+
$uiDropdowns.filter('.upward').dropdown({
115117
direction: 'upward',
116118
fullTextSearch: 'exact'
117119
});
120+
attachDropdownAria($uiDropdowns);
121+
118122
$('.ui.checkbox').checkbox();
119123

120124
// init popups

0 commit comments

Comments
 (0)