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