PageSourceSearch

https://mikvah.org/js/redesign.js?v=1788813916

js mikvah.org collected 2026-10-02 07:50:34 UTC 40,461 bytes, 875 lines download raw bytes

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.