Skip to content

Commit 420d015

Browse files
authored
Fix aria.js bugs: incorrect role element problem, mobile focus problem, tippy problem (#23450) (#23486)
Before: the `aria.js` is still buggy in some cases. After: tested with AppleVoice, Android TalkBack (I tested it with 1.19 again) * Fix incorrect dropdown init code * Fix incorrect role element (the menu role should be on the `$menu` element, but not on the `$focusable`) * Fix the focus-show-click-hide problem on mobile. Now the language menu works as expected * Fix incorrect dropdown template function setting * Clarify the logic in aria.js * Fix incorrect tippy `setProps` after `destroy` * Improve comments * Implement the layout proposed by #19861
1 parent 22911a1 commit 420d015

File tree

8 files changed

+202
-89
lines changed

8 files changed

+202
-89
lines changed

templates/base/footer_content.tmpl

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
{{end}}
2222
<div class="ui language bottom floating slide up dropdown link item">
2323
{{svg "octicon-globe"}}
24-
<div class="text">{{.locale.LangName}}</div>
24+
<span>{{.locale.LangName}}</span>
2525
<div class="menu language-menu">
2626
{{range .AllLangs}}
2727
<a lang="{{.Lang}}" data-url="{{AppSubUrl}}/?lang={{.Lang}}" class="item {{if eq $.locale.Lang .Lang}}active selected{{end}}">{{.Name}}</a>

templates/repo/issue/view_content/add_reaction.tmpl

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{{if .ctx.IsSigned}}
2-
<div class="item action ui pointing select-reaction dropdown top right" data-action-url="{{.ActionURL}}">
2+
<div class="item action ui dropdown jump pointing top right select-reaction" data-action-url="{{.ActionURL}}">
33
<a class="add-reaction">
44
{{svg "octicon-smiley"}}
55
</a>

templates/repo/issue/view_content/context_menu.tmpl

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{{if .ctx.IsSigned}}
2-
<div class="item action ui pointing custom dropdown top right context-dropdown">
2+
<div class="item action ui dropdown jump pointing top right context-dropdown">
33
<a class="context-menu">
44
{{svg "octicon-kebab-horizontal"}}
55
</a>

web_src/js/features/aria.js

+112-51
Original file line numberDiff line numberDiff line change
@@ -6,42 +6,16 @@ function generateAriaId() {
66
return `_aria_auto_id_${ariaIdCounter++}`;
77
}
88

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-
289
function attachOneDropdownAria($dropdown) {
29-
if ($dropdown.attr('data-aria-attached')) return;
10+
if ($dropdown.attr('data-aria-attached') || $dropdown.hasClass('custom')) return;
3011
$dropdown.attr('data-aria-attached', 1);
3112

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.
13+
// Dropdown has 2 different focusing behaviors
14+
// * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
4215
// * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown
16+
// Some desktop screen readers may change the focus, but dropdown requires that the focus must be on its primary element, then they don't work well.
4317

44-
// expected user interactions for dropdown with aria support:
18+
// Expected user interactions for dropdown with aria support:
4519
// * user can use Tab to focus in the dropdown, then the dropdown menu (list) will be shown
4620
// * user presses Tab on the focused dropdown to move focus to next sibling focusable element (but not the menu item)
4721
// * user can use arrow key Up/Down to navigate between menu items
@@ -51,31 +25,83 @@ function attachOneDropdownAria($dropdown) {
5125

5226
// TODO: multiple selection is not supported yet.
5327

54-
$focusable.attr({
55-
'role': 'menu',
56-
'aria-haspopup': 'menu',
57-
'aria-controls': $menu.attr('id'),
58-
'aria-expanded': 'false',
59-
});
28+
const $textSearch = $dropdown.find('input.search').eq(0);
29+
const $focusable = $textSearch.length ? $textSearch : $dropdown; // the primary element for focus, see comment above
30+
if (!$focusable.length) return;
6031

61-
if ($dropdown.attr('data-content') && !$dropdown.attr('aria-label')) {
32+
// There are 2 possible solutions about the role: combobox or menu.
33+
// The idea is that if there is an input, then it's a combobox, otherwise it's a menu.
34+
// Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before.
35+
const isComboBox = $dropdown.find('input').length > 0;
36+
37+
const focusableRole = isComboBox ? 'combobox' : 'button';
38+
const listPopupRole = isComboBox ? 'listbox' : 'menu';
39+
const listItemRole = isComboBox ? 'option' : 'menuitem';
40+
41+
// make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable
42+
// the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
43+
function prepareMenuItem($item) {
44+
if (!$item.attr('id')) $item.attr('id', generateAriaId());
45+
$item.attr({'role': listItemRole, 'tabindex': '-1'});
46+
$item.find('a').attr('tabindex', '-1');
47+
}
48+
49+
// delegate the dropdown's template function to add aria attributes.
50+
// the "template" functions are used for dynamic creation (eg: AJAX)
51+
const dropdownTemplates = {...$dropdown.dropdown('setting', 'templates')};
52+
const dropdownTemplatesMenuOld = dropdownTemplates.menu;
53+
dropdownTemplates.menu = function(response, fields, preserveHTML, className) {
54+
// when the dropdown menu items are loaded from AJAX requests, the items are created dynamically
55+
const menuItems = dropdownTemplatesMenuOld(response, fields, preserveHTML, className);
56+
const $wrapper = $('<div>').append(menuItems);
57+
const $items = $wrapper.find('> .item');
58+
$items.each((_, item) => prepareMenuItem($(item)));
59+
return $wrapper.html();
60+
};
61+
$dropdown.dropdown('setting', 'templates', dropdownTemplates);
62+
63+
// use tooltip's content as aria-label if there is no aria-label
64+
if ($dropdown.hasClass('tooltip') && $dropdown.attr('data-content') && !$dropdown.attr('aria-label')) {
6265
$dropdown.attr('aria-label', $dropdown.attr('data-content'));
6366
}
6467

68+
// prepare dropdown menu list popup
69+
const $menu = $dropdown.find('> .menu');
70+
if (!$menu.attr('id')) $menu.attr('id', generateAriaId());
6571
$menu.find('> .item').each((_, item) => {
6672
prepareMenuItem($(item));
6773
});
74+
// this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash
75+
$menu.attr('role', listPopupRole);
6876

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');
77+
// make the primary element (focusable) aria-friendly
78+
$focusable.attr({
79+
'role': $focusable.attr('role') ?? focusableRole,
80+
'aria-haspopup': listPopupRole,
81+
'aria-controls': $menu.attr('id'),
82+
'aria-expanded': 'false',
83+
});
7384

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
85+
// when showing, it has class: ".animating.in"
86+
// when hiding, it has class: ".visible.animating.out"
87+
const isMenuVisible = () => ($menu.hasClass('visible') && !$menu.hasClass('out')) || $menu.hasClass('in');
7688

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'));
89+
// update aria attributes according to current active/selected item
90+
const refreshAria = () => {
91+
const menuVisible = isMenuVisible();
92+
$focusable.attr('aria-expanded', menuVisible ? 'true' : 'false');
93+
94+
// if there is an active item, use it (the user is navigating between items)
95+
// otherwise use the "selected" for combobox (for the last selected item)
96+
const $active = $menu.find('> .item.active, > .item.selected');
97+
// if the popup is visible and has an active/selected item, use its id as aria-activedescendant
98+
if (menuVisible) {
99+
$focusable.attr('aria-activedescendant', $active.attr('id'));
100+
} else if (!isComboBox) {
101+
// for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item
102+
$focusable.removeAttr('aria-activedescendant');
103+
$active.removeClass('active').removeClass('selected');
104+
}
79105
};
80106

81107
$dropdown.on('keydown', (e) => {
@@ -85,16 +111,51 @@ function attachOneDropdownAria($dropdown) {
85111
if (!$item) $item = $menu.find('> .item.selected'); // when dropdown filters items by input, there is no "value", so query the "selected" item
86112
// if the selected item is clickable, then trigger the click event.
87113
// we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click.
88-
if ($item && ($item.is('a') || $item.is('.js-aria-clickable'))) $item[0].click();
114+
if ($item && ($item.is('a') || $item.hasClass('js-aria-clickable'))) $item[0].click();
89115
}
90116
});
91117

92118
// use setTimeout to run the refreshAria in next tick (to make sure the Fomantic UI code has finished its work)
93-
const deferredRefreshAria = () => { setTimeout(refreshAria, 0) }; // do not return any value, jQuery has return-value related behaviors.
94-
$focusable.on('focus', deferredRefreshAria);
95-
$focusable.on('mouseup', deferredRefreshAria);
96-
$focusable.on('blur', deferredRefreshAria);
119+
// do not return any value, jQuery has return-value related behaviors.
120+
// when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation
121+
// without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation.
122+
const deferredRefreshAria = (delay = 0) => { setTimeout(refreshAria, delay) };
97123
$dropdown.on('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAria(); });
124+
125+
// if the dropdown has been opened by focus, do not trigger the next click event again.
126+
// otherwise the dropdown will be closed immediately, especially on Android with TalkBack
127+
// * desktop event sequence: mousedown -> focus -> mouseup -> click
128+
// * mobile event sequence: focus -> mousedown -> mouseup -> click
129+
// Fomantic may stop propagation of blur event, use capture to make sure we can still get the event
130+
let ignoreClickPreEvents = 0, ignoreClickPreVisible = 0;
131+
$dropdown[0].addEventListener('mousedown', () => {
132+
ignoreClickPreVisible += isMenuVisible() ? 1 : 0;
133+
ignoreClickPreEvents++;
134+
}, true);
135+
$dropdown[0].addEventListener('focus', () => {
136+
ignoreClickPreVisible += isMenuVisible() ? 1 : 0;
137+
ignoreClickPreEvents++;
138+
deferredRefreshAria();
139+
}, true);
140+
$dropdown[0].addEventListener('blur', () => {
141+
ignoreClickPreVisible = ignoreClickPreEvents = 0;
142+
deferredRefreshAria(100);
143+
}, true);
144+
$dropdown[0].addEventListener('mouseup', () => {
145+
setTimeout(() => {
146+
ignoreClickPreVisible = ignoreClickPreEvents = 0;
147+
deferredRefreshAria(100);
148+
}, 0);
149+
}, true);
150+
$dropdown[0].addEventListener('click', (e) => {
151+
if (isMenuVisible() &&
152+
ignoreClickPreVisible !== 2 && // dropdown is switch from invisible to visible
153+
ignoreClickPreEvents === 2 // the click event is related to mousedown+focus
154+
) {
155+
e.stopPropagation(); // if the dropdown menu has been opened by focus, do not trigger the next click event again
156+
}
157+
ignoreClickPreEvents = ignoreClickPreVisible = 0;
158+
}, true);
98159
}
99160

100161
export function attachDropdownAria($dropdowns) {

web_src/js/features/aria.md

+62-19
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,27 @@
1-
**This document is used as aria/a11y reference for future developers**
1+
# Background
2+
3+
This document is used as aria/accessibility(a11y) reference for future developers.
4+
5+
There are a lot of a11y problems in the Fomantic UI library. This `aria.js` is used
6+
as a workaround to make the UI more accessible.
7+
8+
The `aria.js` is designed to avoid touching the official Fomantic UI library,
9+
and to be as independent as possible, so it can be easily modified/removed in the future.
10+
11+
To test the aria/accessibility with screen readers, developers can use the following steps:
12+
13+
* On macOS, you can use VoiceOver.
14+
* Press `Command + F5` to turn on VoiceOver.
15+
* Try to operate the UI with keyboard-only.
16+
* Use Tab/Shift+Tab to switch focus between elements.
17+
* Arrow keys to navigate between menu/combobox items (only aria-active, not really focused).
18+
* Press Enter to trigger the aria-active element.
19+
* On Android, you can use TalkBack.
20+
* Go to Settings -> Accessibility -> TalkBack, turn it on.
21+
* Long-press or press+swipe to switch the aria-active element (not really focused).
22+
* Double-tap means old single-tap on the aria-active element.
23+
* Double-finger swipe means old single-finger swipe.
24+
* TODO: on Windows, on Linux, on iOS
225

326
# Checkbox
427

@@ -10,7 +33,8 @@ The ideal checkboxes should be:
1033
<label><input type="checkbox"> ... </label>
1134
```
1235

13-
However, related styles aren't supported (not implemented) yet, so at the moment, almost all the checkboxes are still using Fomantic UI checkbox.
36+
However, related CSS styles aren't supported (not implemented) yet, so at the moment,
37+
almost all the checkboxes are still using Fomantic UI checkbox.
1438

1539
## Fomantic UI Checkbox
1640

@@ -21,33 +45,52 @@ However, related styles aren't supported (not implemented) yet, so at the moment
2145
</div>
2246
```
2347

24-
Then the JS `$.checkbox()` should be called to make it work with keyboard and label-clicking, then it works like the ideal checkboxes.
48+
Then the JS `$.checkbox()` should be called to make it work with keyboard and label-clicking,
49+
then it works like the ideal checkboxes.
2550

26-
There is still a problem: Fomantic UI checkbox is not friendly to screen readers, so we add IDs to all the Fomantic UI checkboxes automatically by JS.
51+
There is still a problem: Fomantic UI checkbox is not friendly to screen readers,
52+
so we add IDs to all the Fomantic UI checkboxes automatically by JS.
53+
If the `label` part is empty, then the checkbox needs to get the `aria-label` attribute manually.
2754

2855
# Dropdown
2956

30-
## ARIA Dropdown
57+
## Fomantic UI Dropdown
58+
59+
Fomantic Dropdown is designed to be used for many purposes:
60+
61+
* Menu (the profile menu in navbar, the language menu in footer)
62+
* Popup (the branch/tag panel, the review box)
63+
* Simple `<select>` , used in many forms
64+
* Searchable option-list with static items (used in many forms)
65+
* Searchable option-list with dynamic items (ajax)
66+
* Searchable multiple selection option-list with dynamic items: the repo topic setting
67+
* More complex usages, like the Issue Label selector
68+
69+
Fomantic Dropdown requires that the focus must be on its primary element.
70+
If the focus changes, it hides or panics.
71+
72+
At the moment, `aria.js` only tries to partially resolve the a11y problems for dropdowns with items.
3173

3274
There are different solutions:
33-
* combobox + listbox + option
34-
* menu + menuitem
3575

36-
At the moment, `menu + menuitem` seems to work better with Fomantic UI Dropdown, so we only use it now.
76+
* combobox + listbox + option:
77+
* https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
78+
* A combobox is an input widget with an associated popup that enables users to select a value for the combobox from
79+
a collection of possible values. In some implementations, the popup presents allowed values, while in other implementations,
80+
the popup presents suggested values, and users may either select one of the suggestions or type a value.
81+
* menu + menuitem:
82+
* https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
83+
* A menu is a widget that offers a list of choices to the user, such as a set of actions or functions.
3784

38-
```html
39-
<div>
40-
<input role="combobox" aria-haspopup="listbox" aria-expanded="false" aria-controls="the-menu-listbox" aria-activedescendant="item-id-123456">
41-
<ul id="the-menu-listbox" role="listbox">
42-
<li role="option" id="item-id-123456" aria-selected="true">
43-
<a tabindex="-1" href="....">....</a>
44-
</li>
45-
</ul>
46-
</div>
47-
```
85+
The current approach is: detect if the dropdown has an input,
86+
if yes, it works like a combobox, otherwise it works like a menu.
87+
Multiple selection dropdown is not well-supported yet, it needs more work.
4888

89+
Some important pages for dropdown testing:
4990

50-
## Fomantic UI Dropdown
91+
* Home(dashboard) page, the "Create Repo" / "Profile" / "Language" menu.
92+
* Create New Repo page, a lot of dropdowns as combobox.
93+
* Collaborators page, the "permission" dropdown (the old behavior was not quite good, it just works).
5194

5295
```html
5396
<!-- read-only dropdown -->

web_src/js/features/common-global.js

+23-12
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,14 @@ export function initGlobalCommon() {
8989

9090
// Semantic UI modules.
9191
const $uiDropdowns = $('.ui.dropdown');
92-
$uiDropdowns.filter(':not(.custom)').dropdown({
93-
fullTextSearch: 'exact'
94-
});
92+
93+
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
94+
$uiDropdowns.filter(':not(.custom)').dropdown({fullTextSearch: 'exact'});
95+
96+
// The "jump" means this dropdown is mainly used for "menu" purpose,
97+
// clicking an item will jump to somewhere else or trigger an action/function.
98+
// When a dropdown is used for non-refresh actions with tippy,
99+
// it must have this "jump" class to hide the tippy when dropdown is closed.
95100
$uiDropdowns.filter('.jump').dropdown({
96101
action: 'hide',
97102
onShow() {
@@ -101,17 +106,23 @@ export function initGlobalCommon() {
101106
},
102107
onHide() {
103108
this._tippy?.enable();
109+
110+
// hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu
111+
setTimeout(() => {
112+
const $dropdown = $(this);
113+
if ($dropdown.dropdown('is hidden')) {
114+
$(this).find('.menu > .item').each((_, item) => {
115+
item._tippy?.hide();
116+
});
117+
}
118+
}, 2000);
104119
},
105-
fullTextSearch: 'exact'
106-
});
107-
$uiDropdowns.filter('.slide.up').dropdown({
108-
transition: 'slide up',
109-
fullTextSearch: 'exact'
110-
});
111-
$uiDropdowns.filter('.upward').dropdown({
112-
direction: 'upward',
113-
fullTextSearch: 'exact'
114120
});
121+
122+
// special animations/popup-directions
123+
$uiDropdowns.filter('.slide.up').dropdown({transition: 'slide up'});
124+
$uiDropdowns.filter('.upward').dropdown({direction: 'upward'});
125+
115126
attachDropdownAria($uiDropdowns);
116127

117128
attachCheckboxAria($('.ui.checkbox'));

web_src/js/features/repo-legacy.js

-3
Original file line numberDiff line numberDiff line change
@@ -601,9 +601,6 @@ export function initRepository() {
601601
}
602602

603603
function initRepoIssueCommentEdit() {
604-
// Issue/PR Context Menus
605-
$('.comment-header-right .context-dropdown').dropdown({action: 'hide'});
606-
607604
// Edit issue or comment content
608605
$(document).on('click', '.edit-content', onEditContent);
609606

0 commit comments

Comments
 (0)