1/** 2 * off-canvas nav 3 * @name ruffnav 4 * @author Dominik Kressler 5 * @version 2.7.1 6 * @thanks Jasmin Reiff 7 * @edit by Malte Zoudlik 8 * @edit by Dario Rieke 9 * @edit by Sami El-Younsi 10 * @edit by Bartosz Adamski 11 * @edit by Daniel Regiment 12 * @lastedit 2025-09-17 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; 217 if (e.type === 'mouseenter' && (this.device === 'mobile' || this.options.forceMobileNav)) return; 218 if (e.type === 'mouseenter') { 219 target = e.target; 220 target.childNodes[1].classList.add('current'); 221 target.childNodes[1].setAttribute('aria-expanded', 'true'); 222 if (this.container.querySelector('.open') != null) this.container.querySelector('.open').classList.remove('open'); 223 target.childNodes[1].classList.add('open'); 224 if (this.options.desktopHover) { 225 target.querySelector(':scope > .sub').setAttribute('aria-expanded', 'true'); 226 target.querySelector(':scope > .sub').classList.add('current'); 227 } 228 } else { 229 target = e.currentTarget; 230 const submenuToOpen = target.nextElementSibling; 231 if (!submenuToOpen || submenuToOpen.classList.contains('open')) return; 232 // close siblings 233 const sameLevel = target.parentElement.parentElement.querySelector('.current'); 234 if (sameLevel && sameLevel !== submenuToOpen) this.closeRecusive(sameLevel, false); 235 // ensure only one .open 236 this.container.querySelectorAll('.open').forEach(openEl => { 237 if (openEl !== submenuToOpen) openEl.classList.remove('open'); 238 }); 239 // mark submenu open/current 240 submenuToOpen.classList.add('open', 'current'); 241 submenuToOpen.setAttribute('aria-expanded', 'true'); 242 target.setAttribute('aria-expanded', 'true'); 243 // position if needed 244 if (this.subMenuPositioner && !(this.device === 'mobile' || this.options.forceMobileNav) && !this.subMenuPositioner.shouldExclude(submenuToOpen)) { 245 this.subMenuPositioner.resetPositioning(submenuToOpen); 246 this.subMenuPositioner.adjustPosition(submenuToOpen); 247 } 248 // inform screenreader 249 const currentSubName = submenuToOpen.getAttribute('aria-label'); 250 this.updateNavigationStatus(`${currentSubName} ${this.lang === 'de' ? 'wurde geöffnet' : 'got opened'}`); 251 // focus submenu and observe 252 setTimeout(() => submenuToOpen.focus(), 250); 253 document.addEventListener('keydown', this.initWatchFocus); 254 } 255 if (!this.isOpen && this.device !== 'mobile') { 256 if (this.options.closeOnScroll) window.addEventListener('scroll', this._closeAll); 257 document.addEventListener('click', this._closeAll); 258 this.isOpen = true; 259 } 260 this.container.classList.remove('first-level'); 261 this.scrollTop(); 262 } 263 264 _close = (e) => { 265 // close event 266 let currentListTree; 267 if (e.type === 'mouseleave' && (this.device === 'mobile' || this.options.forceMobileNav)) return; 268 if (e.type === 'mouseleave') currentListTree = e.currentTarget; 269 else currentListTree = e.currentTarget.parentElement; 270 271 this.closeRecusive(currentListTree, false); 272 if (this.container.querySelectorAll('ul.current').length === 0) this.isOpen = false;
273 if (this.container.querySelectorAll('ul.current').length === 0) this.container.classList.add('first-level'); 274 275 // inform screenreader via aria-live that submenu got closed 276 const currentSubName = currentListTree.getAttribute('aria-label'); 277 const subStatusClosed = (this.lang === 'de') ? `${currentSubName} wurde geschlossen` : `${currentSubName} got closed`; 278 this.updateNavigationStatus(subStatusClosed); 279 280 // set focus 281 const thisSubToggler = currentListTree.parentElement.firstElementChild; 282 thisSubToggler.focus(); 283 284 // update aria-attribute 285 const target = e.currentTarget; 286 if (this.options.desktopHover && e.type === 'mouseleave') target.querySelector(':scope > .sub').setAttribute('aria-expanded', 'false'); 287 288 this.scrollTop(); 289 } 290 291 _closeAll = (e) => { 292 // check for toggle input and update attributes 293 (this.toggler.checked) ? this.html.classList.add('hidden-scroll') : this.html.classList.remove('hidden-scroll'); 294 (this.toggler.checked) ? this.container.setAttribute('aria-expanded', 'true') : this.container.setAttribute('aria-expanded', 'false'); 295 (this.toggler.checked) ? '' : this.alreadyOpened = false; 296 const toggleLabel = document.querySelector(this.options.toggleLabel); 297 const menuOpenTogglerLabel = (this.lang === 'de') ? `Hauptnavigation öffnen` : `open main navigation`; 298 const menuCloseTogglerLabel = (this.lang === 'de') ? `Hauptnavigation schlieÃen` : `close main navigation`; 299 (this.toggler.checked) ? toggleLabel.setAttribute('aria-label', menuCloseTogglerLabel) : toggleLabel.setAttribute('aria-label', menuOpenTogglerLabel); 300 (this.toggler.checked) ? toggleLabel.setAttribute('aria-expanded', 'true') : toggleLabel.setAttribute('aria-expanded', 'false'); 301 302 // inform screenreader via aria-live if navigation got closed or opened on mobile 303 if (this.device === 'mobile' || this.options.forceMobileNav) { 304 const navigationStatusOpened = (this.lang === 'de') ? `Hauptnavigation wurde geöffnet` : `Main navigation got opened`; 305 const navigationStatusClosed = (this.lang === 'de') ? `Hauptnavigation wurde geschlossen` : `Main navigation got closed`; 306 (this.toggler.checked) ? this.updateNavigationStatus(navigationStatusOpened) : this.updateNavigationStatus(navigationStatusClosed); 307 } 308 309 // close all 310 if (e.type === 'scroll') { 311 this.closeRecusive(null, true); 312 } else if (e.type === 'click') { 313 // check if click was not inside navigation 314 if (e.target === this.navList || this.navList.contains(e.target)) return; 315 else this.closeRecusive(null, true); 316 } else if (e.type === 'keydown') { 317 // check if "enter" was pressed 318 if (e.keyCode === 27) this.closeRecusive(null, true); 319 } else { 320 this.closeRecusive(null, true); 321 } 322 323 this.removeListeners(); 324 this.isOpen = false; 325 } 326 327 closeRecusive = (firstList, closeAll) => { 328 // close all open items 329 if (closeAll) { 330 this.container.querySelectorAll('.current').forEach(openSubs => { 331 openSubs.classList.remove('current'); 332 openSubs.setAttribute('aria-expanded', 'false'); 333 openSubs.parentNode.firstElementChild.setAttribute('aria-expanded', 'false'); 334 // if the submenu was opened by keyboard (has no-transition class), 335 // remove the class AFTER the transition duration to ensure no transition on close. 336 if (openSubs.classList.contains('no-transition')) { 337 setTimeout(() => { 338 openSubs.classList.remove('no-transition'); 339 }, 300); // match the CSS transition duration 340 } 341 }); 342 343 if (this.container.querySelector('.open') != null) this.container.querySelector('.open').classList.remove('open'); 344 345 this.container.classList.add('first-level'); 346 } else { 347 if (this.container.querySelector('.open') != null) this.container.querySelector('.open').classList.remove('open'); 348 349 firstList.querySelectorAll('.current').forEach(openSubs => { 350 openSubs.classList.remove('current'); 351 openSubs.setAttribute('aria-expanded', 'false'); 352 openSubs.parentNode.firstElementChild.setAttribute('aria-expanded', 'false'); 353 354 // stop observing focus inside ul 355 if (this.device === 'mobile' || this.options.forceMobileNav) document.removeEventListener('keydown', this.initWatchFocus); 356 }); 357 358 // update classes and attributes 359 firstList.parentNode.parentNode.classList.add('open'); 360 firstList.classList.remove('current'); 361 firstList.setAttribute('aria-expanded', 'false'); 362 firstList.parentNode.firstElementChild.setAttribute('aria-expanded', 'false'); 363 364 // if the submenu was opened by keyboard (has no-transition class), 365 // remove the class AFTER the transition duration to ensure no transition on close. 366 if (firstList.classList.contains('no-transition')) { 367 setTimeout(() => { 368 firstList.classList.remove('no-transition'); 369 }, 300); // match the CSS transition duration (0.3s = 300ms) 370 } 371 } 372 373 // if navigation is opened initially or reopened, set focus and observe focus inside submenu 374 if (this.toggler.checked && this.container.querySelectorAll('ul.current').length === 0 && (this.device === 'mobile' || this.options.forceMobileNav) && !this.alreadyOpened) { 375 if (this.navList.classList.contains('navi')) { 376 this.navList.classList.add('open'); 377 } 378 // observe focus inside submenu 379 setTimeout(() => { document.addEventListener('keydown', this.initWatchFocus); }, 300); 380 381 this.alreadyOpened = true; 382 } 383 } 384 385 removeListeners = () => { 386 // remove eventListener for global close 387 window.removeEventListener('scroll', this._closeAll); 388 document.removeEventListener('keydown', this._closeAll); 389 document.removeEventListener('click', this._closeAll); 390 document.removeEventListener('keydown', this.initWatchFocus); 391 } 392 393 addListener = () => { 394 // add eventListener to sub-togglers and back-buttons 395 this.subToggler = this.container.querySelectorAll('.sub-toggler'); 396 this.backButtons = this.container.querySelectorAll('.nav-back'); 397 const ruffnavInstance = this; // Capture the Ruffnav instance 398 399 if (this.options.desktopHover) { 400 // track input modality (mouse vs keyboard) to avoid .nav-back flicker on mouse 401 this.keyboardMode = false;
402 403 this._onPointerDownForModality = function(e) { 404 // only active for desktop hover mode 405 if (!(ruffnavInstance.device === 'desktop' && ruffnavInstance.options.desktopHover)) return; 406 // if pointer is mouse, switch to mouse-mode and hide all nav-back buttons 407 // pointerType may be undefined in some browsers; fall back to checking pointer event type 408 if (e.pointerType === 'mouse' || e.type === 'mousedown' || e.type === 'mousemove') { 409 ruffnavInstance.keyboardMode = false; 410 ruffnavInstance.backButtons.forEach(btn => { 411 try { btn.style.display = 'none'; } catch (err) { /* ignore */ } 412 }); 413 } 414 }; 415 416 this._onKeyDownForModality = function(e) { 417 if (!(ruffnavInstance.device === 'desktop' && ruffnavInstance.options.desktopHover)) return; 418 // treat Tab and arrow navigation as keyboard navigation 419 const isNavKey = e.key === 'Tab' || e.key === 'Shift' || e.key.startsWith('Arrow'); 420 if (isNavKey) { 421 ruffnavInstance.keyboardMode = true; 422 // if currently focused element is a nav-back, make it visible 423 ruffnavInstance.backButtons.forEach(btn => { 424 try { btn.style.display = 'flex'; } catch (err) { /* ignore */ } 425 }); 426 } 427 }; 428 429 // use capture phase to get events early 430 document.addEventListener('pointerdown', this._onPointerDownForModality, true); 431 document.addEventListener('mousedown', this._onPointerDownForModality, true); 432 document.addEventListener('mousemove', this._onPointerDownForModality, true); 433 document.addEventListener('keydown', this._onKeyDownForModality, true); 434 } 435 436 this.subToggler.forEach(subTogglerItem => { 437 if (this.options.desktopHover) { 438 subTogglerItem.parentNode.addEventListener('mouseenter', ruffnavInstance._open); 439 subTogglerItem.parentNode.addEventListener('mouseleave', ruffnavInstance._close); 440 } 441 subTogglerItem.addEventListener('click', ruffnavInstance._open); 442 if (this.options.desktopHover) subTogglerItem.addEventListener('mouseover', ruffnavInstance._open); 443 444 // add keydown listener for Enter key 445 subTogglerItem.addEventListener('keydown', function(e) { 446 if (e.key === 'Enter' || e.key === ' ') { // Also allow Spacebar 447 e.preventDefault(); // prevent the default action (e.g., click or scrolling for spacebar) 448 e.stopPropagation(); // stop the event from bubbling up 449 // find the submenu (ul.sub) associated with this toggler 450 const submenu = this.nextElementSibling; 451 if (submenu && submenu.classList.contains('sub')) { 452 // add the no-transition class before opening 453 submenu.classList.add('no-transition'); 454 455 // trigger the original open logic using the captured Ruffnav instance 456 // pass the current event object 457 ruffnavInstance._open(e); 458 } 459 } 460 }); 461 }); 462 this.backButtons.forEach(btn => { 463 if (this.device === 'desktop' && this.options.desktopHover) btn.style.display = 'none'; 464 btn.addEventListener('focus', () => { 465 if (ruffnavInstance.device === 'desktop' && ruffnavInstance.options.desktopHover) { 466 btn.style.display = ruffnavInstance.keyboardMode ? 'flex' : 'none'; 467 } 468 }); 469 btn.addEventListener('blur', () => { 470 if (ruffnavInstance.device === 'desktop' && ruffnavInstance.options.desktopHover) { 471 setTimeout(() => { 472 if (!btn.parentElement.contains(document.activeElement)) btn.style.display = 'none'; 473 }, 50); 474 } 475 }); 476 btn.addEventListener('click', this._close, false); 477 btn.addEventListener('keydown', e => { 478 if (e.key === 'Enter' || e.key === ' ') { 479 e.preventDefault(); 480 e.stopPropagation(); 481 this._close(e); 482 } 483 }); 484 }); 485 } 486 487 listenResize = () => { 488 // global resize Event 489 window.onresize = () => { 490 const oldDevice = this.device; // Capture old device type before updating 491 this.device = breakpoint(this.options.mobileBreak) ? 'desktop' : 'mobile'; 492 493 // Always update navigation height on resize, especially for desktop 494 if (this.device === 'desktop') { 495 // Ensure height is updated immediately when switching to or resizing on de
495sktop 496 this.navigationHeightUpdater.updateNavigationHeight(); 497 } 498 499 if (this.device === 'mobile' || this.options.forceMobileNav) { 500 this.removeListeners(); 501 // update aria-attributes 502 this.container.setAttribute('aria-expanded', 'false'); 503 504 // add tabindex for all submenus to make them focusable 505 this.sub.forEach(subItem => { 506 subItem.setAttribute('tabindex', '0'); 507 }); 508 } 509 if (this.windowSize !== window.innerWidth && this.device === 'desktop') { 510 this.closeRecusive(null, true); 511 this.isOpen = false; 512 // update aria-attributes 513 this.container.removeAttribute('aria-expanded'); 514 515 // stop observing focus inside ul 516 document.removeEventListener('keydown', this.initWatchFocus); 517 518 if (this.toggler.checked) this.toggler.checked = false; 519 520 this.sub.forEach(subItem => { 521 subItem.removeAttribute('tabindex'); 522 }); 523 } 524 525 this.windowSize = window.innerWidth; 526 // Only call navHeightSubPositioner for submenu positioning logic, which still has a timeout 527 // The navigation height update is now handled directly in the resize listener. 528 529 // Call the method that contains the debounced submenu positioning logic 530 if (this.sub.length > 0 && this.subMenuPositioner) { 531 this.navHeightSubPositioner(); 532 } 533 }; 534 } 535 536 scrollTop = () => { 537 this.sub.forEach(item => { item.scrollTop = 0; }); 538 this.container.childNodes[0].scrollTop = 0; 539 } 540 541 create = (type, attr, content) => { 542 // create Node element 543 const elm = document.createElement(type); 544 Object.entries(attr).forEach(([name, value]) => { 545 elm.setAttribute(name, value); 546 }); 547 if (content) elm.innerHTML = content; 548 return elm; 549 } 550 551 activeClassFallback = (elem) => { 552 if (elem.href === window.location.origin + '/' + this.activePath + this.path[0] + '/') { 553 elem.classList.add('active'); 554 elem.setAttribute('aria-current', 'page'); 555 if (elem.parentNode.classList.contains(this.options.subParent.substring(1))) elem.parentNode.classList.add('active'); 556 if (this.path.length === 1) elem.parentNode.querySelector('[href="' + window.location.origin + '/' + this.activePath + this.path[0] + '/"]').classList.add('active'); 557 this.activePath += this.path[0] + '/'; 558 this.path.splice(0, 1); 559 } 560 } 561 562 updateNavigationStatus = (text) => { 563 // function to update text inside aria-live element for screenreader 564 const navigationStatusElement = this.navigationStatus; 565 navigationStatusElement.textContent = ''; 566 navigationStatusElement.textContent = text; 567 } 568 569 initWatchFocus = (e) => { 570 // call function to observe focus 571 return this.watchFocus(e, this.container.querySelector('.open')); 572 } 573 574 watchFocus = (e, modalElement) => { 575 // observe focus inside ul function 576 const isTabPressed = e.key === 'Tab' || e.keyCode === 9; 577 const isEscPressed = e.key === 'Escape' || e.keyCode === 27; 578 579 if (!isTabPressed && !isEscPressed) return; 580 581 let focusableElements = `:scope > li > [href], :scope > li[role="button"], :scope > li > span[role="button"], :scope > li > span`; 582 let modal = modalElement; 583 if (modal.classList.contains('navi')) { 584 modal = this.container; 585 focusableElements = `.navi > li > [href], .navi > li[role="button"], .navi > li > span[role="button"], .navi > li > span, .btn, button, [tabindex="0"], [href], a.branding, .phone, .email, .mobile-close-button`; 586 } 587 588 // get focusable and visible elements in modal 589 const visibleElements = []; 590 const focusableContent = modal.querySelectorAll(focusableElements); 591 592 focusableContent.forEach(thisElement => { 593 if (thisElement.checkVisibility()) visibleElements.push(thisElement); 594 }); 595 596 const firstFocusableElement = visibleElements[0]; 597 const lastFocusableElement = visibleElements[visibleElements.length - 1]; 598 599 if (isTabPressed) { 600 if (e.shiftKey) { 601 if (document.activeElement === firstFocusableElement) { 602 // if first element is focused and shift + tab is clicked, close menu 603 e.preventDefault(); 604 this.focusCloseAction(e, modal); 605 } else if (document.activeElement === modal) { 606 // if menu is focused and shift + tab is clicked, close focused menu 607 e.preventDefault(); 608 this.focusCloseAction(e, modal); 609 } 610 } else if (!e.shiftKey && document.activeElement === lastFocusableElement) { 611 // if last element is focused and tab is clicked, close menu 612 e.preventDefault(); 613 this.focusCloseAction(e, modal); 614 } 615 } else if (isEscPressed) { 616 // if Esc is clicked, close current menu 617 e.preventDefault(); 618 this.focusCloseAction(e, modal); 619 } 620 } 621 622 focusCloseAction = (e, modal) => { 623 // handle focus on close 624 if (modal === this.container) { 625 // if first-level, close navigation and set focus to burger icon 626 this.toggler.checked = false;
627 this._closeAll(e); 628 const toggleLabel = document.querySelector(this.options.toggleLabel); 629 toggleLabel.focus(); 630 } else { 631 // if not first-level, toggle nav-back and close submenu 632 modal.querySelector(':scope > .nav-back').click(); 633 } 634 } 635 636 navHeightSubPositioner = () => { 637 // Debounced logic for submenu positioning on resize 638 if (this.viewportTimer) clearTimeout(this.viewportTimer); // Clear previous timeout 639 640 this.viewportTimer = setTimeout(() => { 641 // Use requestAnimationFrame to ensure layout is updated before measuring 642 requestAnimationFrame(() => { 643 if (breakpoint(this.options.mobileBreak) && this.sub.length > 0 && this.subMenuPositioner) { 644 // Desktop: Trigger submenu repositioning via the already initialized SubmenuPositioner 645 // The MutationObserver in SubmenuPositioner handles opening/closing. 646 // This part handles resize. 647 // Re-adjust position for all currently open submenus on resize 648 this.container.querySelectorAll('ul.sub.open').forEach(openSubmenu => { 649 if (!this.subMenuPositioner.shouldExclude(openSubmenu)) { 650 // Reset first, then adjust. Resetting position clears previous transform/top styles. 651 this.subMenuPositioner.resetPositioning(openSubmenu); 652 this.subMenuPositioner.adjustPosition(openSubmenu); 653 } 654 }); 655 } else if (this.sub.length > 0 && this.subMenuPositioner) { 656 // Mobile: Destroy submenu positioning logic and reset inline styles if it was active 657 // Only destroy if SubmenuPositioner was actually initialized 658 this.subMenuPositioner.destroy(); // Stop submenu adjustments and remove resize listener 659 660 // Reset any inline styles added by the submenu positioner on all submenus 661 this.sub.forEach(submenu => { 662 this.subMenuPositioner.resetPositioning(submenu); // Use SubmenuPositioner method to reset 663 }); 664 } 665 // Clear the subMenuPositioner instance on mobile to allow re-initialization on desktop switch 666 if (this.device === 'mobile' || this.options.forceMobileNav) { 667 this.subMenuPositioner = null; // Clear instance 668 } 669 }); // End of requestAnimationFrame 670 671 }, 750); // Increased delay to give layout more time to settle after resize 672 } 673} 674 675// Polyfills for Object.entries and Element.remove 676if (!Object.entries) { 677 Object.entries = function (obj) { 678 const ownProps = Object.keys(obj); 679 const i = ownProps.length; 680 const resArray = new Array(i); // preallocate the Array 681 while (i--) resArray[i] = [ownProps[i], obj[ownProps[i]]]; 682 return resArray; 683 }; 684} 685if (!('remove' in Element.prototype)) { 686 Element.prototype.remove = function () { 687 if (this.parentNode) this.parentNode.removeChild(this); 688 }; 689} 690 691// Class to update and manage the navigation height as a CSS variable 692class NavigationHeightUpdater { 693 constructor(navigationContainer = document.getElementById('navigation')) { 694 this.classNamesToMonitor = ['font-size-110', 'font-size-130']; // Classes to monitor for changes 695 this.navigationContainer = navigationContainer; // navigation element with class from ruffnav 696 this.updateNavigationHeight(); // Perform initial height update 697 this.startMonitoring(); // Monitor <html> class changes 698 this.observeHeightChanges(); // Start observing height changes for dynamic navigation-height-dynamic variable 699 } 700 701 // Method to calculate and update the navigation height 702 updateNavigationHeight = () => { 703 const navigationHeight = this.navigationContainer.offsetHeight; 704 document.documentElement.style.setProperty('--navigation-height', `${navigationHeight}`); 705 } 706 707 // Observe changes in the navigation container's height and update --navigation-height-dynamic 708 observeHeightChanges = () => { 709 let lastHeight = this.navigationContainer.offsetHeight; 710 // Set initial value 711 document.documentElement.style.setProperty('--navigation-height-dynamic', `${lastHeight}`); 712 713 // Use ResizeObserver to detect any height changes (including those caused by CSS) 714 const resizeObserver = new ResizeObserver(entries => {
715 for (let entry of entries) { 716 const newHeight = entry.target.offsetHeight; 717 if (newHeight !== lastHeight) { 718 document.documentElement.style.setProperty('--navigation-height-dynamic', `${newHeight}`); 719 lastHeight = newHeight; 720 } 721 } 722 }); 723 resizeObserver.observe(this.navigationContainer); 724 } 725 726 // Set up monitoring for class changes on <html> 727 startMonitoring = () => { 728 const htmlElement = document.documentElement; 729 730 // Observer to track changes in <html> element's class attribute 731 const observer = new MutationObserver((mutations) => { 732 mutations.forEach((mutation) => { 733 if (mutation.attributeName === 'class') { 734 const classFound = this.classNamesToMonitor.some(className => 735 htmlElement.classList.contains(className)); 736 737 // Update the height if any of the monitored classes are found 738 if (classFound || !classFound) this.updateNavigationHeight(); // Trigger immediate height update 739 } 740 }); 741 }); 742 743 // Observe for attribute changes on <html> 744 observer.observe(htmlElement, { attributes: true }); 745 } 746} 747 748// Class to manage submenu positioning and prevent it from leaving the viewport 749class SubmenuPositioner { 750 constructor(closeButtonWidth = 30, excludedSubmenus = [], submenus = document.getElementById('navigation').querySelectorAll('ul.sub'), ruffnavInstance) { 751 this.closeButtonWidth = closeButtonWidth; // Width of the close button in submenus 752 this.excludedSubmenus = excludedSubmenus; // Submenus to be excluded from repositioning 753 this.submenus = submenus; // all submenus with class from ruffnav 754 this.ruffnavInstance = ruffnavInstance; // Store Ruffnav instance 755 this.init(); // Initialize the observer 756 } 757 758 // Initialize the MutationObserver and event listener 759 init() { 760 // Observer to track when submenus are opened (i.e., when they gain the "open" class) 761 this.observer = new MutationObserver((mutations) => { 762 mutations.forEach((mutation) => { 763 // Check if the target is a submenu and gained the 'open' class or lost the 'open' class (for reset) 764 if (mutation.target.classList && mutation.target.classList.contains('sub')) { 765 if (mutation.target.classList.contains('open')) { 766 const submenu = mutation.target; 767 const oldClasses = mutation.attributeOldValue ? mutation.attributeOldValue.split(' ') : []; 768 const wasCurrentBefore = oldClasses.includes('current'); 769 const isNowOpen = submenu.classList.contains('open'); // Redundant check, but for clarity 770 // Only call adjustPosition if the submenu is gaining the 'open' class 771 // AND it was NOT current before. 772 // This prevents re-positioning when clicking a parent toggler while a child is open. 773 if (isNowOpen && !wasCurrentBefore) { 774 if (!this.shouldExclude(submenu) && !( this.ruffnavInstance.device === 'mobile' || this.ruffnavInstance.options.forceMobileNav)) { 775 this.adjustPosition(submenu); 776 } // Reposition submenu if not excluded 777 } 778 } else { // If it lost the 'open' class, check if it's a genuine close (lost 'current' as well) 779 const submenu = mutation.target; 780 // Only reset if the submenu was truly closed (lost 'open' AND 'current') 781 if (!submenu.classList.contains('open') && !submenu.classList.contains('current')) { 782 this.resetPositioning(submenu); // Reset positioning 783 } 784 } 785 } 786 }); 787 }); 788 789 // Observe all submenus for class changes
790 this.submenus.forEach(submenu => { 791 this.observer.observe(submenu, { attributes: true, attributeFilter: ['class'] }); 792 }); 793 } 794 795 // Cleanup: Disconnect observer 796 destroy = () => { 797 if (this.observer) this.observer.disconnect(); // Stop observing submenu changes 798 } 799 800 // Helper method to determine if a submenu should be excluded from repositioning 801 shouldExclude = (submenu) => { 802 const parentName = submenu.getAttribute('data-parent-name'); 803 return this.excludedSubmenus.includes(parentName); // Return true if it should be excluded 804 } 805 806 // Reset positioning styles for a submenu 807 resetPositioning = (submenu) => { 808 submenu.dataset.positioned = "false"; // Mark submenu as not positioned 809 810 // Only reset transform if the submenu is truly closed (has neither open nor current class) 811 if (!submenu.classList.contains('open') && !submenu.classList.contains('current')) { 812 submenu.style.transform = ''; // Clear any transformation 813 } 814 815 submenu.style.top = ''; // Reset top position 816 submenu.style.bottom = ''; // Reset bottom position 817 submenu.style.left = ''; // Reset left position 818 submenu.style.right = ''; // Reset right position 819 } 820 821 // Adjust the position of the submenu to ensure it stays within the viewport 822 adjustPosition = (submenu) => { 823 if (submenu.dataset.positioned === "true") return; // Skip if already positioned 824 825 const submenuRect = submenu.getBoundingClientRect(); 826 const viewportHeight = window.innerHeight; 827 const viewportWidth = window.innerWidth; 828 const leftMargin = 25; // Margin to leave from the left edge of the screen 829 830 // Vertical adjustments: Ensure the submenu stays within the viewport vertically 831 if (submenuRect.bottom > viewportHeight) { 832 submenu.style.top = `${viewportHeight - submenuRect.bottom}px`; 833 } else if (submenuRect.top < 0) { 834 submenu.style.top = `${-submenuRect.top}px`; 835 } 836 837 // Horizontal adjustments: Ensure the submenu stays within the viewport horizontally 838 let translateXValue = 0; 839 if (submenuRect.right + this.closeButtonWidth > viewportWidth) { 840 const overflowRight = submenuRect.right + this.closeButtonWidth - viewportWidth; 841 translateXValue = -overflowRight*2; // Shift the submenu left if overflowing on the right 842 } 843 844 if (submenuRect.left + translateXValue < leftMargin) { 845 const overflowLeft = leftMargin - (submenuRect.left + translateXValue); 846 translateXValue += overflowLeft; // Shift the submenu right if overflowing on the left 847 } 848 849 // Always apply the calculated transform value, even if it's 0 850 submenu.style.transform = `translateX(${translateXValue}px)`; 851 852 submenu.dataset.positioned = "true"; // Mark submenu as positioned to avoid redundant adjustments 853 } 854}
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.