refactor: improve types in the frontend, misc fixes (#39142)

This commit is contained in:
silverwind
2026-08-31 04:12:25 -07:00
committed by GitHub
parent 535fc29ae8
commit 7c93ead628
46 changed files with 392 additions and 217 deletions
+40 -21
View File
@@ -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"
+4 -4
View File
@@ -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];
+1 -1
View File
@@ -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;