From 58d71cdd6f8125bacc61f6b97b24f6909921784b Mon Sep 17 00:00:00 2001 From: Giteabot Date: Sun, 12 Nov 2023 18:29:56 +0800 Subject: Move some JS code from `fomantic.js` to standalone files (#27994) (#28001) Backport #27994 by @wxiaoguang To improve maintainability, this PR: 1. Rename `web_src/js/modules/aria` to `web_src/js/modules/fomantic` (the code there are all for aria of fomantic) 2. Move api/transition related code to `web_src/js/modules/fomantic/api.js` and `web_src/js/modules/fomantic/transition.js` No logic is changed. Co-authored-by: wxiaoguang --- web_src/js/modules/aria/aria.md | 122 --------------- web_src/js/modules/aria/base.js | 5 - web_src/js/modules/aria/checkbox.js | 38 ----- web_src/js/modules/aria/dropdown.js | 246 ------------------------------ web_src/js/modules/aria/modal.js | 26 ---- web_src/js/modules/fomantic.js | 99 +----------- web_src/js/modules/fomantic/api.js | 40 +++++ web_src/js/modules/fomantic/aria.md | 122 +++++++++++++++ web_src/js/modules/fomantic/base.js | 5 + web_src/js/modules/fomantic/checkbox.js | 38 +++++ web_src/js/modules/fomantic/dropdown.js | 246 ++++++++++++++++++++++++++++++ web_src/js/modules/fomantic/modal.js | 26 ++++ web_src/js/modules/fomantic/transition.js | 54 +++++++ 13 files changed, 537 insertions(+), 530 deletions(-) delete mode 100644 web_src/js/modules/aria/aria.md delete mode 100644 web_src/js/modules/aria/base.js delete mode 100644 web_src/js/modules/aria/checkbox.js delete mode 100644 web_src/js/modules/aria/dropdown.js delete mode 100644 web_src/js/modules/aria/modal.js 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/modal.js create mode 100644 web_src/js/modules/fomantic/transition.js (limited to 'web_src') diff --git a/web_src/js/modules/aria/aria.md b/web_src/js/modules/aria/aria.md deleted file mode 100644 index a32d15f46f..0000000000 --- a/web_src/js/modules/aria/aria.md +++ /dev/null @@ -1,122 +0,0 @@ -# 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. This `aria.js` is used -as a workaround to make the UI more accessible. - -The `aria.js` 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 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, related CSS styles aren't supported (not implemented) yet, so at the moment, -almost all the checkboxes are still using Fomantic UI checkbox. - -## Fomantic UI Checkbox - -```html -
- - -
-``` - -Then the JS `$.checkbox()` should be called to make it work with keyboard and label-clicking, -then it works like the ideal checkboxes. - -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. - -# 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/aria/base.js b/web_src/js/modules/aria/base.js deleted file mode 100644 index c4a01038ba..0000000000 --- a/web_src/js/modules/aria/base.js +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 08af1c2eb6..0000000000 --- a/web_src/js/modules/aria/checkbox.js +++ /dev/null @@ -1,38 +0,0 @@ -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/modules/aria/dropdown.js b/web_src/js/modules/aria/dropdown.js deleted file mode 100644 index c053256dd5..0000000000 --- a/web_src/js/modules/aria/dropdown.js +++ /dev/null @@ -1,246 +0,0 @@ -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) { - 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); - - // 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 $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 - const tooltipContent = $dropdown.attr('data-tooltip-content'); - if (tooltipContent && !$dropdown.attr('aria-label')) { - $dropdown.attr('aria-label', tooltipContent); - } -} - -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. - // * 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.find('input.search').eq(0); - const $focusable = $textSearch.length ? $textSearch : $dropdown; // the primary element for focus, see comment above - if (!$focusable.length) return; - - // as a combobox, the input should not have autocomplete by default - if ($textSearch.length && !$textSearch.attr('autocomplete')) { - $textSearch.attr('autocomplete', 'off'); - } - - 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; - - 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) { - 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 refreshAriaActiveItem = () => { - const menuVisible = isMenuVisible(); - $focusable.attr('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'); - // 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 (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'); - } - }; - - $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') { - 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 && ($item.is('a') || $item.hasClass('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.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 - // * 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/aria/modal.js b/web_src/js/modules/aria/modal.js deleted file mode 100644 index 7c9aade790..0000000000 --- a/web_src/js/modules/aria/modal.js +++ /dev/null @@ -1,26 +0,0 @@ -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". - $(el).find('form button.cancel:not([type])').attr('type', 'button'); - } - } - return ret; -} diff --git a/web_src/js/modules/fomantic.js b/web_src/js/modules/fomantic.js index da693e9a93..0c7a7ae641 100644 --- a/web_src/js/modules/fomantic.js +++ b/web_src/js/modules/fomantic.js @@ -1,7 +1,9 @@ import $ from 'jquery'; -import {initAriaCheckboxPatch} from './aria/checkbox.js'; -import {initAriaDropdownPatch} from './aria/dropdown.js'; -import {initAriaModalPatch} from './aria/modal.js'; +import {initFomanticApiPatch} from './fomantic/api.js'; +import {initAriaCheckboxPatch} from './fomantic/checkbox.js'; +import {initAriaDropdownPatch} from './fomantic/dropdown.js'; +import {initAriaModalPatch} from './fomantic/modal.js'; +import {initFomanticTransition} from './fomantic/transition.js'; import {svg} from '../svg.js'; export const fomanticMobileScreen = window.matchMedia('only screen and (max-width: 767.98px)'); @@ -22,57 +24,7 @@ export function initGiteaFomantic() { return escape(text, preserveHTML) + svg('octicon-x', 16, `${className.delete} icon`); }; - 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; - }; - + initFomanticTransition(); initFomanticApiPatch(); // 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. @@ -80,42 +32,3 @@ export function initGiteaFomantic() { initAriaDropdownPatch(); initAriaModalPatch(); } - -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/api.js b/web_src/js/modules/fomantic/api.js new file mode 100644 index 0000000000..ca212c9fef --- /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 0000000000..a32d15f46f --- /dev/null +++ b/web_src/js/modules/fomantic/aria.md @@ -0,0 +1,122 @@ +# 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. This `aria.js` is used +as a workaround to make the UI more accessible. + +The `aria.js` 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 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, related CSS styles aren't supported (not implemented) yet, so at the moment, +almost all the checkboxes are still using Fomantic UI checkbox. + +## Fomantic UI Checkbox + +```html +
+ + +
+``` + +Then the JS `$.checkbox()` should be called to make it work with keyboard and label-clicking, +then it works like the ideal checkboxes. + +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. + +# 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 0000000000..c4a01038ba --- /dev/null +++ b/web_src/js/modules/fomantic/base.js @@ -0,0 +1,5 @@ +let ariaIdCounter = 0; + +export function generateAriaId() { + return `_aria_auto_id_${ariaIdCounter++}`; +} diff --git a/web_src/js/modules/fomantic/checkbox.js b/web_src/js/modules/fomantic/checkbox.js new file mode 100644 index 0000000000..08af1c2eb6 --- /dev/null +++ b/web_src/js/modules/fomantic/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/modules/fomantic/dropdown.js b/web_src/js/modules/fomantic/dropdown.js new file mode 100644 index 0000000000..c053256dd5 --- /dev/null +++ b/web_src/js/modules/fomantic/dropdown.js @@ -0,0 +1,246 @@ +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) { + 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); + + // 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 $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 + const tooltipContent = $dropdown.attr('data-tooltip-content'); + if (tooltipContent && !$dropdown.attr('aria-label')) { + $dropdown.attr('aria-label', tooltipContent); + } +} + +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. + // * 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.find('input.search').eq(0); + const $focusable = $textSearch.length ? $textSearch : $dropdown; // the primary element for focus, see comment above + if (!$focusable.length) return; + + // as a combobox, the input should not have autocomplete by default + if ($textSearch.length && !$textSearch.attr('autocomplete')) { + $textSearch.attr('autocomplete', 'off'); + } + + 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; + + 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) { + 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 refreshAriaActiveItem = () => { + const menuVisible = isMenuVisible(); + $focusable.attr('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'); + // 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 (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'); + } + }; + + $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') { + 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 && ($item.is('a') || $item.hasClass('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.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 + // * 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/modal.js b/web_src/js/modules/fomantic/modal.js new file mode 100644 index 0000000000..7c9aade790 --- /dev/null +++ b/web_src/js/modules/fomantic/modal.js @@ -0,0 +1,26 @@ +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". + $(el).find('form button.cancel:not([type])').attr('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 0000000000..78aa0538b0 --- /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