summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--options/locale/locale_en-US.ini1
-rw-r--r--templates/base/head_script.tmpl1
-rw-r--r--web_src/js/features/common-global.js5
-rw-r--r--web_src/js/index.js5
-rw-r--r--web_src/js/modules/aria/aria.md (renamed from web_src/js/features/aria.md)12
-rw-r--r--web_src/js/modules/aria/base.js5
-rw-r--r--web_src/js/modules/aria/checkbox.js38
-rw-r--r--web_src/js/modules/aria/dropdown.js (renamed from web_src/js/features/aria.js)225
8 files changed, 198 insertions, 94 deletions
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 818a6bbef1..da4ad47620 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 ca8c7e6a77..62fb10d89f 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 cdb9132805..113ff2e1f1 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 480661118b..7d74ee6b94 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 <body> 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
index 679cec774c..a32d15f46f 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 0000000000..c4a01038ba
--- /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 0000000000..08af1c2eb6
--- /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: <div class="ui checkbox"><label /><input /></div>
+ // It doesn't work well with <label><input />...</label>
+ // 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
index 676f4cd56c..70d524cfe7 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: "<a|div class="ui label" data-value="1">the-label-name <i|svg class="delete icon"/></a>"
+ 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 = $('<div>').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: <a>), 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 = $('<div class="menu"></div>').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 = $('<div>').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: <div class="ui checkbox"><label /><input /></div>
- // It doesn't work well with <label><input />...</label>
- // 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);
- }
-}