1// navHandler.js 2 3import { getFocusableElements, isTab, isShiftTab } from "./focusTrapHandler.js"; 4 5 6export class NavHandler { 7 // static instance = null; // single instance 8 9 constructor(navElement) { 10 // if (NavHandler.instance) return NavHandler.instance; // singleton pattern 11 this.nav = navElement || document.querySelector('.nav'); 12 this.button = this.nav?.querySelector('.nav__button--control'); 13 this.buttonText = this.nav?.querySelector('.nav__button--control span'); 14 this.body = this.nav?.querySelector('.nav__body'); 15 this.isOpen = this.nav?.classList.contains('is-expanded') || false; 16 this.language = this.nav.dataset.language == 'de-AT' ? 'de' : 'en'; 17 18 if (!this.nav || !this.button || !this.body) { 19 console.warn('NavHandler: required elements missing.'); 20 return; 21 } 22 23 // Setup accessibility 24 this.body.id = this.body.id || 'nav-body'; 25 this.button.setAttribute('role', 'button'); 26 this.button.setAttribute('aria-controls', this.body.id); 27 this.button.setAttribute('aria-expanded', this.isOpen ? 'true' : 'false'); 28 if (this.language == 'de') { 29 this.buttonText.textContent = this.isOpen ? 'Menü schlieÃen' : 'Menü öffnen'; 30 // this.button.setAttribute('aria-label', this.isOpen ? 'Menü schlieÃen' : 'Menü öffnen'); 31 this.buttonText.setAttribute('lang', 'de'); 32 } else { 33 this.buttonText.textContent = this.isOpen ? 'Close menu' : 'Open menu'; 34 // this.button.setAttribute('aria-label', this.isOpen ? 'Close menu' : 'Open menu'); 35 this.buttonText.setAttribute('lang', 'en'); 36 } 37 this.body.setAttribute('aria-hidden', this.isOpen ? 'false' : 'true'); 38 39 // Set initial height 40 this.body.style.height = this.isOpen ? `${this.body.scrollHeight}px` : '0px'; 41 this.body.style.overflow = 'hidden'; 42 this.body.style.display = 'none'; 43 44 // Bind events 45 this.button.addEventListener('click', () => this.toggle()); 46 this.button.addEventListener('keydown', (e) => { 47 if (e.key === 'Enter' || e.key === ' ') { 48 e.preventDefault(); 49 this.toggle(); 50 } 51 }); 52 53 // NavHandler.instance = this; 54 } 55 56 toggle() { 57 this.isOpen ? this.collapse() : this.expand(); 58 } 59 60 expand() { 61 if (!this.body) return; 62 63 this.body.style.display = 'flex'; 64 65 const fullHeight = this.body.scrollHeight + 'px'; 66 this.body.style.height = fullHeight; 67 68 this.nav.classList.add('is-expanded'); 69 this.body.setAttribute('aria-hidden', 'false'); 70 this.button.setAttribute('aria-expanded', 'true'); 71 if (this.language == 'de') { 72 this.buttonText.setAttribute('aria-label', 'Menü schlieÃen'); 73 } else { 74 this.buttonText.setAttribute('aria-label', 'Close menu'); 75 } 76 this.isOpen = true; 77 78 const transitionDuration = 79 parseFloat( 80 getComputedStyle(document.documentElement).getPropertyValue('--nav-transition-duration') 81 ) || 0.4; 82 83 // Reset height after transition to allow natural resizing 84 setTimeout(() => { 85 if (this.isOpen) this.body.style.height = 'auto'; 86 }, transitionDuration * 1000); 87 88 89 this.body.addEventListener('keydown', event => { 90 const focusables = getFocusableElements(this.body).keyboardOnly 91 92 // prepend the toggle button 93 const all = [this.button, ...focusables] 94 95 const first = all[0] 96 const last = all[all.length - 1] 97 98 if (document.activeElement === last && isTab(event)) { 99 event.preventDefault() 100 first.focus() 101 } 102 103 if (document.activeElement === first && isShiftTab(event)) { 104 event.preventDefault() 105 last.focus() 106 } 107 }) 108 109 110 } 111 112 collapse() { 113 if (!this.body) return; 114 115 const currentHeight = this.body.scrollHeight + 'px'; 116 this.body.style.height = currentHeight; 117 118 // Force reflow to ensure transition 119 void this.body.offsetHeight; 120 121 this.body.style.height = '0px'; 122 this.nav.classList.remove('is-expanded'); 123 this.body.setAttribute('aria-hidden', 'true'); 124 this.button.setAttribute('aria-expanded', 'false'); 125 if (this.language == 'de') { 126 this.buttonText.setAttribute('aria-label', 'Menü öffnen'); 127 } else { 128 this.buttonText.setAttribute('aria-label', 'Open menu'); 129 } 130 this.isOpen = false;
131 132 const transitionDuration = 133 parseFloat( 134 getComputedStyle(document.documentElement).getPropertyValue('--nav-transition-duration') 135 ) || 0.4; 136 137 // Reset height after transition to allow natural resizing 138 setTimeout(() => { 139 this.body.style.display = 'none'; 140 }, transitionDuration * 1000); 141 } 142} 143 144/** 145 * Initialize navigation handler 146 * @returns {NavHandler | null} 147 */ 148export function initNavHandler() { 149 const navEl = document.querySelector('.nav'); 150 if (!navEl) return null; 151 152 const navHandler = new NavHandler(navEl); 153 154 // Close on outside click 155 document.addEventListener('click', (e) => { 156 157 const noCloseSelectors = [ 158 '.nav__body', 159 '.nav__button' 160 ]; 161 162 if (noCloseSelectors.some(sel => e.target.closest(sel))) { 163 return; 164 } 165 166 // Otherwise close the nav 167 if (navHandler.isOpen) { 168 navHandler.collapse(); 169 } 170 }); 171 172 // Close when pressing 'esc' 173 window.addEventListener('keydown', e => { 174 if (e.key === 'Escape') { 175 if (navHandler.isOpen) { 176 navHandler.collapse(); 177 } 178 } 179 }); 180 181 182 return navHandler; 183}
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.