PageSourceSearch

https://cdn.searchenginejournal.com/wp-content/themes/sej/js/components/mega-menu-v2.js?ver=1787033718

js searchenginejournal.com collected 2026-09-24 06:23:36 UTC 13,310 bytes, 370 lines download raw bytes

1(function () {
2    'use strict';
3
4    const header = document.querySelector('.sej-header-v2');
5
6    if (!header || header.dataset.megaMenuReady === 'true') {
7        return;
8    }
9
10    header.dataset.megaMenuReady = 'true';
11
12    const mobileQuery = window.matchMedia('(max-width: 1199px)');
13    const menu = header.querySelector('.sej-mega-v2');
14    const openButton = header.querySelector('.sej-mega-v2__open');
15    const closeButton = header.querySelector('.sej-mega-v2__close');
16    const overlay = header.querySelector('.sej-mega-v2__overlay');
17    const panelItems = Array.from(header.querySelectorAll('.sej-mega-v2__item.has-panel'));
18    let pointerCloseTimer = 0;
19
20    if (!menu || !openButton || !closeButton || !overlay) {
21        return;
22    }
23
24    menu.dataset.enhanced = 'true';
25
26    // Above 1600px the header container is viewport-centred, so the tuned
27    // per-variant anchors in CSS track their triggers and reproduce the design.
28    // Below that the container goes full-bleed and left-anchored, and a fixed
29    // anchor drifts away from its trigger -- far enough on the right-hand items
30    // that the panel opens to the left of the item you are pointing at. There,
31    // centre each panel on its own trigger instead. The CSS clamp still keeps it
32    // inside the viewport. One rect read per open; nothing on the move path.
33    const anchorQuery = window.matchMedia('(min-width: 100rem)');
34
35    const positionPanel = (item) => {
36        const panel = item.querySelector(':scope > .sej-mega-v2__panel');
37
38        if (!panel) {
39            return;
40        }
41
42        if (mobileQuery.matches || anchorQuery.matches) {
43            panel.style.removeProperty('--sej-panel-anchor');
44            return;
45        }
46
47        const link = item.querySelector(':scope > .sej-mega-v2__top-row > .sej-mega-v2__top-link');
48
49        if (!link) {
50            return;
51        }
52
53        const rect = link.getBoundingClientRect();
54        panel.style.setProperty('--sej-panel-anchor', ((rect.left + rect.right) / 2) + 'px');
55    };
56
57    const setItemOpen = (item, open) => {
58        const toggle = item.querySelector(':scope > .sej-mega-v2__top-row > .sej-mega-v2__submenu-toggle');
59
60        if (open) {
61            positionPanel(item);
62        }
63
64        item.dataset.open = open ? 'true' : 'false';
65        if (toggle) {
66            toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
67        }
68    };
69
70    const closeOtherItems = (currentItem) => {
71        panelItems.forEach((item) => {
72            if (item !== currentItem) {
73                setItemOpen(item, false);
74            }
75        });
76    };
77
78    const closeAllItems = () => {
79        panelItems.forEach((item) => setItemOpen(item, false));
80    };
81
82    // ---- Hover intent -----------------------------------------------------
83    // Moving diagonally from a trigger into its open panel crosses the
84    // neighbouring triggers and the row itself. Untreated, a neighbour
85    // steals the panel instantly and the short leave-grace closes it. Two
86    // rules fix both without hurting deliberate use:
87    //   1. a sibling only takes over after the pointer DWELLS on it —
88    //      crossing at speed never switches;
89    //   2. while the pointer is still travelling inside the header row (or
90    //      has already reached the panel), the open panel is kept alive up
91    //      to a budget; leaving the row anywhere else closes it quickly.
92    // A geometric pointer-path triangle was tried first and rejected: with
93    // the panel edge only ~40px below the triggers, any horizontal travel
94    // along the row falls outside every cone.
95    const GRACE_RECHECK_MS = 100;
96    const GRACE_MAX_MS = 650;
97
98    let currPointer = null;
99    let pendingSwitchTimer = 0;
100
101    document.addEventListener('pointermove', (event) => {
102        currPointer = { x: event.clientX, y: event.clientY, t: event.timeStamp };
103    }, { passive: true });
104
105    const pointerInsidePanel = (item) => {
106        const panel = item.querySelector(':scope > .sej-mega-v2__panel');
107        if (!panel || !currPointer) {
108            return false;
109        }
110        const r = panel.getBoundingClientRect();
111        return r.width > 0 && currPointer.x >= r.left && currPointer.x <= r.right &&
112            currPointer.y >= r.top && currPointer.y <= r.bottom;
113    };
114
115    // Coordinate checks, not :hover — timers racing multi-step pointer moves
116    // read stale :hover state (Firefox), while currPointer is always the last
117    // dispatched position.
118    const pointerInsideItem = (item) => {
119        if (!currPointer) {
120            return false;
121        }
122        const r = item.getBoundingClientRect();
123        return r.width > 0 && currPointer.x >= r.left && currPointer.x <= r.right &&
124            currPointer.y >= r.top && currPointer.y <= r.bottom;
125    };
126
127    const pointerReached = (item) => pointerInsideItem(item) || pointerInsidePanel(item);
128
129    // Still on the way? True while the pointer travels within the header
130    // row band — sibling triggers and the gaps between them.
131    const pointerInHeaderRow = () => {
132        if (!currPointer) {
133            return false;
134        }
135        const r = header.getBoundingClientRect();
136        return currPointer.y >= r.top && currPointer.y <= r.bottom;
137    };
138
139    const cancelPendingSwitch = () => {
140        window.clearTimeout(pendingSwitchTimer);
141        pendingSwitchTimer = 0;
142    };
143
144    // A sibling only takes the panel over after the pointer DWELLS on it.
145    // Crossing triggers mid-flight (the diagonal swoop into the open panel)
146    // never counts: by the time the timer fires the pointer has either moved
147    // on or reached the open item's panel, and both cancel the switch.
148    const DWELL_SWITCH_MS = 200;
149
150    const schedulePendingSwitch = (target, open) => {
151        cancelPendingSwitch();
152        pendingSwitchTimer = window.setTimeout(() => {
153            pendingSwitchTimer = 0;
154            if (pointerReached(open)) {
155                return; // pointer reached the open item or its panel
156            }
157            if (!pointerInsideItem(target)) {
158                return; // pointer kept travelling
159            }
160            closeOtherItems(target);
161            setItemOpen(target, true);
162        }, DWELL_SWITCH_MS);
163    };
164
165    const setMobileMenuOpen = (open, returnFocus) => {
166        const mobileOpen = mobileQuery.matches && open;
167
168        menu.classList.toggle('is-open', open);
169        overlay.classList.toggle('is-open', open);
170        openButton.setAttribute('aria-expanded', open ? 'true' : 'false');
171        document.body.classList.toggle('sej-mega-v2-lock', open);
172
173        if (mobileQuery.matches) {
174            menu.toggleAttribute('inert', !mobileOpen);
175            menu.setAttribute('aria-hidden', mobileOpen ? 'false' : 'true');
176        } else {
177            menu.removeAttribute('inert');
178            menu.removeAttribute('aria-hidden');
179        }
180
181        if (open) {
182            window.requestAnimationFrame(() => closeButton.focus());
183        } else {
184            closeAllItems();
185            if (returnFocus) {
186                openButton.focus();
187            }
188        }
189    };
190
191    openButton.addEventListener('click', () => setMobileMenuOpen(true, false));
192    closeButton.addEventListener('click', () => setMobileMenuOpen(false, true));
193    overlay.addEventListener('click', () => setMobileMenuOpen(false, true));
194
195    panelItems.forEach((item) => {
196        const toggle = item.querySelector(':scope > .sej-mega-v2__top-row > .sej-mega-v2__submenu-toggle');
197        const topLink = item.querySelector(':scope > .sej-mega-v2__top-row > .sej-mega-v2__top-link');
198
199        if (!toggle || !topLink) {
200            return;
201        }
202
203        const toggleItem = () => {
204            const willOpen = item.dataset.open !== 'true';
205            closeOtherItems(item);
206            setItemOpen(item, willOpen);
207        };
208
209        toggle.addEventListener('click', toggleItem);
210
211        // Drawer parity with the legacy menu: below the breakpoint, tapping the
212        // parent row expands it rather than navigating. Each panel leads with an
213        // "All <section>" link, so the parent destination stays reachable.
214        topLink.addEventListener('click', (event) => {
215            if (!mobileQuery.matches) {
216                return;
217            }
218
219            event.preventDefault();
220            toggleItem();
221        });
222
223        item.addEventListener('pointerenter', (event) => {
224            if (mobileQuery.matches || event.pointerType === 'touch') {
225                return;
226            }
227
228            window.clearTimeout(pointerCloseTimer);
229
230            const openNeighbour = panelItems.find(
231                (other) => other !== item && other.dataset.open === 'true'
232            );
233
234            if (openNeighbour) {
235                // Another panel is open — take over only after a dwell
236                schedulePendingSwitch(item, openNeighbour);
237                return;
238            }
239
240            cancelPendingSwitch();
241            closeOtherItems(item);
242            setItemOpen(item, true);
243        });
244
245        item.addEventListener('pointerleave', (event) => {
246            if (mobileQuery.matches || event.pointerType === 'touch') {
247                return;
248            }
249
250            window.clearTimeout(pointerCloseTimer);
251            const started = performance.now();
252            const attemptClose = () => {
253                if (pointerReached(item)) {
254                    return; // re-entered the item or its panel
255                }
256                if (item.dataset.open === 'true' && pointerInHeaderRow() &&
257                    performance.now() - started < GRACE_MAX_MS) {
258                    pointerCloseTimer = window.setTimeout(attemptClose, GRACE_RECHECK_MS);
259                    return;
260                }
261                setItemOpen(item, false);
262            };
263            pointerCloseTimer = window.setTimeout(attemptClose, 90);
264        });
265
266        item.addEventListener('focusin', () => {
267            if (mobileQuery.matches) {
268                return;
269            }
270
271            closeOtherItems(item);
272            setItemOpen(item, true);
273        });
274
275        item.addEventListener('focusout', (event) => {
276            if (mobileQuery.matches || item.contains(event.relatedTarget)) {
277                return;
278            }
279
280            setItemOpen(item, false);
281        });
282
283        item.addEventListener('keydown', (event) => {
284            if (event.key !== 'Escape') {
285                return;
286            }
287
288            event.preventDefault();
289            topLink.focus();
290            setItemOpen(item, false);
291        });
292    });
293
294    menu.addEventListener('keydown', (event) => {
295        if (event.key !== 'Tab' || !mobileQuery.matches || !menu.classList.contains('is-open')) {
296            return;
297        }
298
299        const focusableItems = Array.from(
300            menu.querySelectorAll('a[href], button:not([disabled]), input:not([disabled])')
301        ).filter((element) => element.getClientRects().length > 0);
302
303        if (!focusableItems.length) {
304            return;
305        }
306
307        const firstItem = focusableItems[0];
308        const lastItem = focusableItems[focusableItems.length - 1];
309
310        if (event.shiftKey && document.activeElement === firstItem) {
311            event.preventDefault();
312            lastItem.focus();
313        } else if (!event.shiftKey && document.activeElement === lastItem) {
314            event.preventDefault();
315            firstItem.focus();
316        }
317    });
318
319    document.addEventListener('keydown', (event) => {
320        if (event.key !== 'Escape') {
321            return;
322        }
323
324        if (menu.classList.contains('is-open')) {
325            setMobileMenuOpen(false, true);
326            return;
327        }
328
329        closeAllItems();
330    });
331
332    document.addEventListener('pointerdown', (event) => {
333        if (!mobileQuery.matches && !header.contains(event.target)) {
334            closeAllItems();
335        }
336    });
337
338    // The header hides on scroll (.desktop-noscrolled-body -> .desktop-scrolled-body),
339    // but panels are fixed to the viewport rather than to the header, so an open
340    // panel would keep hanging in mid-air after its trigger scrolled away. The
341    // legacy menu opened on CSS :hover, so losing the header closed it implicitly;
342    // this restores that. Panels keep focus-opened state so keyboard users are not
343    // interrupted mid-scroll. Passive, with no layout reads, to keep scrolling cheap.
344    window.addEventListener('scroll', () => {
345        if (mobileQuery.matches || header.contains(document.activeElement)) {
346            return;
347        }
348
349        if (panelItems.some((item) => item.dataset.open === 'true')) {
350            closeAllItems();
351        }
352    }, { passive: true });
353
354    const handleViewportChange = () => {
355        setMobileMenuOpen(false, false);
356        closeAllItems();
357    };
358
359    // Crossing 1600px swaps which anchor applies, so close panels there too
360    // rather than leaving one open with an anchor from the other mode.
361    [mobileQuery, anchorQuery].forEach((query) => {
362        if (typeof query.addEventListener === 'function') {
363            query.addEventListener('change', handleViewportChange);
364        } else {
365            query.addListener(handleViewportChange);
366        }
367    });
368
369    setMobileMenuOpen(false, false);
370}());

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.