Skip to content

Commit 694441f

Browse files
wxiaoguangzeripathtechknowlogick
authored
Remove customized (unmaintained) dropdown, improve aria a11y for dropdown (#19861)
* Remove customized (unmaintained) dropdown, improve aria a11y for dropdown * fix repo permission * use action instead of onChange * re-order the CSS selector * fix dropdown behavior for repo permissions, make elements inside menu item non-focusable * use menu/menuitem instead of combobox/option. use tooltip(data-content) for aria-label, prevent from repeated attaching * click menu item when pressing Enter * code format * fix repo permission * repo setting: prevent from misleading users when error occurs * fine tune the repo collaboration access mode dropdown (in case the access mode is undefined in the template) Co-authored-by: zeripath <[email protected]> Co-authored-by: techknowlogick <[email protected]>
1 parent 1d04e86 commit 694441f

File tree

9 files changed

+195
-4459
lines changed

9 files changed

+195
-4459
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

templates/repo/settings/collaboration.tmpl

+5-5
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,13 @@
1919
</div>
2020
<div class="ui eight wide column">
2121
{{svg "octicon-shield-lock"}}
22-
<div class="ui inline dropdown">
22+
<div class="ui inline dropdown access-mode" data-url="{{$.Link}}/access_mode" data-uid="{{.ID}}" data-last-value="{{printf "%d" .Collaboration.Mode}}">
2323
<div class="text">{{if eq .Collaboration.Mode 1}}{{$.i18n.Tr "repo.settings.collaboration.read"}}{{else if eq .Collaboration.Mode 2}}{{$.i18n.Tr "repo.settings.collaboration.write"}}{{else if eq .Collaboration.Mode 3}}{{$.i18n.Tr "repo.settings.collaboration.admin"}}{{else}}{{$.i18n.Tr "repo.settings.collaboration.undefined"}}{{end}}</div>
2424
{{svg "octicon-triangle-down" 14 "dropdown icon"}}
25-
<div class="access-mode menu" data-url="{{$.Link}}/access_mode" data-uid="{{.ID}}">
26-
<div class="item" data-text="{{$.i18n.Tr "repo.settings.collaboration.admin"}}" data-value="3">{{$.i18n.Tr "repo.settings.collaboration.admin"}}</div>
27-
<div class="item" data-text="{{$.i18n.Tr "repo.settings.collaboration.write"}}" data-value="2">{{$.i18n.Tr "repo.settings.collaboration.write"}}</div>
28-
<div class="item" data-text="{{$.i18n.Tr "repo.settings.collaboration.read"}}" data-value="1">{{$.i18n.Tr "repo.settings.collaboration.read"}}</div>
25+
<div class="menu">
26+
<div class="item" data-text="{{$.i18n.Tr "repo.settings.collaboration.admin"}}" data-value="3">{{$.i18n.Tr "repo.settings.collaboration.admin"}}</div>
27+
<div class="item" data-text="{{$.i18n.Tr "repo.settings.collaboration.write"}}" data-value="2">{{$.i18n.Tr "repo.settings.collaboration.write"}}</div>
28+
<div class="item" data-text="{{$.i18n.Tr "repo.settings.collaboration.read"}}" data-value="1">{{$.i18n.Tr "repo.settings.collaboration.read"}}</div>
2929
</div>
3030
</div>
3131
</div>

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

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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+
if (!$item.attr('id')) $item.attr('id', generateAriaId());
12+
$item.attr({'role': 'menuitem', 'tabindex': '-1'});
13+
$item.find('a').attr('tabindex', '-1'); // as above, the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
14+
}
15+
16+
// when the menu items are loaded from AJAX requests, the items are created dynamically
17+
const defaultCreateDynamicMenu = $.fn.dropdown.settings.templates.menu;
18+
$.fn.dropdown.settings.templates.menu = function(response, fields, preserveHTML, className) {
19+
const ret = defaultCreateDynamicMenu(response, fields, preserveHTML, className);
20+
const $wrapper = $('<div>').append(ret);
21+
const $items = $wrapper.find('> .item');
22+
$items.each((_, item) => {
23+
prepareMenuItem($(item));
24+
});
25+
return $wrapper.html();
26+
};
27+
28+
function attachOneDropdownAria($dropdown) {
29+
if ($dropdown.attr('data-aria-attached')) return;
30+
$dropdown.attr('data-aria-attached', 1);
31+
32+
const $textSearch = $dropdown.find('input.search').eq(0);
33+
const $focusable = $textSearch.length ? $textSearch : $dropdown; // see comment below
34+
if (!$focusable.length) return;
35+
36+
// prepare menu list
37+
const $menu = $dropdown.find('> .menu');
38+
if (!$menu.attr('id')) $menu.attr('id', generateAriaId());
39+
40+
// dropdown has 2 different focusing behaviors
41+
// * with search input: the input is focused, and it works perfectly with aria-activedescendant pointing another sibling element.
42+
// * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown
43+
44+
// expected user interactions for dropdown with aria support:
45+
// * user can use Tab to focus in the dropdown, then the dropdown menu (list) will be shown
46+
// * user presses Tab on the focused dropdown to move focus to next sibling focusable element (but not the menu item)
47+
// * user can use arrow key Up/Down to navigate between menu items
48+
// * when user presses Enter:
49+
// - if the menu item is clickable (eg: <a>), then trigger the click event
50+
// - otherwise, the dropdown control (low-level code) handles the Enter event, hides the dropdown menu
51+
52+
// TODO: multiple selection is not supported yet.
53+
54+
$focusable.attr({
55+
'role': 'menu',
56+
'aria-haspopup': 'menu',
57+
'aria-controls': $menu.attr('id'),
58+
'aria-expanded': 'false',
59+
});
60+
61+
if ($dropdown.attr('data-content') && !$dropdown.attr('aria-label')) {
62+
$dropdown.attr('aria-label', $dropdown.attr('data-content'));
63+
}
64+
65+
$menu.find('> .item').each((_, item) => {
66+
prepareMenuItem($(item));
67+
});
68+
69+
// update aria attributes according to current active/selected item
70+
const refreshAria = () => {
71+
const isMenuVisible = !$menu.is('.hidden') && !$menu.is('.animating.out');
72+
$focusable.attr('aria-expanded', isMenuVisible ? 'true' : 'false');
73+
74+
let $active = $menu.find('> .item.active');
75+
if (!$active.length) $active = $menu.find('> .item.selected'); // it's strange that we need this fallback at the moment
76+
77+
// if there is an active item, use its id. if no active item, then the empty string is set
78+
$focusable.attr('aria-activedescendant', $active.attr('id'));
79+
};
80+
81+
$dropdown.on('keydown', (e) => {
82+
// here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler
83+
if (e.key === 'Enter') {
84+
const $item = $dropdown.dropdown('get item', $dropdown.dropdown('get value'));
85+
// if the selected item is clickable, then trigger the click event. in the future there could be a special CSS class for it.
86+
if ($item && $item.is('a')) $item[0].click();
87+
}
88+
});
89+
90+
// use setTimeout to run the refreshAria in next tick (to make sure the Fomantic UI code has finished its work)
91+
const deferredRefreshAria = () => { setTimeout(refreshAria, 0) }; // do not return any value, jQuery has return-value related behaviors.
92+
$focusable.on('focus', deferredRefreshAria);
93+
$focusable.on('mouseup', deferredRefreshAria);
94+
$focusable.on('blur', deferredRefreshAria);
95+
$dropdown.on('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAria(); });
96+
}
97+
98+
export function attachDropdownAria($dropdowns) {
99+
$dropdowns.each((_, e) => attachOneDropdownAria($(e)));
100+
}

web_src/js/features/aria.md

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

0 commit comments

Comments
 (0)