PageSourceSearch

https://www.ambulance-koepke.de/js/_essentials/ruffnav.js

js ambulance-koepke.de collected 2026-10-06 16:31:33 UTC 42,444 bytes, 843 lines download raw bytes

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.