loading up the forgejo repo on tangled to test page performance
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix aria.js bugs: incorrect role element problem, mobile focus problem, tippy problem (#23450)

This PR is extracted from #23346 to address some unclear (I don't
understand) code-belonging concerns.

This PR needs to be backported, otherwise the `aria.js` is too buggy in
some cases. Since there would be two minor conflicts, I will do the
backport manually.

Before: the `aria.js` is still buggy in some cases.

After: tested with AppleVoice, Android TalkBack

* 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
* Hide item's tippy after menu gets hidden
* Fix incorrect tippy `setProps` after `destroy`
* Fix UI lag problem when page gets redirected during menu hiding
animation with screen reader
* Improve comments
* Implement the layout proposed by #19861

<details>


https://github.com/go-gitea/gitea/blob/d74a7efb60f94a4b8e6e5f65332f94f1be31b761/web_src/js/features/aria.md?plain=1#L38-L47

</details>

authored by

wxiaoguang and committed by
GitHub
345aa097 e200c68b

+201 -88
+1 -1
templates/base/footer_content.tmpl
··· 21 21 {{end}} 22 22 <div class="ui language bottom floating slide up dropdown link item"> 23 23 {{svg "octicon-globe"}} 24 - <div class="text">{{.locale.LangName}}</div> 24 + <span>{{.locale.LangName}}</span> 25 25 <div class="menu language-menu"> 26 26 {{range .AllLangs}} 27 27 <a lang="{{.Lang}}" data-url="{{AppSubUrl}}/?lang={{.Lang}}" class="item {{if eq $.locale.Lang .Lang}}active selected{{end}}">{{.Name}}</a>
+1 -1
templates/repo/issue/view_content/add_reaction.tmpl
··· 1 1 {{if .ctxData.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}}"> 3 3 <a class="add-reaction"> 4 4 {{svg "octicon-smiley"}} 5 5 </a>
+1 -1
templates/repo/issue/view_content/context_menu.tmpl
··· 1 1 {{if .ctxData.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"> 3 3 <a class="context-menu"> 4 4 {{svg "octicon-kebab-horizontal"}} 5 5 </a>
+111 -50
web_src/js/features/aria.js
··· 6 6 return `_aria_auto_id_${ariaIdCounter++}`; 7 7 } 8 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 9 function attachOneDropdownAria($dropdown) { 29 - if ($dropdown.attr('data-aria-attached')) return; 10 + if ($dropdown.attr('data-aria-attached') || $dropdown.hasClass('custom')) return; 30 11 $dropdown.attr('data-aria-attached', 1); 31 12 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. 42 15 // * 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. 43 17 44 - // expected user interactions for dropdown with aria support: 18 + // Expected user interactions for dropdown with aria support: 45 19 // * user can use Tab to focus in the dropdown, then the dropdown menu (list) will be shown 46 20 // * user presses Tab on the focused dropdown to move focus to next sibling focusable element (but not the menu item) 47 21 // * user can use arrow key Up/Down to navigate between menu items ··· 51 25 52 26 // TODO: multiple selection is not supported yet. 53 27 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; 31 + 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; 60 36 61 - if ($dropdown.attr('data-content') && !$dropdown.attr('aria-label')) { 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')) { 62 65 $dropdown.attr('aria-label', $dropdown.attr('data-content')); 63 66 } 64 67 68 + // prepare dropdown menu list popup 69 + const $menu = $dropdown.find('> .menu'); 70 + if (!$menu.attr('id')) $menu.attr('id', generateAriaId()); 65 71 $menu.find('> .item').each((_, item) => { 66 72 prepareMenuItem($(item)); 67 73 }); 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); 76 + 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 + }); 84 + 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'); 68 88 69 89 // update aria attributes according to current active/selected item 70 90 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 91 + const menuVisible = isMenuVisible(); 92 + $focusable.attr('aria-expanded', menuVisible ? 'true' : 'false'); 76 93 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')); 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 + } 79 105 }; 80 106 81 107 $dropdown.on('keydown', (e) => { ··· 85 111 if (!$item) $item = $menu.find('> .item.selected'); // when dropdown filters items by input, there is no "value", so query the "selected" item 86 112 // if the selected item is clickable, then trigger the click event. 87 113 // 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(); 89 115 } 90 116 }); 91 117 92 118 // 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) }; 97 123 $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); 98 159 } 99 160 100 161 export function attachDropdownAria($dropdowns) {
+62 -19
web_src/js/features/aria.md
··· 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 2 25 3 26 # Checkbox 4 27 ··· 10 33 <label><input type="checkbox"> ... </label> 11 34 ``` 12 35 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. 14 38 15 39 ## Fomantic UI Checkbox 16 40 ··· 21 45 </div> 22 46 ``` 23 47 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. 25 50 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. 27 54 28 55 # Dropdown 29 56 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. 31 73 32 74 There are different solutions: 33 - * combobox + listbox + option 34 - * menu + menuitem 35 75 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. 37 84 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. 48 88 89 + Some important pages for dropdown testing: 49 90 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). 51 94 52 95 ```html 53 96 <!-- read-only dropdown -->
+23 -12
web_src/js/features/common-global.js
··· 89 89 90 90 // Semantic UI modules. 91 91 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. 95 100 $uiDropdowns.filter('.jump').dropdown({ 96 101 action: 'hide', 97 102 onShow() { ··· 101 106 }, 102 107 onHide() { 103 108 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); 104 119 }, 105 - fullTextSearch: 'exact' 106 120 }); 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' 114 - }); 121 + 122 + // special animations/popup-directions 123 + $uiDropdowns.filter('.slide.up').dropdown({transition: 'slide up'}); 124 + $uiDropdowns.filter('.upward').dropdown({direction: 'upward'}); 125 + 115 126 attachDropdownAria($uiDropdowns); 116 127 117 128 attachCheckboxAria($('.ui.checkbox'));
-3
web_src/js/features/repo-legacy.js
··· 602 602 } 603 603 604 604 function initRepoIssueCommentEdit() { 605 - // Issue/PR Context Menus 606 - $('.comment-header-right .context-dropdown').dropdown({action: 'hide'}); 607 - 608 605 // Edit issue or comment content 609 606 $(document).on('click', '.edit-content', onEditContent); 610 607
+2 -1
web_src/js/modules/tippy.js
··· 53 53 onHidden: (tippy) => { 54 54 if (oldContent) { 55 55 tippy.setContent(oldContent); 56 + tippy.setProps({onHidden: undefined}); 56 57 } else { 57 58 tippy.destroy(); 59 + // after destroy, the `_tippy` is detached, it can't do "setProps (etc...)" anymore 58 60 } 59 - tippy.setProps({onHidden: undefined}); 60 61 }, 61 62 }); 62 63 }