|
|
|
@@ -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"
|
|
|
|
|