diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 818a6bbef1b24..da4ad47620209 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -84,6 +84,7 @@ add = Add add_all = Add All remove = Remove remove_all = Remove All +remove_label_str = Remove item "%s" edit = Edit enabled = Enabled diff --git a/templates/base/head_script.tmpl b/templates/base/head_script.tmpl index ca8c7e6a77341..62fb10d89fa96 100644 --- a/templates/base/head_script.tmpl +++ b/templates/base/head_script.tmpl @@ -41,6 +41,7 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly. copy_error: '{{.locale.Tr "copy_error"}}', error_occurred: '{{.locale.Tr "error.occurred"}}', network_error: '{{.locale.Tr "error.network_error"}}', + remove_label_str: '{{.locale.Tr "remove_label_str"}}', }, }; {{/* in case some pages don't render the pageData, we make sure it is an object to prevent null access */}} diff --git a/web_src/js/features/common-global.js b/web_src/js/features/common-global.js index cdb9132805d39..113ff2e1f1592 100644 --- a/web_src/js/features/common-global.js +++ b/web_src/js/features/common-global.js @@ -4,7 +4,6 @@ import {mqBinarySearch} from '../utils.js'; import {createDropzone} from './dropzone.js'; import {initCompColorPicker} from './comp/ColorPicker.js'; import {showGlobalErrorMessage} from '../bootstrap.js'; -import {attachCheckboxAria, attachDropdownAria} from './aria.js'; import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.js'; import {initTooltip} from '../modules/tippy.js'; import {svg} from '../svg.js'; @@ -123,9 +122,7 @@ export function initGlobalCommon() { $uiDropdowns.filter('.slide.up').dropdown({transition: 'slide up'}); $uiDropdowns.filter('.upward').dropdown({direction: 'upward'}); - attachDropdownAria($uiDropdowns); - - attachCheckboxAria($('.ui.checkbox')); + $('.ui.checkbox').checkbox(); $('.tabular.menu .item').tab(); $('.tabable.menu .item').tab(); diff --git a/web_src/js/index.js b/web_src/js/index.js index 480661118bc65..7d74ee6b944c4 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -89,6 +89,8 @@ import {initFormattingReplacements} from './features/formatting.js'; import {initCopyContent} from './features/copycontent.js'; import {initCaptcha} from './features/captcha.js'; import {initRepositoryActionView} from './components/RepoActionView.vue'; +import {initAriaCheckboxPatch} from './modules/aria/checkbox.js'; +import {initAriaDropdownPatch} from './modules/aria/dropdown.js'; // Run time-critical code as soon as possible. This is safe to do because this // script appears at the end of and rendered HTML is accessible at that point. @@ -98,6 +100,9 @@ initFormattingReplacements(); $.fn.tab.settings.silent = true; // Disable the behavior of fomantic to toggle the checkbox when you press enter on a checkbox element. $.fn.checkbox.settings.enableEnterKey = false; +// Use the patches to improve accessibility, these patches are designed to be as independent as possible, make it easy to modify or remove in the future. +initAriaCheckboxPatch(); +initAriaDropdownPatch(); $(document).ready(() => { initGlobalCommon(); diff --git a/web_src/js/features/aria.md b/web_src/js/modules/aria/aria.md similarity index 88% rename from web_src/js/features/aria.md rename to web_src/js/modules/aria/aria.md index 679cec774cd2c..a32d15f46fe8d 100644 --- a/web_src/js/features/aria.md +++ b/web_src/js/modules/aria/aria.md @@ -23,6 +23,14 @@ To test the aria/accessibility with screen readers, developers can use the follo * Double-finger swipe means old single-finger swipe. * TODO: on Windows, on Linux, on iOS +# Known Problems + +* Tested with Apple VoiceOver: If a dropdown menu/combobox is opened by mouse click, then arrow keys don't work. + But if the dropdown is opened by keyboard Tab, then arrow keys work, and from then on, the keys almost work with mouse click too. + The clue: when the dropdown is only opened by mouse click, VoiceOver doesn't send 'keydown' events of arrow keys to the DOM, + VoiceOver expects to use arrow keys to navigate between some elements, but it couldn't. + Users could use Option+ArrowKeys to navigate between menu/combobox items or selection labels if the menu/combobox is opened by mouse click. + # Checkbox ## Accessibility-friendly Checkbox @@ -52,9 +60,7 @@ 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. If the `label` part is empty, then the checkbox needs to get the `aria-label` attribute manually. -# Dropdown - -## Fomantic UI Dropdown +# Fomantic Dropdown Fomantic Dropdown is designed to be used for many purposes: diff --git a/web_src/js/modules/aria/base.js b/web_src/js/modules/aria/base.js new file mode 100644 index 0000000000000..c4a01038ba3e0 --- /dev/null +++ b/web_src/js/modules/aria/base.js @@ -0,0 +1,5 @@ +let ariaIdCounter = 0; + +export function generateAriaId() { + return `_aria_auto_id_${ariaIdCounter++}`; +} diff --git a/web_src/js/modules/aria/checkbox.js b/web_src/js/modules/aria/checkbox.js new file mode 100644 index 0000000000000..08af1c2eb6357 --- /dev/null +++ b/web_src/js/modules/aria/checkbox.js @@ -0,0 +1,38 @@ +import $ from 'jquery'; +import {generateAriaId} from './base.js'; + +const ariaPatchKey = '_giteaAriaPatchCheckbox'; +const fomanticCheckboxFn = $.fn.checkbox; + +// use our own `$.fn.checkbox` to patch Fomantic's checkbox module +export function initAriaCheckboxPatch() { + if ($.fn.checkbox === ariaCheckboxFn) throw new Error('initAriaCheckboxPatch could only be called once'); + $.fn.checkbox = ariaCheckboxFn; + ariaCheckboxFn.settings = fomanticCheckboxFn.settings; +} + +// the patched `$.fn.checkbox` checkbox function +// * it does the one-time attaching on the first call +function ariaCheckboxFn(...args) { + const ret = fomanticCheckboxFn.apply(this, args); + for (const el of this) { + if (el[ariaPatchKey]) continue; + attachInit(el); + } + return ret; +} + +function attachInit(el) { + // Fomantic UI checkbox needs to be something like:
+ // It doesn't work well with + // To make it work with aria, the "id"/"for" attributes are necessary, so add them automatically if missing. + // In the future, refactor to use native checkbox directly, then this patch could be removed. + el[ariaPatchKey] = {}; // record that this element has been patched + const label = el.querySelector('label'); + const input = el.querySelector('input'); + if (!label || !input || input.getAttribute('id')) return; + + const id = generateAriaId(); + input.setAttribute('id', id); + label.setAttribute('for', id); +} diff --git a/web_src/js/features/aria.js b/web_src/js/modules/aria/dropdown.js similarity index 52% rename from web_src/js/features/aria.js rename to web_src/js/modules/aria/dropdown.js index 676f4cd56c77e..70d524cfe77c7 100644 --- a/web_src/js/features/aria.js +++ b/web_src/js/modules/aria/dropdown.js @@ -1,14 +1,116 @@ import $ from 'jquery'; +import {generateAriaId} from './base.js'; -let ariaIdCounter = 0; +const ariaPatchKey = '_giteaAriaPatchDropdown'; +const fomanticDropdownFn = $.fn.dropdown; -function generateAriaId() { - return `_aria_auto_id_${ariaIdCounter++}`; +// use our own `$().dropdown` function to patch Fomantic's dropdown module +export function initAriaDropdownPatch() { + if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once'); + $.fn.dropdown = ariaDropdownFn; + ariaDropdownFn.settings = fomanticDropdownFn.settings; } -function attachOneDropdownAria($dropdown) { - if ($dropdown.attr('data-aria-attached') || $dropdown.hasClass('custom')) return; - $dropdown.attr('data-aria-attached', 1); +// the patched `$.fn.dropdown` function, it passes the arguments to Fomantic's `$.fn.dropdown` function, and: +// * it does the one-time attaching on the first call +// * it delegates the `onLabelCreate` to the patched `onLabelCreate` to add necessary aria attributes +function ariaDropdownFn(...args) { + const ret = fomanticDropdownFn.apply(this, args); + + // if the `$().dropdown()` call is without arguments, or it has non-string (object) argument, + // it means that this call will reset the dropdown internal settings, then we need to re-delegate the callbacks. + const needDelegate = (!args.length || typeof args[0] !== 'string'); + for (const el of this) { + const $dropdown = $(el); + if (!el[ariaPatchKey]) { + attachInit($dropdown); + } + if (needDelegate) { + delegateOne($dropdown); + } + } + return ret; +} + +// make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable +// the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element. +function updateMenuItem(dropdown, item) { + if (!item.id) item.id = generateAriaId(); + item.setAttribute('role', dropdown[ariaPatchKey].listItemRole); + item.setAttribute('tabindex', '-1'); + for (const a of item.querySelectorAll('a')) a.setAttribute('tabindex', '-1'); +} + +// make the label item and its "delete icon" has correct aria attributes +function updateSelectionLabel($label) { + // the "label" is like this: "the-label-name " + if (!$label.attr('id')) $label.attr('id', generateAriaId()); + $label.attr('tabindex', '-1'); + $label.find('.delete.icon').attr({ + 'aria-hidden': 'false', + 'aria-label': window.config.i18n.remove_label_str.replace('%s', $label.attr('data-value')), + 'role': 'button', + }); +} + +// delegate the dropdown's template functions and callback functions to add aria attributes. +function delegateOne($dropdown) { + const dropdownCall = fomanticDropdownFn.bind($dropdown); + + // the "template" functions are used for dynamic creation (eg: AJAX) + const dropdownTemplates = {...dropdownCall('setting', 'templates'), t: performance.now()}; + const dropdownTemplatesMenuOld = dropdownTemplates.menu; + dropdownTemplates.menu = function(response, fields, preserveHTML, className) { + // when the dropdown menu items are loaded from AJAX requests, the items are created dynamically + const menuItems = dropdownTemplatesMenuOld(response, fields, preserveHTML, className); + const $wrapper = $('
').append(menuItems); + const $items = $wrapper.find('> .item'); + $items.each((_, item) => updateMenuItem($dropdown[0], item)); + $dropdown[0][ariaPatchKey].deferredRefreshAriaActiveItem(); + return $wrapper.html(); + }; + dropdownCall('setting', 'templates', dropdownTemplates); + + // the `onLabelCreate` is used to add necessary aria attributes for dynamically created selection labels + const dropdownOnLabelCreateOld = dropdownCall('setting', 'onLabelCreate'); + dropdownCall('setting', 'onLabelCreate', function(value, text) { + const $label = dropdownOnLabelCreateOld.call(this, value, text); + updateSelectionLabel($label); + return $label; + }); +} + +// for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes +function attachStaticElements($dropdown, $focusable, $menu) { + const dropdown = $dropdown[0]; + + // prepare static dropdown menu list popup + if (!$menu.attr('id')) $menu.attr('id', generateAriaId()); + $menu.find('> .item').each((_, item) => updateMenuItem(dropdown, item)); + // this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash + $menu.attr('role', dropdown[ariaPatchKey].listPopupRole); + + // prepare selection label items + $dropdown.find('.ui.label').each((_, label) => updateSelectionLabel($(label))); + + // make the primary element (focusable) aria-friendly + $focusable.attr({ + 'role': $focusable.attr('role') ?? dropdown[ariaPatchKey].focusableRole, + 'aria-haspopup': dropdown[ariaPatchKey].listPopupRole, + 'aria-controls': $menu.attr('id'), + 'aria-expanded': 'false', + }); + + // use tooltip's content as aria-label if there is no aria-label + if ($dropdown.hasClass('tooltip') && $dropdown.attr('data-content') && !$dropdown.attr('aria-label')) { + $dropdown.attr('aria-label', $dropdown.attr('data-content')); + } +} + +function attachInit($dropdown) { + const dropdown = $dropdown[0]; + dropdown[ariaPatchKey] = {}; + if ($dropdown.hasClass('custom')) return; // Dropdown has 2 different focusing behaviors // * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element. @@ -23,71 +125,39 @@ function attachOneDropdownAria($dropdown) { // - if the menu item is clickable (eg: ), then trigger the click event // - otherwise, the dropdown control (low-level code) handles the Enter event, hides the dropdown menu - // TODO: multiple selection is not supported yet. + // TODO: multiple selection is only partially supported. Check and test them one by one in the future. const $textSearch = $dropdown.find('input.search').eq(0); const $focusable = $textSearch.length ? $textSearch : $dropdown; // the primary element for focus, see comment above if (!$focusable.length) return; + let $menu = $dropdown.find('> .menu'); + if (!$menu.length) { + // some "multiple selection" dropdowns don't have a static menu element in HTML, we need to pre-create it to make it have correct aria attributes + $menu = $('').appendTo($dropdown); + } + // There are 2 possible solutions about the role: combobox or menu. // The idea is that if there is an input, then it's a combobox, otherwise it's a menu. // Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before. const isComboBox = $dropdown.find('input').length > 0; - const focusableRole = isComboBox ? 'combobox' : 'button'; - const listPopupRole = isComboBox ? 'listbox' : 'menu'; - const listItemRole = isComboBox ? 'option' : 'menuitem'; + dropdown[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'button'; + dropdown[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : 'menu'; + dropdown[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem'; - // make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable - // the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element. - function prepareMenuItem($item) { - if (!$item.attr('id')) $item.attr('id', generateAriaId()); - $item.attr({'role': listItemRole, 'tabindex': '-1'}); - $item.find('a').attr('tabindex', '-1'); - } - - // delegate the dropdown's template function to add aria attributes. - // the "template" functions are used for dynamic creation (eg: AJAX) - const dropdownTemplates = {...$dropdown.dropdown('setting', 'templates')}; - const dropdownTemplatesMenuOld = dropdownTemplates.menu; - dropdownTemplates.menu = function(response, fields, preserveHTML, className) { - // when the dropdown menu items are loaded from AJAX requests, the items are created dynamically - const menuItems = dropdownTemplatesMenuOld(response, fields, preserveHTML, className); - const $wrapper = $('
').append(menuItems); - const $items = $wrapper.find('> .item'); - $items.each((_, item) => prepareMenuItem($(item))); - return $wrapper.html(); - }; - $dropdown.dropdown('setting', 'templates', dropdownTemplates); - - // use tooltip's content as aria-label if there is no aria-label - if ($dropdown.hasClass('tooltip') && $dropdown.attr('data-content') && !$dropdown.attr('aria-label')) { - $dropdown.attr('aria-label', $dropdown.attr('data-content')); - } - - // prepare dropdown menu list popup - const $menu = $dropdown.find('> .menu'); - if (!$menu.attr('id')) $menu.attr('id', generateAriaId()); - $menu.find('> .item').each((_, item) => { - prepareMenuItem($(item)); - }); - // this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash - $menu.attr('role', listPopupRole); - - // make the primary element (focusable) aria-friendly - $focusable.attr({ - 'role': $focusable.attr('role') ?? focusableRole, - 'aria-haspopup': listPopupRole, - 'aria-controls': $menu.attr('id'), - 'aria-expanded': 'false', - }); + attachDomEvents($dropdown, $focusable, $menu); + attachStaticElements($dropdown, $focusable, $menu); +} +function attachDomEvents($dropdown, $focusable, $menu) { + const dropdown = $dropdown[0]; // when showing, it has class: ".animating.in" // when hiding, it has class: ".visible.animating.out" const isMenuVisible = () => ($menu.hasClass('visible') && !$menu.hasClass('out')) || $menu.hasClass('in'); // update aria attributes according to current active/selected item - const refreshAria = () => { + const refreshAriaActiveItem = () => { const menuVisible = isMenuVisible(); $focusable.attr('aria-expanded', menuVisible ? 'true' : 'false'); @@ -97,7 +167,7 @@ function attachOneDropdownAria($dropdown) { // if the popup is visible and has an active/selected item, use its id as aria-activedescendant if (menuVisible) { $focusable.attr('aria-activedescendant', $active.attr('id')); - } else if (!isComboBox) { + } else if (dropdown[ariaPatchKey].listPopupRole === 'menu') { // for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item $focusable.removeAttr('aria-activedescendant'); $active.removeClass('active').removeClass('selected'); @@ -107,7 +177,8 @@ function attachOneDropdownAria($dropdown) { $dropdown.on('keydown', (e) => { // here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler if (e.key === 'Enter') { - let $item = $dropdown.dropdown('get item', $dropdown.dropdown('get value')); + const dropdownCall = fomanticDropdownFn.bind($dropdown); + let $item = dropdownCall('get item', dropdownCall('get value')); if (!$item) $item = $menu.find('> .item.selected'); // when dropdown filters items by input, there is no "value", so query the "selected" item // if the selected item is clickable, then trigger the click event. // we can not click any item without check, because Fomantic code might also handle the Enter event. that would result in double click. @@ -119,8 +190,9 @@ function attachOneDropdownAria($dropdown) { // do not return any value, jQuery has return-value related behaviors. // when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation // without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation. - const deferredRefreshAria = (delay = 0) => { setTimeout(refreshAria, delay) }; - $dropdown.on('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAria(); }); + const deferredRefreshAriaActiveItem = (delay = 0) => { setTimeout(refreshAriaActiveItem, delay) }; + dropdown[ariaPatchKey].deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem; + $dropdown.on('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAriaActiveItem(); }); // if the dropdown has been opened by focus, do not trigger the next click event again. // otherwise the dropdown will be closed immediately, especially on Android with TalkBack @@ -128,26 +200,26 @@ function attachOneDropdownAria($dropdown) { // * mobile event sequence: focus -> mousedown -> mouseup -> click // Fomantic may stop propagation of blur event, use capture to make sure we can still get the event let ignoreClickPreEvents = 0, ignoreClickPreVisible = 0; - $dropdown[0].addEventListener('mousedown', () => { + dropdown.addEventListener('mousedown', () => { ignoreClickPreVisible += isMenuVisible() ? 1 : 0; ignoreClickPreEvents++; }, true); - $dropdown[0].addEventListener('focus', () => { + dropdown.addEventListener('focus', () => { ignoreClickPreVisible += isMenuVisible() ? 1 : 0; ignoreClickPreEvents++; - deferredRefreshAria(); + deferredRefreshAriaActiveItem(); }, true); - $dropdown[0].addEventListener('blur', () => { + dropdown.addEventListener('blur', () => { ignoreClickPreVisible = ignoreClickPreEvents = 0; - deferredRefreshAria(100); + deferredRefreshAriaActiveItem(100); }, true); - $dropdown[0].addEventListener('mouseup', () => { + dropdown.addEventListener('mouseup', () => { setTimeout(() => { ignoreClickPreVisible = ignoreClickPreEvents = 0; - deferredRefreshAria(100); + deferredRefreshAriaActiveItem(100); }, 0); }, true); - $dropdown[0].addEventListener('click', (e) => { + dropdown.addEventListener('click', (e) => { if (isMenuVisible() && ignoreClickPreVisible !== 2 && // dropdown is switch from invisible to visible ignoreClickPreEvents === 2 // the click event is related to mousedown+focus @@ -157,24 +229,3 @@ function attachOneDropdownAria($dropdown) { ignoreClickPreEvents = ignoreClickPreVisible = 0; }, true); } - -export function attachDropdownAria($dropdowns) { - $dropdowns.each((_, e) => attachOneDropdownAria($(e))); -} - -export function attachCheckboxAria($checkboxes) { - $checkboxes.checkbox(); - - // Fomantic UI checkbox needs to be something like:
- // It doesn't work well with - // To make it work with aria, the "id"/"for" attributes are necessary, so add them automatically if missing. - // In the future, refactor to use native checkbox directly, then this patch could be removed. - for (const el of $checkboxes) { - const label = el.querySelector('label'); - const input = el.querySelector('input'); - if (!label || !input || input.getAttribute('id')) continue; - const id = generateAriaId(); - input.setAttribute('id', id); - label.setAttribute('for', id); - } -}