1/** 2 * off-canvas nav 3 * @name ruffnav 4 * @author Dominik Kressler 5 * @version 2.6.1 6 * @thanks Jasmin Reiff 7 * @edit by Malte Zoudlik 8 * @edit by Dario Rieke 9 * @edit by Marc-Kevin Scharlipp 10 * @edit by Sami El-Younsi 11 * @edit by Daniel Regiment 12 * @lastedit 2025-06-13 13 * @copyright (c) 2018 14 */ 15class Ruffnav { 16 constructor(options) { 17 this.options = { 18 backText: false, 19 container: '#navigation', 20 closeOnScroll: true, 21 desktopHover: false, 22 mobileBreak: window.cssData.break, 23 navAppend: false, 24 navList: '.navi-main', 25 subClass: '.sub', 26 subParent: '.has-sub', 27 toggler: '#navi-toggled', 28 toggleLabel: 'label[for="navi-toggled"] [role="button"]', 29 navSplit: false, 30 forceMobileNav: false 31 }; 32 33 this.options = { ...this.options, ...options }; 34 35 this.activePath = ''; 36 this.alreadyOpened = false; 37 this.container = document.querySelector(this.options.container); 38 this.depth = 0; 39 this.device = breakpoint(this.options.mobileBreak) ? 'desktop' : 'mobile'; 40 this.html = document.documentElement; 41 this.isOpen = false; 42 this.lang = this.html.lang.split(/[-_]/)[0].toLowerCase(); 43 this.navigationStatus = document.querySelector('[data-navigation-status]'); 44 // both selectors are needed to maintain backwards compatibility for websites creates with 45 // initial files > 1.8.0 46 this.navList = this.container.querySelector(`${this.options.navList} > ul`) ?? this.container.querySelector(this.options.navList); 47 this.navSize = 0; 48 this.path = window.location.pathname.split('/').filter(el => el !== ''); 49 this.sub = this.container.querySelectorAll(this.options.subClass); 50 this.subMenuPositioner = null; 51 this.subParent = this.container.querySelectorAll(this.options.subParent); 52 this.subToggler = null; 53 this.toggler = document.querySelector(this.options.toggler); 54 this.windowSize = window.innerWidth; 55 56 // Initialize navigation height updater immediately 57 this.navigationHeightUpdater = new NavigationHeightUpdater(this.container); 58 59 // Initialize SubmenuPositioner immediately if submenus exist 60 if (this.sub.length > 0) { 61 // Specify submenus to exclude from repositioning if any (empty array for now) 62 const excludedNames = []; 63 this.subMenuPositioner = new SubmenuPositioner(30, excludedNames, this.sub, this); 64 } 65 66 // set back button text by language, or by overwrite 67 if (this.options.backText === false) this.options.backText = (this.lang === 'de') ? 'Zurück' : 'back'; 68 69 this.init(); 70 } 71 72 init() { 73 if (!this.navList) { 74 console.warn(`No element found with class "${this.options.navList}". Ruffnav couldn't be initialized. Please add the class to your main navigation UL-Element`); 75 return; 76 } 77 78 this.backButton = this.create('li', { 79 'class': 'nav-back', 80 'tabindex': '0', 81 'aria-label': this.options.backText, 82 'role': 'button' 83 }, `<span>${this.options.backText}</span>`); 84 85 document.querySelector(this.options.toggleLabel).addEventListener('keydown', function(e) { 86 if (e.key === "Enter" || e.key === ' ') { 87 e.preventDefault(); 88 this.click(); 89 } 90 }) 91 92 // add class for desktop hover 93 this.container.classList.add('first-level'); 94 if (this.options.desktopHover) this.container.classList.add('hover-navigation'); 95 96 // aria-attribute for nav element 97 const navigationName = (this.lang === 'de') ? `Hauptnavigation` : `main navigation`; 98 this.container.firstElementChild.setAttribute('aria-label', navigationName); 99 100 // remove expanded attribute for desktop navigation 101 let deviceType = breakpoint(this.options.mobileBreak) ? 'desktop' : 'mobile'; 102 if (deviceType === 'desktop') this.container.removeAttribute('aria-expanded'); 103 104 // append custom Content to Navigation 105 if (this.options.navAppend) { 106 const itemToAppend = this.create('li', { 'class': 'nav-item appended-item' }, this.options.navAppend); 107 this.navList.appendChild(itemToAppend); 108 } 109 110 this.navSize = this.navList.children.length; 111 112 // transform Navigation markup 113 this.subParent.forEach(parentItem => { 114 const childNode = parentItem.firstElementChild; 115 const categoryLabel = (childNode.dataset && childNode.dataset.categoryLabel) ? childNode.dataset.categoryLabel : childNode.innerHTML.replace(/(<([^>]+)>)/gi, ""); 116 117 // parent-name attribute for UL's 118 let subParentName = childNode.textContent.replace(/(\r\n|\n|\r)/gm, ''); 119 childNode.nextElementSibling.setAttribute('data-parent-name', subParentName.trim()); 120 121 // only clone when Element is a link 122 if (childNode.nodeName !== 'SPAN') { 123 const activeLinkClass = (childNode.classList.contains('active') && window.location.origin + window.location.pathname === childNode.href) ? 'active' : ''; 124 const nodeLinkClass = childNode.className.trim().replace(/\bactive\b/g, '').trim(); 125 126 // copy link inside UL's 127 childNode.nextElementSibling.insertBefore(this.create('li', { 128 'class': 'nav-item ' + childNode.className.trim() 129 }, `<a href="${childNode.href}" title="${childNode.title}" class="${activeLinkClass} ${nodeLinkClass}">${categoryLabel}</a>`), parentItem.children[1].children[0]); 130 131 if (this.path.length) this.activeClassFallback(childNode); 132 } 133 134 // replace link's with span's 135 const label = (this.lang === 'de') ? `${categoryLabel} Unterverzeichnis öffnen` : `open ${categoryLabel} sub-navigation`; 136 137 const hasClass = !!childNode.className; 138 const rawParentName = parentItem.parentElement?.dataset?.parentName || ''; 139 const parentNameAttribute = rawParentName.replaceAll(" ","-").toLowerCase().trim(); 140 const childName = childNode.innerHTML.replaceAll(" ","-").toLowerCase().trim(); 141 const className = hasClass ? childNode.className.trim() : `${parentNameAttribute}-${childName}`; 142 143 if (!hasClass && parentItem.classList.contains('has-sub')) { 144 parentItem.classList.add(className); 145 } 146 147 const attrs = { 148 class: `sub-toggler ${className}`, 149 tabindex: 0, 150 'aria-haspopup': 'true', 151 'aria-expanded': 'false', 152 'aria-label': label, 153 role: 'button', 154 ...(hasClass && { 'data-href': childNode.href }) 155 }; 156 157 parentItem.replaceChild(this.create('span', attrs, childNode.innerHTML), childNode); 158 }); 159 160 // navigation split 161 if (this.options.navSplit) { 162 // add a class to the element which will split the navigation 163 this.navList.classList.add("nav-split"); 164 const middle = Math.round(this.navSize / 2); 165 if(middle) { 166 this.navList.children.item(middle - 1)?.classList.add("nav-split-li"); 167 } 168 } 169 170 // add aria-attributes and back buttons for all submenus 171 this.sub.forEach(subItem => { 172 subItem.setAttribute('aria-expanded', 'false'); 173 const subMenuLabel = (this.lang === 'de') ? `${subItem.dataset.parentName} Unterverzeichnis` : `${subItem.dataset.parentName} sub-navigation`; 174 subItem.setAttribute('aria-label', subMenuLabel); 175 176 // add tabindex for mobile navigation to make submenu focusable 177 if (deviceType === 'desktop') { 178 subItem.removeAttribute('tabindex'); 179 } else if (deviceType === 'mobile') { 180 subItem.setAttribute('tabindex', '0'); 181 } 182 183 // add back buttons to UL's and add aria-label 184 const thisBackButton = this.backButton.cloneNode(true); 185 const backButtonLabel = (this.lang === 'de') ? `${subItem.dataset.parentName}
185 Unterverzeichnis schlieÃen` : `close ${subItem.dataset.parentName} sub-navigation`; 186 thisBackButton.setAttribute('aria-label', backButtonLabel); 187 subItem.insertBefore(thisBackButton, subItem.childNodes[0]); 188 }); 189 190 // add aria attributes to each link inside navigation 191 const allNavigationLinks = this.navList.querySelectorAll('a'); 192 allNavigationLinks.forEach(naviLink => { 193 // update / set aria-attributes for current location or extern links 194 if (naviLink.classList.contains('active')) { 195 let naviLinkName = naviLink.textContent.replace(/(\r\n|\n|\r)/gm, ''); 196 const activePageLabel = (this.lang === 'de') ? `${naviLinkName} (aktuelle Seite)` : `${naviLinkName} (current location)`; 197 naviLink.setAttribute('aria-label', activePageLabel); 198 naviLink.setAttribute('aria-current', 'page'); 199 } else if (naviLink.getAttribute('target') === '_blank') { 200 let naviLinkName = naviLink.textContent.replace(/(\r\n|\n|\r)/gm, ''); 201 const externPageLabel = (this.lang === 'de') ? `${naviLinkName} (öffnet eine neue Seite)` : `${naviLinkName} (opens a new window)`; 202 naviLink.setAttribute('aria-label', externPageLabel); 203 } 204 }); 205 206 // close on mobile toggle 207 this.toggler.addEventListener('click', this._closeAll); 208 // add Listeners for different events 209 this.addListener(); 210 // add global resize listener 211 this.listenResize(); 212 } 213 214 _open = (e) => { 215 // open event 216 let target, sameLevelCurrents; 217 218 if (e.type === 'mouseenter' && (this.device === 'mobile' || this.options.forceMobileNav)) return; 219 if (e.type === 'mouseenter') { 220 target = e.target; 221 target.childNodes[1].classList.add('current'); 222 target.childNodes[1].setAttribute('aria-expanded', 'true'); 223 if (this.container.querySelector('.open') != null) this.container.querySelector('.open').classList.remove('open'); 224 target.childNodes[1].classList.add('open'); 225 226 if (this.options.desktopHover) { 227 target.querySelector(':scope > .sub').setAttribute('aria-expanded', 'true'); 228 target.querySelector(':scope > .sub').classList.add('current'); 229 } 230 } else { 231 target = e.currentTarget; 232 233 const submenuToOpen = target.nextElementSibling; 234 235 // If the target submenu is already the one with the 'open' class, prevent re-opening/re-positioning 236 // Check specifically for 'open' class as 'current' might be present even if a child is open. 237 if (submenuToOpen && submenuToOpen.classList.contains('open')) { 238 // Optionally, set focus back to the toggler or the submenu if needed 239 // submenuToOpen.focus(); 240 return; // Exit the function early 241 } 242 243 // --- Logic to run ONLY when opening the submenu for the first time or re-entering this level --- // 244 // close other on same level when already opened 245 sameLevelCurrents = target.parentElement.parentElement.querySelector('.current'); 246 if (sameLevelCurrents !== null) { 247 // Check if sameLevelCurrents is the submenu we are about to open 248 // This prevents closing itself if it's the top-level menu being re-clicked 249 if (sameLevelCurrents !== submenuToOpen) { 250 this.closeRecusive(sameLevelCurrents, false); 251 } 252 } 253 254 // Remove 'open' from any other currently open submenu in the container 255 // This ensures only one submenu is visually "open" at any given time at any level 256 const currentlyOpenSubmenu = this.container.querySelector('.open'); 257 if (currentlyOpenSubmenu !== null) { 258 // Make sure we don't remove 'open' from the submenu we are about to open 259 // if it somehow got marked open already without passing the check above (unlikely) 260 if (currentlyOpenSubmenu !== submenuToOpen) { 261 currentlyOpenSubmenu.classList.remove('open'); 262 } 263 } 264 265 // Add 'current' to the parent of the submenu we are opening 266 // target.nextElementSibling.classList.add('current'); // This is already handled below 267 target.setAttribute('aria-expanded', 'true'); // Toggler should reflect expanded state 268 269 // Check if the submenu already had the 'current' class before we add it 270 const wasAlreadyCurrent = submenuToOpen.classList.contains('current'); 271 272 // Add 'open' and 'current' classes to the target submenu 273 // Only add 'open' and 'current' and trigger positioning if we are truly entering this level 274 // i.e., the submenu was NOT already 'current'. 275 if (!wasAlreadyCurrent) { 276 submenuToOpen.classList.add('open', 'current'); // Add both open and current 277 submenuToOpen.setAttribute('aria-expanded', 'true'); // Submenu should reflect expanded state 278 279 // Trigger submenu positioning immediately if SubmenuPositioner is initialized and not excluded. 280 // ONLY position if the submenu was NOT already 'current' (meaning we are entering this level/opening it for the first time on this level) 281 if (this.subMenuPositioner && !(this.device === 'mobile' || this.options.forceMobileNav)) { 282 if (submenuToOpen && submenuToOpen.classList.contains('sub') && !this.subMenuPositioner.shouldExclude(submenuToOpen)) { 283 // Reset position before adjusting, similar to resize logic 284 this.subMenuPositioner.resetPositioning(submenuToOpen); 285 this.subMenuPositioner.adjustPosition(submenuToOpen); 286 } 287 } 288 289 // inform screenreader via aria-live that submenu got opened
290 const currentSubName = submenuToOpen.getAttribute('aria-label'); 291 const subStatusOpened = (this.lang === 'de') ? `${currentSubName} wurde geöffnet` : `${currentSubName} got opened`; 292 this.updateNavigationStatus(subStatusOpened); 293 294 // set focus on submenu 295 setTimeout(() => { submenuToOpen.focus(); }, 250); 296 297 // observe focus inside submenu 298 document.addEventListener('keydown', this.initWatchFocus); 299 } else { 300 // If it was already 'current', it means a child submenu was likely opened. 301 // We just need to make sure THIS submenu gets the 'open' class again if needed. 302 // The logic to remove 'open' from other submenus and add it to this one 303 // should already be handled by the lines before this if/else block. 304 // Ensure this submenu has the 'open' class. 305 submenuToOpen.classList.add('open'); 306 submenuToOpen.setAttribute('aria-expanded', 'true'); // Ensure aria-expanded is true 307 } 308 // --- End of Logic to run when opening or re-entering level --- // 309 } 310 if (!this.isOpen && this.device !== 'mobile') { 311 if (this.options.closeOnScroll) window.addEventListener('scroll', this._closeAll); 312 document.addEventListener('click', this._closeAll); 313 this.isOpen = true; 314 } 315 316 this.container.classList.remove('first-level'); 317 this.scrollTop(); 318 } 319 320 _close = (e) => { 321 // close event 322 let currentListTree; 323 if (e.type === 'mouseleave' && (this.device === 'mobile' || this.options.forceMobileNav)) return; 324 if (e.type === 'mouseleave') currentListTree = e.currentTarget; 325 else currentListTree = e.currentTarget.parentElement; 326 327 this.closeRecusive(currentListTree, false); 328 if (this.container.querySelectorAll('ul.current').length === 0) this.isOpen = false; 329 if (this.container.querySelectorAll('ul.current').length === 0) this.container.classList.add('first-level'); 330 331 // inform screenreader via aria-live that submenu got closed 332 const currentSubName = currentListTree.getAttribute('aria-label'); 333 const subStatusClosed = (this.lang === 'de') ? `${currentSubName} wurde geschlossen` : `${currentSubName} got closed`; 334 this.updateNavigationStatus(subStatusClosed); 335 336 // set focus 337 const thisSubToggler = currentListTree.parentElement.firstElementChild; 338 thisSubToggler.focus(); 339 340 // update aria-attribute 341 const target = e.currentTarget; 342 if (this.options.desktopHover && e.type === 'mouseleave') target.querySelector(':scope > .sub').setAttribute('aria-expanded', 'false'); 343 344 this.scrollTop(); 345 } 346 347 _closeAll = (e) => { 348 // check for toggle input and update attributes 349 (this.toggler.checked) ? this.html.classList.add('hidden-scroll') : this.html.classList.remove('hidden-scroll'); 350 (this.toggler.checked) ? this.container.setAttribute('aria-expanded', 'true') : this.container.setAttribute('aria-expanded', 'false'); 351 (this.toggler.checked) ? '' : this.alreadyOpened = false; 352 const toggleLabel = document.querySelector(this.options.toggleLabel); 353 const menuOpenTogglerLabel = (this.lang === 'de') ? `Hauptnavigation öffnen` : `open main navigation`; 354 const menuCloseTogglerLabel = (this.lang === 'de') ? `Hauptnavigation schlieÃen` : `close main navigation`; 355 (this.toggler.checked) ? toggleLabel.setAttribute('aria-label', menuCloseTogglerLabel) : toggleLabel.setAttribute('aria-label', menuOpenTogglerLabel); 356 (this.toggler.checked) ? toggleLabel.setAttribute('aria-expanded', 'true') : toggleLabel.setAttribute('aria-expanded', 'false'); 357 358 // inform screenreader via aria-live if navigation got closed or opened on mobile 359 if (this.device === 'mobile' || this.options.forceMobileNav) { 360 const navigationStatusOpened = (this.lang === 'de') ? `Hauptnavigation wurde geöffnet` : `Main navigation got opened`; 361 const navigationStatusClosed = (this.lang === 'de') ? `Hauptnavigation wurde geschlossen` : `Main navigation got closed`; 362 (this.toggler.checked) ? this.updateNavigationStatus(navigationStatusOpened) : this.updateNavigationStatus(navigationStatusClosed); 363 } 364 365 // close all 366 if (e.type === 'scroll') { 367 this.closeRecusive(null, true); 368 } else if (e.type === 'click') { 369 // check if click was not inside navigation 370 if (e.target === this.container || this.container.contains(e.target)) return; 371 else this.closeRecusive(null, true); 372 } else if (e.type === 'keydown') { 373 // check if "enter" was pressed 374 if (e.keyCode === 27) this.closeRecusive(null, true); 375 } else { 376 this.closeRecusive(null, true); 377 } 378 379 this.removeListeners(); 380 this.isOpen = false;
381 } 382 383 closeRecusive = (firstList, closeAll) => { 384 // close all open items 385 if (closeAll) { 386 this.container.querySelectorAll('.current').forEach(openSubs => { 387 openSubs.classList.remove('current'); 388 openSubs.setAttribute('aria-expanded', 'false'); 389 openSubs.parentNode.firstElementChild.setAttribute('aria-expanded', 'false'); 390 // if the submenu was opened by keyboard (has no-transition class), 391 // remove the class AFTER the transition duration to ensure no transition on close. 392 if (openSubs.classList.contains('no-transition')) { 393 setTimeout(() => { 394 openSubs.classList.remove('no-transition'); 395 }, 300); // match the CSS transition duration 396 } 397 }); 398 399 if (this.container.querySelector('.open') != null) this.container.querySelector('.open').classList.remove('open'); 400 401 this.container.classList.add('first-level'); 402 } else { 403 if (this.container.querySelector('.open') != null) this.container.querySelector('.open').classList.remove('open'); 404 405 firstList.querySelectorAll('.current').forEach(openSubs => { 406 openSubs.classList.remove('current'); 407 openSubs.setAttribute('aria-expanded', 'false'); 408 openSubs.parentNode.firstElementChild.setAttribute('aria-expanded', 'false'); 409 410 // stop observing focus inside ul 411 if (this.device === 'mobile' || this.options.forceMobileNav) document.removeEventListener('keydown', this.initWatchFocus); 412 }); 413 414 // update classes and attributes 415 firstList.parentNode.parentNode.classList.add('open'); 416 firstList.classList.remove('current'); 417 firstList.setAttribute('aria-expanded', 'false'); 418 firstList.parentNode.firstElementChild.setAttribute('aria-expanded', 'false'); 419 420 // if the submenu was opened by keyboard (has no-transition class), 421 // remove the class AFTER the transition duration to ensure no transition on close. 422 if (firstList.classList.contains('no-transition')) { 423 setTimeout(() => { 424 firstList.classList.remove('no-transition'); 425 }, 300); // match the CSS transition duration (0.3s = 300ms) 426 } 427 } 428 429 // if navigation is opened initially or reopened, set focus and observe focus inside submenu 430 if (this.toggler.checked && this.container.querySelectorAll('ul.current').length === 0 && (this.device === 'mobile' || this.options.forceMobileNav) && !this.alreadyOpened) { 431 if (this.navList.classList.contains('navi')) { 432 this.navList.classList.add('open'); 433 } 434 // observe focus inside submenu 435 setTimeout(() => { document.addEventListener('keydown', this.initWatchFocus); }, 300); 436 437 this.alreadyOpened = true; 438 } 439 } 440 441 removeListeners = () => { 442 // remove eventListener for global close 443 window.removeEventListener('scroll', this._closeAll); 444 document.removeEventListener('keydown', this._closeAll); 445 document.removeEventListener('click', this._closeAll); 446 document.removeEventListener('keydown', this.initWatchFocus); 447 } 448 449 addListener = () => { 450 // add eventListener to sub-togglers and back-buttons 451 this.subToggler = this.container.querySelectorAll('.sub-toggler'); 452 this.backButtons = this.container.querySelectorAll('.nav-back'); 453 const ruffnavInstance = this; // Capture the Ruffnav instance 454 455 this.subToggler.forEach(subTogglerItem => { 456 if (this.options.desktopHover) { 457 subTogglerItem.parentNode.addEventListener('mouseenter', ruffnavInstance._open); 458 subTogglerItem.parentNode.addEventListener('mouseleave', ruffnavInstance._close); 459 } 460 subTogglerItem.addEventListener('click', ruffnavInstance._open); 461 if (this.options.desktopHover) subTogglerItem.addEventListener('mouseover', ruffnavInstance._open); 462 463 // add keydown listener for Enter key 464 subTogglerItem.addEventListener('keydown', function(e) { 465 if (e.key === 'Enter' || e.key === ' ') { // Also allow Spacebar 466 e.preventDefault(); // prevent the default action (e.g., click or scrolling for spacebar) 467 e.stopPropagation(); // stop the event from bubbling up 468 // find the submenu (ul.sub) associated with this toggler 469 const submenu = this.nextElementSibling; 470 if (submenu && submenu.classList.contains('sub')) { 471 // add the no-transition class before opening 472 submenu.classList.add('no-transition'); 473 474 // trigger the original open logic using the captured Ruffnav instance 475 // pass the current event object 476 ruffnavInstance._open(e); 477 } 478 } 479 }); 480 }); 481 this.backButtons.forEach(backButtonsItem => { 482 backButtonsItem.addEventListener('click', this._close, false); 483 // Add keydown listener for Spacebar to close 484 backButtonsItem.addEventListener('keydown', (e) => { 485 if (e.key === 'Enter' || e.key === ' ') { // React to Spacebar 486 e.preventDefault(); // Prevent default scroll behavior 487 e.stopPropagation(); // Stop propagation 488 this._close(e); // Call the close logic 489 } 490 // Allow default Enter key behavior on button/list item if applicable, or rely on click for Enter 491 // Note: Standard button/list item behavior for Enter is often a click, which is covered by the click listener. 492 }, false); 493 }); 494 } 495 496 listenResize = () => { 497 // global resize Event 498 window.onresize = () => { 499 const oldDevice = this.device; // Capture old device type before updating 500 this.device = breakpoint(this.options.mobileBreak) ? 'desktop' : 'mobile'; 501 502 // Always update navigation height on resize, especially for desktop 503 if (this.device === 'desktop') { 504 // Ensure height is updated immediately when switching to or resizing on de
504sktop 505 this.navigationHeightUpdater.updateNavigationHeight(); 506 } 507 508 if (this.device === 'mobile' || this.options.forceMobileNav) { 509 this.removeListeners(); 510 // update aria-attributes 511 this.container.setAttribute('aria-expanded', 'false'); 512 513 // add tabindex for all submenus to make them focusable 514 this.sub.forEach(subItem => { 515 subItem.setAttribute('tabindex', '0'); 516 }); 517 } 518 if (this.windowSize !== window.innerWidth && this.device === 'desktop') { 519 this.closeRecusive(null, true); 520 this.isOpen = false; 521 // update aria-attributes 522 this.container.removeAttribute('aria-expanded'); 523 524 // stop observing focus inside ul 525 document.removeEventListener('keydown', this.initWatchFocus); 526 527 if (this.toggler.checked) this.toggler.checked = false; 528 529 this.sub.forEach(subItem => { 530 subItem.removeAttribute('tabindex'); 531 }); 532 } 533 534 this.windowSize = window.innerWidth; 535 // Only call navHeightSubPositioner for submenu positioning logic, which still has a timeout 536 // The navigation height update is now handled directly in the resize listener. 537 538 // Call the method that contains the debounced submenu positioning logic 539 if (this.sub.length > 0 && this.subMenuPositioner) { 540 this.navHeightSubPositioner(); 541 } 542 }; 543 } 544 545 scrollTop = () => { 546 this.sub.forEach(item => { item.scrollTop = 0; }); 547 this.container.childNodes[0].scrollTop = 0; 548 } 549 550 create = (type, attr, content) => { 551 // create Node element 552 const elm = document.createElement(type); 553 Object.entries(attr).forEach(([name, value]) => { 554 elm.setAttribute(name, value); 555 }); 556 if (content) elm.innerHTML = content; 557 return elm; 558 } 559 560 activeClassFallback = (elem) => { 561 if (elem.href === window.location.origin + '/' + this.activePath + this.path[0] + '/') { 562 elem.classList.add('active'); 563 elem.setAttribute('aria-current', 'page'); 564 if (elem.parentNode.classList.contains(this.options.subParent.substring(1))) elem.parentNode.classList.add('active'); 565 if (this.path.length === 1) elem.parentNode.querySelector('[href="' + window.location.origin + '/' + this.activePath + this.path[0] + '/"]').classList.add('active'); 566 this.activePath += this.path[0] + '/'; 567 this.path.splice(0, 1); 568 } 569 } 570 571 updateNavigationStatus = (text) => { 572 // function to update text inside aria-live element for screenreader 573 const navigationStatusElement = this.navigationStatus; 574 navigationStatusElement.textContent = ''; 575 navigationStatusElement.textContent = text; 576 } 577 578 initWatchFocus = (e) => { 579 // call function to observe focus 580 return this.watchFocus(e, this.container.querySelector('.open')); 581 } 582 583 watchFocus = (e, modalElement) => { 584 // observe focus inside ul function 585 const isTabPressed = e.key === 'Tab' || e.keyCode === 9; 586 const isEscPressed = e.key === 'Escape' || e.keyCode === 27; 587 588 if (!isTabPressed && !isEscPressed) return; 589 590 let focusableElements = `:scope > li > [href], :scope > li[role="button"], :scope > li > span[role="button"], :scope > li > span`; 591 let modal = modalElement; 592 if (modal.classList.contains('navi')) { 593 modal = this.container; 594 focusableElements = `.navi > li > [href], .navi > li[role="button"], .navi > li > span[role="button"], .navi > li > span, button, a.branding, .phone, .email, .mobile-close-button`; 595 } 596 597 // get focusable and visible elements in modal 598 const visibleElements = []; 599 const focusableContent = modal.querySelectorAll(focusableElements); 600 601 focusableContent.forEach(thisElement => { 602 if (thisElement.checkVisibility()) visibleElements.push(thisElement); 603 }); 604 605 const firstFocusableElement = visibleElements[0]; 606 const lastFocusableElement = visibleElements[visibleElements.length - 1]; 607 608 if (isTabPressed) { 609 if (e.shiftKey) { 610 if (document.activeElement === firstFocusableElement) { 611 // if first element is focused and shift + tab is clicked, close menu 612 e.preventDefault(); 613 this.focusCloseAction(e, modal); 614 } else if (document.activeElement === modal) { 615 // if menu is focused and shift + tab is clicked, close focused menu 616 e.preventDefault(); 617 this.focusCloseAction(e, modal); 618 } 619 } else if (!e.shiftKey && document.activeElement === lastFocusableElement) { 620 // if last element is focused and tab is clicked, close menu 621 e.preventDefault(); 622 this.focusCloseAction(e, modal); 623 } 624 } else if (isEscPressed) { 625 // if Esc is clicked, close current menu 626 e.preventDefault(); 627 this.focusCloseAction(e, modal); 628 } 629 } 630 631 focusCloseAction = (e, modal) => { 632 // handle focus on close 633 if (modal === this.container) { 634 // if first-level, close navigation and set focus to burger icon 635 this.toggler.checked = false;
636 this._closeAll(e); 637 const toggleLabel = document.querySelector(this.options.toggleLabel); 638 toggleLabel.focus(); 639 } else { 640 // if not first-level, toggle nav-back and close submenu 641 modal.querySelector(':scope > .nav-back').click(); 642 } 643 } 644 645 navHeightSubPositioner = () => { 646 // Debounced logic for submenu positioning on resize 647 if (this.viewportTimer) clearTimeout(this.viewportTimer); // Clear previous timeout 648 649 this.viewportTimer = setTimeout(() => { 650 // Use requestAnimationFrame to ensure layout is updated before measuring 651 requestAnimationFrame(() => { 652 if (breakpoint(this.options.mobileBreak) && this.sub.length > 0 && this.subMenuPositioner) { 653 // Desktop: Trigger submenu repositioning via the already initialized SubmenuPositioner 654 // The MutationObserver in SubmenuPositioner handles opening/closing. 655 // This part handles resize. 656 // Re-adjust position for all currently open submenus on resize 657 this.container.querySelectorAll('ul.sub.open').forEach(openSubmenu => { 658 if (!this.subMenuPositioner.shouldExclude(openSubmenu)) { 659 // Reset first, then adjust. Resetting position clears previous transform/top styles. 660 this.subMenuPositioner.resetPositioning(openSubmenu); 661 this.subMenuPositioner.adjustPosition(openSubmenu); 662 } 663 }); 664 } else if (this.sub.length > 0 && this.subMenuPositioner) { 665 // Mobile: Destroy submenu positioning logic and reset inline styles if it was active 666 // Only destroy if SubmenuPositioner was actually initialized 667 this.subMenuPositioner.destroy(); // Stop submenu adjustments and remove resize listener 668 669 // Reset any inline styles added by the submenu positioner on all submenus 670 this.sub.forEach(submenu => { 671 this.subMenuPositioner.resetPositioning(submenu); // Use SubmenuPositioner method to reset 672 }); 673 } 674 // Clear the subMenuPositioner instance on mobile to allow re-initialization on desktop switch 675 if (this.device === 'mobile' || this.options.forceMobileNav) { 676 this.subMenuPositioner = null; // Clear instance 677 } 678 }); // End of requestAnimationFrame 679 680 }, 750); // Increased delay to give layout more time to settle after resize 681 } 682} 683 684// Polyfills for Object.entries and Element.remove 685if (!Object.entries) { 686 Object.entries = function (obj) { 687 const ownProps = Object.keys(obj); 688 const i = ownProps.length; 689 const resArray = new Array(i); // preallocate the Array 690 while (i--) resArray[i] = [ownProps[i], obj[ownProps[i]]]; 691 return resArray; 692 }; 693} 694if (!('remove' in Element.prototype)) { 695 Element.prototype.remove = function () { 696 if (this.parentNode) this.parentNode.removeChild(this); 697 }; 698} 699 700// Class to update and manage the navigation height as a CSS variable 701class NavigationHeightUpdater { 702 constructor(navigationContainer = document.getElementById('navigation')) { 703 this.classNamesToMonitor = ['font-size-110', 'font-size-130']; // Classes to monitor for changes 704 this.navigationContainer = navigationContainer; // navigation element with class from ruffnav 705 this.updateNavigationHeight(); // Perform initial height update 706 this.startMonitoring(); // Monitor <html> class changes 707 } 708 709 // Method to calculate and update the navigation height 710 updateNavigationHeight = () => { 711 const navigationHeight = this.navigationContainer.offsetHeight; 712 document.documentElement.style.setProperty('--navigation-height', `${navigationHeight}`); 713 } 714 715 // Set up monitoring for class changes on <html> 716 startMonitoring = () => { 717 const htmlElement = document.documentElement; 718 719 // Observer to track changes in <html> element's class attribute 720 const observer = new MutationObserver((mutations) => { 721 mutations.forEach((mutation) => { 722 if (mutation.attributeName === 'class') { 723 const classFound = this.classNamesToMonitor.some(className => 724 htmlElement.classList.contains(className)); 725 726 // Update the height if any of the monitored classes are found 727 if (classFound || !classFound) this.updateNavigationHeight();
727 // Trigger immediate height update 728 } 729 }); 730 }); 731 732 // Observe for attribute changes on <html> 733 observer.observe(htmlElement, { attributes: true }); 734 } 735} 736 737// Class to manage submenu positioning and prevent it from leaving the viewport 738class SubmenuPositioner { 739 constructor(closeButtonWidth = 30, excludedSubmenus = [], submenus = document.getElementById('navigation').querySelectorAll('ul.sub'), ruffnavInstance) { 740 this.closeButtonWidth = closeButtonWidth; // Width of the close button in submenus 741 this.excludedSubmenus = excludedSubmenus; // Submenus to be excluded from repositioning 742 this.submenus = submenus; // all submenus with class from ruffnav 743 this.ruffnavInstance = ruffnavInstance; // Store Ruffnav instance 744 this.init(); // Initialize the observer 745 } 746 747 // Initialize the MutationObserver and event listener 748 init() { 749 // Observer to track when submenus are opened (i.e., when they gain the "open" class) 750 this.observer = new MutationObserver((mutations) => { 751 mutations.forEach((mutation) => { 752 // Check if the target is a submenu and gained the 'open' class or lost the 'open' class (for reset) 753 if (mutation.target.classList && mutation.target.classList.contains('sub')) { 754 if (mutation.target.classList.contains('open')) { 755 const submenu = mutation.target; 756 const oldClasses = mutation.attributeOldValue ? mutation.attributeOldValue.split(' ') : []; 757 const wasCurrentBefore = oldClasses.includes('current'); 758 const isNowOpen = submenu.classList.contains('open'); // Redundant check, but for clarity 759 // Only call adjustPosition if the submenu is gaining the 'open' class 760 // AND it was NOT current before. 761 // This prevents re-positioning when clicking a parent toggler while a child is open. 762 if (isNowOpen && !wasCurrentBefore) { 763 if (!this.shouldExclude(submenu) && !( this.ruffnavInstance.device === 'mobile' || this.ruffnavInstance.options.forceMobileNav)) { 764 this.adjustPosition(submenu); 765 } // Reposition submenu if not excluded 766 } 767 } else { // If it lost the 'open' class, check if it's a genuine close (lost 'current' as well) 768 const submenu = mutation.target; 769 // Only reset if the submenu was truly closed (lost 'open' AND 'current') 770 if (!submenu.classList.contains('open') && !submenu.classList.contains('current')) { 771 this.resetPositioning(submenu); // Reset positioning 772 } 773 } 774 } 775 }); 776 }); 777 778 // Observe all submenus for class changes 779 this.submenus.forEach(submenu => { 780 this.observer.observe(submenu, { attributes: true, attributeFilter: ['class'] }); 781 }); 782 } 783 784 // Cleanup: Disconnect observer 785 destroy = () => { 786 if (this.observer) this.observer.disconnect(); // Stop observing submenu changes 787 } 788 789 // Helper method to determine if a submenu should be excluded from repositioning 790 shouldExclude = (submenu) => { 791 const parentName = submenu.getAttribute('data-parent-name'); 792 return this.excludedSubmenus.includes(parentName); // Return true if it should be excluded 793 } 794 795 // Reset positioning styles for a submenu 796 resetPositioning = (submenu) => { 797 submenu.dataset.positioned = "false"; // Mark submenu as not positioned 798 799 // Only reset transform if the submenu is truly closed (has neither open nor current class) 800 if (!submenu.classList.contains('open') && !submenu.classList.contains('current')) { 801 submenu.style.transform = ''; // Clear any transformation 802 } 803 804 submenu.style.top = ''; // Reset top position 805 submenu.style.bottom = ''; // Reset bottom position 806 submenu.style.left = ''; // Reset left position 807 submenu.style.right = ''; // Reset right position 808 } 809 810 // Adjust the position of the submenu to ensure it stays within the viewport 811 adjustPosition = (submenu) => { 812 if (submenu.dataset.positioned === "true") return; // Skip if already positioned 813 814 const submenuRect = submenu.getBoundingClientRect();
815 const viewportHeight = window.innerHeight; 816 const viewportWidth = window.innerWidth; 817 const leftMargin = 25; // Margin to leave from the left edge of the screen 818 819 // Vertical adjustments: Ensure the submenu stays within the viewport vertically 820 if (submenuRect.bottom > viewportHeight) { 821 submenu.style.top = `${viewportHeight - submenuRect.bottom}px`; 822 } else if (submenuRect.top < 0) { 823 submenu.style.top = `${-submenuRect.top}px`; 824 } 825 826 // Horizontal adjustments: Ensure the submenu stays within the viewport horizontally 827 let translateXValue = 0; 828 if (submenuRect.right + this.closeButtonWidth > viewportWidth) { 829 const overflowRight = submenuRect.right + this.closeButtonWidth - viewportWidth; 830 translateXValue = -overflowRight*2; // Shift the submenu left if overflowing on the right 831 } 832 833 if (submenuRect.left + translateXValue < leftMargin) { 834 const overflowLeft = leftMargin - (submenuRect.left + translateXValue); 835 translateXValue += overflowLeft; // Shift the submenu right if overflowing on the left 836 } 837 838 // Always apply the calculated transform value, even if it's 0 839 submenu.style.transform = `translateX(${translateXValue}px)`; 840 841 submenu.dataset.positioned = "true"; // Mark submenu as positioned to avoid redundant adjustments 842 } 843}
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.