From dd136858f1ea40ad3c94191d647487fa4f31926c Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 18 Oct 2024 20:33:49 +0200 Subject: Adding upstream version 9.0.0. Signed-off-by: Daniel Baumann --- web_src/js/modules/fomantic/api.js | 40 +++++ web_src/js/modules/fomantic/aria.md | 117 ++++++++++++++ web_src/js/modules/fomantic/base.js | 18 +++ web_src/js/modules/fomantic/checkbox.js | 13 ++ web_src/js/modules/fomantic/dropdown.js | 256 ++++++++++++++++++++++++++++++ web_src/js/modules/fomantic/form.js | 13 ++ web_src/js/modules/fomantic/modal.js | 28 ++++ web_src/js/modules/fomantic/transition.js | 54 +++++++ 8 files changed, 539 insertions(+) create mode 100644 web_src/js/modules/fomantic/api.js create mode 100644 web_src/js/modules/fomantic/aria.md create mode 100644 web_src/js/modules/fomantic/base.js create mode 100644 web_src/js/modules/fomantic/checkbox.js create mode 100644 web_src/js/modules/fomantic/dropdown.js create mode 100644 web_src/js/modules/fomantic/form.js create mode 100644 web_src/js/modules/fomantic/modal.js create mode 100644 web_src/js/modules/fomantic/transition.js (limited to 'web_src/js/modules/fomantic') diff --git a/web_src/js/modules/fomantic/api.js b/web_src/js/modules/fomantic/api.js new file mode 100644 index 0000000..ca212c9 --- /dev/null +++ b/web_src/js/modules/fomantic/api.js @@ -0,0 +1,40 @@ +import $ from 'jquery'; + +export function initFomanticApiPatch() { + // + // Fomantic API module has some very buggy behaviors: + // + // If encodeParameters=true, it calls `urlEncodedValue` to encode the parameter. + // However, `urlEncodedValue` just tries to "guess" whether the parameter is already encoded, by decoding the parameter and encoding it again. + // + // There are 2 problems: + // 1. It may guess wrong, and skip encoding a parameter which looks like encoded. + // 2. If the parameter can't be decoded, `decodeURIComponent` will throw an error, and the whole request will fail. + // + // This patch only fixes the second error behavior at the moment. + // + const patchKey = '_giteaFomanticApiPatch'; + const oldApi = $.api; + $.api = $.fn.api = function(...args) { + const apiCall = oldApi.bind(this); + const ret = oldApi.apply(this, args); + + if (typeof args[0] !== 'string') { + const internalGet = apiCall('internal', 'get'); + if (!internalGet.urlEncodedValue[patchKey]) { + const oldUrlEncodedValue = internalGet.urlEncodedValue; + internalGet.urlEncodedValue = function (value) { + try { + return oldUrlEncodedValue(value); + } catch { + // if Fomantic API module's `urlEncodedValue` throws an error, we encode it by ourselves. + return encodeURIComponent(value); + } + }; + internalGet.urlEncodedValue[patchKey] = true; + } + } + return ret; + }; + $.api.settings = oldApi.settings; +} diff --git a/web_src/js/modules/fomantic/aria.md b/web_src/js/modules/fomantic/aria.md new file mode 100644 index 0000000..5836a34 --- /dev/null +++ b/web_src/js/modules/fomantic/aria.md @@ -0,0 +1,117 @@ +# Background + +This document is used as aria/accessibility(a11y) reference for future developers. + +There are a lot of a11y problems in the Fomantic UI library. Files in +`web_src/js/modules/fomantic/` are used as a workaround to make the UI more accessible. + +The aria-related code is designed to avoid touching the official Fomantic UI library, +and to be as independent as possible, so it can be easily modified/removed in the future. + +To test the aria/accessibility with screen readers, developers can use the following steps: + +* On macOS, you can use VoiceOver. + * Press `Command + F5` to turn on VoiceOver. + * Try to operate the UI with keyboard-only. + * Use Tab/Shift+Tab to switch focus between elements. + * Arrow keys (Option+Up/Down) to navigate between menu/combobox items (only aria-active, not really focused). + * Press Enter to trigger the aria-active element. +* On Android, you can use TalkBack. + * Go to Settings -> Accessibility -> TalkBack, turn it on. + * Long-press or press+swipe to switch the aria-active element (not really focused). + * Double-tap means old single-tap on the aria-active element. + * 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 + +The ideal checkboxes should be: + +```html + +``` + +However, the templates still have the Fomantic-style HTML layout: + +```html +
+ + +
+``` + +We call `initAriaCheckboxPatch` to link the `input` and `label` which makes clicking the +label etc. work. There is still a problem: These checkboxes are 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. + +# Fomantic Dropdown + +Fomantic Dropdown is designed to be used for many purposes: + +* Menu (the profile menu in navbar, the language menu in footer) +* Popup (the branch/tag panel, the review box) +* Simple ` +
Default
+ + + + + +``` diff --git a/web_src/js/modules/fomantic/base.js b/web_src/js/modules/fomantic/base.js new file mode 100644 index 0000000..7574fdd --- /dev/null +++ b/web_src/js/modules/fomantic/base.js @@ -0,0 +1,18 @@ +let ariaIdCounter = 0; + +export function generateAriaId() { + return `_aria_auto_id_${ariaIdCounter++}`; +} + +export function linkLabelAndInput(label, input) { + const labelFor = label.getAttribute('for'); + const inputId = input.getAttribute('id'); + + if (inputId && !labelFor) { // missing "for" + label.setAttribute('for', inputId); + } else if (!inputId && !labelFor) { // missing both "id" and "for" + const id = generateAriaId(); + input.setAttribute('id', id); + label.setAttribute('for', id); + } +} diff --git a/web_src/js/modules/fomantic/checkbox.js b/web_src/js/modules/fomantic/checkbox.js new file mode 100644 index 0000000..ed77406 --- /dev/null +++ b/web_src/js/modules/fomantic/checkbox.js @@ -0,0 +1,13 @@ +import {linkLabelAndInput} from './base.js'; + +export function initAriaCheckboxPatch() { + // link the label and the input element so it's clickable and accessible + for (const el of document.querySelectorAll('.ui.checkbox')) { + if (el.hasAttribute('data-checkbox-patched')) continue; + const label = el.querySelector('label'); + const input = el.querySelector('input'); + if (!label || !input) continue; + linkLabelAndInput(label, input); + el.setAttribute('data-checkbox-patched', 'true'); + } +} diff --git a/web_src/js/modules/fomantic/dropdown.js b/web_src/js/modules/fomantic/dropdown.js new file mode 100644 index 0000000..82e7108 --- /dev/null +++ b/web_src/js/modules/fomantic/dropdown.js @@ -0,0 +1,256 @@ +import $ from 'jquery'; +import {generateAriaId} from './base.js'; + +const ariaPatchKey = '_giteaAriaPatchDropdown'; +const fomanticDropdownFn = $.fn.dropdown; + +// 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; +} + +// 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) { + if (!el[ariaPatchKey]) { + attachInit(el); + } + if (needDelegate) { + delegateOne($(el)); + } + } + 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 el of item.querySelectorAll('a, input, button')) el.setAttribute('tabindex', '-1'); +} +/** + * make the label item and its "delete icon" have correct aria attributes + * @param {HTMLElement} label + */ +function updateSelectionLabel(label) { + // the "label" is like this: "the-label-name " + if (!label.id) { + label.id = generateAriaId(); + } + label.tabIndex = -1; + + const deleteIcon = label.querySelector('.delete.icon'); + if (deleteIcon) { + deleteIcon.setAttribute('aria-hidden', 'false'); + deleteIcon.setAttribute('aria-label', window.config.i18n.remove_label_str.replace('%s', label.getAttribute('data-value'))); + deleteIcon.setAttribute('role', 'button'); + } +} + +// delegate the dropdown's template functions and callback functions to add aria attributes. +function delegateOne($dropdown) { + const dropdownCall = fomanticDropdownFn.bind($dropdown); + + // If there is a "search input" in the "menu", Fomantic will only "focus the input" but not "toggle the menu" when the "dropdown icon" is clicked. + // Actually, Fomantic UI doesn't support such layout/usage. It needs to patch the "focusSearch" / "blurSearch" functions to make sure it toggles the menu. + const oldFocusSearch = dropdownCall('internal', 'focusSearch'); + const oldBlurSearch = dropdownCall('internal', 'blurSearch'); + // * If the "dropdown icon" is clicked, Fomantic calls "focusSearch", so show the menu + dropdownCall('internal', 'focusSearch', function () { dropdownCall('show'); oldFocusSearch.call(this) }); + // * If the "dropdown icon" is clicked again when the menu is visible, Fomantic calls "blurSearch", so hide the menu + dropdownCall('internal', 'blurSearch', function () { oldBlurSearch.call(this); dropdownCall('hide') }); + + // 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 div = document.createElement('div'); + div.innerHTML = menuItems; + const $wrapper = $(div); + 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[0]); + return $label; + }); +} + +// for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes +function attachStaticElements(dropdown, focusable, menu) { + // prepare static dropdown menu list popup + if (!menu.id) { + menu.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.setAttribute('role', dropdown[ariaPatchKey].listPopupRole); + + // prepare selection label items + for (const label of dropdown.querySelectorAll('.ui.label')) { + updateSelectionLabel(label); + } + + // make the primary element (focusable) aria-friendly + focusable.setAttribute('role', focusable.getAttribute('role') ?? dropdown[ariaPatchKey].focusableRole); + focusable.setAttribute('aria-haspopup', dropdown[ariaPatchKey].listPopupRole); + focusable.setAttribute('aria-controls', menu.id); + focusable.setAttribute('aria-expanded', 'false'); + + // use tooltip's content as aria-label if there is no aria-label + const tooltipContent = dropdown.getAttribute('data-tooltip-content'); + if (tooltipContent && !dropdown.getAttribute('aria-label')) { + dropdown.setAttribute('aria-label', tooltipContent); + } +} + +function attachInit(dropdown) { + dropdown[ariaPatchKey] = {}; + if (dropdown.classList.contains('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. + // * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown + // 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. + + // Expected user interactions for dropdown with aria support: + // * user can use Tab to focus in the dropdown, then the dropdown menu (list) will be shown + // * user presses Tab on the focused dropdown to move focus to next sibling focusable element (but not the menu item) + // * user can use arrow key Up/Down to navigate between menu items + // * when user presses Enter: + // - 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 only partially supported. Check and test them one by one in the future. + + const textSearch = dropdown.querySelector('input.search'); + const focusable = textSearch || dropdown; // the primary element for focus, see comment above + if (!focusable) return; + + // as a combobox, the input should not have autocomplete by default + if (textSearch && !textSearch.getAttribute('autocomplete')) { + textSearch.setAttribute('autocomplete', 'off'); + } + + let menu = $(dropdown).find('> .menu')[0]; + if (!menu) { + // 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 = document.createElement('div'); + menu.classList.add('menu'); + dropdown.append(menu); + } + + // 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.querySelectorAll('input').length > 0; + + dropdown[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu'; + dropdown[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : ''; + dropdown[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem'; + + attachDomEvents(dropdown, focusable, menu); + attachStaticElements(dropdown, focusable, menu); +} + +function attachDomEvents(dropdown, focusable, menu) { + // when showing, it has class: ".animating.in" + // when hiding, it has class: ".visible.animating.out" + const isMenuVisible = () => (menu.classList.contains('visible') && !menu.classList.contains('out')) || menu.classList.contains('in'); + + // update aria attributes according to current active/selected item + const refreshAriaActiveItem = () => { + const menuVisible = isMenuVisible(); + focusable.setAttribute('aria-expanded', menuVisible ? 'true' : 'false'); + + // if there is an active item, use it (the user is navigating between items) + // otherwise use the "selected" for combobox (for the last selected item) + const active = $(menu).find('> .item.active, > .item.selected')[0]; + if (!active) return; + // if the popup is visible and has an active/selected item, use its id as aria-activedescendant + if (menuVisible) { + focusable.setAttribute('aria-activedescendant', active.id); + } 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.removeAttribute('aria-activedescendant'); + active.classList.remove('active', 'selected'); + } + }; + + dropdown.addEventListener('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') { + 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. + if ($item?.[0]?.matches('a, .js-aria-clickable')) $item[0].click(); + } + }); + + // use setTimeout to run the refreshAria in next tick (to make sure the Fomantic UI code has finished its work) + // 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 deferredRefreshAriaActiveItem = (delay = 0) => { setTimeout(refreshAriaActiveItem, delay) }; + dropdown[ariaPatchKey].deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem; + dropdown.addEventListener('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 + // * desktop event sequence: mousedown -> focus -> mouseup -> click + // * 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.addEventListener('mousedown', () => { + ignoreClickPreVisible += isMenuVisible() ? 1 : 0; + ignoreClickPreEvents++; + }, true); + dropdown.addEventListener('focus', () => { + ignoreClickPreVisible += isMenuVisible() ? 1 : 0; + ignoreClickPreEvents++; + deferredRefreshAriaActiveItem(); + }, true); + dropdown.addEventListener('blur', () => { + ignoreClickPreVisible = ignoreClickPreEvents = 0; + deferredRefreshAriaActiveItem(100); + }, true); + dropdown.addEventListener('mouseup', () => { + setTimeout(() => { + ignoreClickPreVisible = ignoreClickPreEvents = 0; + deferredRefreshAriaActiveItem(100); + }, 0); + }, true); + 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 + ) { + e.stopPropagation(); // if the dropdown menu has been opened by focus, do not trigger the next click event again + } + ignoreClickPreEvents = ignoreClickPreVisible = 0; + }, true); +} diff --git a/web_src/js/modules/fomantic/form.js b/web_src/js/modules/fomantic/form.js new file mode 100644 index 0000000..3bb0058 --- /dev/null +++ b/web_src/js/modules/fomantic/form.js @@ -0,0 +1,13 @@ +import {linkLabelAndInput} from './base.js'; + +export function initAriaFormFieldPatch() { + // link the label and the input element so it's clickable and accessible + for (const el of document.querySelectorAll('.ui.form .field')) { + if (el.hasAttribute('data-field-patched')) continue; + const label = el.querySelector(':scope > label'); + const input = el.querySelector(':scope > input'); + if (!label || !input) continue; + linkLabelAndInput(label, input); + el.setAttribute('data-field-patched', 'true'); + } +} diff --git a/web_src/js/modules/fomantic/modal.js b/web_src/js/modules/fomantic/modal.js new file mode 100644 index 0000000..8b455cf --- /dev/null +++ b/web_src/js/modules/fomantic/modal.js @@ -0,0 +1,28 @@ +import $ from 'jquery'; + +const fomanticModalFn = $.fn.modal; + +// use our own `$.fn.modal` to patch Fomantic's modal module +export function initAriaModalPatch() { + if ($.fn.modal === ariaModalFn) throw new Error('initAriaModalPatch could only be called once'); + $.fn.modal = ariaModalFn; + ariaModalFn.settings = fomanticModalFn.settings; +} + +// the patched `$.fn.modal` modal function +// * it does the one-time attaching on the first call +function ariaModalFn(...args) { + const ret = fomanticModalFn.apply(this, args); + if (args[0] === 'show' || args[0]?.autoShow) { + for (const el of this) { + // If there is a form in the modal, there might be a "cancel" button before "ok" button (all buttons are "type=submit" by default). + // In such case, the "Enter" key will trigger the "cancel" button instead of "ok" button, then the dialog will be closed. + // It breaks the user experience - the "Enter" key should confirm the dialog and submit the form. + // So, all "cancel" buttons without "[type]" must be marked as "type=button". + for (const button of el.querySelectorAll('form button.cancel:not([type])')) { + button.setAttribute('type', 'button'); + } + } + } + return ret; +} diff --git a/web_src/js/modules/fomantic/transition.js b/web_src/js/modules/fomantic/transition.js new file mode 100644 index 0000000..78aa053 --- /dev/null +++ b/web_src/js/modules/fomantic/transition.js @@ -0,0 +1,54 @@ +import $ from 'jquery'; + +export function initFomanticTransition() { + const transitionNopBehaviors = new Set([ + 'clear queue', 'stop', 'stop all', 'destroy', + 'force repaint', 'repaint', 'reset', + 'looping', 'remove looping', 'disable', 'enable', + 'set duration', 'save conditions', 'restore conditions', + ]); + // stand-in for removed transition module + $.fn.transition = function (arg0, arg1, arg2) { + if (arg0 === 'is supported') return true; + if (arg0 === 'is animating') return false; + if (arg0 === 'is inward') return false; + if (arg0 === 'is outward') return false; + + let argObj; + if (typeof arg0 === 'string') { + // many behaviors are no-op now. https://fomantic-ui.com/modules/transition.html#/usage + if (transitionNopBehaviors.has(arg0)) return this; + // now, the arg0 is an animation name, the syntax: (animation, duration, complete) + argObj = {animation: arg0, ...(arg1 && {duration: arg1}), ...(arg2 && {onComplete: arg2})}; + } else if (typeof arg0 === 'object') { + argObj = arg0; + } else { + throw new Error(`invalid argument: ${arg0}`); + } + + const isAnimationIn = argObj.animation?.startsWith('show') || argObj.animation?.endsWith(' in'); + const isAnimationOut = argObj.animation?.startsWith('hide') || argObj.animation?.endsWith(' out'); + this.each((_, el) => { + let toShow = isAnimationIn; + if (!isAnimationIn && !isAnimationOut) { + // If the animation is not in/out, then it must be a toggle animation. + // Fomantic uses computed styles to check "visibility", but to avoid unnecessary arguments, here it only checks the class. + toShow = this.hasClass('hidden'); // maybe it could also check "!this.hasClass('visible')", leave it to the future until there is a real problem. + } + argObj.onStart?.call(el); + if (toShow) { + el.classList.remove('hidden'); + el.classList.add('visible', 'transition'); + if (argObj.displayType) el.style.setProperty('display', argObj.displayType, 'important'); + argObj.onShow?.call(el); + } else { + el.classList.add('hidden'); + el.classList.remove('visible'); // don't remove the transition class because the Fomantic animation style is `.hidden.transition`. + el.style.removeProperty('display'); + argObj.onHidden?.call(el); + } + argObj.onComplete?.call(el); + }); + return this; + }; +} -- cgit v1.2.3