mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-02 11:03:23 +09:00
refactor: improve types in the frontend, misc fixes (#39142)
This commit is contained in:
@@ -1,10 +1,19 @@
|
||||
import type {FomanticInitFunction} from '../../types.ts';
|
||||
import type {FomanticInitFunction, JQueryElem} from '../../types.ts';
|
||||
import {generateElemId, queryElems} from '../../utils/dom.ts';
|
||||
import {trString} from '../i18n.ts';
|
||||
|
||||
const ariaPatchKey = '_giteaAriaPatchDropdown';
|
||||
const fomanticDropdownFn = $.fn.dropdown;
|
||||
|
||||
type AriaDropdownElement = HTMLElement & {
|
||||
[ariaPatchKey]: {
|
||||
focusableRole: 'combobox' | 'menu';
|
||||
listPopupRole: 'listbox' | '';
|
||||
listItemRole: 'option' | 'menuitem';
|
||||
deferredRefreshAriaActiveItem: (delay?: number) => void;
|
||||
};
|
||||
};
|
||||
|
||||
// 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');
|
||||
@@ -44,9 +53,9 @@ function ariaDropdownFn(this: any, ...args: Parameters<FomanticInitFunction>) {
|
||||
|
||||
// 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: HTMLElement, item: HTMLElement) {
|
||||
function updateMenuItem(dropdown: AriaDropdownElement, item: HTMLElement) {
|
||||
if (!item.id) item.id = generateElemId('_aria_dropdown_item_');
|
||||
item.setAttribute('role', (dropdown as any)[ariaPatchKey].listItemRole);
|
||||
item.setAttribute('role', dropdown[ariaPatchKey].listItemRole);
|
||||
item.setAttribute('tabindex', '-1');
|
||||
for (const el of item.querySelectorAll('a, input, button')) el.setAttribute('tabindex', '-1');
|
||||
}
|
||||
@@ -69,15 +78,15 @@ function updateSelectionLabel(label: HTMLElement) {
|
||||
}
|
||||
}
|
||||
|
||||
function onDropdownAfterFiltered(this: any) {
|
||||
const $dropdown = $(this).closest('.ui.dropdown'); // "this" can be the "ui dropdown" or "<select>"
|
||||
function onDropdownAfterFiltered(this: HTMLElement) {
|
||||
const $dropdown = $(this).closest<AriaDropdownElement>('.ui.dropdown'); // "this" can be the "ui dropdown" or "<select>"
|
||||
const hideEmptyDividers = $dropdown.dropdown('setting', 'hideDividers') === 'empty';
|
||||
const itemsMenu = $dropdown[0].querySelector('.scrolling.menu') || $dropdown[0].querySelector('.menu');
|
||||
if (hideEmptyDividers && itemsMenu) hideScopedEmptyDividers(itemsMenu);
|
||||
}
|
||||
|
||||
// delegate the dropdown's template functions and callback functions to add aria attributes.
|
||||
function delegateDropdownModule($dropdown: any) {
|
||||
function delegateDropdownModule($dropdown: JQueryElem<AriaDropdownElement>) {
|
||||
const dropdownCall = fomanticDropdownFn.bind($dropdown);
|
||||
|
||||
// the "template" functions are used for dynamic creation (eg: AJAX)
|
||||
@@ -104,9 +113,18 @@ function delegateDropdownModule($dropdown: any) {
|
||||
return $label;
|
||||
});
|
||||
|
||||
// some close paths fire no DOM event (Escape, programmatic hide, synthetic click) and call sites
|
||||
// replace the "onHide" setting, so wrap the internal hide that every close path goes through
|
||||
const dropdownHideOld = dropdownCall('internal', 'hide');
|
||||
dropdownCall('internal', 'hide', function(this: unknown, ...args: unknown[]) {
|
||||
const ret = dropdownHideOld.apply(this, args);
|
||||
$dropdown[0][ariaPatchKey].deferredRefreshAriaActiveItem();
|
||||
return ret;
|
||||
});
|
||||
|
||||
const oldSet = dropdownCall('internal', 'set');
|
||||
const oldSetDirection = oldSet.direction;
|
||||
oldSet.direction = function($menu: any) {
|
||||
oldSet.direction = function($menu?: JQueryElem) {
|
||||
oldSetDirection.call(this, $menu);
|
||||
const classNames = dropdownCall('setting', 'className');
|
||||
$menu = $menu || $dropdown.find('> .menu');
|
||||
@@ -122,7 +140,7 @@ function delegateDropdownModule($dropdown: any) {
|
||||
}
|
||||
|
||||
// for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes
|
||||
function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, menu: HTMLElement) {
|
||||
function attachStaticElements(dropdown: AriaDropdownElement, focusable: HTMLElement, menu: HTMLElement) {
|
||||
// prepare static dropdown menu list popup
|
||||
if (!menu.id) {
|
||||
menu.id = generateElemId('_aria_dropdown_menu_');
|
||||
@@ -131,7 +149,7 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men
|
||||
$(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 as any)[ariaPatchKey].listPopupRole);
|
||||
menu.setAttribute('role', dropdown[ariaPatchKey].listPopupRole);
|
||||
|
||||
// prepare selection label items
|
||||
for (const label of dropdown.querySelectorAll<HTMLElement>('.ui.label')) {
|
||||
@@ -139,8 +157,8 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men
|
||||
}
|
||||
|
||||
// make the primary element (focusable) aria-friendly
|
||||
focusable.setAttribute('role', focusable.getAttribute('role') ?? (dropdown as any)[ariaPatchKey].focusableRole);
|
||||
focusable.setAttribute('aria-haspopup', (dropdown as any)[ariaPatchKey].listPopupRole);
|
||||
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');
|
||||
|
||||
@@ -151,9 +169,7 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men
|
||||
}
|
||||
}
|
||||
|
||||
function attachInitElements(dropdown: HTMLElement) {
|
||||
(dropdown as any)[ariaPatchKey] = {};
|
||||
|
||||
function attachInitElements(dropdown: AriaDropdownElement) {
|
||||
// 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
|
||||
@@ -191,15 +207,17 @@ function attachInitElements(dropdown: HTMLElement) {
|
||||
// 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 as any)[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu';
|
||||
(dropdown as any)[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : '';
|
||||
(dropdown as any)[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem';
|
||||
dropdown[ariaPatchKey] = {
|
||||
focusableRole: isComboBox ? 'combobox' : 'menu',
|
||||
listPopupRole: isComboBox ? 'listbox' : '',
|
||||
listItemRole: isComboBox ? 'option' : 'menuitem',
|
||||
deferredRefreshAriaActiveItem: attachDomEvents(dropdown, focusable, menu),
|
||||
};
|
||||
|
||||
attachDomEvents(dropdown, focusable, menu);
|
||||
attachStaticElements(dropdown, focusable, menu);
|
||||
}
|
||||
|
||||
function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HTMLElement) {
|
||||
function attachDomEvents(dropdown: AriaDropdownElement, focusable: HTMLElement, menu: HTMLElement) {
|
||||
// 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');
|
||||
@@ -216,7 +234,7 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
|
||||
// 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 as any)[ariaPatchKey].listPopupRole === 'menu') {
|
||||
} else if (dropdown[ariaPatchKey].focusableRole === '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');
|
||||
@@ -242,7 +260,6 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
|
||||
// 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 as any)[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.
|
||||
@@ -279,6 +296,8 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
|
||||
}
|
||||
ignoreClickPreEvents = ignoreClickPreVisible = 0;
|
||||
}, {capture: true});
|
||||
|
||||
return deferredRefreshAriaActiveItem;
|
||||
}
|
||||
|
||||
// Although Fomantic Dropdown supports "hideDividers", it doesn't really work with our "scoped dividers"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {FomanticInitFunction} from '../../types.ts';
|
||||
import type {FomanticInitFunction, JQueryElem} from '../../types.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {hideToastsFrom} from '../toast.ts';
|
||||
|
||||
@@ -36,7 +36,7 @@ export function initAriaModalPatch() {
|
||||
|
||||
// the patched `$.fn.modal` modal function
|
||||
// * it does the one-time attaching on the first call
|
||||
function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) {
|
||||
function ariaModalFn(this: JQueryElem, ...args: Parameters<FomanticInitFunction>) {
|
||||
const ret = fomanticModalFn.apply(this, args);
|
||||
if (args[0] === 'show' || args[0]?.autoShow) {
|
||||
for (const el of this) {
|
||||
@@ -52,7 +52,7 @@ function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
function onModalBeforeHidden(this: any) {
|
||||
function onModalBeforeHidden(this: HTMLElement) {
|
||||
const $modal = $(this);
|
||||
const elModal = $modal[0];
|
||||
hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body);
|
||||
@@ -63,7 +63,7 @@ function onModalBeforeHidden(this: any) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onModalApproveDefault(this: any) {
|
||||
function onModalApproveDefault(this: HTMLElement) {
|
||||
const $modal = $(this);
|
||||
const selectors = $modal.modal('setting', 'selector');
|
||||
const elModal = $modal[0];
|
||||
|
||||
@@ -6,7 +6,7 @@ export function initFomanticTransition() {
|
||||
'set duration', 'save conditions', 'restore conditions',
|
||||
]);
|
||||
// stand-in for removed transition module
|
||||
$.fn.transition = function (arg0: any, arg1: any, arg2: any) {
|
||||
$.fn.transition = function (arg0: any, arg1?: number, arg2?: (this: HTMLElement) => void) {
|
||||
if (arg0 === 'is supported') return true;
|
||||
if (arg0 === 'is animating') return false;
|
||||
if (arg0 === 'is inward') return false;
|
||||
|
||||
Reference in New Issue
Block a user