1// site-header.js â stable arming + hysteresis (final) 2(function () { 3 const html = document.documentElement; 4 const body = document.body; 5 6 const utility = document.querySelector('.utility-navbar-wrapper'); 7 const mainBar = document.querySelector('.main-navbar-wrapper'); 8 const mobileMenu = document.querySelector('.mobile-menu'); 9 const mobileOverlay = document.querySelector('.mobile-menu-overlay'); 10 const mobileOpenBtn = document.querySelector('.mobile-menu-toggle'); 11 const mobileCloseBtn = document.querySelector('.mobile-menu-close'); 12 13 if (!utility || !mainBar) return; 14 15 // ----- tuning ----- 16 const REVEAL_PX = 28; // need 28px continuous upward motion to reveal utility 17 const HIDE_PX = 18; // need 18px continuous downward motion to hide utility 18 const ARM_HYST = 24; // arm/disarm band around the threshold 19 const COOL_MS = 260; // min time between toggles 20 21 // ----- state ----- 22 const state = { 23 lastY: window.scrollY, 24 armed: false, 25 revealed: false, 26 dir: 0, // 1 = up, -1 = down, 0 = none 27 acc: 0, // accumulated px in current direction 28 ticking: false, 29 lastToggleTime: 0, 30 noEaseTimer: 0, 31 }; 32 33 // helpers 34 function docTop(el) { let y = 0; while (el) { y += el.offsetTop || 0; el = el.offsetParent; } return y; } 35 36 function setUtilityHeightVar() { 37 const h = Math.round(utility.getBoundingClientRect().height); 38 html.style.setProperty('--utility-h', h + 'px'); 39 return h; 40 } 41 42 // Stable arming threshold = original document-bottom of the utility bar 43 let utilTopDoc = 0, utilH = 0, armY = 0; 44 45 function recomputeThreshold() { 46 utilTopDoc = docTop(utility); 47 utilH = setUtilityHeightVar(); 48 armY = utilTopDoc + utilH; 49 } 50 51 function shouldPauseReveal() { 52 return html.classList.contains('drawer-open') || 53 (mobileMenu && mobileMenu.classList.contains('open')) || 54 body.hasAttribute('data-no-utility-reveal'); 55 } 56 57 function setRevealed(on, opts = {}) { 58 if (on === state.revealed) return; 59 state.revealed = on; 60 61 if (on) { 62 body.classList.add('header-utility-reveal'); 63 // ensure any no-ease is cleared when revealing 64 if (state.noEaseTimer) { clearTimeout(state.noEaseTimer); state.noEaseTimer = 0; } 65 body.classList.remove('header-utility-noease'); 66 } else { 67 body.classList.remove('header-utility-reveal'); 68 // if weâre hiding during a downward scroll, snap (no easing) 69 if (opts.noEase) { 70 body.classList.add('header-utility-noease'); 71 if (state.noEaseTimer) clearTimeout(state.noEaseTimer); 72 state.noEaseTimer = setTimeout(() => { 73 body.classList.remove('header-utility-noease'); 74 state.noEaseTimer = 0; 75 }, 240); 76 } 77 } 78 79 state.lastToggleTime = performance.now(); 80 state.acc = 0; 81 } 82 83 function armIfNeeded(y) { 84 if (!state.armed && y >= armY + ARM_HYST) { 85 state.armed = true; 86 body.classList.add('header-utility-fixed'); 87 // ensure we start hidden when arming 88 setRevealed(false); 89 state.lastToggleTime = performance.now(); 90 } else if (state.armed && y <= armY - ARM_HYST) { 91 state.armed = false; 92 body.classList.remove('header-utility-fixed', 'header-utility-reveal', 'header-utility-noease'); 93 state.revealed = false; 94 state.acc = 0; 95 state.lastToggleTime = performance.now(); 96 } 97 } 98 99 function update() { 100 const y = window.scrollY; 101 let newDir = 0; 102 if (y < state.lastY) newDir = 1; // up 103 else if (y > state.lastY) newDir = -1; // down 104 105 // direction change resets accumulator 106 if (newDir !== 0 && newDir !== state.dir) { 107 state.dir = newDir; 108 state.acc = 0; 109 } 110 111 // accumulate only when moving 112 if (newDir !== 0) state.acc += Math.abs(y - state.lastY); 113 114 armIfNeeded(y); 115 116 if (state.armed) { 117 const now = performance.now(); 118 const cooled = (now - state.lastToggleTime) > COOL_MS; 119 120 if (cooled && !shouldPauseReveal()) { 121 // show after meaningful upward gesture 122 if (!state.revealed && state.dir === 1 && state.acc >= REVEAL_PX) { 123 setRevealed(true); 124 } 125 // hide after meaningful downward gesture 126 else if (state.revealed && state.dir === -1 && state.acc >= HIDE_PX) { 127 setRevealed(false, { noEase: true }); // <-- snap shut on slow downward scrolls 128 } 129 } 130 } 131 132 state.lastY = y; 133 state.ticking = false;
134 } 135 136 function onScroll() { 137 if (!state.ticking) { 138 state.ticking = true; 139 requestAnimationFrame(update); 140 } 141 } 142 143 // init 144 function kick() { recomputeThreshold(); update(); } 145 146 if (document.readyState === 'loading') { 147 document.addEventListener('DOMContentLoaded', kick, { once: true }); 148 } else { 149 kick(); 150 } 151 window.addEventListener('load', recomputeThreshold, { once: true }); 152 document.addEventListener('scroll', onScroll, { passive: true }); 153 window.addEventListener('resize', () => { recomputeThreshold(); update(); }, { passive: true }); 154 window.addEventListener('orientationchange', () => { recomputeThreshold(); update(); }, { passive: true }); 155 156 // utility height might change (e.g., translator), keep --utility-h fresh 157 if ('ResizeObserver' in window) { 158 const ro = new ResizeObserver(() => { 159 utilH = setUtilityHeightVar(); 160 armY = utilTopDoc + utilH; 161 }); 162 ro.observe(utility); 163 } 164 165 // mobile drawer helpers 166 function openDrawer() { 167 if (!mobileMenu) return; 168 mobileMenu.classList.add('open'); 169 if (mobileOverlay) mobileOverlay.classList.add('open'); 170 html.classList.add('drawer-open'); 171 setRevealed(false); // hide utility while drawer is open 172 } 173 function closeDrawer() { 174 if (!mobileMenu) return; 175 mobileMenu.classList.remove('open'); 176 if (mobileOverlay) mobileOverlay.classList.remove('open'); 177 html.classList.remove('drawer-open'); 178 } 179 if (mobileOpenBtn && mobileMenu) { 180 mobileOpenBtn.addEventListener('click', (e) => { e.preventDefault(); openDrawer(); }); 181 } 182 if (mobileCloseBtn && mobileMenu) { 183 mobileCloseBtn.addEventListener('click', (e) => { e.preventDefault(); closeDrawer(); }); 184 } 185 if (mobileOverlay && mobileMenu) { 186 mobileOverlay.addEventListener('click', () => { closeDrawer(); }); 187 } 188 document.addEventListener('keydown', (e) => { 189 if (e.key === 'Escape' && !document.documentElement.classList.contains('search-open')) { 190 closeDrawer(); 191 } 192 }); 193 194 195 if ('MutationObserver' in window && mobileMenu) { 196 const mo = new MutationObserver(() => { setUtilityHeightVar(); update(); }); 197 mo.observe(mobileMenu, { attributes: true, attributeFilter: ['class'] }); 198 } 199 200 // --- Desktop dropdown toggles (append just above the closing IIFE) --- 201 (function setupDesktopDropdowns(){ 202 const nav = mainBar && mainBar.querySelector('nav'); 203 if (!nav) return; 204 205 const DESKTOP_MQ = '(min-width: 1100px)'; 206 const mq = window.matchMedia(DESKTOP_MQ); 207 const items = Array.from(nav.querySelectorAll('li.nav-item-with-dropdown')); 208 209 // Pause utility reveal while hovering/focus is in the main bar 210 if (mainBar) { 211 mainBar.addEventListener('mouseenter', () => body.setAttribute('data-no-utility-reveal','1')); 212 mainBar.addEventListener('mouseleave', () => body.removeAttribute('data-no-utility-reveal')); 213 mainBar.addEventListener('focusin', () => body.setAttribute('data-no-utility-reveal','1')); 214 mainBar.addEventListener('focusout', () => body.removeAttribute('data-no-utility-reveal')); 215 } 216 217 // Stop outside-closer from seeing events that start inside the nav 218 nav.addEventListener('pointerdown', (e) => e.stopPropagation()); 219 nav.addEventListener('click', (e) => e.stopPropagation()); 220 items.forEach(li => { 221 const dd = li.querySelector('.dropdown'); 222 if (dd) { 223 dd.addEventListener('pointerdown', (e) => e.stopPropagation()); 224 dd.addEventListener('click', (e) => e.stopPropagation()); 225 } 226 }); 227 228 const isDesktop = () => mq.matches; 229 230 // Guard against immediately closing right after open 231 const OUTSIDE_GUARD_MS = 200; 232 let suppressOutsideUntil = 0; 233 234 // Track last opener to return focus on ESC 235 let lastOpenTrigger = null; 236 237 // â Track last input method to gate focus-open to keyboard users 238 let lastInputWasKeyboard = false; 239 document.addEventListener('keydown', (e) => { 240 if (e.key === 'Tab' || e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') { 241 lastInputWasKeyboard = true; 242 } 243 }, { passive: true }); 244 document.addEventListener('mousedown', () => { lastInputWasKeyboard = false; }, { passive: true }); 245 document.addEventListener('pointerdown', () => { lastInputWasKeyboard = false; }, { passive: true }); 246 247 function closeAll(except) {
248 items.forEach(li => { 249 if (li !== except) { 250 li.classList.remove('active'); 251 const t = li.querySelector('a.nav-link, button, .accordion-toggle'); 252 if (t) t.setAttribute('aria-expanded', 'false'); 253 } 254 }); 255 if (!except) lastOpenTrigger = null; 256 } 257 258 // Top-level triggers 259 items.forEach(li => { 260 const trigger = li.querySelector('a.nav-link'); 261 if (!trigger) return; 262 263 // a11y hints 264 trigger.setAttribute('aria-haspopup', 'true'); 265 trigger.setAttribute('aria-expanded', 'false'); 266 267 // Desktop: toggle-only; allow modifier-click to open real URLs in new tab 268 trigger.addEventListener('click', (e) => { 269 if (!isDesktop()) return; 270 271 const href = trigger.getAttribute('href') || ''; 272 const hasRealURL = 273 href && href !== '#' && !href.startsWith('javascript:') && !href.startsWith('#'); 274 275 if (hasRealURL && (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) { 276 return; // let new-tab/window happen 277 } 278 279 e.preventDefault(); 280 e.stopPropagation(); 281 e.stopImmediatePropagation(); // prevent any other click handlers on the same node 282 283 const isActive = li.classList.contains('active'); 284 if (!isActive) { 285 closeAll(li); 286 li.classList.add('active'); 287 trigger.setAttribute('aria-expanded', 'true'); 288 lastOpenTrigger = trigger; 289 suppressOutsideUntil = performance.now() + OUTSIDE_GUARD_MS; // ignore the opening click 290 } else { 291 li.classList.remove('active'); 292 trigger.setAttribute('aria-expanded', 'false'); 293 } 294 }); 295 296 // Keyboard: Enter/Space toggles on desktop 297 trigger.addEventListener('keydown', (e) => { 298 if (!isDesktop()) return; 299 if (e.key === 'Enter' || e.key === ' ') { 300 e.preventDefault(); 301 const isActive = li.classList.contains('active'); 302 if (!isActive) { 303 closeAll(li); 304 li.classList.add('active'); 305 trigger.setAttribute('aria-expanded', 'true'); 306 lastOpenTrigger = trigger; 307 suppressOutsideUntil = performance.now() + OUTSIDE_GUARD_MS; 308 } else { 309 li.classList.remove('active'); 310 trigger.setAttribute('aria-expanded', 'false'); 311 } 312 } 313 }); 314 315 // ð Focus-to-open ONLY for keyboard focus (prevents click race) 316 trigger.addEventListener('focus', () => { 317 if (!isDesktop()) return; 318 // Only open if focus arrived via keyboard; also compatible with :focus-visible 319 if (!lastInputWasKeyboard && !trigger.matches(':focus-visible')) return; 320 321 closeAll(li); 322 li.classList.add('active'); 323 trigger.setAttribute('aria-expanded', 'true'); 324 lastOpenTrigger = trigger; 325 suppressOutsideUntil = performance.now() + OUTSIDE_GUARD_MS; 326 }); 327 }); 328 329 // Outside close â bubble-phase click 330 document.addEventListener('click', (e) => { 331 if (!isDesktop()) return; 332 if (performance.now() < suppressOutsideUntil) return; // ignore the opening click 333 if (!mainBar.contains(e.target)) closeAll(); 334 }); 335 336 // ESC closes (and returns focus to the opener) 337 document.addEventListener('keydown', (e) => { 338 if (!isDesktop()) return; 339 if (e.key === 'Escape') { 340 closeAll(); 341 if (lastOpenTrigger) { lastOpenTrigger.focus(); lastOpenTrigger = null; } 342 } 343 }); 344
345 // Breakpoint changes: close everything when switching modes 346 mq.addEventListener('change', () => closeAll()); 347 })(); 348 349 // --- Mobile menu accordion (put this below the desktop block, above the main closing IIFE) --- 350 351 (function setupMobileAccordion(){ 352 const mobileMenu = document.querySelector('.mobile-menu'); 353 if (!mobileMenu) return; 354 355 const mobileNav = mobileMenu.querySelector('.mobile-nav'); 356 if (!mobileNav) return; 357 358 // Only act on mobile widths; keep in sync with your CSS breakpoint 359 const MOBILE_MQ = '(max-width: 1099px)'; 360 const mq = window.matchMedia(MOBILE_MQ); 361 const isMobile = () => mq.matches; 362 363 // Allow multiple sections open; set to true for single-open accordion 364 const SINGLE_OPEN = false; 365 366 // Helpers to grab *direct* children 367 const child = (li, sel) => Array.from(li.children).find(el => el.matches(sel)); 368 369 function toggleItem(li, toOpen) { 370 const trigger = child(li, '.accordion-toggle') || child(li, 'a, a.nav-link'); 371 const submenu = child(li, '.submenu') || child(li, 'ul'); // fallback if walker uses <ul> without .submenu 372 if (!submenu) return; 373 374 const open = (toOpen !== undefined) ? toOpen : !li.classList.contains('open'); 375 376 if (open) { 377 if (SINGLE_OPEN) { 378 // Close siblings 379 const siblings = Array.from(li.parentElement.children).filter(n => n !== li); 380 siblings.forEach(s => s.classList.remove('open')); 381 } 382 li.classList.add('open'); 383 if (trigger) trigger.setAttribute('aria-expanded', 'true'); 384 } else { 385 li.classList.remove('open'); 386 if (trigger) trigger.setAttribute('aria-expanded', 'false'); 387 } 388 } 389 390 // Delegate clicks inside the mobile nav 391 mobileNav.addEventListener('click', (e) => { 392 if (!isMobile()) return; 393 394 // Prefer a dedicated .accordion-toggle; otherwise fall back to the top-level link 395 const t = e.target.closest('.accordion-toggle, a, button'); 396 if (!t || !mobileNav.contains(t)) return; 397 398 const li = t.closest('li'); 399 if (!li) return; 400 401 const hasAccordionToggle = !!child(li, '.accordion-toggle'); 402 const submenu = child(li, '.submenu') || child(li, 'ul'); 403 404 // No submenu? let the link navigate normally 405 if (!submenu) return; 406 407 // If there *is* a dedicated accordion toggle and the click was on the link, let it navigate 408 if (hasAccordionToggle && !t.classList.contains('accordion-toggle')) return; 409 410 // Otherwise, toggle the item (and stop navigation) 411 e.preventDefault(); 412 e.stopPropagation(); 413 toggleItem(li); 414 }); 415 416 // Keyboard support (Enter/Space) 417 mobileNav.addEventListener('keydown', (e) => { 418 if (!isMobile()) return; 419 if (e.key !== 'Enter' && e.key !== ' ') return; 420 421 const t = e.target.closest('.accordion-toggle, a, button'); 422 if (!t || !mobileNav.contains(t)) return; 423 const li = t.closest('li'); 424 if (!li) return; 425 426 const submenu = child(li, '.submenu') || child(li, 'ul'); 427 if (!submenu) return; 428 429 e.preventDefault(); 430 toggleItem(li); 431 }); 432 433 // Close all when switching between mobile/desktop layouts 434 mq.addEventListener('change', () => { 435 const openLis = mobileNav.querySelectorAll('li.open'); 436 openLis.forEach(li => toggleItem(li, false)); 437 }); 438 439 // a11y: set initial aria-expanded on items with submenu 440 Array.from(mobileNav.querySelectorAll('li')).forEach(li => { 441 const submenu = child(li, '.submenu') || child(li, 'ul'); 442 if (!submenu) return; 443 const trigger = child(li, '.accordion-toggle') || child(li, 'a, a.nav-link'); 444 if (trigger) trigger.setAttribute('aria-expanded', li.classList.contains('open') ? 'true' : 'false'); 445 }); 446 })(); 447 448})(); 449 450// --- Search overlay (desktop utility trigger) --- 451(function setupSearchOverlay(){ 452 const html = document.documentElement; 453 const body = document.body; 454 const overlay = document.getElementById('search-overlay'); 455 if (!overlay) return; 456 457 const openers = Array.from(document.querySelectorAll( 458 '.utility-navbar .search, .js-open-search' 459 )); 460 const closeBtn = overlay.querySelector('.search-overlay__close'); 461 const form = overlay.querySelector('.search-overlay__form'); 462 const input = overlay.querySelector('.search-overlay__input'); 463 464 let lastFocus = null; 465 466 // Pause header reveal while overlay is open 467 function openOverlay(e){ 468 if (e) e.preventDefault(); 469 if (overlay.classList.contains('is-open')) return; 470 471 lastFocus = document.activeElement; 472 body.setAttribute('data-no-utility-reveal','1'); 473 html.classList.add('search-open'); 474 overlay.classList.add('is-open'); 475 overlay.setAttribute('aria-hidden', 'false'); 476 477 // Focus the input shortly after paint 478 setTimeout(() => { input && input.focus(); }, 0); 479 480 document.addEventListener('keydown', onKeydown); 481 overlay.addEventListener('click', onBackdrop); 482 } 483 484 function closeOverlay(){ 485 overlay.classList.remove('is-open'); 486 overlay.setAttribute('aria-hidden', 'true'); 487 html.classList.remove('search-open'); 488 body.removeAttribute('data-no-utility-reveal'); 489 490 document.removeEventListener('keydown', onKeydown); 491 overlay.removeEventListener('click', onBackdrop); 492 493 // restore focus 494 if (lastFocus && typeof lastFocus.focus === 'function') { 495 lastFocus.focus(); 496 } 497 } 498 499 function onBackdrop(e){ 500 // only close when clicking the dark backdrop (not inner panel) 501 if (e.target === overlay) closeOverlay(); 502 } 503 504 function onKeydown(e){ 505 if (e.key === 'Escape') { 506 e.preventDefault(); 507 closeOverlay(); 508 return; 509 } 510 // focus trap 511 if (e.key === 'Tab') { 512 trapFocus(e, overlay); 513 } 514 } 515 516 function trapFocus(e, root){ 517 const focusables = root.querySelectorAll(
518 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])' 519 ); 520 const list = Array.from(focusables).filter(el => !el.hasAttribute('disabled')); 521 if (!list.length) return; 522 523 const first = list[0]; 524 const last = list[list.length - 1]; 525 526 // Move focus on first open 527 if (!root.contains(document.activeElement)) { 528 first.focus(); 529 e.preventDefault(); 530 return; 531 } 532 533 // Wrap-around 534 if (e.shiftKey && document.activeElement === first) { 535 last.focus(); e.preventDefault(); 536 } else if (!e.shiftKey && document.activeElement === last) { 537 first.focus(); e.preventDefault(); 538 } 539 } 540 541 // click hooks 542 openers.forEach(el => el.addEventListener('click', openOverlay)); 543 if (closeBtn) closeBtn.addEventListener('click', closeOverlay); 544 545 // optional: open with "/" key when not typing in an input/textarea 546 document.addEventListener('keydown', (e) => { 547 if (e.key === '/' && !overlay.classList.contains('is-open')) { 548 const tag = (document.activeElement && document.activeElement.tagName || '').toLowerCase(); 549 if (tag !== 'input' && tag !== 'textarea') { 550 e.preventDefault(); 551 openOverlay(); 552 } 553 } 554 }); 555})(); 556// --- End search overlay ---
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.