|
| 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 | + if (!$item.attr('id')) $item.attr('id', generateAriaId()); |
| 12 | + $item.attr({'role': 'menuitem', 'tabindex': '-1'}); |
| 13 | + $item.find('a').attr('tabindex', '-1'); // as above, the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element. |
| 14 | +} |
| 15 | + |
| 16 | +// when the menu items are loaded from AJAX requests, the items are created dynamically |
| 17 | +const defaultCreateDynamicMenu = $.fn.dropdown.settings.templates.menu; |
| 18 | +$.fn.dropdown.settings.templates.menu = function(response, fields, preserveHTML, className) { |
| 19 | + const ret = defaultCreateDynamicMenu(response, fields, preserveHTML, className); |
| 20 | + const $wrapper = $('<div>').append(ret); |
| 21 | + const $items = $wrapper.find('> .item'); |
| 22 | + $items.each((_, item) => { |
| 23 | + prepareMenuItem($(item)); |
| 24 | + }); |
| 25 | + return $wrapper.html(); |
| 26 | +}; |
| 27 | + |
| 28 | +function attachOneDropdownAria($dropdown) { |
| 29 | + if ($dropdown.attr('data-aria-attached')) return; |
| 30 | + $dropdown.attr('data-aria-attached', 1); |
| 31 | + |
| 32 | + const $textSearch = $dropdown.find('input.search').eq(0); |
| 33 | + const $focusable = $textSearch.length ? $textSearch : $dropdown; // see comment below |
| 34 | + if (!$focusable.length) return; |
| 35 | + |
| 36 | + // prepare menu list |
| 37 | + const $menu = $dropdown.find('> .menu'); |
| 38 | + if (!$menu.attr('id')) $menu.attr('id', generateAriaId()); |
| 39 | + |
| 40 | + // dropdown has 2 different focusing behaviors |
| 41 | + // * with search input: the input is focused, and it works perfectly with aria-activedescendant pointing another sibling element. |
| 42 | + // * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown |
| 43 | + |
| 44 | + // expected user interactions for dropdown with aria support: |
| 45 | + // * user can use Tab to focus in the dropdown, then the dropdown menu (list) will be shown |
| 46 | + // * user presses Tab on the focused dropdown to move focus to next sibling focusable element (but not the menu item) |
| 47 | + // * user can use arrow key Up/Down to navigate between menu items |
| 48 | + // * when user presses Enter: |
| 49 | + // - if the menu item is clickable (eg: <a>), then trigger the click event |
| 50 | + // - otherwise, the dropdown control (low-level code) handles the Enter event, hides the dropdown menu |
| 51 | + |
| 52 | + // TODO: multiple selection is not supported yet. |
| 53 | + |
| 54 | + $focusable.attr({ |
| 55 | + 'role': 'menu', |
| 56 | + 'aria-haspopup': 'menu', |
| 57 | + 'aria-controls': $menu.attr('id'), |
| 58 | + 'aria-expanded': 'false', |
| 59 | + }); |
| 60 | + |
| 61 | + if ($dropdown.attr('data-content') && !$dropdown.attr('aria-label')) { |
| 62 | + $dropdown.attr('aria-label', $dropdown.attr('data-content')); |
| 63 | + } |
| 64 | + |
| 65 | + $menu.find('> .item').each((_, item) => { |
| 66 | + prepareMenuItem($(item)); |
| 67 | + }); |
| 68 | + |
| 69 | + // update aria attributes according to current active/selected item |
| 70 | + const refreshAria = () => { |
| 71 | + const isMenuVisible = !$menu.is('.hidden') && !$menu.is('.animating.out'); |
| 72 | + $focusable.attr('aria-expanded', isMenuVisible ? 'true' : 'false'); |
| 73 | + |
| 74 | + let $active = $menu.find('> .item.active'); |
| 75 | + if (!$active.length) $active = $menu.find('> .item.selected'); // it's strange that we need this fallback at the moment |
| 76 | + |
| 77 | + // if there is an active item, use its id. if no active item, then the empty string is set |
| 78 | + $focusable.attr('aria-activedescendant', $active.attr('id')); |
| 79 | + }; |
| 80 | + |
| 81 | + $dropdown.on('keydown', (e) => { |
| 82 | + // here it must use keydown event before dropdown's keyup handler, otherwise there is no Enter event in our keyup handler |
| 83 | + if (e.key === 'Enter') { |
| 84 | + const $item = $dropdown.dropdown('get item', $dropdown.dropdown('get value')); |
| 85 | + // if the selected item is clickable, then trigger the click event. in the future there could be a special CSS class for it. |
| 86 | + if ($item && $item.is('a')) $item[0].click(); |
| 87 | + } |
| 88 | + }); |
| 89 | + |
| 90 | + // use setTimeout to run the refreshAria in next tick (to make sure the Fomantic UI code has finished its work) |
| 91 | + const deferredRefreshAria = () => { setTimeout(refreshAria, 0) }; // do not return any value, jQuery has return-value related behaviors. |
| 92 | + $focusable.on('focus', deferredRefreshAria); |
| 93 | + $focusable.on('mouseup', deferredRefreshAria); |
| 94 | + $focusable.on('blur', deferredRefreshAria); |
| 95 | + $dropdown.on('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAria(); }); |
| 96 | +} |
| 97 | + |
| 98 | +export function attachDropdownAria($dropdowns) { |
| 99 | + $dropdowns.each((_, e) => attachOneDropdownAria($(e))); |
| 100 | +} |
0 commit comments