|
| 1 | +import $ from 'jquery'; |
| 2 | + |
| 3 | +let ariaIdCounter = 0; |
| 4 | + |
| 5 | +function generateAriaId() { |
| 6 | + return `_aria_auto_id_${ariaIdCounter++}`; |
| 7 | +} |
| 8 | + |
| 9 | +// make the item has role=option, and add an id if there wasn't one yet. |
| 10 | +function prepareMenuItem($item) { |
| 11 | + $item.attr({'role': 'option'}); |
| 12 | + if (!$item.attr('id')) $item.attr('id', generateAriaId()); |
| 13 | +} |
| 14 | + |
| 15 | +// when the menu items are loaded from AJAX requests, the items are created dynamically |
| 16 | +const defaultCreateDynamicMenu = $.fn.dropdown.settings.templates.menu; |
| 17 | +$.fn.dropdown.settings.templates.menu = function(response, fields, preserveHTML, className) { |
| 18 | + const ret = defaultCreateDynamicMenu(response, fields, preserveHTML, className); |
| 19 | + const $wrapper = $('<div>').append(ret); |
| 20 | + const $items = $wrapper.find('> .item'); |
| 21 | + $items.each((_, item) => { |
| 22 | + prepareMenuItem($(item)); |
| 23 | + }); |
| 24 | + return $wrapper.html(); |
| 25 | +}; |
| 26 | + |
| 27 | +function attachOneDropdownAria($dropdown) { |
| 28 | + const $textSearch = $dropdown.find('input.search').eq(0); |
| 29 | + const $focusable = $textSearch.length ? $textSearch : $dropdown; // see comment below |
| 30 | + if (!$focusable.length) return; |
| 31 | + |
| 32 | + // prepare menu list |
| 33 | + const $menu = $dropdown.find('> .menu'); |
| 34 | + if (!$menu.attr('id')) $menu.attr('id', generateAriaId()); |
| 35 | + $menu.attr('role', 'listbox'); |
| 36 | + |
| 37 | + // dropdown has 2 different focusing behaviors |
| 38 | + // * with search input: the input is focused, and it works perfectly with aria-activedescendant pointing another sibling element. |
| 39 | + // * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown, |
| 40 | + // which make the UI flicking when navigating between list options, that's the best effect at the moment. |
| 41 | + |
| 42 | + $focusable.attr({ |
| 43 | + 'role': 'combobox', |
| 44 | + 'aria-controls': $menu.attr('id'), |
| 45 | + 'aria-expanded': 'false', |
| 46 | + }); |
| 47 | + |
| 48 | + $menu.find('> .item').each((_, item) => { |
| 49 | + prepareMenuItem($(item)); |
| 50 | + }); |
| 51 | + |
| 52 | + // update aria attributes according current active/selected item |
| 53 | + const refreshAria = () => { |
| 54 | + const isMenuVisible = !$menu.is('.hidden') && !$menu.is('.animating.out'); |
| 55 | + $focusable.attr('aria-expanded', isMenuVisible ? 'true' : 'false'); |
| 56 | + |
| 57 | + let $active = $menu.find('> .item.active'); |
| 58 | + if (!$active.length) $active = $menu.find('> .item.selected'); // it's strange that we need this fallback at the moment |
| 59 | + |
| 60 | + // if there is an active item, use its id. if no active item, then the empty string is set |
| 61 | + $focusable.attr('aria-activedescendant', $active.attr('id')); |
| 62 | + }; |
| 63 | + |
| 64 | + // use setTimeout to run the refreshAria in next tick |
| 65 | + $focusable.on('focus', () => { |
| 66 | + setTimeout(refreshAria, 0); |
| 67 | + }); |
| 68 | + $focusable.on('mouseup', () => { |
| 69 | + setTimeout(refreshAria, 0); |
| 70 | + }); |
| 71 | + $focusable.on('blur', () => { |
| 72 | + setTimeout(refreshAria, 0); |
| 73 | + }); |
| 74 | + $dropdown.on('keyup', (e) => { |
| 75 | + const key = e.key; |
| 76 | + if (key === 'Tab' || key === 'Space' || key === 'Enter' || key.startsWith('Arrow')) { |
| 77 | + setTimeout(refreshAria, 0); |
| 78 | + } |
| 79 | + }); |
| 80 | +} |
| 81 | + |
| 82 | +export function attachDropdownAria($dropdowns) { |
| 83 | + $dropdowns.each((_, e) => attachOneDropdownAria($(e))); |
| 84 | +} |
0 commit comments