1 2class Accessibility { 3 constructor() { 4 this.accessibility = document.querySelector('.js-accessibility'); 5 if(!this.accessibility) return; 6 this.toolbar_icon = this.accessibility.querySelector('.js-accessibility-icon'); 7 this.close_toolbar = this.accessibility.querySelector('.js-close-toolbar'); 8 var open_shortcut = this.accessibility.querySelector('.js-shortcut-open'); 9 var close_shortcut = this.accessibility.querySelector('.js-shortcut-close'); 10 this.items = this.accessibility.querySelectorAll('.js-accessibility-items li a'); 11 this.has_tab = false; 12 this.accessibility_option = JSON.parse(this.accessibility.dataset.option) 13 this.variables = { 14 'increase' : 0, 15 'grayscale': false, 16 'high_contrast': false, 17 'negative_contrast': false, 18 'light_background': false, 19 'links_underline': false, 20 'readable_font': false, 21 'text_spacing': 0, 22 'line_height': 0, 23 'text_align': 0, 24 'play_animations': false, 25 } 26 27 this.init(); 28 this.openToolbar(); 29 this.closeToolbar(); 30 this.toggle_on_click(open_shortcut, this.accessibility, 'shortcut-open') 31 this.toggle_on_click(close_shortcut, this.accessibility, 'shortcut-open'); 32 // this.check_item_status(); 33 this.items_click(); 34 } 35 36 init() { 37 this.get_general() 38 this.manage_html_tag(); 39 this.manage_image(); 40 this.manage_ul(); 41 this.manage_li(); 42 this.manage_anchor(); 43 this.manage_button(); 44 this.manage_heading(); 45 this.manage_input(); 46 this.manage_select(); 47 this.manage_form(); 48 this.manage_splide(); 49 this.manage_iframe(); 50 this.manage_label(); 51 this.manage_existing_skip_content('main-content'); 52 new Menu(this.accessibility_option); 53 54 this.manage_screen_reader(); 55 if(this.general.skip_content) { 56 this.skip_to_content(); 57 } 58 } 59 60 openToolbar() { 61 var _self = this; 62 this.popup = this.accessibility.querySelector('.js-popup-inner'); 63 _self.toolbar_icon.addEventListener('click', () => { 64 this.accessibility.classList.add('is-open') 65 this.popup.style.height = '500px' 66 _self.popupIsInViewpost(); 67 }); 68 } 69 70 closeToolbar() { 71 var _self = this; 72 73 this.close_toolbar.addEventListener('click', () => { 74 this.accessibility.classList.remove('is-open') 75 this.popup.style.height = '0px' 76 this.popup.style.bottom = 0 77 }) 78 } 79 80 // Ensure the <html> tag has a lang attribute for accessibility 81 manage_html_tag() { 82 var html_tag = document.querySelector('html'); 83 this.has_attribute(html_tag, 'lang', 'en-US'); 84 } 85 86 get_general() { 87 this.general = this.accessibility_option.general; 88 89 if(this.general.outline_focus) { 90 document.body.classList.add('fo-outline-focus'); 91 } 92 } 93 94 // Ensure all <img> tags have an alt attribute for accessibility 95 manage_image() { 96 var images = document.querySelectorAll('img'); 97 98 images.forEach((image) => { 99 100 if(image.getAttribute('alt') === '' || !image.hasAttribute('alt')) { 101 image.setAttribute('alt', 'Image'); 102 } 103 }); 104 } 105 106 manage_screen_reader() { 107 var _self = this 108 document.addEventListener('click', function(event) { 109 const element = event.target; 110 const text = _self.getDirectInnerText(element); 111 const has_screen_reader = document.body.classList.contains('fo-accessibility__has-screen_reader'); 112 113 114 if(text !== '' && has_screen_reader) { 115 _self.speakText(text, element); 116 } 117 118 }) 119 } 120 121 speakText(text, elem) { 122 if (typeof speechSynthesis === "undefined") { 123 return; 124 } 125 126 const speak_elem = document.querySelector('.fo-speak-text') ?? false 127 if(speak_elem) { 128 speak_elem.classList.remove('fo-speak-text'); 129 window.speechSynthesis.cancel(); 130 } 131 132 elem.classList.add('fo-speak-text'); 133 let speech = new SpeechSynthesisUtterance(text); 134 135 speech.lang = "en-US"; 136 speech.rate = 1; 137 speech.pitch = 1; 138 window.speechSynthesis.speak(speech); 139 speech.onend = function() { 140 elem.classList.remove('fo-speak-text'); 141 }; 142 } 143 144 getDirectInnerText(element) { 145 return Array.from(element.childNodes) 146 .filter(node =>
146 node.nodeType === Node.TEXT_NODE) // Select only text nodes 147 .map(node => node.nodeValue.trim()) // Get text content and trim spaces 148 .join(""); // Join text if multiple text nodes exist 149 } 150 151 /** 152 * Manages accessibility attributes and behavior for all anchor tags on the page 153 * - Adds appropriate aria-labels for social media links 154 * - Adds tabindex for keyboard navigation 155 * - Adds placeholder text for empty links 156 * - Sets default href if missing 157 * - Adds aria-labels from title or innerText 158 * - Handles role attributes for navigation menus and button-like links 159 * - Adds visual and semantic indicators for links that open in new tabs 160 */ 161 manage_anchor() { 162 var anchor = document.querySelectorAll('a'); 163 164 anchor.forEach((a) => { 165 // Check if this is a social media link 166 var socialMediaName = this.getSocialMediaName(a.href); 167 if (socialMediaName) { 168 // Add appropriate aria-label for social media links 169 a.setAttribute('aria-label', socialMediaName); 170 } else { 171 // Add keyboard navigation support 172 this.has_attribute(a, 'tabindex', '0'); 173 // Add placeholder text for empty links 174 this.inner_text(a, 'Link'); 175 // Set default href if missing 176 this.has_attribute(a, 'href', 'javascript:void(0);'); 177 const title = a.getAttribute('title') ?? false; 178 // Use title attribute for aria-label if available 179 if(title) { 180 this.has_attribute(a, 'aria-label', title); 181 } else { 182 // Otherwise use link text for aria-label 183 this.has_attribute(a, 'aria-label', a.innerText); 184 } 185 186 if(this.general.role_link) { 187 // Add appropriate role attributes 188 if(this.isNavigationMenu(a)) { 189 // this.has_attribute(a, 'role', 'menuitem'); 190 } else if(a.getAttribute('href') === '#') { 191 this.has_attribute(a, 'role', 'button'); 192 } else { 193 194 } 195 } 196 197 // Handle links that open in new tabs 198 if(a.getAttribute('target') === '_blank') { 199 // Add security attributes 200 this.has_attribute(a, 'rel', 'noopener noreferrer'); 201 202 var element_style = window.getComputedStyle(a); 203 // Only add visual indicator for static/relative positioned links 204 if(element_style.position === 'static' || element_style.position === 'relative') { 205 a.classList.add('fo-target-blank-link') 206 207 // Add screen reader text 208 const new_span = document.createElement('span'); 209 new_span.classList.add('fo-target-blank-text'); 210 new_span.innerText = 'Opens in new tab'; 211 a.appendChild(new_span); 212 } 213 } 214 } 215 }); 216 } 217 218 /** 219 * Gets social media name from a URL 220 * @param {string} url - The URL to check 221 * @returns {string|false} - The social media name or false if not a social media URL 222 */ 223 getSocialMediaName(url) { 224 if (!url) return false; 225 226 const socialMedia = [ 227 { domain: 'facebook.com', name: 'Facebook' }, 228 { domain: 'fb.com', name: 'Facebook' }, 229 { domain: 'twitter.com', name: 'Twitter' }, 230 { domain: 'x.com', name: 'Twitter' }, 231 { domain: 'instagram.com', name: 'Instagram' }, 232 { domain: 'linkedin.com', name: 'LinkedIn' }, 233 { domain: 'youtube.com', name: 'YouTube' }, 234 { domain: 'youtu.be', name: 'YouTube' }, 235 { domain: 'pinterest.com', name: 'Pinterest' }, 236 { domain: 'tiktok.com', name: 'TikTok' }, 237 { domain: 'snapchat.com', name: 'Snapchat' }, 238 { domain: 'reddit.com', name: 'Reddit' }, 239 { domain: 'tumblr.com', name: 'Tumblr' }, 240 { domain: 'whatsapp.com', name: 'WhatsApp' } 241 ]; 242 243 for (const platform of socialMedia) { 244 if (url.includes(platform.domain)) { 245 return platform.name; 246 } 247 } 248 249 return false;
250 } 251 252 /** 253 * Sets an attribute on an HTML element if it doesn't already exist 254 * @param {HTMLElement} tag - The HTML element to modify 255 * @param {string} attr - The attribute name to set 256 * @param {string} text - The attribute value to set 257 * Special handling for aria-label: 258 * - If aria-label exists but is empty, tries to generate label from element's classes 259 * - If aria-label doesn't exist, sets it to the provided text 260 * For other attributes: 261 * - Simply sets the attribute if it doesn't exist 262 */ 263 has_attribute(tag, attr, text) { 264 if (attr === 'aria-label') { 265 if(tag.getAttribute(attr) === '' || tag.getAttribute(attr) === null) { 266 var aria_text = this.class_to_srting(tag); 267 if(text) { 268 aria_text = text; 269 } 270 tag.setAttribute(attr, aria_text); 271 272 } else if(!tag.hasAttribute(attr)) { 273 tag.setAttribute(attr, text); 274 } 275 } else if(!tag.hasAttribute(attr)) { 276 tag.setAttribute(attr, text); 277 } 278 } 279 280 /** 281 * Adds inner text to an HTML element if it is empty 282 */ 283 284 inner_text(tag, text='text') { 285 if(tag.innerText.trim() === '') { 286 const new_span = document.createElement('span'); 287 new_span.classList.add('fo-none-text'); 288 new_span.innerText = text; 289 tag.appendChild(new_span); 290 } 291 } 292 293 /** 294 * Ensures all heading elements (h1-h6) are accessible by: 295 * - Adding inner text if missing. 296 * - Setting aria-label based on inner text or class name. 297 */ 298 manage_heading() { 299 var headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); 300
301 headings.forEach((heading) => { 302 this.inner_text(heading, 'Heading'); 303 var aria_text = heading.innerText; 304 if(aria_text === '') { 305 aria_text = this.class_to_srting(heading); 306 } 307 308 if(aria_text === '') { 309 aria_text = 'Heading'; 310 } 311 this.has_attribute(heading, 'aria-label', aria_text); 312 }); 313 } 314 315 /** 316 * Ensures all <input> elements are accessible by: 317 * - Adding an aria-label based on the input's name or placeholder. 318 * - Adding tabindex="0" for keyboard navigation. 319 */ 320 manage_input() { 321 var inputs = document.querySelectorAll('input'); 322 323 inputs.forEach((input) => { 324 const name = input.getAttribute('name') ?? false; 325 if(input.type === 'submit') { 326 this.has_attribute(input, 'aria-label', input.value); 327 return; 328 } 329 if(name) { 330 this.has_attribute(input, 'aria-label', name); 331 } else { 332 this.has_attribute(input, 'aria-label', input.getAttribute('placeholder')); 333 } 334 this.has_attribute(input, 'tabindex', '0'); 335 }); 336 } 337 338 /** 339 * Ensures all <select> elements are accessible by: 340 * - Adding an aria-label based on the select's name or a default value. 341 * - Adding tabindex="0" for keyboard navigation. 342 */ 343 manage_select() { 344 var select = document.querySelectorAll('select'); 345 346 select.forEach((input) => { 347 const name = input.getAttribute('name') ?? false; 348 if(name) { 349 this.has_attribute(input, 'aria-label', name); 350 } else { 351 this.has_attribute(input, 'aria-label', 'Select Option'); 352 } 353 this.has_attribute(input, 'tabindex', '0'); 354 }); 355 356 } 357 358 /** 359 * Ensures all <form> elements are accessible by: 360 * - Adding an aria-label attribute with a unique label for each form. 361 */ 362 manage_form() { 363 var form_tag = document.querySelectorAll('form'); 364 if(form_tag.length === 0) return; 365 366 form_tag.forEach((form, i) => { 367 this.has_attribute(form, 'aria-label', 'Form ' + i); 368 }); 369 } 370 371 /** 372 * Ensures all elements with the .splide class (typically sliders/carousels) are accessible by: 373 * - Adding an aria-label attribute to each slider for screen readers. 374 */ 375 manage_splide() { 376 var splide = document.querySelectorAll('.splide'); 377 if(splide.length === 0) return; 378 379 splide.forEach((slider, i) => { 380 slider.setAttribute('aria-label', 'Slider'+ i); 381 }); 382 } 383 384 /** 385 * Ensures all <iframe> elements are accessible by: 386 * - Adding a unique title attribute to each iframe for screen readers. 387 */ 388 manage_iframe() { 389 var iframe = document.querySelectorAll('iframe'); 390 if(iframe.length === 0) return; 391 392 iframe.forEach((frame, i) => { 393 this.has_attribute(frame, 'title', 'Iframe'+ i); 394 }); 395 } 396 397 manage_label() { 398 var labels = document.querySelectorAll('label'); 399 if(labels.length === 0) return; 400 labels.forEach((label) => { 401 // Ensure label has a for attribute that matches an input's id 402 const inputId = label.getAttribute('for'); 403 if(inputId) { 404 const input = document.getElementById(inputId); 405 if(input) { 406 this.has_attribute(label, 'aria-label', input.getAttribute('name') || input.getAttribute('placeholder') || 'Label'); 407 } else { 408 this.has_attribute(label, 'aria-label', 'Label for ' + inputId); 409 } 410 } else { 411 this.has_attribute(label, 'aria-label', 'Label'); 412 } 413 }); 414 } 415 416 417 /** 418 * Modifies an existing skip-to-content link by replacing its href with 'javascript:void(0)'. 419 * This prevents the default jump behavior and allows for custom skip logic. 420 * @param {string} id - The id of the element to skip to. 421 */ 422 manage_existing_skip_content(id) { 423 var skip = document.querySelector('[href="#'+ id +'"]'); 424 if(!skip) return; 425 skip.href = 'javascript:void(0)'; 426 } 427 428 /** 429 * Ensures all <button> elements are accessible by adding tabindex, inner text, and ARIA roles. 430 * - Adds tabindex="0" for keyboard navigation. 431 * - Adds inner text if missing. 432 * - Adds role="menuitem" if the button is part of a navigation menu. 433 */ 434 manage_button() { 435 var buttons = document.querySelectorAll('button'); 436 437 buttons.forEach((btn) => { 438 this.has_attribute(btn, 'tabindex', '0'); 439 this.inner_text(btn, 'button'); 440 if(this.isNavigationMenu(btn)) { 441 // this.has_attribute(btn, 'role', 'menuitem'); 442 } 443 }); 444 } 445 446 // Adds appropriate ARIA roles to <ul> elements for accessibility 447 manage_ul() { 448 var ul = document.querySelectorAll('ul'); 449 450 ul.forEach((list) => { 451 if(this.isNavigationMenu(list)) { 452 // If the <ul> is part of a navigation menu, set role to 'menu' 453 // this.has_attribute(list, 'role', 'menu'); 454 } else { 455 // Otherwise, you could set role to 'list' if needed 456 // this.has_attribute(list, 'role', 'list'); 457 } 458 // Optionally, generate a role text from class names
459 // var role_text = this.class_to_srting(list.classList); 460 // if(!role_text) { 461 // role_text = 'Item-list'; 462 // } 463 // this.has_attribute(list, 'role', role_text) 464 }); 465 } 466 467 /** 468 * Determines if a <ul> element is part of a navigation menu for accessibility purposes. 469 * Adds an aria-label to the closest <nav> if found. 470 * @param {HTMLElement} ul - The <ul> element to check. 471 * @returns {boolean} True if the <ul> is considered a navigation menu, otherwise false. 472 */ 473 isNavigationMenu(ul) { 474 // Check if <ul> is inside a <nav> element 475 if (ul.closest('nav')) { 476 var aria_text = this.class_to_srting(ul.closest('nav')); 477 if(!aria_text) { 478 aria_text = 'Nav'; 479 } 480 this.has_attribute(ul.closest('nav'), 'aria-label', aria_text); 481 return true; 482 } 483 484 // Check if <ul> has navigation-related classes 485 const navClasses = ['nav', 'navbar', 'menu', 'navigation', 'dropdown-menu']; 486 if (navClasses.some(cls => ul.classList.contains(cls))) return true; 487 488 // Check if <ul> has an ARIA role related to navigation 489 // const role = ul.getAttribute('role'); 490 // if (role === 'navigation' || role === 'menu') return true; 491 492 // Check if <li> elements contain <a> tags (common in nav menus) 493 // if (ul.querySelectorAll('li > a').length > 0) return true; 494 495 // If none of the conditions match, it's not a nav menu 496 return false; 497 } 498 499 // Adds appropriate ARIA roles to <li> elements for accessibility 500 manage_li() { 501 var li = document.querySelectorAll('li'); 502 503 li.forEach((list) => { 504 // If the <li> is part of a navigation menu, set role to 'none' 505 if(this.isNavigationMenu(list)) { 506 // list.setAttribute('role', 'none'); 507 } 508 }); 509 } 510 511 class_to_srting(tag, get_srting = false) { 512 const class_list_array = tag.classList; 513 if(class_list_array.length === 0) return false; 514 for (let str of class_list_array) { 515 var srting = this.cleanAndFormatString(str); 516 var meaningful = this.isMeaningfulString(srting); 517 518 if(meaningful) { 519 return srting; 520 } 521 }; 522 return false; 523 } 524 525 cleanAndFormatString(str) { 526 return str 527 .replace(/[_-]+/g, ' ') // Replace multiple hyphens or underscores with a single space 528 .trim() // Remove leading/trailing spaces 529 .replace(/\b\w/g, char => char.toUpperCase()); // Capitalize first letter of each word 530 } 531 532 isMeaningfulString(str) { 533 return /\b[a-zA-Z]{3,}\b/.test(str); // Checks if words contain letters and are at least 3 characters long 534 } 535 536 skip_to_content() { 537 this.create_skip_to_content_html(); 538 this.skip_link_click(); 539 } 540 541 create_skip_to_content_html() { 542 // Create the container div with landmark role 543 const skipContainer = document.createElement('div'); 544 skipContainer.setAttribute('role', 'region'); 545 skipContainer.setAttribute('aria-label', 'Skip link'); 546 547 // Create the skip link 548 const new_skip_link = document.createElement('a'); 549 new_skip_link.classList.add('fo-skip-link'); 550 new_skip_link.setAttribute('href', 'javascript:void(0)'); 551 new_skip_link.setAttribute('tabindex', '0'); 552 new_skip_link.innerText = 'Skip to content'; 553 554 // Add the skip link to the container 555 skipContainer.appendChild(new_skip_link); 556 557 // Insert at the beginning of the body 558 document.body.insertAdjacentElement("afterbegin", skipContainer); 559 } 560 561 skip_link_click() { 562 const link = document.querySelector('.fo-skip-link'); 563 link.addEventListener('click', (e) => { 564 e.preventDefault(); 565 function findFirstElementWithTag(tag) { 566 return document.querySelector(tag); 567 } 568 569 const h1Element = findFirstElementWithTag('h1'); 570 const h2Element = findFirstElementWithTag('h2'); 571 const introTextElement = findFirstElementWithTag('.voyager-intro-text'); 572 573 // Determine the first element to scroll to 574 let targetElement = null; 575 576 // Prioritize h1 > h2 > .voyager-intro-text 577 if (h1Element) { 578 targetElement = h1Element; 579 } else if (h2Element) { 580 targetElement = h2Element; 581 } else if (introTextElement) { 582 targetElement = introTextElement; 583 } 584 585 // Scroll to the target element if found 586 if (targetElement) { 587 targetElement.scrollIntoView({ behavior: 'smooth' }); 588 targetElement.setAttribute('tabindex', '-1'); // Make the target element focusable 589 targetElement.focus(); // Focus the target element for screen readers 590 } 591 }); 592 } 593 594 toggle_on_click(elem, selector, toggle_class = 'is-open') { 595 var _self = this; 596 if(elem) { 597 elem.addEventListener('click', (e) => { 598 e.preventDefault(); 599 selector.classList.toggle(toggle_class); 600 }); 601 } 602 } 603 604 popupIsInViewpost() { 605 var toolbar_position = this.accessibility_option.toolbar_position; 606 const rect = this.popup.getBoundingClientRect(); 607 if(toolbar_position === 'bottom_right' || toolbar_position === 'bottom_left') { 608 const white_space = window.innerHeight - ((window.innerHeight - rect.bottom) + 500) 609 610 if(white_space < 30) { 611 this.popup.style.bottom = `${white_space - 10}px`; 612 } 613 } 614 615 616 if(toolbar_position === 'top_right' || toolbar_position === 'top_left') { 617 const white_space = window.innerHeight - (rect.top + 500) 618 619 if(white_space < 0) { 620 this.popup.style.top = `${white_space - 10}px`; 621 } 622 } 623 } 624 625 check_item_status() { 626 for (let key in this.variables) { 627 let item_class = 'js-item-'+ key; 628 let body_class = 'fo-accessibility__has-'+ key; 629 let class_add = 'body'; 630 if(key === 'increase') { 631 item_class = 'js-item-increase'; 632 body_class = 'fo-accessibility__resize-font-0'; 633 } 634 635 if(key === 'grayscale' || key === 'negative_contrast') { 636 class_add = 'html'; 637 } 638 639 this.manage__active_class(this.variables[key], item_class, body_class, class_add); 640 } 641 } 642 643 manage__active_class(condition, item_class, body_class, class_add = 'body') { 644 if(class_add === 'body') { 645 class_add = document.body; 646 } else { 647 class_add = document.documentElement; 648 } 649 650 if(condition) { 651 this.accessibility.querySelector(`.${item_class}`).classList.add('is-active'); 652 class_add.classList.add(body_class); 653 } else { 654 this.accessibility.querySelector(`.${item_class}`).classList.remove('is-active'); 655 class_add.classList.remove(body_class); 656 } 657 } 658 659 items_click() {
660 this.items.forEach((item) => { 661 662 item.addEventListener('click', (e) => { 663 e.preventDefault(); 664 var action = item.getAttribute('data-action'); 665 666 switch (action) { 667 case "increase": 668 this.handle_increase(); 669 break; 670 case "decrease": 671 this.handle_decrease(); 672 break; 673 case "text_spacing": 674 this.handle_four_click('text_spacing'); 675 break; 676 case "line_height": 677 this.handle_four_click('line_height'); 678 break; 679 case "text_align": 680 this.handle_four_click('text_align'); 681 break; 682 case "reset": 683 this.handle_reset(); 684 break; 685 default: 686 this.variables[action] = !this.variables[action]; 687 } 688 this.check_item_status(); 689 }); 690 }); 691 } 692 693 handle_increase() { 694 if(this.variables.increase !== 8) { 695 document.body.classList.remove('fo-accessibility__resize-font-' + this.variables.increase); 696 this.variables.increase += 1; 697 document.body.classList.add('fo-accessibility__resize-font-' + this.variables.increase); 698 } 699 } 700 701 handle_decrease() { 702 if(this.variables.increase !== 0) { 703 document.body.classList.remove('fo-accessibility__resize-font-' + this.variables.increase); 704 this.variables.increase -= 1; 705 document.body.classList.add('fo-accessibility__resize-font-' + this.variables.increase); 706 } 707 } 708 709 handle_four_click(value) { 710 if(this.variables[value] !== 4) { 711 document.body.classList.remove('fo-accessibility__'+ value +'-' + this.variables[value]); 712 this.variables[value] += 1; 713 document.body.classList.add('fo-accessibility__'+ value +'-' + this.variables[value]); 714 } else { 715 document.body.classList.remove('fo-accessibility__'+ value +'-' + this.variables[value]); 716 this.variables[value] = 0; 717 } 718 } 719 720 handle_reset() { 721 document.body.classList.remove('fo-accessibility__resize-font-' + this.variables.increase); 722 document.body.classList.remove('fo-accessibility__text_spacing-' + this.variables.text_spacing); 723 document.body.classList.remove('fo-accessibility__line_height-' + this.variables.line_height); 724 document.body.classList.remove('fo-accessibility__text_align-' + this.variables.text_align); 725 726 this.variables = { 727 'increase' : 0, 728 'grayscale': false, 729 'high_contrast': false, 730 'negative_contrast': false, 731 'light_background': false, 732 'links_underline': false, 733 'readable_font': false, 734 'text_spacing': 0, 735 'line_height': 0, 736 'text_align': false, 737 'play_animations': false, 738 } 739 } 740} 741 742document.addEventListener('DOMContentLoaded', function() { 743 setTimeout(function() { 744 new Accessibility(); 745 }, 1000); // 1000 milliseconds = 1 secon 746}); 747class Menu { 748 constructor(accessibility_option) { 749 this.accessibility_option = accessibility_option; 750 this.keys = { 751 ARROW_DOWN: 'ArrowDown', 752 ARROW_UP: 'ArrowUp', 753 ARROW_RIGHT: 'ArrowRight', 754 ARROW_LEFT: 'ArrowLeft', 755 SPACE: ' ', 756 ESCAPE: 'Escape', 757 HOME: 'Home', 758 END: 'End', 759 ENTER: 'Enter' 760 }; 761 // this.hamburger_close_btn = ''; 762 this.init(); 763 this.clickOutside(); 764 // this.menuOpener(); 765 // this.closeSubmenuByHover(); 766 } 767 768 init() { 769 this.menu_element = this.accessibility_option?.menu_element_selector?.menu_wrapper_selector; 770 this.is_hamburger = this.accessibility_option?.menu_element_selector?.hamburger ?? false; 771 this.center_logo = this.accessibility_option?.menu_element_selector?.center_logo ?? false; 772 var menu_closer = this.accessibility_option?.menu_element_selector?.menu_closer ?? false; 773 var menu_opener = this.accessibility_option?.menu_element_selector?.menu_opener ?? false; 774 775 if(menu_opener) { 776 this.menu_opener = document.querySelector(menu_opener); 777 } 778 779 if(menu_closer) { 780 this.menu_closer = document.querySelector(menu_closer); 781 } 782 783 if(!this.menu_element) return false;
784 this.menu_selector = document.querySelector(this.menu_element); 785 this.setupMenuRoles(); 786 this.bindEvent(); 787 } 788 789 setupMenuRoles() { 790 this.menu = document.querySelector(this.menu_element); 791 if(!this.menu) return; 792 793 let uls = ':scope > ul'; 794 if(this.center_logo) { 795 uls = ':scope > div > ul'; 796 } 797 798 if(this.menu.tagName !== 'UL') { 799 this.menu = this.menu.querySelectorAll(uls); 800 this.menu.forEach(item => { 801 this.menuRole(item); 802 }); 803 } else { 804 this.menuRole(this.menu); 805 } 806 807 808 } 809 810 menuRole(items) { 811 items.setAttribute('role', 'menubar'); 812 items.setAttribute('aria-label', 'Main Navigation Menu'); 813 814 const menuItems = items.querySelectorAll('li'); 815 menuItems.forEach(item => { 816 item.setAttribute('role', 'none'); 817 const anchor = item.querySelector('a'); 818 if(!anchor) return; 819 anchor.setAttribute('role', 'menuitem'); 820 if(item.querySelector('ul')) { 821 const hasButton = this.hasButtonInItem(item); 822 if(!hasButton) { 823 this.createSubmenuButton(item, anchor); 824 } 825 item.querySelector('ul').setAttribute('role', 'menu'); 826 anchor.setAttribute('aria-haspopup', 'true'); // Indicate that it has a submenu 827 anchor.setAttribute('aria-expanded', 'false'); // Initially not expanded 828 } 829 }); 830 831 if(this.is_hamburger && this.menu_opener) { 832 this.menu_opener.setAttribute('aria-haspopup', 'dialog'); 833 this.menu_opener.setAttribute('aria-expanded', 'false'); 834 this.menu_opener.setAttribute('aria-label', 'Open menu'); 835 836 if(this.menu_closer && this.menu_opener !== this.menu_closer) { 837 this.menu_closer.setAttribute('aria-label', 'Close menu'); 838 } 839 840 document.querySelector(this.menu_element).setAttribute('role', 'dialog'); 841 this.hamburgerClick(); 842 } 843 } 844 845 hasButtonInItem(item) { 846 // Get only direct children of the li element, excluding ul elements 847 const directChildren = Array.from(item.children).filter(child => child.tagName !== 'UL'); 848 // Return the button element if found, otherwise null 849 return directChildren.find(child => child.tagName === 'BUTTON') || null; 850 } 851 852 createSubmenuButton(item, anchor) { 853 const button = document.createElement('button'); 854 button.setAttribute('aria-expanded', 'false'); 855 button.setAttribute('aria-label', `Expand ${anchor.innerText} submenu`); 856 button.classList.add('fo-submenu-btn'); 857 button.textContent = 'â¼'; 858 item.appendChild(button); 859 } 860 861 bindEvent() { 862 document.addEventListener('keydown', this.handleKeydown.bind(this)); 863 } 864 865 handleKeydown(e) { 866 var menu_opener = this.menu_opener; 867 if(menu_opener && this.is_hamburger) { 868 if(document.activeElement === menu_opener) { 869 const hamburger_key_actions = { 870 [this.keys.SPACE]: () => this.openHamburger(), 871 [this.keys.ARROW_DOWN]: () => this.openHamburger(), 872 [this.keys.ENTER]: () => this.openHamburger() 873 } 874 875 const hamburger_action = hamburger_key_actions[e.key]; 876 if(hamburger_action) { 877 e.preventDefault(); 878 hamburger_action(); 879 return; 880 } 881 } 882 } 883 884 if(!this.isFocusInMenu()) return; 885 886 const key_actions = { 887 [this.keys.ARROW_DOWN]: () => this.handleArrowDown(), 888 [this.keys.ARROW_UP]: () => this.handleArrowUp(), 889 [this.keys.ARROW_RIGHT]: () => this.handleArrowRight(), 890 [this.keys.ARROW_LEFT]: () => this.handleArrowLeft(), 891 [this.keys.SPACE]: () => this.handleSpace(), 892 [this.keys.ESCAPE]: () => this.handleEsc(), 893 [this.keys.HOME]: () => this.jumpToPosition(0), 894 [this.keys.END]: () => this.jumpToPosition(-1) 895 } 896 897 const action = key_actions[e.key]; 898 if(action) { 899 e.preventDefault(); 900 setTimeout(action, 0); 901 } 902 903 } 904 905 /** 906 * Checks if the currently focused element is within the menu 907 * @returns {boolean} True if focus is within menu, false otherwise 908 */ 909 isFocusInMenu() { 910 const menu_element = document.querySelectorAll(this.menu_element); 911 return Array.from(menu_element).some(menuEl => { 912 return menuEl.contains(document.activeElement); 913 }); 914 } 915 916 /** 917 * Handles right arrow key press events 918 * - For top level items: moves to next item and opens submenu if present 919 * - For second level items with submenu: opens submenu and focuses first item 920 * - For items without submenu: moves focus to next top level item 921 */ 922 handleArrowRight() { 923 let position = this.getFocusedPosition(); 924 925 if (position.isTopLevel) { 926 this.closeAllSubmenus(); 927 if(!this.centerLogoMenuFocusMove('right')) { 928 this.jumpEndToEnd('last_to_first'); 929 } 930 931 // Check if new position has submenu and open it 932 const newPosition = this.getFocusedPosition(); 933 if (newPosition.hasSubmenu) { 934 this.openSubmenu(); 935 } 936 return; 937 } 938 939 // Second level with submenu: enter submenu 940 if (position.secondLevelMenu && position.hasSubmenu) { 941 this.openSubmenu();
942 this.focusSubmenuItem(); 943 return; 944 } 945 946 // // Any other level without submenu: navigate to top level 947 if (!position.hasSubmenu) { 948 this.closeAllSubmenus(); 949 if(!this.centerLogoMenuFocusMove('submenu_right')) { 950 this.moveToFirstLevel(); 951 } 952 } 953 } 954 955 handleArrowLeft() { 956 let position = this.getFocusedPosition(); 957 if(position.isTopLevel) { 958 this.closeAllSubmenus(); 959 if(!this.centerLogoMenuFocusMove('left')) { 960 this.jumpEndToEnd(); 961 } 962 } 963 964 if(position.secondLevelMenu) { 965 this.closeAllSubmenus(); 966 this.moveToFirstLevel('previous'); 967 } 968 969 if(position.thirdLevelMenu) { 970 this.moveToPreviousLevel(); 971 } 972 973 position = this.getFocusedPosition(); 974 975 if (position.isTopLevel && position.hasSubmenu) { 976 this.openSubmenu(); 977 } 978 } 979 980 handleArrowDown() { 981 const position = this.getFocusedPosition(); 982 983 // HORIZONTAL MENU: Context-aware navigation 984 if (position.hasSubmenu && position.isTopLevel) { 985 // Top level with submenu: Enter submenu 986 this.openSubmenu(); 987 this.focusSubmenuItem(); 988 } else if (!position.isTopLevel) { 989 // Already in submenu: Navigate within submenu 990 this.jumpEndToEnd('last_to_first'); 991 } 992 // Top level without submenu: No action (stays in place) 993 } 994 995 /** 996 * PRIMARY NAVIGATION: Handles arrow up key press events 997 * 998 * This is the core upward navigation method that adapts behavior based on: 999 * - Menu type (hamburger vs horizontal) 1000 * - Current position (top level vs submenu) 1001 * - Submenu availability 1002 * 1003 * HAMBURGER MENU BEHAVIOR: 1004 * - Always moves to previous item in vertical list 1005 * 1006 * HORIZONTAL MENU BEHAVIOR: 1007 * - Top level with submenu: Opens submenu and focuses last item 1008 * - Submenu levels: Moves to previous item within current submenu 1009 */ 1010 handleArrowUp() { 1011 const position = this.getFocusedPosition(); 1012 // HORIZONTAL MENU: Context-aware navigation 1013 if (position.hasSubmenu && position.isTopLevel) { 1014 // Top level with submenu: Enter submenu at LAST item 1015 this.openSubmenu(); 1016 this.focusSubmenuItem('last'); 1017 } else if (!position.isTopLevel) { 1018 // Already in submenu: Navigate within submenu (reverse) 1019 this.jumpEndToEnd(); 1020 } 1021 // Top level without submenu: No action (stays in place) 1022 } 1023 1024 handleSpace() { 1025 const position = this.getFocusedPosition(); 1026 1027 if (position.hasSubmenu) { 1028 if (!this.is_hamburger) this.openSubmenu(); 1029 else this.openSubmenuByClick(); 1030 this.focusSubmenuItem(); 1031 } else { 1032 if (!this.is_hamburger) this.closeAllSubmenus(); 1033 document.activeElement.click(); 1034 } 1035 } 1036 1037 // Handles escape key press events 1038 // Checks if escape key was pressed and if current focus is not on top level menu 1039 // If so, moves focus to previous menu level 1040 handleEsc() { 1041 const focue_possion = this.getFocusedPosition(); 1042 if(this.is_hamburger) { 1043 if(focue_possion.isTopLevel) { 1044 this.closeHamburger(); 1045 } else { 1046 this.closeSubmenuByClick(); 1047 } 1048 } else { 1049 if(!focue_possion.isTopLevel) { 1050 this.moveToPreviousLevel(); 1051 } 1052 } 1053 } 1054 1055 getFocusedPosition() { 1056 const li = document.activeElement.closest('li'); 1057 const hasSubmenu = li?.querySelector('ul'); 1058 1059 return { 1060 isTopLevel: this.isTopLevelMenuItem(), 1061 secondLevelMenu: this.isSecondLevelMenuItem(), 1062 thirdLevelMenu: this.isThirdLevelMenuItem(), 1063 currentIndex : this.getCurrentIndex(), 1064 hasSubmenu: hasSubmenu, 1065 }; 1066 } 1067 1068 getCurrentIndex() { 1069 const li = document.activeElement.closest('li'); 1070 const lis = Array.from(li.closest('ul').children).filter(el => 1071 el.tagName === 'LI' && el.querySelector('a') 1072 ); 1073 const currentIndex = lis.indexOf(li); 1074 return { 1075 length: lis.length, 1076 index: currentIndex 1077 } 1078 } 1079 1080 isTopLevelMenuItem() { 1081 const li = document.activeElement.closest('li'); 1082 if (!li) return false; 1083 1084 // Check if this li has no parent li (meaning it's top level) 1085 return !li.parentElement.closest('li'); 1086 } 1087 1088 isSecondLevelMenuItem() { 1089 const li = document.activeElement.closest('li'); 1090 if (!li) return false;
1091 1092 const parentLis = li.closest('ul').parentElement.closest('li'); 1093 // Second level: has exactly one parent li 1094 return parentLis && !parentLis.parentElement.closest('li'); 1095 } 1096 1097 isThirdLevelMenuItem() { 1098 const li = document.activeElement.closest('li'); 1099 if (!li) return false; 1100 1101 let parentCount = 0; 1102 let currentLi = li; 1103 1104 // Count parent li elements 1105 while (currentLi.parentElement.closest('li')) { 1106 currentLi = currentLi.parentElement.closest('li'); 1107 parentCount++; 1108 } 1109 1110 // Third level: has exactly two parent li elements 1111 return parentCount === 2; 1112 } 1113 1114 /** 1115 * Handles jumping between menu items at the same level 1116 * @param {string} level - Direction to jump: 'last_to_first' or 'first_to_last' 1117 * - Gets current focused list item 1118 * - Gets array of all list items at same level 1119 * - If 'last_to_first': moves to next sibling or wraps to first item 1120 * - If 'first_to_last': moves to previous sibling or wraps to last item 1121 * - Focuses the link in the target list item 1122 */ 1123 jumpEndToEnd(level = '') { 1124 const li = document.activeElement.closest('li'); 1125 const lis = Array.from(li.closest('ul').children).filter(el => 1126 el.tagName === 'LI' && el.querySelector('a') 1127 ); 1128 const currentIndex = lis.indexOf(li); 1129 const next = level === 'last_to_first' 1130 ? lis[currentIndex + 1] || lis[0] 1131 : lis[currentIndex - 1] || lis[lis.length - 1]; 1132 1133 next.querySelector('a')?.focus(); 1134 } 1135 1136 centerLogoMenuFocusMove(key) { 1137 const menu_element = this.menu; 1138 1139 if(menu_element.length === 1 || !menu_element.length) return false; 1140 1141 const left = Array.from(menu_element[0].children); 1142 const right = Array.from(menu_element[1].children); 1143 const current = this.getTopLevelLi(); 1144 1145 const moves = { 1146 right: [ 1147 [left[left.length - 1], () => menu_element[1].querySelector('a').focus()], 1148 [right[right.length - 1], () => menu_element[0].querySelector('a').focus()] 1149 ], 1150 left: [ 1151 [left[0], () => right[right.length - 1].querySelector('a').focus()], 1152 [right[0], () => left[left.length - 1].querySelector('a').focus()] 1153 ] 1154 }; 1155 1156 const moveSet = moves[key.replace('submenu_', '')] || []; 1157 for (const [target, action] of moveSet) { 1158 if (current === target) { 1159 action(); 1160 return true; 1161 } 1162 } 1163 1164 return false; 1165 } 1166 1167 getTopLevelLi() { 1168 let li = document.activeElement.closest('li'); 1169 if (!li) return null; 1170 1171 // Keep going up until we find the top level li 1172 while (li.parentElement.closest('li')) { 1173 li = li.parentElement.closest('li'); 1174 } 1175 1176 return li; 1177 } 1178 1179 openSubmenu() { 1180 var active_element = document.activeElement; 1181 const li = active_element.closest('li'); 1182 const hasButton = this.hasButtonInItem(li); 1183 if(hasButton) { 1184 hasButton.setAttribute('aria-expanded', 'true'); 1185 } 1186 1187 active_element.setAttribute('aria-expanded', 'true'); 1188 let next = document.activeElement.nextElementSibling; 1189 if(!next) { 1190 next = document.activeElement.parentElement.nextElementSibling; 1191 } 1192 1193 if(next) { 1194 var css = window.getComputedStyle(next); 1195 next.classList.add('fo-submenu-is-expanded'); 1196 1197 if(css.display === 'none') { 1198 this.addCssRule('.fo-submenu-is-expanded', 'display: block !important;'); 1199 } 1200 } 1201 } 1202 1203 closeAllSubmenus() { 1204 const open_elements = this.menu_selector.querySelectorAll(`.fo-submenu-is-expanded`); 1205 open_elements.forEach(item => { 1206 item.classList.remove('fo-submenu-is-expanded'); 1207 }); 1208 1209 this.menu_selector.querySelectorAll('[aria-expanded="true"]').forEach(item => { 1210 item.setAttribute('aria-expanded', 'false'); 1211 }); 1212 1213 } 1214 1215 1216 /** 1217 * Adds a CSS rule to the document 1218 * @param {string} selector - CSS selector 1219 * @param {string} rules - CSS rules 1220 */ 1221 addCssRule(selector, rules) { 1222 // Create a style element if it doesn't exist 1223 if (!this.styleElement) { 1224 this.styleElement = document.createElement('style'); 1225 this.styleElement.type = 'text/css'; 1226 document.head.appendChild(this.styleElement); 1227 } 1228 1229 // Add the rule to the style sheet 1230 const sheet = this.styleElement.sheet; 1231 sheet.insertRule(`${selector} { ${rules} }`, sheet.cssRules.length); 1232 } 1233 1234 /** 1235 * Focuses a submenu item based on position 1236 * @param {string} position - Position to focus ('first' or 'last') 1237 h* Finds the submenu of the currently focused menu item and focuses either 1238 * the first or last link within it based on the position parameter. 1239 * Returns early if there is no submenu. 1240 */ 1241 focusSubmenuItem(position = 'first') { 1242 const focus_position = this.getFocusedPosition(); 1243 if (!focus_position.hasSubmenu) return; 1244 1245 const child = Array.from(focus_position.hasSubmenu.children).filter(item => 1246 item.querySelector('a') 1247 ); 1248 1249 const target_link = position === 'last' 1250 ? child[child.length - 1].querySelector('a') 1251 : child[0].querySelector('a'); 1252 1253 target_link?.focus(); 1254 } 1255 1256 moveToFirstLevel(level = 'next') { 1257 let topLevelLi = this.getTopLevelLi(); 1258 let focus_li = null; 1259 1260 if(level === 'next') { 1261 focus_li = topLevelLi.nextElementSibling || topLevelLi.parentElement.children[0]; 1262 } else { 1263 focus_li = topLevelLi.previousElementSibling || topLevelLi.parentElement.children[topLevelLi.parentElement.children.length - 1]; 1264 } 1265 this.closeAllSubmenus(); 1266 focus_li.querySelector('a')?.focus(); 1267 this.openSubmenu(); 1268 } 1269 1270 /**
1271 * Jumps focus to a specific position in the current menu list 1272 * @param {number} position - Index of the menu item to focus (0-based) 1273 * Supports: 0 (first), -1 (last), or any valid index 1274 */ 1275 jumpToPosition(position) { 1276 const li = document.activeElement.closest('li'); 1277 if (!li) return; 1278 1279 const ul = li.closest('ul'); 1280 const lis = Array.from(ul.children).filter(el => 1281 el.tagName === 'LI' && el.querySelector('a') 1282 ); 1283 1284 // Handle negative index (e.g., -1 for last item) 1285 const targetIndex = position < 0 ? lis.length + position : position; 1286 1287 // Ensure index is within bounds 1288 if (targetIndex >= 0 && targetIndex < lis.length) { 1289 lis[targetIndex].querySelector('a')?.focus(); 1290 } 1291 } 1292 1293 // Closes any open submenus when clicking outside the menu 1294 clickOutside() { 1295 document.addEventListener('click', () => { 1296 this.closeAllSubmenus(); 1297 }); 1298 } 1299 1300 // Moves focus to the parent level menu item 1301 moveToPreviousLevel() { 1302 const li = document.activeElement.closest('li'); // - Gets the current active list item 1303 const ul = li.closest('ul'); 1304 const parentLi = ul.closest('li'); // - Finds its parent ul and parent li elements 1305 parentLi.classList.remove('is-open'); // - Removes the 'is-open' class from parent li 1306 parentLi.querySelector('a')?.focus(); // - Focuses the parent li's link 1307 } 1308 1309 openHamburger() { 1310 this.hamburger_button = document.activeElement; 1311 this.hamburger_button.click(); 1312 this.menu_selector.querySelector('a').focus(); 1313 } 1314 1315 hamburgerClick() { 1316 var _self = this; 1317 _self.menu_opener?.addEventListener('click', () => { 1318 var expanded = _self.menu_opener.getAttribute('aria-expanded'); 1319 if(expanded === 'true') { 1320 _self.menu_opener.setAttribute('aria-expanded', 'false'); 1321 _self.handleMenuCloser(false); 1322 } else { 1323 _self.menu_opener.setAttribute('aria-expanded', 'true'); 1324 _self.handleMenuCloser(true); 1325 } 1326 }); 1327 } 1328 1329 handleMenuCloser(status) { 1330 if(this.menu_closer && this.menu_closer !== this.menu_opener) return false; 1331 1332 if(status) { 1333 this.menu_opener.setAttribute('aria-label', 'Close menu') 1334 } else { 1335 this.menu_opener.setAttribute('aria-label', 'Open menu') 1336 } 1337 } 1338 1339 closeHamburger() { 1340 let menu_closer = this.accessibility_option?.menu_element_selector?.menu_closer ?? false; 1341 1342 if(menu_closer) { 1343 menu_closer = document.querySelector(menu_closer); 1344 } else { 1345 menu_closer = this.hamburger_button; 1346 } 1347 menu_closer.click(); 1348 this.hamburger_button.focus(); 1349 } 1350 1351 openSubmenuByClick() { 1352 let button = document.activeElement.nextElementSibling; 1353 if(button.tagName !== 'BUTTON') { 1354 button = document.activeElement.closest('li'); 1355 } 1356 document.activeElement.setAttribute('aria-expanded', 'true'); 1357 button.click(); 1358 } 1359 1360 closeSubmenuByClick() { 1361 this.moveToPreviousLevel(); 1362 this.openSubmenuByClick(); 1363 } 1364}
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.