PageSourceSearch

https://www.bbbs.org/wp-content/themes/BBBSA-2025/assets/js/site-h…&new_bbbs=1&ver=1791489787&new_bbbs=1

js bbbs.org collected 2026-10-09 23:27:40 UTC 19,412 bytes, 556 lines download raw bytes

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.