| 1 | efrain | 1 | // This file is part of Moodle - http://moodle.org/
 | 
        
           |  |  | 2 | //
 | 
        
           |  |  | 3 | // Moodle is free software: you can redistribute it and/or modify
 | 
        
           |  |  | 4 | // it under the terms of the GNU General Public License as published by
 | 
        
           |  |  | 5 | // the Free Software Foundation, either version 3 of the License, or
 | 
        
           |  |  | 6 | // (at your option) any later version.
 | 
        
           |  |  | 7 | //
 | 
        
           |  |  | 8 | // Moodle is distributed in the hope that it will be useful,
 | 
        
           |  |  | 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
        
           |  |  | 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
        
           |  |  | 11 | // GNU General Public License for more details.
 | 
        
           |  |  | 12 | //
 | 
        
           |  |  | 13 | // You should have received a copy of the GNU General Public License
 | 
        
           |  |  | 14 | // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 | 
        
           |  |  | 15 |   | 
        
           |  |  | 16 | /**
 | 
        
           |  |  | 17 |  * Enhancements to Bootstrap components for accessibility.
 | 
        
           |  |  | 18 |  *
 | 
        
           |  |  | 19 |  * @module     theme_universe/aria
 | 
        
           |  |  | 20 |  * @copyright  2018 Damyon Wiese <damyon@moodle.com>
 | 
        
           |  |  | 21 |  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 | 
        
           |  |  | 22 |  */
 | 
        
           |  |  | 23 |   | 
        
           | 1441 | ariadna | 24 | import Tab from 'theme_universe/bootstrap/tab';
 | 
        
           | 1 | efrain | 25 | import Pending from 'core/pending';
 | 
        
           | 1441 | ariadna | 26 | import * as FocusLockManager from 'core/local/aria/focuslock';
 | 
        
           | 1 | efrain | 27 |   | 
        
           |  |  | 28 | /**
 | 
        
           |  |  | 29 |  * Drop downs from bootstrap don't support keyboard accessibility by default.
 | 
        
           |  |  | 30 |  */
 | 
        
           |  |  | 31 | const dropdownFix = () => {
 | 
        
           |  |  | 32 |     let focusEnd = false;
 | 
        
           |  |  | 33 |     const setFocusEnd = (end = true) => {
 | 
        
           |  |  | 34 |         focusEnd = end;
 | 
        
           |  |  | 35 |     };
 | 
        
           |  |  | 36 |     const getFocusEnd = () => {
 | 
        
           |  |  | 37 |         const result = focusEnd;
 | 
        
           |  |  | 38 |         focusEnd = false;
 | 
        
           |  |  | 39 |         return result;
 | 
        
           |  |  | 40 |     };
 | 
        
           |  |  | 41 |   | 
        
           |  |  | 42 |     // Special handling for navigation keys when menu is open.
 | 
        
           |  |  | 43 |     const shiftFocus = (element, focusCheck = null) => {
 | 
        
           |  |  | 44 |         const pendingPromise = new Pending('core/aria:delayed-focus');
 | 
        
           |  |  | 45 |         setTimeout(() => {
 | 
        
           |  |  | 46 |             if (!focusCheck || focusCheck()) {
 | 
        
           |  |  | 47 |                 element.focus();
 | 
        
           |  |  | 48 |             }
 | 
        
           |  |  | 49 |   | 
        
           |  |  | 50 |             pendingPromise.resolve();
 | 
        
           |  |  | 51 |         }, 50);
 | 
        
           |  |  | 52 |     };
 | 
        
           |  |  | 53 |   | 
        
           |  |  | 54 |     // Event handling for the dropdown menu button.
 | 
        
           |  |  | 55 |     const handleMenuButton = e => {
 | 
        
           |  |  | 56 |         const trigger = e.key;
 | 
        
           |  |  | 57 |         let fixFocus = false;
 | 
        
           |  |  | 58 |   | 
        
           |  |  | 59 |         // Space key or Enter key opens the menu.
 | 
        
           |  |  | 60 |         if (trigger === ' ' || trigger === 'Enter') {
 | 
        
           |  |  | 61 |             fixFocus = true;
 | 
        
           |  |  | 62 |             // Cancel random scroll.
 | 
        
           |  |  | 63 |             e.preventDefault();
 | 
        
           |  |  | 64 |             // Open the menu instead.
 | 
        
           |  |  | 65 |             e.target.click();
 | 
        
           |  |  | 66 |         }
 | 
        
           |  |  | 67 |   | 
        
           |  |  | 68 |         // Up and Down keys also open the menu.
 | 
        
           |  |  | 69 |         if (trigger === 'ArrowUp' || trigger === 'ArrowDown') {
 | 
        
           |  |  | 70 |             fixFocus = true;
 | 
        
           |  |  | 71 |         }
 | 
        
           |  |  | 72 |   | 
        
           |  |  | 73 |         if (!fixFocus) {
 | 
        
           |  |  | 74 |             // No need to fix the focus. Return early.
 | 
        
           |  |  | 75 |             return;
 | 
        
           |  |  | 76 |         }
 | 
        
           |  |  | 77 |   | 
        
           |  |  | 78 |         // Fix the focus on the menu items when the menu is opened.
 | 
        
           |  |  | 79 |         const menu = e.target.parentElement.querySelector('[role="menu"]');
 | 
        
           |  |  | 80 |         let menuItems = false;
 | 
        
           |  |  | 81 |         let foundMenuItem = false;
 | 
        
           |  |  | 82 |   | 
        
           |  |  | 83 |         if (menu) {
 | 
        
           |  |  | 84 |             menuItems = menu.querySelectorAll('[role="menuitem"]');
 | 
        
           |  |  | 85 |         }
 | 
        
           |  |  | 86 |         if (menuItems && menuItems.length > 0) {
 | 
        
           |  |  | 87 |             // Up key opens the menu at the end.
 | 
        
           |  |  | 88 |             if (trigger === 'ArrowUp') {
 | 
        
           |  |  | 89 |                 setFocusEnd();
 | 
        
           |  |  | 90 |             } else {
 | 
        
           |  |  | 91 |                 setFocusEnd(false);
 | 
        
           |  |  | 92 |             }
 | 
        
           |  |  | 93 |   | 
        
           |  |  | 94 |             if (getFocusEnd()) {
 | 
        
           |  |  | 95 |                 foundMenuItem = menuItems[menuItems.length - 1];
 | 
        
           |  |  | 96 |             } else {
 | 
        
           |  |  | 97 |                 // The first menu entry, pretty reasonable.
 | 
        
           |  |  | 98 |                 foundMenuItem = menuItems[0];
 | 
        
           |  |  | 99 |             }
 | 
        
           |  |  | 100 |         }
 | 
        
           |  |  | 101 |   | 
        
           | 1441 | ariadna | 102 |         if (foundMenuItem) {
 | 
        
           | 1 | efrain | 103 |             shiftFocus(foundMenuItem);
 | 
        
           |  |  | 104 |         }
 | 
        
           |  |  | 105 |     };
 | 
        
           |  |  | 106 |   | 
        
           |  |  | 107 |     // Search for menu items by finding the first item that has
 | 
        
           |  |  | 108 |     // text starting with the typed character (case insensitive).
 | 
        
           |  |  | 109 |     document.addEventListener('keypress', e => {
 | 
        
           | 1441 | ariadna | 110 |         if (e.target.matches('[role="menu"] [role="menuitem"]')) {
 | 
        
           | 1 | efrain | 111 |             const menu = e.target.closest('[role="menu"]');
 | 
        
           |  |  | 112 |             if (!menu) {
 | 
        
           |  |  | 113 |                 return;
 | 
        
           |  |  | 114 |             }
 | 
        
           |  |  | 115 |             const menuItems = menu.querySelectorAll('[role="menuitem"]');
 | 
        
           |  |  | 116 |             if (!menuItems) {
 | 
        
           |  |  | 117 |                 return;
 | 
        
           |  |  | 118 |             }
 | 
        
           |  |  | 119 |   | 
        
           |  |  | 120 |             const trigger = e.key.toLowerCase();
 | 
        
           |  |  | 121 |   | 
        
           |  |  | 122 |             for (let i = 0; i < menuItems.length; i++) {
 | 
        
           |  |  | 123 |                 const item = menuItems[i];
 | 
        
           |  |  | 124 |                 const itemText = item.text.trim().toLowerCase();
 | 
        
           |  |  | 125 |                 if (itemText.indexOf(trigger) == 0) {
 | 
        
           |  |  | 126 |                     shiftFocus(item);
 | 
        
           |  |  | 127 |                     break;
 | 
        
           |  |  | 128 |                 }
 | 
        
           |  |  | 129 |             }
 | 
        
           |  |  | 130 |         }
 | 
        
           |  |  | 131 |     });
 | 
        
           |  |  | 132 |   | 
        
           |  |  | 133 |     // Keyboard navigation for arrow keys, home and end keys.
 | 
        
           |  |  | 134 |     document.addEventListener('keydown', e => {
 | 
        
           |  |  | 135 |   | 
        
           |  |  | 136 |         // We only want to set focus when users access the dropdown via keyboard as per
 | 
        
           |  |  | 137 |         // guidelines defined in w3 aria practices 1.1 menu-button.
 | 
        
           | 1441 | ariadna | 138 |         if (e.target.matches('[data-bs-toggle="dropdown"]')) {
 | 
        
           | 1 | efrain | 139 |             handleMenuButton(e);
 | 
        
           |  |  | 140 |         }
 | 
        
           |  |  | 141 |   | 
        
           | 1441 | ariadna | 142 |         if (e.target.matches('[role="menu"] [role="menuitem"]')) {
 | 
        
           | 1 | efrain | 143 |             const trigger = e.key;
 | 
        
           |  |  | 144 |             let next = false;
 | 
        
           |  |  | 145 |             const menu = e.target.closest('[role="menu"]');
 | 
        
           |  |  | 146 |   | 
        
           |  |  | 147 |             if (!menu) {
 | 
        
           |  |  | 148 |                 return;
 | 
        
           |  |  | 149 |             }
 | 
        
           |  |  | 150 |             const menuItems = menu.querySelectorAll('[role="menuitem"]');
 | 
        
           |  |  | 151 |             if (!menuItems) {
 | 
        
           |  |  | 152 |                 return;
 | 
        
           |  |  | 153 |             }
 | 
        
           |  |  | 154 |             // Down key.
 | 
        
           |  |  | 155 |             if (trigger == 'ArrowDown') {
 | 
        
           |  |  | 156 |                 for (let i = 0; i < menuItems.length - 1; i++) {
 | 
        
           |  |  | 157 |                     if (menuItems[i] == e.target) {
 | 
        
           |  |  | 158 |                         next = menuItems[i + 1];
 | 
        
           |  |  | 159 |                         break;
 | 
        
           |  |  | 160 |                     }
 | 
        
           |  |  | 161 |                 }
 | 
        
           |  |  | 162 |                 if (!next) {
 | 
        
           |  |  | 163 |                     // Wrap to first item.
 | 
        
           |  |  | 164 |                     next = menuItems[0];
 | 
        
           |  |  | 165 |                 }
 | 
        
           |  |  | 166 |             } else if (trigger == 'ArrowUp') {
 | 
        
           |  |  | 167 |                 // Up key.
 | 
        
           |  |  | 168 |                 for (let i = 1; i < menuItems.length; i++) {
 | 
        
           |  |  | 169 |                     if (menuItems[i] == e.target) {
 | 
        
           |  |  | 170 |                         next = menuItems[i - 1];
 | 
        
           |  |  | 171 |                         break;
 | 
        
           |  |  | 172 |                     }
 | 
        
           |  |  | 173 |                 }
 | 
        
           |  |  | 174 |                 if (!next) {
 | 
        
           |  |  | 175 |                     // Wrap to last item.
 | 
        
           |  |  | 176 |                     next = menuItems[menuItems.length - 1];
 | 
        
           |  |  | 177 |                 }
 | 
        
           |  |  | 178 |             } else if (trigger == 'Home') {
 | 
        
           |  |  | 179 |                 // Home key.
 | 
        
           |  |  | 180 |                 next = menuItems[0];
 | 
        
           |  |  | 181 |   | 
        
           |  |  | 182 |             } else if (trigger == 'End') {
 | 
        
           |  |  | 183 |                 // End key.
 | 
        
           |  |  | 184 |                 next = menuItems[menuItems.length - 1];
 | 
        
           |  |  | 185 |             }
 | 
        
           |  |  | 186 |   | 
        
           |  |  | 187 |             // Variable next is set if we do want to act on the keypress.
 | 
        
           |  |  | 188 |             if (next) {
 | 
        
           |  |  | 189 |                 e.preventDefault();
 | 
        
           |  |  | 190 |                 shiftFocus(next);
 | 
        
           |  |  | 191 |             }
 | 
        
           |  |  | 192 |             return;
 | 
        
           |  |  | 193 |         }
 | 
        
           |  |  | 194 |     });
 | 
        
           |  |  | 195 |   | 
        
           | 1441 | ariadna | 196 |     // Trap focus if the dropdown is a dialog.
 | 
        
           |  |  | 197 |     document.addEventListener('shown.bs.dropdown', e => {
 | 
        
           |  |  | 198 |         const dialog = e.target.querySelector('.dropdown-menu[role="dialog"]');
 | 
        
           |  |  | 199 |         if (dialog) {
 | 
        
           |  |  | 200 |             // Use setTimeout to make sure the dialog is positioned correctly to prevent random scrolling.
 | 
        
           |  |  | 201 |             setTimeout(() => {
 | 
        
           |  |  | 202 |                 FocusLockManager.trapFocus(dialog);
 | 
        
           |  |  | 203 |             });
 | 
        
           |  |  | 204 |         }
 | 
        
           |  |  | 205 |     });
 | 
        
           |  |  | 206 |   | 
        
           |  |  | 207 |     // Untrap focus when the dialog dropdown is closed.
 | 
        
           |  |  | 208 |     document.addEventListener('hidden.bs.dropdown', e => {
 | 
        
           |  |  | 209 |         const dialog = e.target.querySelector('.dropdown-menu[role="dialog"]');
 | 
        
           |  |  | 210 |         if (dialog) {
 | 
        
           |  |  | 211 |             FocusLockManager.untrapFocus();
 | 
        
           |  |  | 212 |         }
 | 
        
           |  |  | 213 |   | 
        
           | 1 | efrain | 214 |         // We need to focus on the menu trigger.
 | 
        
           | 1441 | ariadna | 215 |         const trigger = e.target.querySelector('[data-bs-toggle="dropdown"]');
 | 
        
           |  |  | 216 |         // If it's a click event, then no element is focused because the clicked element is inside a closed dropdown.
 | 
        
           |  |  | 217 |         const focused = e.clickEvent?.target || (document.activeElement !== document.body ? document.activeElement : null);
 | 
        
           | 1 | efrain | 218 |         if (trigger && focused && e.target.contains(focused)) {
 | 
        
           |  |  | 219 |             shiftFocus(trigger, () => {
 | 
        
           |  |  | 220 |                 if (document.activeElement === document.body) {
 | 
        
           |  |  | 221 |                     // If the focus is currently on the body, then we can safely assume that the focus needs to be updated.
 | 
        
           |  |  | 222 |                     return true;
 | 
        
           |  |  | 223 |                 }
 | 
        
           |  |  | 224 |   | 
        
           |  |  | 225 |                 // If the focus is on a child of the clicked element still, then update the focus.
 | 
        
           |  |  | 226 |                 return e.target.contains(document.activeElement);
 | 
        
           |  |  | 227 |             });
 | 
        
           |  |  | 228 |         }
 | 
        
           |  |  | 229 |     });
 | 
        
           |  |  | 230 | };
 | 
        
           |  |  | 231 |   | 
        
           |  |  | 232 | /**
 | 
        
           |  |  | 233 |  * A lot of Bootstrap's out of the box features don't work if dropdown items are not focusable.
 | 
        
           |  |  | 234 |  */
 | 
        
           |  |  | 235 | const comboboxFix = () => {
 | 
        
           | 1441 | ariadna | 236 |     document.addEventListener('show.bs.dropdown', e => {
 | 
        
           | 1 | efrain | 237 |         if (e.relatedTarget.matches('[role="combobox"]')) {
 | 
        
           |  |  | 238 |             const combobox = e.relatedTarget;
 | 
        
           |  |  | 239 |             const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
 | 
        
           |  |  | 240 |   | 
        
           |  |  | 241 |             if (listbox) {
 | 
        
           |  |  | 242 |                 const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
 | 
        
           |  |  | 243 |   | 
        
           |  |  | 244 |                 // To make sure ArrowDown doesn't move the active option afterwards.
 | 
        
           |  |  | 245 |                 setTimeout(() => {
 | 
        
           |  |  | 246 |                     if (selectedOption) {
 | 
        
           |  |  | 247 |                         selectedOption.classList.add('active');
 | 
        
           |  |  | 248 |                         combobox.setAttribute('aria-activedescendant', selectedOption.id);
 | 
        
           |  |  | 249 |                     } else {
 | 
        
           |  |  | 250 |                         const firstOption = listbox.querySelector('[role="option"]');
 | 
        
           |  |  | 251 |                         firstOption.setAttribute('aria-selected', 'true');
 | 
        
           |  |  | 252 |                         firstOption.classList.add('active');
 | 
        
           |  |  | 253 |                         combobox.setAttribute('aria-activedescendant', firstOption.id);
 | 
        
           |  |  | 254 |                     }
 | 
        
           |  |  | 255 |                 }, 0);
 | 
        
           |  |  | 256 |             }
 | 
        
           |  |  | 257 |         }
 | 
        
           |  |  | 258 |     });
 | 
        
           |  |  | 259 |   | 
        
           | 1441 | ariadna | 260 |     document.addEventListener('hidden.bs.dropdown', e => {
 | 
        
           | 1 | efrain | 261 |         if (e.relatedTarget.matches('[role="combobox"]')) {
 | 
        
           |  |  | 262 |             const combobox = e.relatedTarget;
 | 
        
           |  |  | 263 |             const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
 | 
        
           |  |  | 264 |   | 
        
           |  |  | 265 |             combobox.removeAttribute('aria-activedescendant');
 | 
        
           |  |  | 266 |   | 
        
           |  |  | 267 |             if (listbox) {
 | 
        
           |  |  | 268 |                 setTimeout(() => {
 | 
        
           |  |  | 269 |                     // Undo all previously highlighted options.
 | 
        
           |  |  | 270 |                     listbox.querySelectorAll('.active[role="option"]').forEach(option => {
 | 
        
           |  |  | 271 |                         option.classList.remove('active');
 | 
        
           |  |  | 272 |                     });
 | 
        
           |  |  | 273 |                 }, 0);
 | 
        
           |  |  | 274 |             }
 | 
        
           |  |  | 275 |         }
 | 
        
           |  |  | 276 |     });
 | 
        
           |  |  | 277 |   | 
        
           |  |  | 278 |     // Handling keyboard events for both navigating through and selecting options.
 | 
        
           |  |  | 279 |     document.addEventListener('keydown', e => {
 | 
        
           |  |  | 280 |         if (e.target.matches('[role="combobox"][aria-controls]:not([aria-haspopup=dialog])')) {
 | 
        
           |  |  | 281 |             const combobox = e.target;
 | 
        
           |  |  | 282 |             const trigger = e.key;
 | 
        
           |  |  | 283 |             let next = null;
 | 
        
           |  |  | 284 |             const listbox = document.querySelector(`#${combobox.getAttribute('aria-controls')}[role="listbox"]`);
 | 
        
           |  |  | 285 |             const options = listbox.querySelectorAll('[role="option"]');
 | 
        
           |  |  | 286 |             const activeOption = listbox.querySelector('.active[role="option"]');
 | 
        
           |  |  | 287 |             const editable = combobox.hasAttribute('aria-autocomplete');
 | 
        
           |  |  | 288 |   | 
        
           |  |  | 289 |             // Under the special case that the dropdown menu is being shown as a result of the key press (like when the user
 | 
        
           |  |  | 290 |             // presses ArrowDown or Enter or ... to open the dropdown menu), activeOption is not set yet.
 | 
        
           |  |  | 291 |             // It's because of a race condition with show.bs.dropdown event handler.
 | 
        
           |  |  | 292 |             if (options && (activeOption || editable)) {
 | 
        
           |  |  | 293 |                 if (trigger == 'ArrowDown') {
 | 
        
           |  |  | 294 |                     for (let i = 0; i < options.length - 1; i++) {
 | 
        
           |  |  | 295 |                         if (options[i] == activeOption) {
 | 
        
           |  |  | 296 |                             next = options[i + 1];
 | 
        
           |  |  | 297 |                             break;
 | 
        
           |  |  | 298 |                         }
 | 
        
           |  |  | 299 |                     }
 | 
        
           |  |  | 300 |                     if (editable && !next) {
 | 
        
           |  |  | 301 |                         next = options[0];
 | 
        
           |  |  | 302 |                     }
 | 
        
           |  |  | 303 |                 } if (trigger == 'ArrowUp') {
 | 
        
           |  |  | 304 |                     for (let i = 1; i < options.length; i++) {
 | 
        
           |  |  | 305 |                         if (options[i] == activeOption) {
 | 
        
           |  |  | 306 |                             next = options[i - 1];
 | 
        
           |  |  | 307 |                             break;
 | 
        
           |  |  | 308 |                         }
 | 
        
           |  |  | 309 |                     }
 | 
        
           |  |  | 310 |                     if (editable && !next) {
 | 
        
           |  |  | 311 |                         next = options[options.length - 1];
 | 
        
           |  |  | 312 |                     }
 | 
        
           | 1441 | ariadna | 313 |                 } else if (trigger == 'Home' && !editable) {
 | 
        
           | 1 | efrain | 314 |                     next = options[0];
 | 
        
           | 1441 | ariadna | 315 |                 } else if (trigger == 'End' && !editable) {
 | 
        
           | 1 | efrain | 316 |                     next = options[options.length - 1];
 | 
        
           |  |  | 317 |                 } else if ((trigger == ' ' && !editable) || trigger == 'Enter') {
 | 
        
           |  |  | 318 |                     e.preventDefault();
 | 
        
           |  |  | 319 |                     selectOption(combobox, activeOption);
 | 
        
           |  |  | 320 |                 } else if (!editable) {
 | 
        
           |  |  | 321 |                     // Search for options by finding the first option that has
 | 
        
           |  |  | 322 |                     // text starting with the typed character (case insensitive).
 | 
        
           |  |  | 323 |                     for (let i = 0; i < options.length; i++) {
 | 
        
           |  |  | 324 |                         const option = options[i];
 | 
        
           |  |  | 325 |                         const optionText = option.textContent.trim().toLowerCase();
 | 
        
           |  |  | 326 |                         const keyPressed = e.key.toLowerCase();
 | 
        
           |  |  | 327 |                         if (optionText.indexOf(keyPressed) == 0) {
 | 
        
           |  |  | 328 |                             next = option;
 | 
        
           |  |  | 329 |                             break;
 | 
        
           |  |  | 330 |                         }
 | 
        
           |  |  | 331 |                     }
 | 
        
           |  |  | 332 |                 }
 | 
        
           |  |  | 333 |   | 
        
           |  |  | 334 |                 // Variable next is set if we do want to act on the keypress.
 | 
        
           |  |  | 335 |                 if (next) {
 | 
        
           |  |  | 336 |                     e.preventDefault();
 | 
        
           |  |  | 337 |                     if (activeOption) {
 | 
        
           |  |  | 338 |                         activeOption.classList.remove('active');
 | 
        
           |  |  | 339 |                     }
 | 
        
           |  |  | 340 |                     next.classList.add('active');
 | 
        
           |  |  | 341 |                     combobox.setAttribute('aria-activedescendant', next.id);
 | 
        
           |  |  | 342 |                     next.scrollIntoView({block: 'nearest'});
 | 
        
           |  |  | 343 |                 }
 | 
        
           |  |  | 344 |             }
 | 
        
           |  |  | 345 |         }
 | 
        
           |  |  | 346 |     });
 | 
        
           |  |  | 347 |   | 
        
           |  |  | 348 |     document.addEventListener('click', e => {
 | 
        
           |  |  | 349 |         const option = e.target.closest('[role="listbox"] [role="option"]');
 | 
        
           |  |  | 350 |         if (option) {
 | 
        
           |  |  | 351 |             const listbox = option.closest('[role="listbox"]');
 | 
        
           |  |  | 352 |             const combobox = document.querySelector(`[role="combobox"][aria-controls="${listbox.id}"]`);
 | 
        
           |  |  | 353 |             if (combobox) {
 | 
        
           |  |  | 354 |                 selectOption(combobox, option);
 | 
        
           |  |  | 355 |             }
 | 
        
           |  |  | 356 |         }
 | 
        
           |  |  | 357 |     });
 | 
        
           |  |  | 358 |   | 
        
           |  |  | 359 |     // In case some code somewhere else changes the value of the combobox.
 | 
        
           |  |  | 360 |     document.addEventListener('change', e => {
 | 
        
           |  |  | 361 |         if (e.target.matches('input[type="hidden"][id]')) {
 | 
        
           |  |  | 362 |             const combobox = document.querySelector(`[role="combobox"][data-input-element="${e.target.id}"]`);
 | 
        
           |  |  | 363 |             const option = e.target.parentElement.querySelector(`[role="option"][data-value="${e.target.value}"]`);
 | 
        
           |  |  | 364 |   | 
        
           |  |  | 365 |             if (combobox && option) {
 | 
        
           |  |  | 366 |                 selectOption(combobox, option);
 | 
        
           |  |  | 367 |             }
 | 
        
           |  |  | 368 |         }
 | 
        
           |  |  | 369 |     });
 | 
        
           |  |  | 370 |   | 
        
           |  |  | 371 |     const selectOption = (combobox, option) => {
 | 
        
           |  |  | 372 |         const listbox = option.closest('[role="listbox"]');
 | 
        
           |  |  | 373 |         const oldSelectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
 | 
        
           |  |  | 374 |   | 
        
           |  |  | 375 |         if (oldSelectedOption != option) {
 | 
        
           |  |  | 376 |             if (oldSelectedOption) {
 | 
        
           |  |  | 377 |                 oldSelectedOption.removeAttribute('aria-selected');
 | 
        
           |  |  | 378 |             }
 | 
        
           |  |  | 379 |             option.setAttribute('aria-selected', 'true');
 | 
        
           |  |  | 380 |         }
 | 
        
           |  |  | 381 |   | 
        
           |  |  | 382 |         if (combobox.hasAttribute('value')) {
 | 
        
           | 1441 | ariadna | 383 |             combobox.value = option.dataset.shortText || option.textContent.replace(/[\n\r]+|[\s]{2,}/g, ' ').trim();
 | 
        
           | 1 | efrain | 384 |         } else {
 | 
        
           | 1441 | ariadna | 385 |             const selectedOptionContainer = combobox.querySelector('[data-selected-option]');
 | 
        
           |  |  | 386 |             if (selectedOptionContainer) {
 | 
        
           |  |  | 387 |                 selectedOptionContainer.textContent = option.dataset.shortText || option.textContent;
 | 
        
           |  |  | 388 |             } else {
 | 
        
           |  |  | 389 |                 combobox.textContent = option.dataset.shortText || option.textContent;
 | 
        
           |  |  | 390 |             }
 | 
        
           | 1 | efrain | 391 |         }
 | 
        
           |  |  | 392 |   | 
        
           |  |  | 393 |         if (combobox.dataset.inputElement) {
 | 
        
           |  |  | 394 |             const inputElement = document.getElementById(combobox.dataset.inputElement);
 | 
        
           |  |  | 395 |             if (inputElement && (inputElement.value != option.dataset.value)) {
 | 
        
           |  |  | 396 |                 inputElement.value = option.dataset.value;
 | 
        
           |  |  | 397 |                 inputElement.dispatchEvent(new Event('change', {bubbles: true}));
 | 
        
           |  |  | 398 |             }
 | 
        
           |  |  | 399 |         }
 | 
        
           |  |  | 400 |     };
 | 
        
           |  |  | 401 | };
 | 
        
           |  |  | 402 |   | 
        
           |  |  | 403 | /**
 | 
        
           |  |  | 404 |  * After page load, focus on any element with special autofocus attribute.
 | 
        
           |  |  | 405 |  */
 | 
        
           |  |  | 406 | const autoFocus = () => {
 | 
        
           |  |  | 407 |     window.addEventListener("load", () => {
 | 
        
           |  |  | 408 |         const alerts = document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');
 | 
        
           |  |  | 409 |         Array.prototype.forEach.call(alerts, autofocusElement => {
 | 
        
           |  |  | 410 |             // According to the specification an role="alert" region is only read out on change to the content
 | 
        
           |  |  | 411 |             // of that region.
 | 
        
           |  |  | 412 |             autofocusElement.innerHTML += ' ';
 | 
        
           |  |  | 413 |             autofocusElement.removeAttribute('data-aria-autofocus');
 | 
        
           |  |  | 414 |         });
 | 
        
           |  |  | 415 |     });
 | 
        
           |  |  | 416 | };
 | 
        
           |  |  | 417 |   | 
        
           |  |  | 418 | /**
 | 
        
           | 1441 | ariadna | 419 |  * Changes the focus to the correct element based on the key that is pressed.
 | 
        
           |  |  | 420 |  * @param {NodeList} elements A NodeList of focusable elements to navigate between.
 | 
        
           |  |  | 421 |  * @param {KeyboardEvent} e The keyboard event that triggers the roving focus.
 | 
        
           |  |  | 422 |  * @param {boolean} vertical Whether the navigation is vertical.
 | 
        
           |  |  | 423 |  * @param {boolean} updateTabIndex Whether to update the tabIndex of the elements.
 | 
        
           | 1 | efrain | 424 |  */
 | 
        
           | 1441 | ariadna | 425 | const rovingFocus = (elements, e, vertical, updateTabIndex) => {
 | 
        
           | 1 | efrain | 426 |     const rtl = window.right_to_left();
 | 
        
           |  |  | 427 |     const arrowNext = vertical ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');
 | 
        
           |  |  | 428 |     const arrowPrevious = vertical ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');
 | 
        
           | 1441 | ariadna | 429 |     const keys = [arrowNext, arrowPrevious, 'Home', 'End'];
 | 
        
           | 1 | efrain | 430 |   | 
        
           | 1441 | ariadna | 431 |     if (!keys.includes(e.key)) {
 | 
        
           |  |  | 432 |         return;
 | 
        
           | 1 | efrain | 433 |     }
 | 
        
           |  |  | 434 |   | 
        
           | 1441 | ariadna | 435 |     const focusElement = index => {
 | 
        
           |  |  | 436 |         elements[index].focus();
 | 
        
           |  |  | 437 |         if (updateTabIndex) {
 | 
        
           |  |  | 438 |             elements.forEach((element, i) => element.setAttribute('tabindex', i === index ? '0' : '-1'));
 | 
        
           |  |  | 439 |         }
 | 
        
           |  |  | 440 |     };
 | 
        
           |  |  | 441 |   | 
        
           |  |  | 442 |     const currentIndex = Array.prototype.indexOf.call(elements, e.target);
 | 
        
           |  |  | 443 |     let nextIndex;
 | 
        
           |  |  | 444 |   | 
        
           | 1 | efrain | 445 |     switch (e.key) {
 | 
        
           |  |  | 446 |         case arrowNext:
 | 
        
           |  |  | 447 |             e.preventDefault();
 | 
        
           | 1441 | ariadna | 448 |             nextIndex = (currentIndex + 1 < elements.length) ? currentIndex + 1 : 0;
 | 
        
           |  |  | 449 |             focusElement(nextIndex);
 | 
        
           | 1 | efrain | 450 |             break;
 | 
        
           |  |  | 451 |         case arrowPrevious:
 | 
        
           |  |  | 452 |             e.preventDefault();
 | 
        
           | 1441 | ariadna | 453 |             nextIndex = (currentIndex - 1 >= 0) ? currentIndex - 1 : elements.length - 1;
 | 
        
           |  |  | 454 |             focusElement(nextIndex);
 | 
        
           | 1 | efrain | 455 |             break;
 | 
        
           |  |  | 456 |         case 'Home':
 | 
        
           |  |  | 457 |             e.preventDefault();
 | 
        
           | 1441 | ariadna | 458 |             focusElement(0);
 | 
        
           | 1 | efrain | 459 |             break;
 | 
        
           |  |  | 460 |         case 'End':
 | 
        
           |  |  | 461 |             e.preventDefault();
 | 
        
           | 1441 | ariadna | 462 |             focusElement(elements.length - 1);
 | 
        
           | 1 | efrain | 463 |     }
 | 
        
           |  |  | 464 | };
 | 
        
           |  |  | 465 |   | 
        
           |  |  | 466 | /**
 | 
        
           |  |  | 467 |  * Fix accessibility issues regarding tab elements focus and their tab order in Bootstrap navs.
 | 
        
           |  |  | 468 |  */
 | 
        
           |  |  | 469 | const tabElementFix = () => {
 | 
        
           |  |  | 470 |     document.addEventListener('keydown', e => {
 | 
        
           |  |  | 471 |         if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
 | 
        
           |  |  | 472 |             if (e.target.matches('[role="tablist"] [role="tab"]')) {
 | 
        
           | 1441 | ariadna | 473 |                 const tabList = e.target.closest('[role="tablist"]');
 | 
        
           |  |  | 474 |                 const tabs = Array.prototype.filter.call(
 | 
        
           |  |  | 475 |                     tabList.querySelectorAll('[role="tab"]'),
 | 
        
           |  |  | 476 |                     tab => !!tab.offsetHeight
 | 
        
           |  |  | 477 |                 ); // We only work with the visible tabs.
 | 
        
           |  |  | 478 |                 const vertical = tabList.getAttribute('aria-orientation') == 'vertical';
 | 
        
           |  |  | 479 |   | 
        
           |  |  | 480 |                 rovingFocus(tabs, e, vertical, false);
 | 
        
           | 1 | efrain | 481 |             }
 | 
        
           |  |  | 482 |         }
 | 
        
           |  |  | 483 |     });
 | 
        
           |  |  | 484 |   | 
        
           |  |  | 485 |     document.addEventListener('click', e => {
 | 
        
           | 1441 | ariadna | 486 |         if (e.target.matches('[role="tablist"] [data-bs-toggle="tab"], [role="tablist"] [data-bs-toggle="pill"]')) {
 | 
        
           |  |  | 487 |             const tabs = e.target.closest('[role="tablist"]').querySelectorAll('[data-bs-toggle="tab"], [data-bs-toggle="pill"]');
 | 
        
           | 1 | efrain | 488 |             e.preventDefault();
 | 
        
           | 1441 | ariadna | 489 |             Tab.getOrCreateInstance(e.target).show();
 | 
        
           | 1 | efrain | 490 |             tabs.forEach(tab => {
 | 
        
           |  |  | 491 |                 tab.tabIndex = -1;
 | 
        
           |  |  | 492 |             });
 | 
        
           |  |  | 493 |             e.target.tabIndex = 0;
 | 
        
           |  |  | 494 |         }
 | 
        
           |  |  | 495 |     });
 | 
        
           |  |  | 496 | };
 | 
        
           |  |  | 497 |   | 
        
           |  |  | 498 | /**
 | 
        
           |  |  | 499 |  * Fix keyboard interaction with Bootstrap Collapse elements.
 | 
        
           |  |  | 500 |  *
 | 
        
           |  |  | 501 |  * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/#disclosure|WAI-ARIA Authoring Practices 1.1 - Disclosure (Show/Hide)}
 | 
        
           |  |  | 502 |  */
 | 
        
           |  |  | 503 | const collapseFix = () => {
 | 
        
           |  |  | 504 |     document.addEventListener('keydown', e => {
 | 
        
           | 1441 | ariadna | 505 |         if (e.target.matches('[data-bs-toggle="collapse"]')) {
 | 
        
           | 1 | efrain | 506 |             // Pressing space should toggle expand/collapse.
 | 
        
           |  |  | 507 |             if (e.key === ' ') {
 | 
        
           |  |  | 508 |                 e.preventDefault();
 | 
        
           |  |  | 509 |                 e.target.click();
 | 
        
           |  |  | 510 |             }
 | 
        
           |  |  | 511 |         }
 | 
        
           |  |  | 512 |     });
 | 
        
           |  |  | 513 | };
 | 
        
           |  |  | 514 |   | 
        
           | 1441 | ariadna | 515 | /**
 | 
        
           |  |  | 516 |  * Fix accessibility issues
 | 
        
           |  |  | 517 |  */
 | 
        
           |  |  | 518 | const toolbarFix = () => {
 | 
        
           |  |  | 519 |     document.addEventListener('keydown', e => {
 | 
        
           |  |  | 520 |         if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {
 | 
        
           |  |  | 521 |             if (e.target.matches('[role="toolbar"] button')) {
 | 
        
           |  |  | 522 |                 const buttons = e.target.closest('[role="toolbar"]').querySelectorAll('button');
 | 
        
           |  |  | 523 |                 rovingFocus(buttons, e, false, true);
 | 
        
           |  |  | 524 |             }
 | 
        
           |  |  | 525 |         }
 | 
        
           |  |  | 526 |     });
 | 
        
           |  |  | 527 | };
 | 
        
           |  |  | 528 |   | 
        
           | 1 | efrain | 529 | export const init = () => {
 | 
        
           |  |  | 530 |     dropdownFix();
 | 
        
           |  |  | 531 |     comboboxFix();
 | 
        
           |  |  | 532 |     autoFocus();
 | 
        
           |  |  | 533 |     tabElementFix();
 | 
        
           |  |  | 534 |     collapseFix();
 | 
        
           | 1441 | ariadna | 535 |     toolbarFix();
 | 
        
           | 1 | efrain | 536 | };
 |