PageSourceSearch

https://www.solitaire-overseas.com/js/mega-menu.js?v=20260824-5

js solitaire-overseas.com collected 2026-10-04 03:52:57 UTC 13,447 bytes, 332 lines download raw bytes

1/**
2 * Mega menu header behavior — UAT prototype.
3 *
4 * The nav is plain nested <details>/<summary> so it works with zero JS
5 * (keyboard operable, screen-reader friendly, all links crawlable). This
6 * module only adds the desktop/mobile conveniences native <details> can't
7 * do on its own: closing sibling dropdowns, an off-canvas mobile drawer,
8 * outside-click / Escape dismissal, and a mobile accordion behavior for the
9 * nested category groups.
10 */
11(function () {
12  "use strict";
13
14  var DESKTOP_QUERY = "(min-width: 992px)";
15
16  function init(root) {
17    var header = root.querySelector(".site-header");
18    if (!header) return;
19
20    var nav = header.querySelector(".primary-nav");
21    var toggle = header.querySelector(".nav-toggle");
22    var closeBtn = header.querySelector(".primary-nav__close");
23    var scrim = header.querySelector(".nav-scrim");
24    var topDetails = Array.prototype.slice.call(
25      nav.querySelectorAll(":scope > .primary-nav__list > li > details.nav-dropdown")
26    );
27    var categoryDetails = Array.prototype.slice.call(
28      nav.querySelectorAll("details.nav-category")
29    );
30
31    setupTopLevelExclusivity(topDetails);
32    setupDesktopHoverOpen(topDetails);
33    setupMobileCategoryAccordion(categoryDetails);
34    setupMobileDrawer(nav, toggle, closeBtn, scrim, topDetails);
35    setupOutsideAndEscapeDismiss(header, nav, topDetails, toggle);
36    setupMegaTabs(nav, topDetails);
37    markCurrentPage(nav);
38  }
39
40  // The mega panel's category/grid tabs are pure-CSS (:hover via :has()) so
41  // they work with zero JS, but that means a panel only stays open while the
42  // pointer is literally on top of its label — crossing the gap to the
43  // content grid drops hover and snaps back to the first tab mid-move (see
44  // mega-menu.css). Track the last-entered label per panel instead and make
45  // that "sticky" via .is-active, which content-grid hovering doesn't clear.
46  function setupMegaTabs(nav, topDetails) {
47    var bodies = Array.prototype.slice.call(nav.querySelectorAll(".nav-tabs__body"));
48    bodies.forEach(function (body) {
49      var labels = Array.prototype.slice.call(body.querySelectorAll(".nav-tabs__label"));
50      var panels = Array.prototype.slice.call(body.querySelectorAll(".nav-tabs__panel"));
51      if (!labels.length || !panels.length) return;
52
53      function activate(index) {
54        labels.forEach(function (label, i) {
55          label.classList.toggle("is-active", i === index);
56          label.setAttribute("aria-expanded", i === index ? "true" : "false");
57        });
58        panels.forEach(function (panel, i) {
59          panel.classList.toggle("is-active", i === index);
60        });
61      }
62
63      function deactivateAll() {
64        labels.forEach(function (label) {
65          label.classList.remove("is-active");
66          label.setAttribute("aria-expanded", "false");
67        });
68        panels.forEach(function (panel) {
69          panel.classList.remove("is-active");
70        });
71      }
72
73      labels.forEach(function (label, index) {
74        // Desktop: real mouse hover / keyboard focus previews the panel.
75        // Gated to desktop widths only — see the click handler below for why.
76        label.addEventListener("mouseenter", function () {
77          if (window.matchMedia(DESKTOP_QUERY).matches) activate(index);
78        });
79        label.addEventListener("focus", function () {
80          if (window.matchMedia(DESKTOP_QUERY).matches) activate(index);
81        });
82
83        // Mobile/touch: there's no hover, and each label is a real link, so
84        // a tap without this handler navigates away instantly — the grade
85        // panel below it is never reachable. On mobile a tap NEVER follows
86        // the link: it only opens/closes that category's panel (tapping an
87        // already-open label collapses it again, standard accordion style).
88        // This isn't a loss of navigation — every label's own href is
89        // already duplicated as the first link inside its own panel, so the
90        // exact same destination is one more tap away once it's open.
91        // (A touch tap fires `focus` before `click`, so the desktop handler
92        // above is deliberately skipped below 992px — otherwise the label
93        // would already read as active by the time this runs, and every
94        // tap would navigate straight through with no preview.)
95        label.addEventListener("click", function (evt) {
96          if (window.matchMedia(DESKTOP_QUERY).matches) return;
97          evt.preventDefault();
98          if (label.classList.contains("is-active")) {
99            deactivateAll();
100            return;
101          }
102          activate(index);
103          var panel = panels[index];
104          if (panel) panel.scrollIntoView({ behavior: "smooth", block: "nearest" });
105        });
106      });
107
108      activate(0);
109      body.classList.add("js-tabs");
110    });
111
112    // Reset every mega panel whenever its dropdown is (re)opened, so it
113    // doesn't reopen showing whatever was last hovered/tapped. Desktop
114    // starts back on the first tab (it's always previewing something);
115    // mobile starts fully collapsed, since there each category is opened
116    // by an explicit tap (see the click handler above).
117    topDetails.forEach(function (details) {
118      details.addEventListener("toggle", function () {
119        if (!details.open) return;
120        var isDesktop = window.matchMedia(DESKTOP_QUERY).matches;
121        var bodiesInPanel = details.querySelectorAll(".nav-tabs__body.js-tabs");
122        bodiesInPanel.forEach(function (body) {
123          var labels = body.querySelectorAll(".nav-tabs__label");
124          var panels = body.querySelectorAll(".nav-tabs__panel");
125          labels.forEach(function (label, i) {
126            var isOpen = isDesktop && i === 0;
127            label.classList.toggle("is-active", isOpen);
128            label.setAttribute("aria-expanded", isOpen ? "true" : "false");
129          });
130          panels.forEach(function (panel, i) {
131            panel.classList.toggle("is-active", isDesktop && i === 0);
132          });
133        });
134      });
135    });
136  }
137
138  // Only one top-level dropdown open at a time (desktop hover-alternative
139  // and mobile accordion both benefit from this).
140  function setupTopLevelExclusivity(topDetails) {
141    topDetails.forEach(function (details) {
142      details.addEventListener("toggle", function () {
143        if (!details.open) return;
144        topDetails.forEach(function (other) {
145          if (other !== details) other.open = false;
146        });
147      });
148    });
149  }
150
151  // Desktop only: hovering "About Us" / "Pipes & Tubes" / "Our Products"
152  // opens its panel without needing a click. This sets the real [open]
153  // attribute (rather than just showing the panel via CSS on :hover), since
154  // a closed <details>'s content is hidden by the browser's own UA
155  // stylesheet — an author :hover rule can't reliably override that across
156  // browsers, but setting [open] uses the same native mechanism a click
157  // already does, so it's guaranteed to work and automatically fires the
158  // existing toggle listener above (closing any other open sibling) and the
159  // mega-tab reset-to-first-tab listener in setupMegaTabs. A short delay on
160  // mouseleave avoids it snapping shut if the pointer briefly dips outside
161  // the label/panel while moving between them. Mobile never gets these
162  // listeners' effect since they bail out below 992px, leaving its own
163  // tap-to-toggle behavior (native summary activation) untouched.
164  function setupDesktopHoverOpen(topDetails) {
165    topDetails.forEach(function (details) {
166      var closeTimer = null;
167      details.addEventListener("mouseenter", function () {
168        if (!window.matchMedia(DESKTOP_QUERY).matches) return;
169        clearTimeout(closeTimer);
170        details.open = true;
171      });
172      details.addEventListener("mouseleave", function () {
173        if (!window.matchMedia(DESKTOP_QUERY).matches) return;
174        closeTimer = setTimeout(function () {
175          details.open = false;
176        }, 150);
177      });
178    });
179  }
180
181  // On mobile, keep the nested per-material accordions to one-open-at-a-time
182  // per group so the drawer doesn't turn into an endless scroll. Desktop
183  // leaves them alone — a mega menu grid is supposed to show many columns.
184  function setupMobileCategoryAccordion(categoryDetails) {
185    categoryDetails.forEach(function (details) {
186      details.addEventListener("toggle", function () {
187        if (window.matchMedia(DESKTOP_QUERY).matches) return;
188        if (!details.open) return;
189        var siblings = details.parentElement.querySelectorAll(":scope > details.nav-category");
190        siblings.forEach(function (sib) {
191          if (sib !== details) sib.open = false;
192        });
193      });
194    });
195  }
196
197  function setupMobileDrawer(nav, toggle, closeBtn, scrim, topDetails) {
198    if (!toggle) return;
199
200    function openDrawer() {
201      nav.classList.add("is-open");
202      toggle.setAttribute("aria-expanded", "true");
203      if (scrim) scrim.classList.add("is-visible");
204      document.body.style.overflow = "hidden";
205    }
206
207    function closeDrawer() {
208      nav.classList.remove("is-open");
209      toggle.setAttribute("aria-expanded", "false");
210      if (scrim) scrim.classList.remove("is-visible");
211      document.body.style.overflow = "";
212      topDetails.forEach(function (d) {
213        d.open = false;
214      });
215    }
216
217    toggle.addEventListener("click", function () {
218      var isOpen = nav.classList.contains("is-open");
219      if (isOpen) closeDrawer();
220      else openDrawer();
221    });
222
223    if (closeBtn) closeBtn.addEventListener("click", closeDrawer);
224    if (scrim) scrim.addEventListener("click", closeDrawer);
225
226    window.addEventListener("resize", debounce(function () {
227      if (window.matchMedia(DESKTOP_QUERY).matches) closeDrawer();
228    }, 150));
229  }
230
231  function setupOutsideAndEscapeDismiss(header, nav, topDetails, toggle) {
232    document.addEventListener("click", function (evt) {
233      if (header.contains(evt.target)) return;
234      topDetails.forEach(function (d) {
235        d.open = false;
236      });
237    });
238
239    document.addEventListener("keydown", function (evt) {
240      if (evt.key !== "Escape") return;
241      var anyOpen = topDetails.some(function (d) {
242        return d.open;
243      });
244      if (anyOpen) {
245        topDetails.forEach(function (d) {
246          d.open = false;
247        });
248        return;
249      }
250      if (nav.classList.contains("is-open") && toggle) {
251        toggle.click();
252        toggle.focus();
253      }
254    });
255  }
256
257  // Highlight the nav item matching the current document, if any.
258  function markCurrentPage(nav) {
259    // Home now links to the bare root (https://.../) rather than index.html,
260    // so both sides need the same "empty path segment = index.html" fallback
261    // — otherwise visiting either "/" or "/index.html" stops matching Home's
262    // href, which would never highlight as the current page again.
263    var here = location.pathname.split("/").pop() || "index.html";
264    var links = nav.querySelectorAll(".primary-nav__list > li > a.primary-nav__link");
265    links.forEach(function (link) {
266      var file = link.getAttribute("href").split("/").pop() || "index.html";
267      if (file === here) {
268        link.closest(".primary-nav__item").classList.add("primary-nav__item--current");
269      }
270    });
271  }
272
273  function debounce(fn, wait) {
274    var t;
275    return function () {
276      clearTimeout(t);
277      var args = arguments;
278      t = setTimeout(function () {
279        fn.apply(null, args);
280      }, wait);
281    };
282  }
283
284  // The sticky "Get in Touch" bar (#sbcta-bar, per-page markup, styled in
285  // mega-menu.css) is position:fixed with no space reserved for it, so once
286  // its own inline per-page script adds "is-visible" it sits on top of
287  // whatever content is at the bottom of the viewport — e.g. another
288  // contact form's submit button or a table's last row. Rather than edit
289  // every page's copy-pasted toggle script, watch the bar's class here
290  // (this file is already shared/loaded on every page) and push the page
291  // content up by the bar's own height while it's showing.
292  function setupStickyCtaSpacing(root) {
293    var bar = root.getElementById("sbcta-bar");
294    if (!bar) return;
295
296    function sync() {
297      var visible = bar.classList.contains("is-visible");
298      document.body.style.paddingBottom = visible ? bar.offsetHeight + "px" : "";
299    }
300
301    sync();
302    new MutationObserver(sync).observe(bar, { attributes: true, attributeFilter: ["class"] });
303    window.addEventListener("resize", debounce(sync, 150));
304  }
305
306  // On the live site the header markup isn't in the page source — it's
307  // fetched into #globalmainheader and injected via innerHTML, which
308  // finishes well after DOMContentLoaded. Try immediately (works when the
309  // header is already in the page, e.g. this test file); otherwise watch
310  // the DOM until it shows up, then initialize once and stop watching.
311  function boot() {
312    setupStickyCtaSpacing(document);
313
314    if (document.querySelector(".site-header")) {
315      init(document);
316      return;
317    }
318    var observer = new MutationObserver(function () {
319      if (document.querySelector(".site-header")) {
320        observer.disconnect();
321        init(document);
322      }
323    });
324    observer.observe(document.body, { childList: true, subtree: true });
325  }
326
327  if (document.readyState === "loading") {
328    document.addEventListener("DOMContentLoaded", boot);
329  } else {
330    boot();
331  }
332})();

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.