diff options
Diffstat (limited to '')
-rw-r--r-- | web_src/js/modules/fomantic/dropdown.js | 256 |
1 files changed, 256 insertions, 0 deletions
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); +} |