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 = $('