// ============================================================================ // 1. Mobile navigation toggle // - Handles opening/closing the mobile nav panel // - Updates ARIA attributes for accessibility // - Closes panel when a link inside it is clicked // ============================================================================ (function () { const btn = document.getElementById('nav-toggle'); const panel = document.getElementById('mobile-nav'); if (!btn || !panel) return; // No mobile nav in this layout, abort btn.addEventListener('click', () => { // Toggle the "hidden" class on the panel. // classList.toggle returns true if the class is present AFTER the call. const isHidden = panel.classList.toggle('hidden'); const expanded = !isHidden; // aria-expanded = true when the panel is visible btn.setAttribute('aria-expanded', String(expanded)); btn.setAttribute('aria-label', expanded ? 'Close menu' : 'Open menu'); }); // Close panel when clicking any link inside the mobile nav panel.addEventListener('click', (e) => { const a = e.target.closest('a'); if (!a) return; panel.classList.add('hidden'); btn.setAttribute('aria-expanded', 'false'); btn.setAttribute('aria-label', 'Open menu'); }); })(); // ============================================================================ // 2. Image gallery // - Supports multiple galleries via [data-gallery-root] // - Thumbnail navigation, prev/next arrows, keyboard arrows, touch swipe // - Runs on initial load and after SxEngine swaps // ============================================================================ (() => { /** * Initialize any galleries found within a given DOM subtree. * @param {ParentNode} root - Root element to search in (defaults to document). */ function initGallery(root) { if (!root) return; // Find all nested gallery roots const galleries = root.querySelectorAll('[data-gallery-root]'); // If root itself is a gallery and no nested galleries exist, // initialize just the root. if (!galleries.length && root.matches?.('[data-gallery-root]')) { initOneGallery(root); return; } galleries.forEach(initOneGallery); } /** * Initialize a single gallery instance. * This attaches handlers only once, even if SxEngine re-inserts the fragment. * @param {Element} root - Element with [data-gallery-root]. */ function initOneGallery(root) { // Prevent double-initialization (SxEngine may re-insert the same fragment) if (root.dataset.galleryInitialized === 'true') return; root.dataset.galleryInitialized = 'true'; let index = 0; // Collect all image URLs from [data-image-src] attributes const imgs = Array.from(root.querySelectorAll('[data-image-src]')) .map(el => el.getAttribute('data-image-src') || el.dataset.imageSrc) .filter(Boolean); const main = root.querySelector('[data-main-img]'); const prevBtn = root.querySelector('[data-prev]'); const nextBtn = root.querySelector('[data-next]'); const thumbs = Array.from(root.querySelectorAll('[data-thumb]')); const titleEl = root.querySelector('[data-title]'); const total = imgs.length; // Without a main image or any sources, the gallery is not usable if (!main || !total) return; /** * Render the gallery to reflect the current `index`: * - Update main image src/alt * - Update active thumbnail highlight * - Keep prev/next button ARIA labels consistent */ function render() { main.setAttribute('src', imgs[index]); // Highlight active thumbnail thumbs.forEach((t, i) => { if (i === index) t.classList.add('ring-2', 'ring-stone-900'); else t.classList.remove('ring-2', 'ring-stone-900'); }); // Basic ARIA labels for navigation buttons if (prevBtn && nextBtn) { prevBtn.setAttribute('aria-label', 'Previous image'); nextBtn.setAttribute('aria-label', 'Next image'); } // Alt text uses base title + position (e.g. "Product image (1/4)") const baseTitle = (titleEl?.textContent || 'Product image').trim(); main.setAttribute('alt', `${baseTitle} (${index + 1}/${total})`); } /** * Move to a specific index, wrapping around at bounds. * @param {number} n - Desired index (can be out-of-bounds; we mod it). */ function go(n) { index = (n + imgs.length) % imgs.length; render(); } // --- Button handlers ---------------------------------------------------- prevBtn?.addEventListener('click', (e) => { e.preventDefault(); go(index - 1); }); nextBtn?.addEventListener('click', (e) => { e.preventDefault(); go(index + 1); }); // --- Thumbnail handlers ------------------------------------------------- thumbs.forEach((t, i) => { t.addEventListener('click', (e) => { e.preventDefault(); go(i); }); }); // --- Keyboard navigation (left/right arrows) --------------------------- // Note: we only act if `root` is still attached to the DOM. const keyHandler = (e) => { if (!root.isConnected) return; if (e.key === 'ArrowLeft') go(index - 1); if (e.key === 'ArrowRight') go(index + 1); }; document.addEventListener('keydown', keyHandler); // --- Touch swipe on main image (horizontal only) ----------------------- let touchStartX = null; let touchStartY = null; const SWIPE_MIN = 30; // px main.addEventListener('touchstart', (e) => { const t = e.changedTouches[0]; touchStartX = t.clientX; touchStartY = t.clientY; }, { passive: true }); main.addEventListener('touchend', (e) => { if (touchStartX === null) return; const t = e.changedTouches[0]; const dx = t.clientX - touchStartX; const dy = t.clientY - touchStartY; // Horizontal swipe: dx large, dy relatively small if (Math.abs(dx) > SWIPE_MIN && Math.abs(dy) < 0.6 * Math.abs(dx)) { if (dx < 0) go(index + 1); else go(index - 1); } touchStartX = touchStartY = null; }, { passive: true }); // Initial UI state render(); } // Initialize all galleries on initial page load document.addEventListener('DOMContentLoaded', () => { initGallery(document); }); // Re-initialize galleries after SxEngine swaps document.addEventListener('sx:afterSettle', (evt) => { initGallery(evt.detail?.target || document); }); })(); // ============================================================================ // 3. "Peek" scroll viewport // - Adds a clipped/peek effect to scrollable containers // - Uses negative margins and optional CSS mask fade // - Automatically updates on resize and DOM mutations // ============================================================================ (() => { /** * Safely parse a numeric value or fall back to a default. */ function px(val, def) { const n = Number(val); return Number.isFinite(n) ? n : def; } /** * Apply the peek effect to a viewport and its inner content. * @param {HTMLElement} vp - The viewport (with data-peek-viewport). * @param {HTMLElement} inner - Inner content wrapper. */ function applyPeek(vp, inner) { const edge = (vp.dataset.peekEdge || 'bottom').toLowerCase(); const useMask = vp.dataset.peekMask === 'true'; // Compute peek size in pixels: // - data-peek-size-px: direct px value // - data-peek-size: "units" that are scaled by root font size * 0.25 // - default: 24px const sizePx = px(vp.dataset.peekSizePx, NaN) || px(vp.dataset.peekSize, NaN) * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16) * 0.25 || 24; const overflowing = vp.scrollHeight > vp.clientHeight; // Reset any previous modifications inner.style.marginTop = ''; inner.style.marginBottom = ''; vp.style.webkitMaskImage = vp.style.maskImage = ''; // Reset last child's margin in case we changed it previously const last = inner.lastElementChild; if (last) last.style.marginBottom = ''; if (!overflowing) return; // NOTE: For clipping to look right, we want the viewport's own bottom padding // to be minimal. Consider also using pb-0 in CSS if needed. // Apply negative margins to "cut" off content at top/bottom, creating peek if (edge === 'bottom' || edge === 'both') inner.style.marginBottom = `-${sizePx}px`; if (edge === 'top' || edge === 'both') inner.style.marginTop = `-${sizePx}px`; // Prevent the very last child from cancelling the visual clip if (edge === 'bottom' || edge === 'both') { if (last) last.style.marginBottom = '0px'; } // Optional fade in/out mask on top/bottom if (useMask) { const topStop = (edge === 'top' || edge === 'both') ? `${sizePx}px` : '0px'; const bottomStop = (edge === 'bottom' || edge === 'both') ? `${sizePx}px` : '0px'; const mask = `linear-gradient( 180deg, transparent 0, black ${topStop}, black calc(100% - ${bottomStop}), transparent 100% )`; vp.style.webkitMaskImage = vp.style.maskImage = mask; } } /** * Set up one viewport with peek behavior. * @param {HTMLElement} vp - Element with [data-peek-viewport]. */ function setupViewport(vp) { const inner = vp.querySelector('[data-peek-inner]') || vp.firstElementChild; if (!inner) return; const update = () => applyPeek(vp, inner); // Observe size changes (viewport & inner) const ro = 'ResizeObserver' in window ? new ResizeObserver(update) : null; ro?.observe(vp); ro?.observe(inner); // Observe DOM changes inside the inner container const mo = new MutationObserver(update); mo.observe(inner, { childList: true, subtree: true }); // Run once on window load and once immediately window.addEventListener('load', update, { once: true }); update(); } /** * Initialize peek behavior for all [data-peek-viewport] elements * inside the given root. */ function initPeek(root = document) { root.querySelectorAll('[data-peek-viewport]').forEach(setupViewport); } // Run on initial DOM readiness if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => initPeek()); } else { initPeek(); } // Expose for dynamic inserts (e.g., from SxEngine or other JS) window.initPeekScroll = initPeek; })(); // ============================================================================ // 4. Exclusive
behavior // - Only one
with the same [data-toggle-group] is open at a time // - Respects SxEngine swaps by re-attaching afterSettle // - Scrolls to top when opening a panel // ============================================================================ /** * Attach behavior so that only one
in each data-toggle-group is open. * @param {ParentNode} root - Limit binding to within this node (defaults to document). */ function attachExclusiveDetailsBehavior(root = document) { const detailsList = root.querySelectorAll('details[data-toggle-group]'); detailsList.forEach((el) => { // Prevent double-binding on the same element if (el.__exclusiveBound) return; el.__exclusiveBound = true; el.addEventListener('toggle', function () { // Only act when this
was just opened if (!el.open) return; const group = el.getAttribute('data-toggle-group'); if (!group) return; // Close all other
with the same data-toggle-group document .querySelectorAll('details[data-toggle-group="' + group + '"]') .forEach((other) => { if (other === el) return; if (other.open) { other.open = false; } }); // Scroll to top when a panel is opened window.scrollTo(0, 0); }); }); } // Initial binding on page load attachExclusiveDetailsBehavior(); // Re-bind for new content after SxEngine swaps document.body.addEventListener('sx:afterSettle', function (evt) { attachExclusiveDetailsBehavior(evt.detail?.target || document); }); // ============================================================================ // 5. Close
panels before SxEngine requests // - When a link/button inside a triggers SxEngine, // we close that panel and scroll to top. // ============================================================================ document.body.addEventListener('sx:beforeRequest', function (evt) { const triggerEl = evt.target; const panel = triggerEl.closest('details[data-toggle-group]'); if (!panel) return; panel.open = false; window.scrollTo(0, 0); }); // ============================================================================ // 6. Ghost / Koenig video card fix // - Ghost/Koenig editors may output
// - This replaces the
with just the