PageSourceSearch

https://www.becu.org/src/js/nav.js

js becu.org collected 2026-10-01 11:25:28 UTC 34,674 bytes, 816 lines download raw bytes

1/* nav.js
2   Shared navigation + UI behaviors for prototype pages.
3   Requires: bootstrap.bundle.min.js loaded BEFORE this file.
4*/
5
6function closeStandardSearch(options) {
7    var searchDropdown = options && options.searchDropdown;
8    var searchInput = options && options.searchInput;
9
10    if (searchDropdown && typeof searchDropdown.hide === "function") {
11        searchDropdown.hide();
12    }
13
14    if (searchInput && typeof searchInput.focus === "function") {
15        searchInput.focus();
16    }
17}
18
19if (typeof module !== "undefined" && module.exports) {
20    module.exports = {
21        closeStandardSearch,
22    };
23}
24
25(() => {
26    // Guard: bootstrap must exist
27    if (!window.bootstrap) {
28        console.warn("nav.js: bootstrap not found. Load bootstrap.bundle.min.js first.");
29        return;
30    }
31
32    // ------------------------------
33    // Mega: left-rail swaps (Products + Resources + Chips)
34    // ------------------------------
35    (() => {
36        // Support multiple mega menus (future-proof): run for each .c-mega on the page
37        const megaRoots = Array.from(document.querySelectorAll(".c-mega"));
38        if (!megaRoots.length) return;
39
40        megaRoots.forEach((megaRoot) => {
41            const railItems = Array.from(megaRoot.querySelectorAll("[data-mega-target]"));
42            const categories = Array.from(megaRoot.querySelectorAll(".c-mega__category[data-mega-id]"));
43            const asideSets = Array.from(megaRoot.querySelectorAll(".c-mega__asideSet[data-mega-aside]"));
44
45            // Chip sets (static HTML, toggled like categories)
46            const chipSets = Array.from(megaRoot.querySelectorAll(".c-mega__chipSet[data-mega-chips-id]"));
47
48            function setActiveRail(targetId) {
49                railItems.forEach((btn) => {
50                    const isActive = btn.getAttribute("data-mega-target") === targetId;
51                    btn.classList.toggle("is-active", isActive);
52                });
53            }
54
55            function setActiveCategory(targetId) {
56                categories.forEach((panel) => {
57                    const isMatch = panel.getAttribute("data-mega-id") === targetId;
58                    panel.classList.toggle("is-active", isMatch);
59                });
60            }
61
62            function setActiveAside(targetId) {
63                if (!asideSets.length) return;
64
65                asideSets.forEach((panel) => {
66                    const isMatch = panel.getAttribute("data-mega-aside") === targetId;
67                    panel.classList.toggle("is-active", isMatch);
68                });
69            }
70
71            function setActiveChips(targetId) {
72                if (!chipSets.length) return;
73
74                chipSets.forEach((set) => {
75                    const isMatch = set.getAttribute("data-mega-chips-id") === targetId;
76                    set.classList.toggle("is-active", isMatch);
77                });
78            }
79
80            function activate(targetId) {
81                if (!targetId) return;
82                setActiveRail(targetId);
83                setActiveCategory(targetId);
84                setActiveAside(targetId);
85                setActiveChips(targetId);
86            }
87
88            railItems.forEach((btn) => {
89                btn.addEventListener("click", () => {
90                    const targetId = btn.getAttribute("data-mega-target");
91                    activate(targetId);
92                });
93            });
94
95            const initial =
96                railItems.find((b) => b.classList.contains("is-active"))?.getAttribute("data-mega-target") ||
97                railItems[0]?.getAttribute("data-mega-target");
98
99            activate(initial);
100
101            // ------------------------------
102            // Keyboard model (a11y spec, 1.9):
103            //   Tab (L1)   confined to the product items — cycles the rail;
104            //              the panel previews as each item takes focus
105            //   Enter      dives into the L2 product-category items and traps
106            //              Tab there: products → promo + resources → the
107            //              "I need to…" action chips, then wraps
108            //   Escape     one level up — L2 → its L1 product item; L1 →
109            //              menu closes, focus returns to Explore
110            //
111            // DOM order puts the active panel right after the rail, so
112            // without interception Tab would drift from the last group into
113            // whatever panel happens to be open. Panel content and chips are
114            // reachable only inside the L2 trap (the chip set swaps with the
115            // category, so it belongs to that context).
116            // ------------------------------
117            const railStops = Array.from(megaRoot.querySelectorAll(".c-mega__rail-item"));
118            let panelMode = false;
119
120            function focusables(container) {
121                if (!container) return [];
122                return Array.from(
123                    container.querySelectorAll('a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])')
124                ).filter((el) => el.offsetParent !== null);
125            }
126
127            function contentFocusables(targetId) {
128                const panel = megaRoot.querySelector('.c-mega__category[data-mega-id="' + targetId + '"]');
129                const aside = megaRoot.querySelector('.c-mega__asideSet[data-mega-aside="' + targetId + '"]');
130                return focusables(panel).concat(focusables(aside));
131            }
132
133            function chipsFocusables() {
134                return focusables(megaRoot.querySelector(".c-mega__chipsrow"));
135            }
136
137            function activeTargetId() {
138                const btn = railItems.find((b) => b.classList.contains("is-active"));
139                return btn ? btn.getAttribute("data-mega-target") : null;
140            }
141
142            // Selection follows focus: the panel previews as the user tabs
143            // across groups, so Enter always opens what they're seeing.
144            railItems.forEach((btn) => {
145                btn.addEventListener("focus", () => {
146                    panelMode = false;
147                    activate(btn.getAttribute("data-mega-target"));
148                });
149            });
150
151            // Any close (Escape at group level, outside click, re-toggle)
152            // resets the panel trap.
153            const dropdownParent = megaRoot.closest(".dropdown") || megaRoot;
154            dropdownParent.addEventListener("hidden.bs.dropdown", () => {
155                panelMode = false;
156            });
157
158            // WINDOW capture: Bootstrap 5.0.2 registers its delegated
159            // dropdown keydown handler at DOCUMENT capture (EventHandler
160            // passes the delegation selector as addEventListener's third
161            // argument — truthy = capture), where it preventDefaults +
162            // stopPropagations Escape for anything inside an open
163            // .dropdown-menu before any deeper listener runs. Window capture
164            // is the only node ahead of it, so the menu's keyboard model is
165            // handled here and stops propagation before Bootstrap acts.
166            window.addEventListener("keydown", (e) => {
167                // Scope: only while THIS mega menu is open and owns focus.
168                const menuEl = megaRoot.querySelector(".dropdown-menu");
169                if (!menuEl || !menuEl.classList.contains("show")) return;
170                if (!megaRoot.contains(document.activeElement)) return;
171
172                const el = document.activeElement;
173                const railIndex = railStops.indexOf(el);
174
175                // ── Enter on a group: dive into its panel and trap ──
176                if (e.key === "Enter" && railIndex !== -1) {
177                    const targetId = el.getAttribute("data-mega-target");
178                    if (!targetId) return; // Membership is a real link — follow it
179                    e.preventDefault(); // buttons also fire click on Enter
180                    activate(targetId);
181                    const items = contentFocusables(targetId);
182                    if (items.length) {
183                        panelMode = true;
184                        items[0].focus();
185                    }
186                    return;
187                }
188
189                // ── Escape: one level up. Panel → its group; group level →
190                //    close the menu and return focus to Explore. Handled
191                //    explicitly at both levels — Bootstrap 5.0.2's own menu
192                //    Escape is unreliable with data-bs-display="static". ──
193                if (e.key === "Escape") {
194                    e.preventDefault();
195                    e.stopPropagation(); // keep Bootstrap from double-handling
196                    if (panelMode) {
197                        panelMode = false;
198                        const btn = railItems.find((b) => b.classList.contains("is-active"));
199                        if (btn) btn.focus();
200                        return;
201                    }
202                    const trigger = megaRoot.querySelector(".c-mega__trigger:not(.c-mega__trigger--mobile)");
203                    if (trigger) {
204                        if (window.bootstrap && bootstrap.Dropdown) {
205                            bootstrap.Dropdown.getOrCreateInstance(trigger).hide();
206                        }
207                        trigger.focus();
208                    }
209                    return;
210                }
211
212                if (e.key !== "Tab") return;
213
214                // ── L2 trap: Tab cycles the open category — product items,
215                //    then promo + resources, then the "I need to…" action
216                //    chips (the chip set swaps with the category, so it
217                //    belongs to the L2 context) — and wraps. Escape exits. ──
218                if (panelMode) {
219                    const items = contentFocusables(activeTargetId()).concat(chipsFocusables());
220                    if (!items.length) return;
221                    const pos = items.indexOf(el);
222                    if (!e.shiftKey && (pos === items.length - 1 || pos === -1)) {
223                        e.preventDefault();
224                        items[0].focus();
225                    } else if (e.shiftKey && (pos === 0 || pos === -1)) {
226                        e.preventDefault();
227                        items[items.length - 1].focus();
228                    }
229                    return;
230                }
231
232                // ── L1: Tab is confined to the product items — it cycles the
233                //    rail (Membership wraps to the first group). Enter goes
234                //    deeper, Escape closes; panel content and chips are only
235                //    reachable inside the L2 trap. ──
236                if (railIndex !== -1) {
237                    e.preventDefault();
238                    const len = railStops.length;
239                    const next = railStops[(railIndex + (e.shiftKey ? -1 : 1) + len) % len];
240                    next.focus(); // focus handler previews its panel
241                }
242            }, true); // capture — see note above
243        });
244    })();
245
246    // ------------------------------
247    // Search dropdown: open on focus/typing (supports multiple instances)
248    // ------------------------------
249    (() => {
250        const searchRoots = document.querySelectorAll(".c-search");
251        if (!searchRoots.length) return;
252
253        searchRoots.forEach((root) => {
254            const searchInput = root.querySelector(".c-search__input");
255            const searchDdEl = root.querySelector(".c-search__dd");
256            if (!searchInput || !searchDdEl) return;
257
258            const toggleEl = searchDdEl.querySelector(".c-search__control");
259            if (!toggleEl) return;
260
261            const searchDropdown = bootstrap.Dropdown.getOrCreateInstance(toggleEl, {
262                autoClose: "outside",
263                offset: [0, 8], // 8px gap below the box (Popper-driven; clears the focus ring)
264                popperConfig: { strategy: "fixed" },
265            });
266
267            const openSearch = () => searchDropdown.show();
268            const closeSearch = () => searchDropdown.hide();
269
270            // Autocomplete behavior: open only once the user types; never on bare
271            // focus/click. Closes again as soon as the field is emptied.
272            searchInput.addEventListener("input", () => {
273                if (searchInput.value.trim()) openSearch();
274                else closeSearch();
275            });
276
277            searchInput.addEventListener("keydown", (e) => {
278                if (e.key === "Escape") {
279                    e.preventDefault();
280                    closeStandardSearch({ searchDropdown, searchInput });
281                }
282            });
283
284            // After the forEach finishes, add ONE global click listener:
285            document.addEventListener("click", (e) => {
286                document.querySelectorAll(".c-search__dd").forEach((ddEl) => {
287                    if (ddEl.contains(e.target)) return;
288
289                    const ctrl = ddEl.querySelector(".c-search__control");
290                    if (!ctrl) return;
291
292                    const inst = bootstrap.Dropdown.getInstance(ctrl);
293                    inst?.hide();
294                });
295            });
296
297            // inside searchRoots.forEach((root) => { ... })
298            const closeBtn = root.querySelector("[data-search-close]");
299
300            const hardReset = () => {
301                // 1) clear
302                searchInput.value = "";
303
304                // 2) hide dropdown (Bootstrap)
305                searchDropdown.hide();
306
307                // 3) blur so focus doesn't instantly re-open it
308                searchInput.blur();
309
310                // 4) hard cleanup (prevents "sticky" show state)
311                const panel = searchDdEl.querySelector(".c-search__panel");
312                toggleEl.classList.remove("show");
313                panel?.classList.remove("show");
314                panel?.removeAttribute("data-popper-placement");
315                if (panel) {
316                    panel.style.transform = "";
317                    panel.style.inset = "";
318                }
319            };
320
321            closeBtn?.addEventListener("click", (e) => {
322                e.preventDefault();
323                e.stopPropagation(); // don't let it trigger other click handlers
324                hardReset();
325            });
326        });
327    })();
328
329    // ------------------------------
330    // Breakpoint safety: close any open search dropdowns on resize / breakpoint change
331    // ------------------------------
332    (() => {
333        const LG = window.matchMedia("(min-width: 992px)"); // Bootstrap lg
334
335        const hardCloseAllSearch = () => {
336            document.querySelectorAll(".c-search__dd").forEach((ddEl) => {
337                const ctrl = ddEl.querySelector(".c-search__control");
338                const panel = ddEl.querySelector(".c-search__panel");
339                if (!ctrl) return;
340
341                const inst = bootstrap.Dropdown.getInstance(ctrl);
342                inst?.hide();
343
344                // Hard cleanup so nothing “sticks” after layout changes
345                ctrl.classList.remove("show");
346                panel?.classList.remove("show");
347                panel?.removeAttribute("data-popper-placement");
348                if (panel) {
349                    panel.style.transform = "";
350                    panel.style.inset = "";
351                }
352            });
353
354            // If product overlay is open, close it too
355            document.querySelectorAll(".c-nav__row2--product.is-search-open")
356                .forEach((row2) => row2.classList.remove("is-search-open"));
357        };
358
359        // Close when the breakpoint flips
360        if (LG.addEventListener) LG.addEventListener("change", hardCloseAllSearch);
361        else LG.addListener(hardCloseAllSearch); // Safari fallback
362
363        // Close on resize as extra insurance
364        window.addEventListener("resize", hardCloseAllSearch);
365    })();
366
367    // ------------------------------
368    // Product Row 2: mini search expands overlay + chip rail arrows/keyboard
369    // ------------------------------
370    (() => {
371        const row2 = document.querySelector(".c-nav__row2--product");
372        if (!row2) return;
373
374        // Search overlay
375        const miniBtn = row2.querySelector("[data-search-mini]");
376        const overlay = row2.querySelector("[data-search-overlay]");
377        // The search box differs by environment, and Coveo injects its box AFTER
378        // page load, so resolve it lazily at use time (with a prototype fallback)
379        // instead of capturing a possibly-null const once.
380        const getBox = () =>
381            row2.querySelector(".magic-box-input input") || // Coveo (Sitecore)
382            row2.querySelector(".c-search__input");         // prototype fallback
383
384        if (miniBtn && overlay) {
385            miniBtn.addEventListener("click", () => {
386                row2.classList.add("is-search-open");
387                // focus after paint (box may be Coveo's late-rendered input)
388                requestAnimationFrame(() => getBox()?.focus());
389            });
390
391            // Close on Escape (desktop)
392            row2.addEventListener("keydown", (e) => {
393                if (e.key !== "Escape") return;
394                e.preventDefault();
395                closeOverlay();
396            });
397
398            // Click outside overlay closes (desktop)
399            document.addEventListener("click", (e) => {
400                if (!row2.classList.contains("is-search-open")) return;
401                if (overlay.contains(e.target) || miniBtn.contains(e.target)) return;
402                closeOverlay();
403            });
404        }
405
406        const closeBtn = row2.querySelector("[data-search-close]");
407
408        const closeOverlay = () => {
409            // clear input (resolved lazily — see getBox)
410            const box = getBox();
411            if (box) box.value = "";
412            // remove open class
413            row2.classList.remove("is-search-open");
414            // return focus to mini trigger
415            miniBtn?.focus();
416
417            // if bootstrap dropdown is open, close it
418            const ctrl = row2.querySelector(".c-search__control");
419            if (ctrl && window.bootstrap) {
420                const dd = window.bootstrap.Dropdown.getInstance(ctrl);
421                dd?.hide();
422            }
423        };
424
425        closeBtn?.addEventListener("click", (e) => {
426            e.preventDefault();
427            closeOverlay();
428        });
429
430        // Chip rail scroll controls
431        const rail = row2.querySelector("[data-chip-rail]");
432        const scroller = row2.querySelector("[data-chip-scroller]");
433        const prev = row2.querySelector("[data-chip-prev]");
434        const next = row2.querySelector("[data-chip-next]");
435
436        if (rail && scroller && prev && next) {
437            const step = () => Math.max(240, Math.floor(scroller.clientWidth * 0.6));
438
439            prev.addEventListener("click", () => scroller.scrollBy({ left: -step(), behavior: "smooth" }));
440            next.addEventListener("click", () => scroller.scrollBy({ left: step(), behavior: "smooth" }));
441
442            // Keyboard scrolling for a11y
443            scroller.addEventListener("keydown", (e) => {
444                if (e.key === "ArrowLeft") scroller.scrollBy({ left: -step(), behavior: "smooth" });
445                if (e.key === "ArrowRight") scroller.scrollBy({ left: step(), behavior: "smooth" });
446            });
447
448            const update = () => {
449                const maxScroll = scroller.scrollWidth - scroller.clientWidth;
450
451                // If content doesn't overflow, hide both arrows
452                const noOverflow = maxScroll <= 1;
453                rail.classList.toggle("is-no-overflow", noOverflow);
454
455                // "Start" if you're basically at 0
456                rail.classList.toggle("is-at-start", scroller.scrollLeft <= 1);
457
458                // "End" if you're basically at max
459                rail.classList.toggle("is-at-end", scroller.scrollLeft >= maxScroll - 1);
460            };
461
462            // Run on load + on interaction
463            update();
464            scroller.addEventListener("scroll", update, { passive: true });
465            window.addEventListener("resize", update);
466        }
467    })();
468
469    // ------------------------------
470    // Anchor-chip active state (IntersectionObserver)
471    // Watches sections linked from the product nav chip rail.
472    // When a section scrolls into view, its chip gets .is-active.
473    // ------------------------------
474    (() => {
475        const scroller = document.querySelector(
476            ".c-nav__row2--product .c-chipRail__scroller"
477        );
478        if (!scroller) return;
479
480        // Build a map: sectionId → chip element
481        const chips = Array.from(scroller.querySelectorAll("a.c-chip[href^='#']"));
482        if (!chips.length) return;
483
484        const map = new Map(); // id → chip
485        const sections = [];
486
487        chips.forEach((chip) => {
488            const id = chip.getAttribute("href").slice(1); // strip leading #
489            const section = id && document.getElementById(id);
490            if (section) {
491                map.set(id, chip);
492                sections.push(section);
493            }
494        });
495
496        if (!sections.length) return;
497
498        let currentId = null;
499
500        // Centre the active chip inside the rail scroller (horizontal only).
501        // Uses scroller.scrollLeft — does NOT touch the page scroll.
502        const scrollChipIntoRail = (chip) => {
503            const chipLeft = chip.offsetLeft;
504            const chipW = chip.offsetWidth;
505            const railW = scroller.clientWidth;
506            const target = chipLeft - (railW / 2) + (chipW / 2);
507            scroller.scrollTo({ left: Math.max(0, target), behavior: "smooth" });
508        };
509
510        const activate = (id) => {
511            if (id === currentId) return;
512            currentId = id;
513
514            chips.forEach((c) => c.classList.remove("is-active"));
515            const active = map.get(id);
516            if (active) {
517                active.classList.add("is-active");
518                scrollChipIntoRail(active);
519            }
520        };
521
522        // rootMargin: negative top = offset for sticky nav,
523        // negative bottom = only top half of viewport triggers
524        const observer = new IntersectionObserver(
525            (entries) => {
526                entries.forEach((entry) => {
527                    if (entry.isIntersecting) {
528                        activate(entry.target.id);
529                    }
530                });
531            },
532            {
533                rootMargin: "-100px 0px -50% 0px",
534                threshold: 0,
535            }
536        );
537
538        sections.forEach((s) => observer.observe(s));
539    })();
540
541    // ------------------------------
542    // Offcanvas (single): DOM-driven + smooth sliding panels (L1 <-> L2)
543    // ------------------------------
544    (() => {
545        const offEl = document.getElementById("offcanvasNav");
546        if (!offEl) return;
547
548        bootstrap.Offcanvas.getOrCreateInstance(offEl);
549
550        const shell = offEl.querySelector(".c-offnav__shell");
551        const l1Panel = offEl.querySelector(".c-offnav__panel--l1");
552        const l2Panel = offEl.querySelector(".c-offnav__panel--l2");
553        const backBtn = offEl.querySelector("[data-offnav-back]");
554        const titleEl = offEl.querySelector("#offcanvasNavLabel");
555
556        const tabButtons = offEl.querySelectorAll("[data-offnav-segment]");
557        const lists = offEl.querySelectorAll(".c-offnav__list[data-segment]");
558        const l2Blocks = offEl.querySelectorAll(".c-offnav__l2[data-l2-segment][data-l2-id]");
559
560        //let currentSegment = "personal";
561        let currentSegment =
562            Array.from(tabButtons).find(btn => btn.classList.contains("is-active"))?.getAttribute("data-offnav-segment")
563            || "personal"; // fallback to personal if nothing is active
564        let lastL1Trigger = null;
565
566        function setSegment(segment) {
567            currentSegment = segment;
568
569            tabButtons.forEach((btn) => {
570                const isActive = btn.getAttribute("data-offnav-segment") === segment;
571                btn.classList.toggle("is-active", isActive);
572                btn.classList.toggle("active", isActive);
573                btn.setAttribute("aria-selected", isActive ? "true" : "false");
574            });
575
576            lists.forEach((list) => {
577                const isMatch = list.getAttribute("data-segment") === segment;
578                list.classList.toggle("d-none", !isMatch);
579            });
580        }
581
582        function setActiveL2(segment, categoryId) {
583            let active = null;
584
585            l2Blocks.forEach((block) => {
586                const match =
587                    block.getAttribute("data-l2-segment") === segment &&
588                    block.getAttribute("data-l2-id") === categoryId;
589
590                block.classList.toggle("is-active", match);
591                if (match) active = block;
592            });
593
594            if (titleEl && active) {
595                const label = active.getAttribute("aria-label") || "Category";
596                titleEl.textContent = label;
597            }
598
599            return !!active;
600        }
601
602        function onShellTransitionEnd(cb) {
603            if (!shell) return cb();
604
605            const reduce =
606                window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
607            if (reduce) return cb();
608
609            const cs = window.getComputedStyle(shell);
610            const dur = parseFloat(cs.transitionDuration || "0");
611            if (!dur) return cb();
612
613            const handler = (e) => {
614                if (e.target !== shell || e.propertyName !== "transform") return;
615                shell.removeEventListener("transitionend", handler);
616                cb();
617            };
618
619            shell.addEventListener("transitionend", handler);
620        }
621
622        function goL2() {
623            offEl.classList.add("is-l2");
624
625            onShellTransitionEnd(() => {
626                if (l1Panel) l1Panel.setAttribute("aria-hidden", "true");
627                if (l2Panel) l2Panel.setAttribute("aria-hidden", "false");
628
629                const focusTarget =
630                    l2Panel?.querySelector(".c-chip, .c-offnav__itemLink, button, a") || backBtn;
631                if (focusTarget?.focus) focusTarget.focus();
632            });
633        }
634
635        function goL1() {
636            offEl.classList.remove("is-l2");
637
638            onShellTransitionEnd(() => {
639                if (l1Panel) l1Panel.setAttribute("aria-hidden", "false");
640                if (l2Panel) l2Panel.setAttribute("aria-hidden", "true");
641
642                if (lastL1Trigger?.focus) lastL1Trigger.focus();
643            });
644        }
645
646        // init
647        setSegment(currentSegment);
648        if (l1Panel) l1Panel.setAttribute("aria-hidden", "false");
649        if (l2Panel) l2Panel.setAttribute("aria-hidden", "true");
650        offEl.classList.remove("is-l2");
651
652        tabButtons.forEach((btn) => {
653            btn.addEventListener("click", () => {
654                const seg = btn.getAttribute("data-offnav-segment");
655                if (seg) setSegment(seg);
656            });
657        });
658
659        if (l1Panel) {
660            l1Panel.addEventListener("click", (e) => {
661                const trigger = e.target.closest("[data-open-l2]");
662                if (!trigger) return;
663
664                lastL1Trigger = trigger;
665
666                const categoryId = trigger.getAttribute("data-open-l2");
667                const segment = trigger.getAttribute("data-segment") || currentSegment;
668
669                if (segment !== currentSegment) setSegment(segment);
670
671                const ok = setActiveL2(segment, categoryId);
672                if (!ok) return;
673
674                requestAnimationFrame(() => goL2());
675            });
676        }
677
678        if (backBtn) {
679            backBtn.addEventListener("click", (e) => {
680                e.preventDefault();
681                goL1();
682            });
683        }
684
685        offEl.addEventListener("keydown", (e) => {
686            if (e.key !== "Escape") return;
687            if (offEl.classList.contains("is-l2")) {
688                e.preventDefault();
689                goL1();
690            }
691        });
692
693        offEl.addEventListener("shown.bs.offcanvas", () => {
694            setSegment(currentSegment);
695            if (l1Panel) l1Panel.setAttribute("aria-hidden", "false");
696            if (l2Panel) l2Panel.setAttribute("aria-hidden", "true");
697            offEl.classList.remove("is-l2");
698        });
699
700        offEl.addEventListener("hidden.bs.offcanvas", () => {
701            if (l1Panel) l1Panel.setAttribute("aria-hidden", "false");
702            if (l2Panel) l2Panel.setAttribute("aria-hidden", "true");
703            offEl.classList.remove("is-l2");
704        });
705    })();
706
707    // ------------------------------
708    // Promo card row: keyboard horizontal scroll (a11y)
709    // ------------------------------
710    (() => {
711        const scrollers = document.querySelectorAll(".card-slider");
712
713        if (!scrollers.length) return;
714
715        scrollers.forEach((el) => {
716            // Make sure it's focusable (in case markup forgot)
717            if (!el.hasAttribute("tabindex")) {
718                el.setAttribute("tabindex", "0");
719            }
720
721            el.addEventListener("keydown", (e) => {
722                const step = 80;
723                const page = Math.round(el.clientWidth * 0.9);
724
725                switch (e.key) {
726                    case "ArrowRight":
727                        e.preventDefault();
728                        el.scrollBy({ left: step, behavior: "smooth" });
729                        break;
730
731                    case "ArrowLeft":
732                        e.preventDefault();
733                        el.scrollBy({ left: -step, behavior: "smooth" });
734                        break;
735
736                    case "PageDown":
737                        e.preventDefault();
738                        el.scrollBy({ left: page, behavior: "smooth" });
739                        break;
740
741                    case "PageUp":
742                        e.preventDefault();
743                        el.scrollBy({ left: -page, behavior: "smooth" });
744                        break;
745
746                    case "Home":
747                        e.preventDefault();
748                        el.scrollTo({ left: 0, behavior: "smooth" });
749                        break;
750
751                    case "End":
752                        e.preventDefault();
753                        el.scrollTo({ left: el.scrollWidth, behavior: "smooth" });
754                        break;
755                }
756            });
757        });
758    })();
759
760    // Hero carousel moved to public/js/hero-carousel.js (Embla + Autoplay)
761})();
762
763// ───────────────────────────────────────────────────────────────────────────
764// Member-consultant greeting wave
765// When the desktop mega menu opens, wave the consultant's arm once as a
766// greeting. Hover/focus waves are handled entirely in CSS; this only adds the
767// one-shot greeting on open. Respects prefers-reduced-motion.
768//
769// Delegated on document: shown.bs.dropdown fires for every Bootstrap dropdown,
770// so we confirm the MEGA trigger is the one that expanded before greeting (the
771// search dropdown shouldn't trigger it). The .is-greeting class is removed on
772// animationend so each open re-triggers cleanly.
773// ───────────────────────────────────────────────────────────────────────────
774(function () {
775    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
776
777    document.addEventListener("shown.bs.dropdown", function () {
778        // Only when the mega menu is the dropdown that opened
779        if (!document.querySelector('.c-mega__trigger[aria-expanded="true"]')) return;
780        var arm = document.getElementById("arm");
781        if (!arm) return;
782        var icon = arm.closest(".c-mega__rail-ic");
783        if (icon) icon.classList.add("is-greeting");
784    });
785
786    document.addEventListener("animationend", function (e) {
787        if (e.animationName !== "c-mega-consultant-wave") return;
788        var arm = document.getElementById("arm");
789        if (!arm) return;
790        var icon = arm.closest(".c-mega__rail-ic");
791        if (icon) icon.classList.remove("is-greeting");
792    });
793})();
794
795// ───────────────────────────────────────────────────────────────────────────
796// Offcanvas Membership greeting — same wave as the mega rail, triggered when
797// the mobile menu opens. The offcanvas arm path uses class="arm" (the mega
798// rail owns id="arm" on the same page).
799// ───────────────────────────────────────────────────────────────────────────
800(function () {
801    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
802
803    var offEl = document.getElementById("offcanvasNav");
804    if (!offEl) return;
805
806    offEl.addEventListener("shown.bs.offcanvas", function () {
807        var icon = offEl.querySelector(".c-offnav__membership-ic");
808        if (icon) icon.classList.add("is-greeting");
809    });
810
811    offEl.addEventListener("animationend", function (e) {
812        if (e.animationName !== "c-mega-consultant-wave") return;
813        var icon = offEl.querySelector(".c-offnav__membership-ic.is-greeting");
814        if (icon) icon.classList.remove("is-greeting");
815    });
816})();

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.