1(function () { 2 'use strict'; 3 4 const header = document.querySelector('.site-header'); 5 const nav = document.getElementById('primary-navigation'); 6 const toggle = document.querySelector('.site-header__toggle'); 7 const drawer = document.getElementById('ebenezer-primary-menu'); 8 const masthead = document.querySelector('.site-masthead'); 9 const topNav = document.querySelector('.top-nav'); 10 11 const togglers = []; 12 13 // ---- Primary-nav submenus (shared between desktop dropdowns and the 14 // mobile accordion; both are driven by the link's aria-expanded). ---- 15 if (nav) { 16 nav.querySelectorAll(':scope > ul > li').forEach(function (li) { 17 const link = li.querySelector(':scope > a'); 18 const submenu = li.querySelector(':scope > ul'); 19 if (!link || !submenu) return; 20 21 link.setAttribute('aria-haspopup', 'true'); 22 link.setAttribute('aria-expanded', 'false'); 23 24 link.addEventListener('click', function (event) { 25 event.preventDefault(); 26 const wasOpen = link.getAttribute('aria-expanded') === 'true'; 27 closeSubmenus(); 28 if (!wasOpen) link.setAttribute('aria-expanded', 'true'); 29 }); 30 31 togglers.push(link); 32 }); 33 } 34 35 function closeSubmenus() { 36 togglers.forEach(function (link) { 37 link.setAttribute('aria-expanded', 'false'); 38 }); 39 } 40 41 // ---- Mobile drawer (hamburger) ---- 42 function closeDrawer() { 43 if (toggle) toggle.setAttribute('aria-expanded', 'false'); 44 } 45 46 // The open drawer is position: fixed, so it would cover the purple top-nav 47 // and yank the logo/X up to the viewport top. Instead its top edge 48 // (--drawer-top in the CSS) is anchored to the bottom of the top-nav when 49 // the bar is on screen: the bar stays visible and nothing jumps. Scrolled 50 // past the bar the offset is 0, a full-screen overlay. 51 // Body scrolling is locked while open, so the value can't go stale except 52 // on resize/rotation, which re-measures below. 53 function setDrawerTop() { 54 if (!header) return; 55 const offset = topNav ? Math.max(0, topNav.getBoundingClientRect().bottom) : 0; 56 header.style.setProperty('--drawer-top', offset + 'px'); 57 } 58 59 // Opening mid-scroll can catch the top-nav half off-screen; anchoring there 60 // would strand a clipped, unreachable bar above the locked drawer. Nudge the 61 // page scroll to the nearest edge first, so the bar is either fully visible 62 // or fully hidden (at most half its height, ~16px, framed as a scroll). 63 function snapTopNavEdge() { 64 if (!topNav) return; 65 const rect = topNav.getBoundingClientRect(); 66 if (rect.bottom <= 0 || rect.bottom >= rect.height) return; 67 const delta = rect.bottom >= rect.height / 2 ? rect.top : rect.bottom; 68 window.scrollBy({ top: delta, behavior: 'instant' }); 69 } 70 71 if (toggle && drawer) { 72 toggle.addEventListener('click', function () { 73 const isOpen = toggle.getAttribute('aria-expanded') === 'true'; 74 if (!isOpen) { 75 snapTopNavEdge(); 76 setDrawerTop(); 77 } 78 toggle.setAttribute('aria-expanded', String(!isOpen)); 79 if (isOpen) closeSubmenus(); 80 }); 81 82 window.addEventListener('resize', function () { 83 if (toggle.getAttribute('aria-expanded') === 'true') setDrawerTop(); 84 }); 85 86 // Following a real link closes the drawer; submenu parents only expand. 87 drawer.addEventListener('click', function (event) { 88 const link = event.target.closest('a'); 89 if (!link) return; 90 if (link.getAttribute('aria-haspopup') === 'true') return; 91 closeDrawer(); 92 }); 93 } 94 95 // ---- Search overlay (the top-nav "Search ACU" link opens a panel that 96 // covers the header; without JS the link just navigates to /?s=). ---- 97 const searchTrigger = document.querySelector('.top-nav__search'); 98 const searchOverlay = document.getElementById('search-overlay'); 99 const searchClose = searchOverlay && searchOverlay.querySelector('.search-overlay__close'); 100 const searchInput = searchOverlay && searchOverlay.querySelector('.search-overlay__input'); 101 102 function openSearch() { 103 if (!searchOverlay) return; 104 searchOverlay.classList.add('is-open'); 105 if (searchTrigger) searchTrigger.setAttribute('aria-expanded', 'true'); 106 if (searchInput) focusSearchInput(0); 107 } 108 109 // The panel animates in from visibility:hidden, which blocks focus, and the 110 // browser can also bounce focus back to the just-clicked trigger link. Force 111 // the open styles to apply (reading layout flushes them), focus, and retry 112 // over the next few frames until it sticks, then give up quietly. 113 function focusSearchInput(attempt) { 114 if (!searchInput) return; 115 void searchOverlay.offsetWidth; 116 searchInput.focus({ preventScroll: true }); 117 if (document.activeElement !== searchInput && attempt < 6) { 118 requestAnimationFrame(function () { focusSearchInput(attempt + 1); }); 119 } 120 } 121 122 function closeSearch(returnFocus) { 123 if (!searchOverlay) return; 124 searchOverlay.classList.remove('is-open'); 125 if (searchTrigger) { 126 searchTrigger.setAttribute('aria-expanded', 'false'); 127 if (returnFocus) searchTrigger.focus(); 128 } 129 } 130 131 if (searchTrigger && searchOverlay) { 132 searchTrigger.setAttribute('aria-haspopup', 'dialog'); 133 searchTrigger.setAttribute('aria-expanded', 'false'); 134 searchTrigger.setAttribute('aria-controls', 'search-overlay'); 135 136 searchTrigger.addEventListener('click', function (event) { 137 event.preventDefault(); 138 if (searchOverlay.classList.contains('is-open')) closeSearch(true); 139 else openSearch(); 140 }); 141 142 if (searchClose) { 143 searchClose.addEventListener('click', function () { 144 closeSearch(true); 145 }); 146 } 147 } 148 149 // ---- Focus trap: the overlay is aria-modal, so while it is open Tab must 150 // cycle within it instead of reaching the page behind. openSearch drops 151 // focus into the input and closeSearch returns it to the trigger; this 152 // keeps it from escaping in between. ---- 153 if (searchOverlay) { 154 const focusableSelector = 155 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not
155([tabindex="-1"])'; 156 157 document.addEventListener('keydown', function (event) { 158 if (event.key !== 'Tab') return; 159 if (!searchOverlay.classList.contains('is-open')) return; 160 161 const items = Array.prototype.filter.call( 162 searchOverlay.querySelectorAll(focusableSelector), 163 function (el) { return el.offsetWidth > 0 || el.offsetHeight > 0; } 164 ); 165 if (!items.length) return; 166 167 const first = items[0]; 168 const last = items[items.length - 1]; 169 const active = document.activeElement; 170 171 if (!searchOverlay.contains(active)) { 172 // Focus has drifted out of (or never entered) the open panel - pull it 173 // back to the first control. 174 event.preventDefault(); 175 first.focus(); 176 } else if (event.shiftKey && active === first) { 177 event.preventDefault(); 178 last.focus(); 179 } else if (!event.shiftKey && active === last) { 180 event.preventDefault(); 181 first.focus(); 182 } 183 }); 184 } 185 186 // ---- Search submit feedback: search is a full-page GET navigation (not 187 // AJAX), so the magnifying-glass icon is swapped for a spinner the 188 // moment the form is submitted, giving the click an acknowledgement 189 // while the results page loads. Covers the desktop overlay and the 190 // mobile inline box; the CSS reads the .is-searching class. ---- 191 const searchForms = document.querySelectorAll( 192 '.search-overlay__form, .site-header__search' 193 ); 194 195 searchForms.forEach(function (form) { 196 form.addEventListener('submit', function (event) { 197 // A re-entrant submit (the deferred form.submit() below, or a second 198 // Enter press) is already on its way - let it navigate. 199 if (form.classList.contains('is-searching')) return; 200 // Navigation would otherwise replace the page before the browser 201 // repaints, so the icon swap never showed on a fast connection. Hold the 202 // submit for two frames - long enough to paint the spinner, too short to 203 // feel like a delay - then navigate. 204 event.preventDefault(); 205 form.classList.add('is-searching'); 206 requestAnimationFrame(function () { 207 requestAnimationFrame(function () { 208 form.submit(); 209 }); 210 }); 211 }); 212 }); 213 214 // Back/forward can restore this page from the bfcache with the submitted 215 // form's DOM intact, which would leave the spinner stuck (and the guard 216 // above latched). Reset it whenever the page is shown again so the search 217 // icon is back and the next search animates. 218 window.addEventListener('pageshow', function () { 219 searchForms.forEach(function (form) { 220 form.classList.remove('is-searching'); 221 }); 222 }); 223 224 // ---- Outside click: collapse open submenus, the drawer, and the search 225 // overlay. A click anywhere outside the search form (the cream panel, 226 // or the page below it) dismisses it; the trigger toggles separately. ---- 227 document.addEventListener('click', function (event) { 228 if (nav && !nav.contains(event.target)) closeSubmenus(); 229 if (header && !header.contains(event.target)) closeDrawer(); 230 if (searchOverlay && searchOverlay.classList.contains('is-open')) { 231 const form = searchOverlay.querySelector('.search-overlay__form'); 232 const onForm = form && form.contains(event.target); 233 const onTrigger = searchTrigger && searchTrigger.contains(event.target); 234 if (!onForm && !onTrigger) closeSearch(false); 235 } 236 }); 237 238 // ---- Sticky masthead: the wrapper sticks at a negative top equal to the 239 // top-nav's height (see .site-masthead in header-navigation.css), so 240 // scrolling slides the purple bar away while the site-header stays 241 // pinned. The height is observed rather than set once because the bar 242 // reflows when the viewport changes or the webfont loads. ---- 243 if (masthead && topNav && 'ResizeObserver' in window) { 244 const setTopNavHeight = function () { 245 masthead.style.setProperty('--top-nav-height', topNav.offsetHeight + 'px'); 246 }; 247 setTopNavHeight(); 248 new ResizeObserver(setTopNavHeight).observe(topNav); 249 } 250
251 // ---- Escape: close everything and drop focus out of the header. ---- 252 document.addEventListener('keydown', function (event) { 253 if (event.key !== 'Escape') return; 254 closeSubmenus(); 255 closeDrawer(); 256 closeSearch(true); 257 if (header && document.activeElement && header.contains(document.activeElement)) { 258 document.activeElement.blur(); 259 } 260 }); 261})();
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.