diff options
author | Daniel Baumann <daniel@debian.org> | 2024-10-18 20:33:49 +0200 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-12-12 23:57:56 +0100 |
commit | e68b9d00a6e05b3a941f63ffb696f91e554ac5ec (patch) | |
tree | 97775d6c13b0f416af55314eb6a89ef792474615 /web_src/js/modules | |
parent | Initial commit. (diff) | |
download | forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.tar.xz forgejo-e68b9d00a6e05b3a941f63ffb696f91e554ac5ec.zip |
Adding upstream version 9.0.3.
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to '')
-rw-r--r-- | web_src/js/modules/dirauto.js | 40 | ||||
-rw-r--r-- | web_src/js/modules/fetch.js | 41 | ||||
-rw-r--r-- | web_src/js/modules/fetch.test.js | 10 | ||||
-rw-r--r-- | web_src/js/modules/fomantic.js | 34 | ||||
-rw-r--r-- | web_src/js/modules/fomantic/api.js | 40 | ||||
-rw-r--r-- | web_src/js/modules/fomantic/aria.md | 117 | ||||
-rw-r--r-- | web_src/js/modules/fomantic/base.js | 18 | ||||
-rw-r--r-- | web_src/js/modules/fomantic/checkbox.js | 13 | ||||
-rw-r--r-- | web_src/js/modules/fomantic/dropdown.js | 256 | ||||
-rw-r--r-- | web_src/js/modules/fomantic/form.js | 13 | ||||
-rw-r--r-- | web_src/js/modules/fomantic/modal.js | 28 | ||||
-rw-r--r-- | web_src/js/modules/fomantic/transition.js | 54 | ||||
-rw-r--r-- | web_src/js/modules/sortable.js | 19 | ||||
-rw-r--r-- | web_src/js/modules/stores.js | 10 | ||||
-rw-r--r-- | web_src/js/modules/tippy.js | 195 | ||||
-rw-r--r-- | web_src/js/modules/toast.js | 55 | ||||
-rw-r--r-- | web_src/js/modules/toast.test.js | 16 |
17 files changed, 959 insertions, 0 deletions
diff --git a/web_src/js/modules/dirauto.js b/web_src/js/modules/dirauto.js new file mode 100644 index 0000000..cd90f81 --- /dev/null +++ b/web_src/js/modules/dirauto.js @@ -0,0 +1,40 @@ +import {isDocumentFragmentOrElementNode} from '../utils/dom.js'; + +// for performance considerations, it only uses performant syntax +function attachDirAuto(el) { + if (el.type !== 'hidden' && + el.type !== 'checkbox' && + el.type !== 'radio' && + el.type !== 'range' && + el.type !== 'color') { + el.dir = 'auto'; + } +} + +export function initDirAuto() { + const observer = new MutationObserver((mutationList) => { + const len = mutationList.length; + for (let i = 0; i < len; i++) { + const mutation = mutationList[i]; + const len = mutation.addedNodes.length; + for (let i = 0; i < len; i++) { + const addedNode = mutation.addedNodes[i]; + if (!isDocumentFragmentOrElementNode(addedNode)) continue; + if (addedNode.nodeName === 'INPUT' || addedNode.nodeName === 'TEXTAREA') attachDirAuto(addedNode); + const children = addedNode.querySelectorAll('input, textarea'); + const len = children.length; + for (let childIdx = 0; childIdx < len; childIdx++) { + attachDirAuto(children[childIdx]); + } + } + } + }); + + const docNodes = document.querySelectorAll('input, textarea'); + const len = docNodes.length; + for (let i = 0; i < len; i++) { + attachDirAuto(docNodes[i]); + } + + observer.observe(document, {subtree: true, childList: true}); +} diff --git a/web_src/js/modules/fetch.js b/web_src/js/modules/fetch.js new file mode 100644 index 0000000..2191a8d --- /dev/null +++ b/web_src/js/modules/fetch.js @@ -0,0 +1,41 @@ +import {isObject} from '../utils.js'; + +const {csrfToken} = window.config; + +// safe HTTP methods that don't need a csrf token +const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); + +// fetch wrapper, use below method name functions and the `data` option to pass in data +// which will automatically set an appropriate headers. For json content, only object +// and array types are currently supported. +export function request(url, {method = 'GET', data, headers = {}, ...other} = {}) { + let body, contentType; + if (data instanceof FormData || data instanceof URLSearchParams) { + body = data; + } else if (isObject(data) || Array.isArray(data)) { + contentType = 'application/json'; + body = JSON.stringify(data); + } + + const headersMerged = new Headers({ + ...(!safeMethods.has(method) && {'x-csrf-token': csrfToken}), + ...(contentType && {'content-type': contentType}), + }); + + for (const [name, value] of Object.entries(headers)) { + headersMerged.set(name, value); + } + + return fetch(url, { + method, + headers: headersMerged, + ...other, + ...(body && {body}), + }); +} + +export const GET = (url, opts) => request(url, {method: 'GET', ...opts}); +export const POST = (url, opts) => request(url, {method: 'POST', ...opts}); +export const PATCH = (url, opts) => request(url, {method: 'PATCH', ...opts}); +export const PUT = (url, opts) => request(url, {method: 'PUT', ...opts}); +export const DELETE = (url, opts) => request(url, {method: 'DELETE', ...opts}); diff --git a/web_src/js/modules/fetch.test.js b/web_src/js/modules/fetch.test.js new file mode 100644 index 0000000..e4bec3c --- /dev/null +++ b/web_src/js/modules/fetch.test.js @@ -0,0 +1,10 @@ +import {GET, POST, PATCH, PUT, DELETE} from './fetch.js'; + +// tests here are only to satisfy the linter for unused functions +test('exports', () => { + expect(GET).toBeTruthy(); + expect(POST).toBeTruthy(); + expect(PATCH).toBeTruthy(); + expect(PUT).toBeTruthy(); + expect(DELETE).toBeTruthy(); +}); diff --git a/web_src/js/modules/fomantic.js b/web_src/js/modules/fomantic.js new file mode 100644 index 0000000..c04bc6e --- /dev/null +++ b/web_src/js/modules/fomantic.js @@ -0,0 +1,34 @@ +import $ from 'jquery'; +import {initFomanticApiPatch} from './fomantic/api.js'; +import {initAriaCheckboxPatch} from './fomantic/checkbox.js'; +import {initAriaFormFieldPatch} from './fomantic/form.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)'); + +export function initGiteaFomantic() { + // Silence fomantic's error logging when tabs are used without a target content element + $.fn.tab.settings.silent = true; + + // By default, use "exact match" for full text search + $.fn.dropdown.settings.fullTextSearch = 'exact'; + // Do not use "cursor: pointer" for dropdown labels + $.fn.dropdown.settings.className.label += ' tw-cursor-default'; + // Always use Gitea's SVG icons + $.fn.dropdown.settings.templates.label = function(_value, text, preserveHTML, className) { + const escape = $.fn.dropdown.settings.templates.escape; + return escape(text, preserveHTML) + svg('octicon-x', 16, `${className.delete} icon`); + }; + + 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. + initAriaCheckboxPatch(); + initAriaFormFieldPatch(); + initAriaDropdownPatch(); + initAriaModalPatch(); +} 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 +<label><input type="checkbox"> ... </label> +``` + +However, the templates still have the Fomantic-style HTML layout: + +```html +<div class="ui checkbox"> + <input type="checkbox"> + <label>...</label> +</div> +``` + +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 `<select>` , used in many forms +* Searchable option-list with static items (used in many forms) +* Searchable option-list with dynamic items (ajax) +* Searchable multiple selection option-list with dynamic items: the repo topic setting +* More complex usages, like the Issue Label selector + +Fomantic Dropdown requires that the focus must be on its primary element. +If the focus changes, it hides or panics. + +At the moment, the aria-related code only tries to partially resolve the a11y problems for dropdowns with items. + +There are different solutions: + +* combobox + listbox + option: + * https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ + * A combobox is an input widget with an associated popup that enables users to select a value for the combobox from + a collection of possible values. In some implementations, the popup presents allowed values, while in other implementations, + the popup presents suggested values, and users may either select one of the suggestions or type a value. +* menu + menuitem: + * https://www.w3.org/WAI/ARIA/apg/patterns/menubar/ + * A menu is a widget that offers a list of choices to the user, such as a set of actions or functions. + +The current approach is: detect if the dropdown has an input, +if yes, it works like a combobox, otherwise it works like a menu. +Multiple selection dropdown is not well-supported yet, it needs more work. + +Some important pages for dropdown testing: + +* Home(dashboard) page, the "Create Repo" / "Profile" / "Language" menu. +* Create New Repo page, a lot of dropdowns as combobox. +* Collaborators page, the "permission" dropdown (the old behavior was not quite good, it just works). + +```html +<!-- read-only dropdown --> +<div class="ui dropdown"> <!-- focused here, then it's not perfect to use aria-activedescendant to point to the menu item --> + <input type="hidden" ...> + <div class="text">Default</div> + <div class="menu" tabindex="-1"> <!-- "transition hidden|visible" classes will be added by $.dropdown() and when the dropdown is working --> + <div class="item active selected">Default</div> + <div class="item">...</div> + </div> +</div> + +<!-- search input dropdown --> +<div class="ui dropdown"> + <input type="hidden" ...> + <input class="search" autocomplete="off" tabindex="0"> <!-- focused here --> + <div class="text"></div> + <div class="menu" tabindex="-1"> <!-- "transition hidden|visible" classes will be added by $.dropdown() and when the dropdown is working --> + <div class="item selected">...</div> + <div class="item">...</div> + </div> +</div> +``` 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: "<a|div class="ui label" data-value="1">the-label-name <i|svg class="delete icon"/></a>" + 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: <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 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; + }; +} diff --git a/web_src/js/modules/sortable.js b/web_src/js/modules/sortable.js new file mode 100644 index 0000000..1c9adb6 --- /dev/null +++ b/web_src/js/modules/sortable.js @@ -0,0 +1,19 @@ +export async function createSortable(el, opts = {}) { + const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs'); + + return new Sortable(el, { + animation: 150, + ghostClass: 'card-ghost', + onChoose: (e) => { + const handle = opts.handle ? e.item.querySelector(opts.handle) : e.item; + handle.classList.add('tw-cursor-grabbing'); + opts.onChoose?.(e); + }, + onUnchoose: (e) => { + const handle = opts.handle ? e.item.querySelector(opts.handle) : e.item; + handle.classList.remove('tw-cursor-grabbing'); + opts.onUnchoose?.(e); + }, + ...opts, + }); +} diff --git a/web_src/js/modules/stores.js b/web_src/js/modules/stores.js new file mode 100644 index 0000000..1a0ed7e --- /dev/null +++ b/web_src/js/modules/stores.js @@ -0,0 +1,10 @@ +import {reactive} from 'vue'; + +let diffTreeStoreReactive; +export function diffTreeStore() { + if (!diffTreeStoreReactive) { + diffTreeStoreReactive = reactive(window.config.pageData.diffFileInfo); + window.config.pageData.diffFileInfo = diffTreeStoreReactive; + } + return diffTreeStoreReactive; +} diff --git a/web_src/js/modules/tippy.js b/web_src/js/modules/tippy.js new file mode 100644 index 0000000..83b28e5 --- /dev/null +++ b/web_src/js/modules/tippy.js @@ -0,0 +1,195 @@ +import tippy, {followCursor} from 'tippy.js'; +import {isDocumentFragmentOrElementNode} from '../utils/dom.js'; +import {formatDatetime} from '../utils/time.js'; + +const visibleInstances = new Set(); +const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`; + +export function createTippy(target, opts = {}) { + // the callback functions should be destructured from opts, + // because we should use our own wrapper functions to handle them, do not let the user override them + const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts; + + const instance = tippy(target, { + appendTo: document.body, + animation: false, + allowHTML: false, + hideOnClick: false, + interactiveBorder: 20, + ignoreAttributes: true, + maxWidth: 500, // increase over default 350px + onHide: (instance) => { + visibleInstances.delete(instance); + return onHide?.(instance); + }, + onDestroy: (instance) => { + visibleInstances.delete(instance); + return onDestroy?.(instance); + }, + onShow: (instance) => { + // hide other tooltip instances so only one tooltip shows at a time + for (const visibleInstance of visibleInstances) { + if (visibleInstance.props.role === 'tooltip') { + visibleInstance.hide(); + } + } + visibleInstances.add(instance); + return onShow?.(instance); + }, + arrow: arrow || (theme === 'bare' ? false : arrowSvg), + role: role || 'menu', // HTML role attribute + theme: theme || role || 'menu', // CSS theme, either "tooltip", "menu", "box-with-header" or "bare" + plugins: [followCursor], + ...other, + }); + + if (role === 'menu') { + target.setAttribute('aria-haspopup', 'true'); + } + + return instance; +} + +/** + * Attach a tooltip tippy to the given target element. + * If the target element already has a tooltip tippy attached, the tooltip will be updated with the new content. + * If the target element has no content, then no tooltip will be attached, and it returns null. + * + * Note: "tooltip" doesn't equal to "tippy". "tooltip" means a auto-popup content, it just uses tippy as the implementation. + * + * @param target {HTMLElement} + * @param content {null|string} + * @returns {null|tippy} + */ +function attachTooltip(target, content = null) { + switchTitleToTooltip(target); + + content = content ?? target.getAttribute('data-tooltip-content'); + if (!content) return null; + + // when element has a clipboard target, we update the tooltip after copy + // in which case it is undesirable to automatically hide it on click as + // it would momentarily flash the tooltip out and in. + const hasClipboardTarget = target.hasAttribute('data-clipboard-target'); + const hideOnClick = !hasClipboardTarget; + + const props = { + content, + delay: 100, + role: 'tooltip', + theme: 'tooltip', + hideOnClick, + placement: target.getAttribute('data-tooltip-placement') || 'top-start', + followCursor: target.getAttribute('data-tooltip-follow-cursor') || false, + ...(target.getAttribute('data-tooltip-interactive') === 'true' ? {interactive: true, aria: {content: 'describedby', expanded: false}} : {}), + }; + + if (!target._tippy) { + createTippy(target, props); + } else { + target._tippy.setProps(props); + } + return target._tippy; +} + +function switchTitleToTooltip(target) { + let title = target.getAttribute('title'); + if (title) { + // apply custom formatting to relative-time's tooltips + if (target.tagName.toLowerCase() === 'relative-time') { + const datetime = target.getAttribute('datetime'); + if (datetime) { + title = formatDatetime(new Date(datetime)); + } + } + target.setAttribute('data-tooltip-content', title); + target.setAttribute('aria-label', title); + // keep the attribute, in case there are some other "[title]" selectors + // and to prevent infinite loop with <relative-time> which will re-add + // title if it is absent + target.setAttribute('title', ''); + } +} + +/** + * Creating tooltip tippy instance is expensive, so we only create it when the user hovers over the element + * According to https://www.w3.org/TR/DOM-Level-3-Events/#events-mouseevent-event-order , mouseover event is fired before mouseenter event + * Some browsers like PaleMoon don't support "addEventListener('mouseenter', capture)" + * The tippy by default uses "mouseenter" event to show, so we use "mouseover" event to switch to tippy + * @param e {Event} + */ +function lazyTooltipOnMouseHover(e) { + e.target.removeEventListener('mouseover', lazyTooltipOnMouseHover, true); + attachTooltip(this); +} + +// Activate the tooltip for current element. +// If the element has no aria-label, use the tooltip content as aria-label. +function attachLazyTooltip(el) { + el.addEventListener('mouseover', lazyTooltipOnMouseHover, {capture: true}); + + // meanwhile, if the element has no aria-label, use the tooltip content as aria-label + if (!el.hasAttribute('aria-label')) { + const content = el.getAttribute('data-tooltip-content'); + if (content) { + el.setAttribute('aria-label', content); + } + } +} + +// Activate the tooltip for all children elements. +function attachChildrenLazyTooltip(target) { + for (const el of target.querySelectorAll('[data-tooltip-content]')) { + attachLazyTooltip(el); + } +} + +export function initGlobalTooltips() { + // use MutationObserver to detect new "data-tooltip-content" elements added to the DOM, or attributes changed + const observerConnect = (observer) => observer.observe(document, { + subtree: true, + childList: true, + attributeFilter: ['data-tooltip-content', 'title'], + }); + const observer = new MutationObserver((mutationList, observer) => { + const pending = observer.takeRecords(); + observer.disconnect(); + for (const mutation of [...mutationList, ...pending]) { + if (mutation.type === 'childList') { + // mainly for Vue components and AJAX rendered elements + for (const el of mutation.addedNodes) { + if (!isDocumentFragmentOrElementNode(el)) continue; + attachChildrenLazyTooltip(el); + if (el.hasAttribute('data-tooltip-content')) { + attachLazyTooltip(el); + } + } + } else if (mutation.type === 'attributes') { + attachTooltip(mutation.target); + } + } + observerConnect(observer); + }); + observerConnect(observer); + + attachChildrenLazyTooltip(document.documentElement); +} + +export function showTemporaryTooltip(target, content) { + // if the target is inside a dropdown, don't show the tooltip because when the dropdown + // closes, the tippy would be pushed unsightly to the top-left of the screen like seen + // on the issue comment menu. + if (target.closest('.ui.dropdown > .menu')) return; + + const tippy = target._tippy ?? attachTooltip(target, content); + tippy.setContent(content); + if (!tippy.state.isShown) tippy.show(); + tippy.setProps({ + onHidden: (tippy) => { + // reset the default tooltip content, if no default, then this temporary tooltip could be destroyed + if (!attachTooltip(target)) { + tippy.destroy(); + } + }, + }); +} diff --git a/web_src/js/modules/toast.js b/web_src/js/modules/toast.js new file mode 100644 index 0000000..d12d203 --- /dev/null +++ b/web_src/js/modules/toast.js @@ -0,0 +1,55 @@ +import {htmlEscape} from 'escape-goat'; +import {svg} from '../svg.js'; +import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown + +const levels = { + info: { + icon: 'octicon-check', + background: 'var(--color-green)', + duration: 2500, + }, + warning: { + icon: 'gitea-exclamation', + background: 'var(--color-orange)', + duration: -1, // requires dismissal to hide + }, + error: { + icon: 'gitea-exclamation', + background: 'var(--color-red)', + duration: -1, // requires dismissal to hide + }, +}; + +// See https://github.com/apvarun/toastify-js#api for options +function showToast(message, level, {gravity, position, duration, useHtmlBody, ...other} = {}) { + const {icon, background, duration: levelDuration} = levels[level ?? 'info']; + const toast = Toastify({ + text: ` + <div class='toast-icon'>${svg(icon)}</div> + <div class='toast-body'>${useHtmlBody ? message : htmlEscape(message)}</div> + <button class='toast-close'>${svg('octicon-x')}</button> + `, + escapeMarkup: false, + gravity: gravity ?? 'top', + position: position ?? 'center', + duration: duration ?? levelDuration, + style: {background}, + ...other, + }); + + toast.showToast(); + toast.toastElement.querySelector('.toast-close').addEventListener('click', () => toast.hideToast()); + return toast; +} + +export function showInfoToast(message, opts) { + return showToast(message, 'info', opts); +} + +export function showWarningToast(message, opts) { + return showToast(message, 'warning', opts); +} + +export function showErrorToast(message, opts) { + return showToast(message, 'error', opts); +} diff --git a/web_src/js/modules/toast.test.js b/web_src/js/modules/toast.test.js new file mode 100644 index 0000000..357f18d --- /dev/null +++ b/web_src/js/modules/toast.test.js @@ -0,0 +1,16 @@ +import {showInfoToast, showErrorToast, showWarningToast} from './toast.js'; + +test('showInfoToast', async () => { + showInfoToast('success 😀', {duration: -1}); + expect(document.querySelector('.toastify')).toBeTruthy(); +}); + +test('showWarningToast', async () => { + showWarningToast('warning 😐', {duration: -1}); + expect(document.querySelector('.toastify')).toBeTruthy(); +}); + +test('showErrorToast', async () => { + showErrorToast('error 🙁', {duration: -1}); + expect(document.querySelector('.toastify')).toBeTruthy(); +}); |