1/** 2 * Custom WebX JS (Not external vendor library) 3 * Initialization of the WCAG menu functionality. 4 */ 5 6/** 7 * setWcagMenuAriaLabels - Example config object 8 * @type {Object} 9 * @property {string} menuSelector - The selector for the main menu. 10 * @property {string} submenuSelector - The selector for the submenu. 11 * @property {string} toggleSelector - The selector for the dropdown toggle button. 12 * @example 13 * const config = { 14 * menuSelector: '.menu-main', 15 * submenuSelector: 'ul.dropdown-submenu', 16 * toggleSelector: '.dropdown-toggle' 17 * }; 18 * setWcagMenuAriaLabels(config); 19 */ 20function setWcagMenuAriaLabels(config) { 21 if (!config) { 22 console.warn('function setWcagMenuAriaLabels: config object required'); 23 return; 24 } 25 const { menuSelector, submenuSelector, toggleSelector } = config; 26 if (!menuSelector || !submenuSelector || !toggleSelector) { 27 console.warn('function setWcagMenuAriaLabels: ' + (menuSelector ? 'menuSelector ' : '') + (submenuSelector ? 'submenuSelector ' : '') + (toggleSelector ? 'toggleSelector ' : '') + 'is required'); 28 return; 29 } 30 const menus = document.querySelectorAll(menuSelector); 31 menus.forEach((menu) => { 32 const toggleButtons = menu.querySelectorAll(toggleSelector); 33 toggleButtons.forEach((button) => { 34 const submenu = button.nextElementSibling; 35 const submenuList = submenu ? submenu.querySelector(submenuSelector) : null; 36 if (submenuList) { 37 submenuList.setAttribute('aria-labelledby', button.id); 38 } 39 }); 40 }); 41} 42/**/ 43 44/** 45 * enableWcagMenuKeyboardNavigation - Example config object 46 * @type {Object} 47 * @property {string} menuSelector - The selector for the main menu. 48 * @property {string} toggleSelector - The selector for the dropdown toggle button. 49 * @property {string} level1PanelSelector - The selector for the dropdown menu. 50 * @property {string} level2PanelSelector - The selector for the dropdown submenu. 51 * @property {string} level1ToggleSelector - The selector for the top-level menu items. 52 * @property {string} level2ToggleSelector - The selector for the submenu items. 53 * @example 54 * const config = { 55 * menuSelector: '.menu-main', 56 * toggleSelector: '.dropdown-toggle', 57 * level1PanelSelector: '.dropdown-menu', 58 * level2PanelSelector: '.dropdown-menu-slide', 59 * level1ToggleSelector: ':scope > li > .nav-link, :scope > li > .dropdown-toggle', // The :scope pseudo-class ensures that querySelectorAll only matches direct descendants of the current element, not from the entire document. 60 * level2ToggleSelector: ':scope > ul > li > .nav-link, :scope > ul > li > .dropdown-toggle', // The :scope pseudo-class ensures that querySelectorAll only matches direct descendants of the current element, not from the entire document. 61 * }; 62 * enableWcagMenuKeyboardNavigation(config); 63 */ 64function enableWcagMenuKeyboardNavigation(config) { 65 if (!config) { 66 console.warn('function enableWcagMenuKeyboardNavigation: config object required'); 67 return; 68 } 69 const { menuSelector, toggleSelector, level1PanelSelector, level2PanelSelector, level1ToggleSelector, level2ToggleSelector } = config; 70 if (!menuSelector || !toggleSelector || !level1PanelSelector || !level2PanelSelector || !level1ToggleSelector || !level2ToggleSelector) { 71 console.warn( 72 'function enableWcagMenuKeyboardNavigation: ' + 73 (menuSelector ? '' : 'menuSelector ') + 74 (toggleSelector ? '' : 'toggleSelector ') + 75 (level1PanelSelector ? '' : 'level1PanelSelector ') + 76 (level2PanelSelector ? '' : 'level2PanelSelector ') + 77 (level1ToggleSelector ? '' : 'level1ToggleSelector ') + 78 (level2ToggleSelector ? '' : 'level2ToggleSelector ') + 79 'is required' 80 ); 81 return; 82 } 83 84 let currentIndex = -1; 85 let currentElement = null; 86 87 const menus = document.querySelectorAll(menuSelector); 88 89 menus.forEach((menu) => { 90 menu.addEventListener('keydown', function (event) { 91 if (currentIndex === -1) { 92 currentElement = document.activeElement; 93 } 94 95 // These variables only handle up to two levels (main and one submenu). 96 let isLevel1 = false;
97 let isLevel2 = false; 98 let level1Elements = []; 99 let level2Elements = []; 100 101 if (currentElement.classList.contains(toggleSelector.replace('.', '')) && currentElement.getAttribute('aria-expanded') === 'true') { 102 isLevel1 = true; 103 const dropdownMenu = currentElement.nextElementSibling; 104 level1Elements = dropdownMenu ? [currentElement, ...dropdownMenu.querySelectorAll(level2ToggleSelector)] : []; 105 106 if (level1Elements.includes(currentElement)) { 107 currentIndex = level1Elements.indexOf(currentElement); 108 } else { 109 currentIndex = -1; 110 } 111 112 if (currentElement.closest(level2PanelSelector)) { 113 isLevel2 = true; 114 const level2Menu = currentElement.closest(level2PanelSelector); 115 level2Elements = level2Menu ? [level2Menu.previousElementSibling, ...level2Menu.querySelectorAll(level2ToggleSelector)] : []; 116 } 117 } else if (currentElement.closest(level1PanelSelector)) { 118 isLevel1 = true; 119 const dropdownMenu = currentElement.closest(level1PanelSelector); 120 level1Elements = dropdownMenu ? [dropdownMenu.previousElementSibling, ...dropdownMenu.querySelectorAll(level2ToggleSelector)] : []; 121 122 if (currentElement.closest(level2PanelSelector)) { 123 isLevel2 = true; 124 const level2Menu = currentElement.closest(level2PanelSelector); 125 level2Elements = level2Menu ? [level2Menu.previousElementSibling, ...level2Menu.querySelectorAll(level2ToggleSelector)] : []; 126 } 127 } 128 129 if (event.key === 'Tab' && event.shiftKey) { 130 if (isLevel2) { 131 event.preventDefault(); 132 event.stopPropagation(); 133 134 currentIndex = (currentIndex - 1 + level2Elements.length) % level2Elements.length; 135 level2Elements[currentIndex].focus(); 136 currentElement = level2Elements[currentIndex]; 137 } else if (isLevel1) { 138 event.preventDefault(); 139 event.stopPropagation(); 140 141 currentIndex = (currentIndex - 1 + level1Elements.length) % level1Elements.length; 142 level1Elements[currentIndex].focus(); 143 currentElement = level1Elements[currentIndex]; 144 } else { 145 const topLevelElements = [...menu.querySelectorAll(level1ToggleSelector)]; 146 if (currentIndex < topLevelElements.length - 2) { 147 event.preventDefault(); 148 event.stopPropagation(); 149 currentIndex = (currentIndex - 1 + topLevelElements.length) % topLevelElements.length; 150 topLevelElements[currentIndex].focus(); 151 currentElement = topLevelElements[currentIndex]; 152 } 153 } 154 return; 155 } 156 157 switch (event.key) { 158 case 'ArrowDown': 159 case 'ArrowRight': 160 if (isLevel2) { 161 currentIndex = (currentIndex + 1) % level2Elements.length; 162 level2Elements[currentIndex].focus(); 163 currentElement = level2Elements[currentIndex]; 164 } else if (isLevel1) { 165 event.preventDefault(); 166 event.stopPropagation(); 167 168 currentIndex = (currentIndex + 1) % level1Elements.length; 169 level1Elements[currentIndex].focus(); 170 currentElement = level1Elements[currentIndex]; 171 } else { 172 event.preventDefault(); 173 event.stopPropagation(); 174 const topLevelElements = [...menu.querySelectorAll(level1ToggleSelector)]; 175 currentIndex = (currentIndex + 1) % topLevelElements.length; 176 topLevelElements[currentIndex].focus(); 177 currentElement = topLevelElements[currentIndex]; 178 } 179 break; 180 181 case 'Tab': 182 if (isLevel2) { 183 event.preventDefault(); 184 event.stopPropagation(); 185 186 currentIndex = (currentIndex + 1) % level2Elements.length; 187 level2Elements[currentIndex].focus(); 188 currentElement = level2Elements[currentIndex]; 189 } else if (isLevel1) { 190 event.preventDefault(); 191 event.stopPropagation(); 192 193 currentIndex = (currentIndex + 1) % level1Elements.length; 194 level1Elements[currentIndex].focus(); 195 currentElement = level1Elements[currentIndex]; 196 } else { 197 const topLevelElements = [...menu.querySelectorAll(level1ToggleSelector)]; 198 if (currentIndex < topLevelElements.length - 2) { 199 event.preventDefault(); 200 event.stopPropagation(); 201 currentIndex = (currentIndex + 1) % topLevelElements.length; 202 topLevelElements[currentIndex].focus(); 203 currentElement = topLevelElements[currentIndex]; 204 } 205 } 206 break; 207 208 case 'ArrowUp': 209 case 'ArrowLeft': 210 event.preventDefault(); 211 event.stopPropagation(); 212 if (isLevel2) { 213 currentIndex = (currentIndex - 1 + level2Elements.length) % level2Elements.length; 214 level2Elements[currentIndex].focus(); 215 currentElement = level2Elements[currentIndex]; 216 } else if (isLevel1) { 217 currentIndex = (currentIndex - 1 + level1Elements.length) % level1Elements.length; 218 level1Elements[currentIndex].focus(); 219 currentElement = level1Elements[currentIndex]; 220 } else { 221 const topLevelElements = [...menu.querySelectorAll(level1ToggleSelector)]; 222 currentIndex = (currentIndex - 1 + topLevelElements.length) % topLevelElements.length; 223 topLevelElements[currentIndex].focus(); 224 currentElement = topLevelElements[currentIndex]; 225 } 226 break; 227 228 case 'Enter': 229 case ' ': 230 event.preventDefault(); 231 if (currentElement && typeof currentElement.click === 'function') { 232 currentElement.click(); 233 } 234 break; 235 236 case 'Escape': 237 case 'Esc': 238 event.preventDefault(); 239 event.stopPropagation(); 240 const toggleExpandedSelector = menu.querySelector(`${toggleSelector}[aria-expanded="true"]`); 241 if (!toggleExpandedSelector) { 242 break; 243 } 244 toggleExpandedSelector.click(); 245 toggleExpandedSelector.focus(); 246 currentElement = toggleExpandedSelector; 247 currentIndex = -1; 248 249 break; 250 } 251 }); 252 }); 253} 254 255/** 256 * enableWcagMenuTabTrapping - Example config object 257 * @type {Object} 258 * @property {string} menuSelector - The selector for the main menu container. 259 * @property {string} toggleSelector - The selector for the menu toggle button (hamburger button). 260 * @property {string} closeSelector - The selector for close buttons within the menu (optional). 261 * @property {string} focusableElementsSelector - Custom selector for focusable elements (optional). 262 * @example 263 * const config = { 264 * menuSelector: '.offcanvas-menu', 265 * toggleSelector: '.hamburger-toggle', 266 * closeSelector: '.close-menu',
267 * focusableElementsSelector: 'a, button, [tabindex]:not([tabindex="-1"])' 268 * }; 269 * enableWcagMenuTabTrapping(config); 270 */ 271function enableWcagMenuTabTrapping(config) { 272 if (!config?.menuSelector || !config?.toggleSelector) { 273 console.warn('enableWcagMenuTabTrapping: menuSelector and toggleSelector are required'); 274 return; 275 } 276 277 const { menuSelector, toggleSelector, closeSelector, focusableElementsSelector = 'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"]):not([disabled])' } = config; 278 279 document.querySelectorAll(menuSelector).forEach((menu) => { 280 let isMenuOpen = false; 281 let focusableElements = []; 282 let lastFocusedElement = null; 283 284 const isVisible = (el) => el.offsetWidth > 0 || el.offsetHeight > 0 || el === document.activeElement; 285 286 const getFocusableElements = () => { 287 const menuElements = Array.from(menu.querySelectorAll(focusableElementsSelector)).filter(isVisible); 288 289 // When menu is open, prefer close selector over toggle selector 290 if (isMenuOpen && closeSelector) { 291 const closeButtons = Array.from(menu.querySelectorAll(closeSelector)).filter(isVisible); 292 if (closeButtons.length > 0) { 293 // Filter out close buttons from menu elements to avoid duplicates 294 const otherMenuElements = menuElements.filter(el => !closeButtons.includes(el)); 295 return [...closeButtons, ...otherMenuElements]; 296 } 297 } 298 299 // When menu is closed or no close selector, use toggle selector 300 const toggleButtons = Array.from(document.querySelectorAll(toggleSelector)).filter(isVisible); 301 // Filter out toggle buttons from menu elements to avoid duplicates (in case they're inside the menu) 302 const otherMenuElements = menuElements.filter(el => !toggleButtons.includes(el)); 303 return [...toggleButtons, ...otherMenuElements]; 304 }; 305 306 const isMenuOpenCheck = () => 307 menu.classList.contains('show') || 308 menu.classList.contains('open') || 309 menu.getAttribute('aria-hidden') === 'false' || 310 window.getComputedStyle(menu).display !== 'none' || 311 document.body.classList.contains('menu-visible') || 312 document.body.classList.contains('show-menu'); 313 314 const trapFocus = (event) => { 315 if (!isMenuOpen || event.key !== 'Tab') { 316 if (event.key === 'Escape') { 317 event.preventDefault(); 318 closeMenu(); 319 } 320 return; 321 } 322 323 // Recalculate focusable elements to get current state (important for close/toggle selector switching) 324 const currentFocusableElements = getFocusableElements(); 325 if (currentFocusableElements.length === 0) return; 326 327 event.preventDefault(); 328 const currentIndex = currentFocusableElements.findIndex(el => el === document.activeElement); 329 const nextIndex = event.shiftKey 330 ? (currentIndex <= 0 ? currentFocusableElements.length - 1 : currentIndex - 1) 331 : (currentIndex >= currentFocusableElements.length - 1 ? 0 : currentIndex + 1); 332 333 currentFocusableElements[nextIndex].focus(); 334 }; 335 336 const openMenu = () => { 337 isMenuOpen = true; 338 focusableElements = getFocusableElements(); 339 focusableElements[0]?.focus(); 340 document.addEventListener('keydown', trapFocus); 341 }; 342 343 const closeMenu = () => { 344 isMenuOpen = false; 345 focusableElements = []; 346 document.removeEventListener('keydown', trapFocus); 347 lastFocusedElement?.focus(); 348 lastFocusedElement = null; 349 }; 350 351 // Toggle button handling 352 document.querySelectorAll(toggleSelector).forEach((toggle) => { 353 toggle.addEventListener('click', () => { 354 lastFocusedElement = toggle; 355 356 if (isMenuOpen) { 357 closeMenu(); 358 return; 359 } 360 361 // Otherwise check if menu opened after click 362 setTimeout(() => { 363 const menuIsOpen = isMenuOpenCheck(); 364 if (menuIsOpen && !isMenuOpen) { 365 openMenu(); 366 } 367 }, 10); 368 }); 369 }); 370 371 // Close button handling 372 if (closeSelector) { 373 menu.querySelectorAll(closeSelector).forEach((closeBtn) => { 374 closeBtn.addEventListener('click', () => setTimeout(closeMenu, 10)); 375 }); 376 } 377 378 // Click outside to close 379 document.addEventListener('click', (event) => { 380 if (isMenuOpen && !menu.contains(event.target) && !Array.from(document.querySelectorAll(toggleSelector)).some(btn => btn.contains(event.target))) { 381 closeMenu(); 382 } 383 }); 384 }); 385}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.