1/* 2 * Mikvah.org 2026 redesign â public-site JS. 3 * 4 * Plain static JS, no build step. Loaded `defer` from layouts/app.blade.php. 5 * Scopes itself to elements inside `.redesign` so it's safe to load even 6 * where redesign chrome isn't present yet. 7 */ 8 9(function () { 10 'use strict'; 11 12 const root = document.querySelector('body.redesign'); 13 if (!root) return; 14 15 /* -------- Sticky nav on scroll ----------------------------------- 16 * On overlay pages (homepage), the nav is transparent over the hero 17 * and should just scroll away with the page â no fixed/stuck state. 18 * Everywhere else, add .is-stuck after 120 px of scroll. */ 19 const nav = document.getElementById('site-nav'); 20 const isOverlayPage = document.body.classList.contains('nav-overlay'); 21 if (nav && !isOverlayPage) { 22 const stickyAfter = 120; 23 const onScroll = () => { 24 if (window.scrollY > stickyAfter) { 25 nav.classList.add('is-stuck'); 26 } else { 27 nav.classList.remove('is-stuck'); 28 } 29 }; 30 window.addEventListener('scroll', onScroll, { passive: true }); 31 onScroll(); 32 } 33 34 /* -------- Desktop dropdowns (keyboard + click) ------------------- */ 35 const dropdownItems = document.querySelectorAll('.site-nav__item[data-dropdown]'); 36 dropdownItems.forEach(item => { 37 const trigger = item.querySelector('.site-nav__link'); 38 if (!trigger) return; 39 40 trigger.addEventListener('click', (e) => { 41 e.preventDefault(); 42 const open = item.classList.toggle('is-open'); 43 trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); 44 // Close sibling dropdowns 45 dropdownItems.forEach(other => { 46 if (other !== item) { 47 other.classList.remove('is-open'); 48 const ot = other.querySelector('.site-nav__link'); 49 if (ot) ot.setAttribute('aria-expanded', 'false'); 50 } 51 }); 52 }); 53 54 // Close on escape or outside click 55 item.addEventListener('keydown', (e) => { 56 if (e.key === 'Escape') { 57 item.classList.remove('is-open'); 58 trigger.setAttribute('aria-expanded', 'false'); 59 trigger.focus(); 60 } 61 }); 62 }); 63 64 document.addEventListener('click', (e) => { 65 dropdownItems.forEach(item => { 66 if (!item.contains(e.target)) { 67 item.classList.remove('is-open'); 68 const t = item.querySelector('.site-nav__link'); 69 if (t) t.setAttribute('aria-expanded', 'false'); 70 } 71 }); 72 }); 73 74 /* -------- Inline header search ----------------------------------- 75 * Click the circular toggle right of Donate to expand a full-width 76 * search input across the nav row. ESC, the close button, or a 77 * click outside the form collapses it. The header carries an 78 * `.is-searching` class while the bar is open so CSS controls the 79 * transition. */ 80 const searchToggle = document.getElementById('site-nav-search-toggle'); 81 const searchForm = document.getElementById('site-nav-search'); 82 const searchClose = document.getElementById('site-nav-search-close'); 83 if (searchToggle && searchForm) { 84 const searchInput = searchForm.querySelector('.site-nav__search-input'); 85 86 const openSearch = () => { 87 nav?.classList.add('is-searching'); 88 searchToggle.setAttribute('aria-expanded', 'true'); 89 searchForm.setAttribute('aria-hidden', 'false'); 90 // Focus on next frame so the input is visible before the 91 // browser starts the focus ring â keeps the transition clean. 92 requestAnimationFrame(() => searchInput?.focus()); 93 }; 94 95 const closeSearch = () => { 96 if (!nav?.classList.contains('is-searching')) return; 97 nav.classList.remove('is-searching'); 98 searchToggle.setAttribute('aria-expanded', 'false'); 99 searchForm.setAttribute('aria-hidden', 'true'); 100 searchToggle.focus(); 101 }; 102 103 searchToggle.addEventListener('click', openSearch);
104 if (searchClose) searchClose.addEventListener('click', closeSearch); 105 106 // Esc anywhere closes the bar (only while open). 107 document.addEventListener('keydown', (e) => { 108 if (e.key === 'Escape' && nav?.classList.contains('is-searching')) { 109 e.stopPropagation(); 110 closeSearch(); 111 } 112 }); 113 114 // Click outside the form (but not the toggle that opened it) 115 // collapses the bar so it doesn't linger behind the page. 116 document.addEventListener('click', (e) => { 117 if (!nav?.classList.contains('is-searching')) return; 118 if (e.target.closest('#site-nav-search')) return; 119 if (e.target.closest('#site-nav-search-toggle')) return; 120 closeSearch(); 121 }); 122 123 // Empty submit shouldn't ship to /search?q= â let the user 124 // re-focus and try again. SearchController already handles a 125 // missing query gracefully but the redirect is jarring. 126 searchForm.addEventListener('submit', (e) => { 127 if (!searchInput || searchInput.value.trim() === '') { 128 e.preventDefault(); 129 searchInput?.focus(); 130 } 131 }); 132 } 133 134 /* -------- Mobile drawer ------------------------------------------ */ 135 const drawer = document.getElementById('drawer'); 136 const backdrop = document.getElementById('drawer-backdrop'); 137 const openBtn = document.getElementById('drawer-open'); 138 const closeBtn = document.getElementById('drawer-close'); 139 140 const openDrawer = () => { 141 if (!drawer) return; 142 drawer.classList.add('is-open'); 143 drawer.setAttribute('aria-hidden', 'false'); 144 if (backdrop) backdrop.classList.add('is-open'); 145 document.body.classList.add('drawer-locked'); 146 if (openBtn) openBtn.setAttribute('aria-expanded', 'true'); 147 if (closeBtn) closeBtn.focus(); 148 }; 149 150 const closeDrawer = () => { 151 if (!drawer) return; 152 drawer.classList.remove('is-open'); 153 drawer.setAttribute('aria-hidden', 'true'); 154 if (backdrop) backdrop.classList.remove('is-open'); 155 document.body.classList.remove('drawer-locked'); 156 if (openBtn) { 157 openBtn.setAttribute('aria-expanded', 'false'); 158 openBtn.focus(); 159 } 160 }; 161 162 if (openBtn) openBtn.addEventListener('click', openDrawer); 163 if (closeBtn) closeBtn.addEventListener('click', closeDrawer); 164 if (backdrop) backdrop.addEventListener('click', closeDrawer); 165 166 document.addEventListener('keydown', (e) => { 167 if (e.key === 'Escape' && drawer && drawer.classList.contains('is-open')) { 168 closeDrawer(); 169 } 170 }); 171 172 /* -------- Drawer accordion submenus ------------------------------ 173 * Exclusive at both levels: expanding an item collapses its open 174 * siblings so only one panel is open per level at a time. */ 175 const closeDrawerEntry = (entry, triggerSelector) => { 176 entry.classList.remove('is-open'); 177 const t = entry.querySelector(triggerSelector); 178 if (t) t.setAttribute('aria-expanded', 'false'); 179 }; 180 181 const drawerItems = document.querySelectorAll('[data-drawer-item]'); 182 drawerItems.forEach(item => { 183 const trigger = item.querySelector('.drawer__trigger'); 184 if (!trigger) return; 185 trigger.addEventListener('click', () => { 186 const open = item.classList.toggle('is-open'); 187 trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); 188 if (open) { 189 drawerItems.forEach(other => { 190 if (other !== item) closeDrawerEntry(other, '.drawer__trigger'); 191 }); 192 } 193 }); 194 }); 195 196 /* Nested level â sections inside Resources/Education (Find a Mikvah, 197 * Preparation Checklist, â¦). Siblings are scoped to the same submenu 198 * so opening a Resources section doesn't touch Education's. */ 199 const drawerSubItems = document.querySelectorAll('[data-drawer-subitem]'); 200 drawerSubItems.forEach(sub => { 201 const subTrigger = sub.querySelector('.drawer__sub-trigger'); 202 if (!subTrigger) return; 203 subTrigger.addEventListener('click', () => { 204 const open = sub.classList.toggle('is-open'); 205 subTrigger.setAttribute('aria-expanded', open ? 'true' : 'false'); 206 if (open && sub.parentElement) { 207 sub.parentElement.querySelectorAll('[data-drawer-subitem].is-open').forEach(other => { 208 if (other !== sub) closeDrawerEntry(other, '.drawer__sub-trigger'); 209 }); 210 } 211 }); 212 }); 213 214 /* Allow opening the drawer with a specific Resources/Education 215 * section pre-expanded â used by the sticky-bar mega-menu triggers 216 * on viewports ⤠1024 px where the desktop panel is hidden. */ 217 const openDrawerWithSection = (key) => { 218 // key is "resources" or "education" â match against the localized 219 // top-level label by index (resources first, education second). 220 openDrawer(); 221 const targets = document.querySelectorAll('.drawer__item--nested'); 222 const index = key === 'resources' ? 0 : key === 'education' ? 1 : -1;
223 targets.forEach((item, i) => { 224 const trigger = item.querySelector('.drawer__trigger'); 225 if (i === index) { 226 item.classList.add('is-open'); 227 if (trigger) trigger.setAttribute('aria-expanded', 'true'); 228 } else { 229 closeDrawerEntry(item, '.drawer__trigger'); 230 } 231 }); 232 }; 233 234 /* -------- Floating sticky bar visibility ------------------------- 235 * Two rules: 236 * 1. Hide the fixed pill while the in-hero pill is on-screen. 237 * (Homepage only; the hero pill doesn't exist elsewhere.) 238 * 2. Hide the fixed pill when the footer is in view so it doesn't 239 * overlap the footer chrome. 240 * Both rules add `.is-hidden`; removing it on both triggers puts the 241 * pill back on screen. */ 242 const stickybar = document.getElementById('stickybar'); 243 244 if (stickybar && 'IntersectionObserver' in window) { 245 let footerVisible = false; 246 let heroBarVisible = false; 247 248 const applyVisibility = () => { 249 if (footerVisible || heroBarVisible) { 250 stickybar.classList.add('is-hidden'); 251 } else { 252 stickybar.classList.remove('is-hidden'); 253 } 254 }; 255 256 const footer = document.querySelector('.site-footer'); 257 if (footer) { 258 new IntersectionObserver((entries) => { 259 footerVisible = entries[0].isIntersecting; 260 applyVisibility(); 261 }, { rootMargin: '0px 0px -20% 0px' }).observe(footer); 262 } 263 264 const heroBar = document.getElementById('stickybar-hero'); 265 if (heroBar) { 266 // Seed true so the fixed bar is hidden on first paint. 267 heroBarVisible = true; 268 stickybar.classList.add('is-hidden'); 269 270 // We want the fixed floating pill to stay hidden *until the 271 // user has actually scrolled past the in-hero sticky bar* â 272 // not just until the in-hero bar happens to be in the 273 // viewport. On short windows the hero bar can start out 274 // below the fold (below 100 vh), in which case a simple 275 // `isIntersecting` check would immediately show the fixed 276 // pill even though the hero pill hasn't been passed. 277 // 278 // Trick: expand the observer's root with a huge bottom 279 // rootMargin so the hero bar is considered "in the root" 280 // whenever it's anywhere at/below the viewport top. The 281 // observer only flips to `isIntersecting: false` once the 282 // bar scrolls above the viewport top â i.e. has been 283 // passed by the user. 284 new IntersectionObserver((entries) => { 285 heroBarVisible = entries[0].isIntersecting; 286 applyVisibility(); 287 }, { rootMargin: '0px 0px 9999px 0px', threshold: 0 }).observe(heroBar); 288 } 289 } 290 291 /* -------- Tap-to-expand search at narrow widths ------------------ 292 * Once the helpline goes icon-only the search collapses to an icon 293 * too, mirroring it. Tapping the SVG (a sibling of the input inside 294 * the form) doesn't focus the input by default, so :focus-within 295 * can't fire and the expanded layout never engages. Redirect any 296 * click on the form to the input itself when the input isn't 297 * already focused. CSS does the rest â see sticky-bar.css 298 * @media (max-width:600px). */ 299 const narrowSearchMQ = window.matchMedia('(max-width: 600px)'); 300 document.querySelectorAll('.stickybar__search').forEach(form => { 301 const input = form.querySelector('input'); 302 if (!input) return; 303 form.addEventListener('click', (e) => { 304 if (!narrowSearchMQ.matches) return; 305 if (e.target === input) return; 306 if (document.activeElement === input) return; 307 e.preventDefault(); 308 input.focus(); 309 }); 310 }); 311 312 /* -------- Filter-bar (listing pages) ----------------------------- 313 * Sticky filter bar rendered three times per listing page (inline-
314 * top, fixed, inline-bottom) sharing a `data-filterbar` group id. 315 * For each group we wire: 316 * - dropdown toggle on each pill (outside-click + Esc closes; 317 * opening one bar's menu closes the others in the same group) 318 * - live filter: input event filters elements matching the bar's 319 * `data-filter-target` selector by data-title / data-author / 320 * data-description (lowercased), toggling `data-filter-empty` 321 * when nothing matches 322 * - query mirroring across all three inputs so the search term 323 * persists as the bar swaps between inline and fixed copies 324 * - fixed-copy visibility: hide while either inline copy is on- 325 * screen (mirrors the homepage heroâfooter-band pattern). 326 * Pages just include the partial 3Ã with a shared filterId â no 327 * per-page JS needed. */ 328 const filterBars = document.querySelectorAll('[data-filterbar]'); 329 if (filterBars.length) { 330 const groups = {}; 331 filterBars.forEach(bar => { 332 const id = bar.getAttribute('data-filterbar'); 333 (groups[id] = groups[id] || []).push(bar); 334 }); 335 336 Object.values(groups).forEach(group => { 337 const byRole = role => group.find(b => b.getAttribute('data-filterbar-role') === role); 338 const fixed = byRole('fixed'); 339 const inlineTop = byRole('inline-top'); 340 const inlineBot = byRole('inline-bottom'); 341 342 const sample = group[0]; 343 const targetSel = sample.getAttribute('data-filter-target') || ''; 344 const emptySel = sample.getAttribute('data-filter-empty') || ''; 345 346 /* Active client-side section â set when the user clicks a 347 * menu item whose category entry carried a `section` key 348 * (only /search uses this today; URL-routed pages navigate 349 * instead). Empty string = "All" / no section filter. */ 350 let activeSection = ''; 351 352 /* ââ Live filter âââââââââââââââââââââââââââââââââââââââ */ 353 const inputs = group 354 .map(b => b.querySelector('[data-filterbar-input]')) 355 .filter(Boolean); 356 357 const applyFilter = (term) => { 358 if (!targetSel) return; 359 const q = (term || '').toLowerCase().trim(); 360 const cards = document.querySelectorAll(targetSel); 361 let visible = 0; 362 cards.forEach(card => { 363 const t = (card.dataset.title || '').toLowerCase(); 364 const a = (card.dataset.author || '').toLowerCase(); 365 const d = (card.dataset.description || '').toLowerCase(); 366 const s = (card.dataset.section || '').toLowerCase(); 367 const matchText = !q || t.includes(q) || a.includes(q) || d.includes(q); 368 const matchSection = !activeSection || s === activeSection; 369 const match = matchText && matchSection; 370 card.style.display = match ? '' : 'none'; 371 if (match) visible++; 372 }); 373 if (emptySel) { 374 const empty = document.querySelector(emptySel); 375 if (empty) empty.style.display = (cards.length && visible === 0) ? 'block' : 'none'; 376 } 377 }; 378 379 inputs.forEach(input => { 380 input.addEventListener('input', (e) => { 381 const v = e.target.value; 382 inputs.forEach(other => { 383 if (other !== input && other.value !== v) other.value = v; 384 }); 385 applyFilter(v); 386 }); 387 }); 388 389 /* ââ Client-side section filter (search page) ââââââââââ * 390 * Menu items carrying data-filter-section act as on-page 391 * section filters instead of nav links. We intercept the 392 * click, update active state across all three pill copies, 393 * close the open menu, and re-apply the combined filter. 394 * The pill label on each bar copy also updates so the user 395 * sees their selection reflected. */ 396 group.forEach(bar => { 397 bar.querySelectorAll('.filterbar__menu-item').forEach(item => { 398 const section = item.getAttribute('data-filter-section'); 399 if (section === null) return; 400 item.addEventListener('click', (e) => { 401 e.preventDefault(); 402 activeSection = section; 403 const newLabel = item.textContent.trim(); 404 group.forEach(b => { 405 b.querySelectorAll('.filterbar__menu-item').forEach(i => { 406 const isMe = i.getAttribute('data-filter-section') === section; 407 i.classList.toggle('is-active', isMe); 408 if (isMe) i.setAttribute('aria-current', 'page'); 409 else i.removeAttribute('aria-current'); 410 }); 411 const label = b.querySelector('.filterbar__pill-label'); 412 if (label) label.textContent = newLabel; 413 const btn = b.querySelector('[data-filterbar-toggle]'); 414 const menu = b.querySelector('.filterbar__menu'); 415 if (btn) btn.setAttribute('aria-expanded', 'false'); 416 if (menu) menu.hidden = true; 417 }); 418 const term = inputs[0] ? inputs[0].value : ''; 419 applyFilter(term); 420 }); 421 }); 422 }); 423 424 /* ââ Dropdown toggles ââââââââââââââââââââââââââââââââââ */ 425 const toggles = group.map(b => ({ 426 bar: b, 427 btn: b.querySelector('[data-filterbar-toggle]'),
428 menu: b.querySelector('.filterbar__menu'), 429 })).filter(o => o.btn && o.menu); 430 431 const closeAll = () => { 432 toggles.forEach(o => { 433 o.btn.setAttribute('aria-expanded', 'false'); 434 o.menu.hidden = true; 435 }); 436 }; 437 438 toggles.forEach(o => { 439 o.btn.addEventListener('click', (e) => { 440 e.stopPropagation(); 441 const open = o.btn.getAttribute('aria-expanded') === 'true'; 442 closeAll(); 443 if (!open) { 444 o.btn.setAttribute('aria-expanded', 'true'); 445 o.menu.hidden = false; 446 } 447 }); 448 }); 449 450 document.addEventListener('click', (e) => { 451 const inside = toggles.some(o => o.bar.contains(e.target)); 452 if (!inside) closeAll(); 453 }); 454 document.addEventListener('keydown', (e) => { 455 if (e.key === 'Escape') closeAll(); 456 }); 457 458 /* ââ Fixed-copy visibility âââââââââââââââââââââââââââââ * 459 * 460 * Short-listing collapse: if the listing fits in a single 461 * viewport the inline-top + inline-bottom bars would render 462 * stacked on the same screen (see /search with a single- 463 * row result, /media with only a few videos, etc.). Hide 464 * the inline-bottom in that case â its job ("filter bar 465 * lands at the end of the list") is already done by the 466 * top copy because the user can see both ends at once. 467 * The fixed floating pill still takes over once the top 468 * scrolls off-screen, so users on slightly taller pages 469 * never lose access. Measured after layout settles so the 470 * banner image (if any) has its height. */ 471 if (inlineTop && inlineBot) { 472 const collapseCheck = () => { 473 const topRect = inlineTop.getBoundingClientRect(); 474 const botRect = inlineBot.getBoundingClientRect(); 475 // If the bottom bar's top edge is within one 476 // viewport of the top bar's bottom edge, they'd 477 // both be on-screen at the same time. Drop the 478 // bottom one. 479 if (botRect.top - topRect.bottom < window.innerHeight) { 480 inlineBot.style.display = 'none'; 481 } 482 }; 483 // Run once after the next layout flush so images 484 // contributing to the listing height have resolved. 485 if (document.readyState === 'complete') { 486 collapseCheck(); 487 } else { 488 window.addEventListener('load', collapseCheck, { once: true }); 489 } 490 } 491 492 if (fixed && 'IntersectionObserver' in window) { 493 let topVis = false; 494 let botVis = false; 495 const apply = () => { 496 fixed.classList.toggle('is-hidden', topVis || botVis); 497 }; 498 /* No bottom-inset shrink: the floating fixed bar hugs 499 * the bottom of the viewport, so as soon as *any* pixel 500 * of an inline copy enters the viewport the user would 501 * see both bars stacked. Use 0/0/0/0 so they swap as 502 * the inline copy starts to appear. */ 503 const obsOpts = { rootMargin: '0px' }; 504 505 if (inlineTop || inlineBot) { 506 if (inlineTop) { 507 new IntersectionObserver((entries) => { 508 topVis = entries[0].isIntersecting; 509 apply(); 510 }, obsOpts).observe(inlineTop); 511 } 512 if (inlineBot) { 513 new IntersectionObserver((entries) => { 514 botVis = entries[0].isIntersecting; 515 apply(); 516 }, obsOpts).observe(inlineBot); 517 } 518 } else { 519 // No anchors â just reveal the fixed bar. 520 apply(); 521 } 522 } 523 }); 524 } 525 526 /* -------- Podcast description "Read more" toggle ----------------- 527 * <x-podcast-description> splits the RSS body before a "Podcast 528 * Summary:" recap and renders the rest inside a hidden sibling. */ 529 document.querySelectorAll('[data-podcast-readmore]').forEach(btn => { 530 btn.addEventListener('click', () => { 531 const hidden = btn.nextElementSibling; 532 if (!hidden || !hidden.hasAttribute('data-podcast-hidden')) return; 533 hidden.hidden = false;
534 btn.setAttribute('aria-expanded', 'true'); 535 }); 536 }); 537 538 /* -------- Mobile carousels w/ dot pagination -------------------- 539 * Any element marked [data-carousel] contains: 540 * - a single horizontally-scrollable child (the first element 541 * child that isn't .carousel-dots, e.g. .featured-row or 542 * .card-grid) holding the slides as its own direct children 543 * - a .carousel-dots list of <button class="carousel-dot"> 544 * 545 * We update `aria-selected` on the dot whose slide is closest to 546 * the scroller's horizontal center, and let clicking a dot scroll 547 * the matching slide into view. Works on any viewport but only 548 * visible (dots shown) on mobile per CSS. */ 549 document.querySelectorAll('[data-carousel]').forEach(carousel => { 550 const scroller = Array.from(carousel.children).find(el => 551 el.classList && !el.classList.contains('carousel-dots') 552 ); 553 const dots = carousel.querySelectorAll('.carousel-dot'); 554 if (!scroller || !dots.length) return; 555 556 const slides = () => Array.from(scroller.children); 557 558 const updateActive = () => { 559 const rect = scroller.getBoundingClientRect(); 560 const scrollerCenter = rect.left + rect.width / 2; 561 let activeIndex = 0; 562 let minDist = Infinity; 563 slides().forEach((slide, i) => { 564 const s = slide.getBoundingClientRect(); 565 const slideCenter = s.left + s.width / 2; 566 const dist = Math.abs(slideCenter - scrollerCenter); 567 if (dist < minDist) { 568 minDist = dist; 569 activeIndex = i; 570 } 571 }); 572 dots.forEach((dot, i) => { 573 dot.setAttribute('aria-selected', i === activeIndex ? 'true' : 'false'); 574 }); 575 }; 576 577 scroller.addEventListener('scroll', () => { 578 // rAF to coalesce rapid scroll events 579 window.requestAnimationFrame(updateActive); 580 }, { passive: true }); 581 582 dots.forEach((dot, i) => { 583 dot.addEventListener('click', () => { 584 const target = slides()[i]; 585 if (!target) return; 586 // scrollIntoView with 'center' inline snaps on the slide 587 target.scrollIntoView({ 588 behavior: 'smooth', 589 inline: 'center', 590 block: 'nearest' 591 }); 592 }); 593 }); 594 595 updateActive(); 596 }); 597 598 /* -------- Mega-menu (Resources / Education) ---------------------- 599 * Single panel per menu, rendered once in the layout and shared by 600 * every [data-megamenu-trigger="<key>"] (top nav, fixed sticky-bar, 601 * in-hero sticky-bar). One panel can be open at a time. The panel 602 * positions itself relative to the trigger: 603 * - Trigger above its midpoint of viewport â panel opens DOWN 604 * - Trigger below midpoint â panel opens UP 605 * That heuristic naturally handles top-nav (down) and sticky-bar 606 * (up) without needing explicit metadata on each trigger. 607 * 608 * Below 1025 px CSS hides the panel â clicks on sticky-bar triggers 609 * fall through to opening the mobile drawer with the correct 610 * top-level section pre-expanded. 611 */ 612 const megaPanels = document.querySelectorAll('.megamenu-panel'); 613 if (megaPanels.length) { 614 // Strip the `hidden` attribute once the JS is alive â from now on, 615 // CSS visibility/opacity controls reveal/hide. Without this, the 616 // panel would stay `display:none` forever and JS sizing would 617 // measure nothing on first open. 618 megaPanels.forEach(p => p.removeAttribute('hidden')); 619 620 // Mega-menu now stays on screen down to 641 px (matches the 621 // hamburger breakpoint in nav.css / mega-menu.css). Below 641 px 622 // the drawer takes over, so trigger clicks fall through to the 623 // drawer with the right top-level pre-expanded. 624 const desktopMQ = window.matchMedia('(min-width: 641px)'); 625 // Compact viewport range â the four secondary nav links are 626 // hidden and the Resources panel exposes them via its
627 // `compact_only` section ("Main Resources"). The JS uses this 628 // to auto-activate that section when the panel opens. 629 const compactMQ = window.matchMedia('(min-width: 641px) and (max-width: 1100px)'); 630 631 // Map of key â { panel, triggers[] } 632 const panels = {}; 633 megaPanels.forEach(panel => { 634 const key = panel.getAttribute('data-megamenu'); 635 if (key) panels[key] = { panel, triggers: [] }; 636 }); 637 638 const triggers = document.querySelectorAll('[data-megamenu-trigger]'); 639 triggers.forEach(trigger => { 640 const key = trigger.getAttribute('data-megamenu-trigger'); 641 if (panels[key]) panels[key].triggers.push(trigger); 642 }); 643 644 let activePanel = null; 645 let activeTrigger = null; 646 let closeTimeout = null; 647 648 const positionPanel = (panel, trigger) => { 649 const rect = trigger.getBoundingClientRect(); 650 651 // Determine slide direction by where the trigger LIVES in the 652 // page chrome rather than by viewport-half. This makes the 653 // animation deterministic per-trigger instead of changing as 654 // the user scrolls: 655 // - Top header (.site-nav) â panel drops DOWN from above the trigger 656 // - Floating sticky bar / hero CTA / inline footer (.stickybar) 657 // â panel slides UP from below the trigger 658 // - Anything else (defensive) â fall back to viewport half 659 // 660 // Both the homepage hero CTA and the footer-inline variants of 661 // the sticky bar use the same .stickybar root class, so they 662 // both inherit the slide-up behaviour and the panel anchors 663 // to where the buttons actually are (not the page bottom). 664 let openAbove; 665 if (trigger.closest('.stickybar')) { 666 openAbove = true; 667 } else if (trigger.closest('.site-nav')) { 668 openAbove = false; 669 } else { 670 openAbove = (rect.top + rect.height / 2) > window.innerHeight / 2; 671 } 672 673 if (openAbove) { 674 panel.setAttribute('data-position', 'above'); 675 const bottom = window.innerHeight - rect.top + 12; 676 panel.style.setProperty('--megamenu-bottom', bottom + 'px'); 677 panel.style.removeProperty('--megamenu-top'); 678 } else { 679 panel.setAttribute('data-position', 'below'); 680 const top = rect.bottom + 12; 681 panel.style.setProperty('--megamenu-top', top + 'px'); 682 panel.style.removeProperty('--megamenu-bottom'); 683 } 684 }; 685 686 const closePanel = () => { 687 if (!activePanel) return; 688 activePanel.classList.remove('is-open'); 689 activePanel = null; 690 // Reset every trigger's expanded state â multiple triggers can 691 // point at the same panel, so we have to clear them all. 692 document.querySelectorAll('[data-megamenu-trigger]') 693 .forEach(t => t.setAttribute('aria-expanded', 'false')); 694 activeTrigger = null; 695 }; 696 697 const openPanel = (key, trigger) => { 698 const entry = panels[key]; 699 if (!entry) return; 700 701 // If a different panel is open, close it first 702 if (activePanel && activePanel !== entry.panel) closePanel(); 703 704 positionPanel(entry.panel, trigger); 705 706 // Force a synchronous reflow so the browser applies the 707 // data-position-specific starting transform (translateY(-12px) 708 // for "below", +12px for "above") BEFORE we add .is-open in 709 // the next statement. Without this flush, the browser batches 710 // both attribute/class changes into a single style recalc and 711 // transitions from the panel's previous rendered transform â 712 // which makes the slide-up vs slide-down direction look 713 // identical (or fail to animate at all). 714 void entry.panel.offsetHeight; 715 716 entry.panel.classList.add('is-open'); 717 activePanel = entry.panel; 718 activeTrigger = trigger; 719 // Set expanded only on triggers for THIS key so the chev only 720 // rotates on the button the user actually pressed.
721 entry.triggers.forEach(t => t.setAttribute('aria-expanded', t === trigger ? 'true' : 'false')); 722 }; 723 724 const togglePanel = (key, trigger) => { 725 const entry = panels[key]; 726 if (!entry) return; 727 if (activePanel === entry.panel && activeTrigger === trigger) { 728 closePanel(); 729 } else { 730 openPanel(key, trigger); 731 } 732 }; 733 734 const cancelClose = () => { 735 if (closeTimeout) { 736 clearTimeout(closeTimeout); 737 closeTimeout = null; 738 } 739 }; 740 741 const scheduleClose = (delay = 180) => { 742 cancelClose(); 743 closeTimeout = setTimeout(closePanel, delay); 744 }; 745 746 // Wire each trigger 747 triggers.forEach(trigger => { 748 const key = trigger.getAttribute('data-megamenu-trigger'); 749 750 trigger.addEventListener('click', (e) => { 751 if (!desktopMQ.matches) { 752 // Mobile/tablet: fall through to drawer with section preselected. 753 e.preventDefault(); 754 openDrawerWithSection(key); 755 return; 756 } 757 e.preventDefault(); 758 togglePanel(key, trigger); 759 }); 760 761 trigger.addEventListener('mouseenter', () => { 762 if (!desktopMQ.matches) return; 763 cancelClose(); 764 openPanel(key, trigger); 765 }); 766 767 trigger.addEventListener('mouseleave', () => { 768 if (!desktopMQ.matches) return; 769 scheduleClose(); 770 }); 771 772 trigger.addEventListener('keydown', (e) => { 773 if (e.key === 'Escape' && activePanel) { 774 e.stopPropagation(); 775 closePanel(); 776 trigger.focus(); 777 } 778 }); 779 }); 780 781 // Keep the panel open while the cursor is inside it 782 megaPanels.forEach(panel => { 783 panel.addEventListener('mouseenter', cancelClose); 784 panel.addEventListener('mouseleave', () => scheduleClose()); 785 }); 786 787 // Click outside â close 788 document.addEventListener('click', (e) => { 789 if (!activePanel) return; 790 if (e.target.closest('.megamenu-panel')) return; 791 if (e.target.closest('[data-megamenu-trigger]')) return; 792 closePanel(); 793 }); 794 795 // Esc anywhere â close 796 document.addEventListener('keydown', (e) => { 797 if (e.key === 'Escape' && activePanel) closePanel(); 798 }); 799 800 // Window resize / scroll â keep panel anchored to its trigger. 801 // The nav becomes position:fixed once .is-stuck and the sticky-bar 802 // is always fixed, so on most pages the trigger doesn't move on 803 // scroll anyway. We reposition rather than close so a quick 804 // scroll doesn't dismiss the panel a user is actively reading. 805 const reposition = () => { 806 if (activePanel && activeTrigger) positionPanel(activePanel, activeTrigger); 807 }; 808 window.addEventListener('resize', reposition); 809 window.addEventListener('scroll', reposition, { passive: true }); 810 811 // ââ Rail tab switching inside any open panel ââ 812 // Hovering or clicking a rail item activates that section. Keeps 813 // both rail and section panels in sync via aria-selected / .is-active. 814 // Per-panel `activate` functions are exposed via `panelActivators` 815 // so openPanel can swap the active section when the viewport is 816 // in compact mode (Resources > Main Resources). 817 const panelActivators = {}; 818 819 document.querySelectorAll('.megamenu-panel').forEach(panel => { 820 const railItems = panel.querySelectorAll('[data-megamenu-section]'); 821 const sectionPanels = panel.querySelectorAll('[data-megamenu-section-panel]'); 822 823 const activate = (sectionKey) => { 824 railItems.forEach(item => { 825 const isMatch = item.getAttribute('data-megamenu-section') === sectionKey; 826 item.classList.toggle('is-active', isMatch); 827 item.setAttribute('aria-selected', isMatch ? 'true' : 'false'); 828 });
829 sectionPanels.forEach(sp => { 830 const isMatch = sp.getAttribute('data-megamenu-section-panel') === sectionKey; 831 sp.classList.toggle('is-active', isMatch); 832 }); 833 }; 834 835 const key = panel.getAttribute('data-megamenu'); 836 if (key) panelActivators[key] = activate; 837 838 railItems.forEach(item => { 839 const sectionKey = item.getAttribute('data-megamenu-section'); 840 item.addEventListener('mouseenter', () => activate(sectionKey)); 841 item.addEventListener('focus', () => activate(sectionKey)); 842 item.addEventListener('click', () => activate(sectionKey)); 843 }); 844 }); 845 846 // When opening a panel in compact mode (641â1100 px), auto-activate 847 // its `compact_only` section if it has one. This is the "Main 848 // Resources" tab in Resources â the user is in the viewport range 849 // where Shop/My Courses/About/Contact were dropped from the nav, 850 // and that section is the catch-all that brings them back. 851 const _originalOpenPanel = openPanel; 852 // Re-declare openPanel-as-const isn't possible; instead patch the 853 // trigger handlers to call the activator after open. 854 triggers.forEach(trigger => { 855 trigger.addEventListener('click', () => { 856 if (!desktopMQ.matches) return; // drawer handles this case 857 if (!compactMQ.matches) return; // full desktop: leave default 858 const key = trigger.getAttribute('data-megamenu-trigger'); 859 const panel = panels[key] && panels[key].panel; 860 const compactRail = panel && panel.querySelector('.megamenu-panel__rail-item--compact-only'); 861 const compactKey = compactRail && compactRail.getAttribute('data-megamenu-section'); 862 if (compactKey && panelActivators[key]) panelActivators[key](compactKey); 863 }, true); // capture phase: runs before the toggle handler closes the panel 864 865 trigger.addEventListener('mouseenter', () => { 866 if (!desktopMQ.matches || !compactMQ.matches) return; 867 const key = trigger.getAttribute('data-megamenu-trigger'); 868 const panel = panels[key] && panels[key].panel; 869 const compactRail = panel && panel.querySelector('.megamenu-panel__rail-item--compact-only'); 870 const compactKey = compactRail && compactRail.getAttribute('data-megamenu-section'); 871 if (compactKey && panelActivators[key]) panelActivators[key](compactKey); 872 }); 873 }); 874 } 875})();
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.