PageSourceSearch

https://www.hotel-engel-ulm.de/js/_essentials/ruffnav.js

js hotel-engel-ulm.de collected 2026-10-08 09:01:54 UTC 46,123 bytes, 937 lines download raw bytes

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.