1/** 2 * LBVB template behaviours. 3 * 4 * Preloader, sticky header, 5 * off-canvas menu, mega-menu keyboard/touch support, header search and 6 * scroll-to-top. Vanilla JS, no jQuery. 7 * 8 * @copyright (C) 2026 Ligue de Bretagne de Volley-Ball 9 * @license GNU General Public License version 2 or later 10 */ 11(function () { 12 'use strict'; 13 14 var doc = document; 15 var reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; 16 17 function on(el, ev, fn) { 18 if (el) { el.addEventListener(ev, fn); } 19 } 20 21 /* -------------------------------------------------- Preloader */ 22 23 function hidePreloader() { 24 var pre = doc.querySelector('.sp-pre-loader'); 25 if (!pre) { return; } 26 pre.classList.add('is-hidden'); 27 window.setTimeout(function () { 28 if (pre.parentNode) { pre.parentNode.removeChild(pre); } 29 }, reduceMotion ? 0 : 400); 30 } 31 32 window.addEventListener('load', hidePreloader); 33 // Safety net: never let a stalled asset trap the page behind the loader. 34 window.setTimeout(hidePreloader, 5000); 35 36 /* -------------------------------------------------- Sticky header */ 37 38 (function stickyHeader() { 39 var header = doc.getElementById('sp-header'); 40 if (!header || !doc.body.classList.contains('has-sticky-header')) { return; } 41 42 // `header-sticky` is what template.css turns into position:fixed. It must only 43 // be applied once the page has scrolled past the header, otherwise the header 44 // is lifted out of flow on load and overlaps the first section. 45 var offset = header.offsetTop; 46 var ticking = false; 47 var spacer = null; 48 49 function ensureSpacer(h) { 50 if (!spacer) { 51 spacer = doc.createElement('div'); 52 spacer.className = 'sp-header-spacer'; 53 header.parentNode.insertBefore(spacer, header.nextSibling); 54 } 55 spacer.style.height = h + 'px'; 56 } 57 58 function update() { 59 var stuck = window.pageYOffset > offset + 10; 60 61 if (stuck && !header.classList.contains('header-sticky')) { 62 // Reserve the space the header is about to vacate, so nothing jumps. 63 ensureSpacer(header.getBoundingClientRect().height); 64 header.classList.add('header-sticky'); 65 } else if (!stuck && header.classList.contains('header-sticky')) { 66 header.classList.remove('header-sticky'); 67 if (spacer) { spacer.style.height = '0px'; } 68 } 69 ticking = false; 70 } 71 72 window.addEventListener('scroll', function () { 73 if (!ticking) { 74 window.requestAnimationFrame(update); 75 ticking = true; 76 } 77 }, { passive: true }); 78 79 update(); 80 }()); 81 82 /* -------------------------------------------------- Off-canvas menu */ 83 84 (function offcanvas() { 85 var toggler = doc.getElementById('offcanvas-toggler'); 86 var menu = doc.getElementById('offcanvas-menu') || doc.querySelector('.offcanvas-menu'); 87 var overlay = doc.querySelector('.offcanvas-overlay'); 88 var closeEl = doc.querySelector('.close-offcanvas'); 89 90 if (!toggler || !menu) { return; } 91 92 var lastFocused = null; 93 94 function open(e) { 95 if (e) { e.preventDefault(); } 96 lastFocused = doc.activeElement; 97 doc.body.classList.add('offcanvas-active'); 98 menu.classList.add('active'); 99 if (overlay) { overlay.hidden = false; } 100 toggler.setAttribute('aria-expanded', 'true'); 101 var focusable = menu.querySelector('a, button, input'); 102 if (focusable) { focusable.focus(); } 103 } 104 105 function close(e) { 106 if (e) { e.preventDefault(); } 107 doc.body.classList.remove('offcanvas-active'); 108 menu.classList.remove('active'); 109 if (overlay) { overlay.hidden = true; } 110 toggler.setAttribute('aria-expanded', 'false'); 111 if (lastFocused) { lastFocused.focus(); } 112 } 113 114 on(toggler, 'click', function (e) { 115 if (doc.body.classList.contains('offcanvas-active')) { close(e); } else { open(e); } 116 }); 117 on(overlay, 'click', close); 118 on(closeEl, 'click', close); 119 120 on(doc, 'keydown', function (e) { 121 if (e.key === 'Escape' && doc.body.classList.contains('offcanvas-active')) { close(); } 122 }); 123 124 // Expand/collapse sub-levels inside the off-canvas menu. 125 Array.prototype.forEach.call(menu.querySelectorAll('.offcanvas-menu-toggler'), function (btn) { 126 on(btn, 'click', function (e) { 127 e.preventDefault(); 128 var target = doc.querySelector(btn.getAttribute('data-bs-target')); 129 if (!target) { return; } 130 var isOpen = target.classList.toggle('show'); 131 btn.classList.toggle('collapsed', !isOpen); 132 btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); 133 }); 134 }); 135 }()); 136 137 /* -------------------------------------------------- Mega menu */ 138 139 (function megamenu() { 140 var items = doc.querySelectorAll('.sp-megamenu-parent .sp-has-child'); 141 if (!items.length) { return; } 142 143 var isTouch = window.matchMedia('(hover: none)').matches; 144 145 Array.prototype.forEach.call(items, function (li) { 146 var link = li.querySelector(':scope > a'); 147 var panel = li.querySelector(':scope > .sp-dropdown'); 148 if (!link || !panel) { return; } 149 150 link.setAttribute('aria-haspopup', 'true'); 151 link.setAttribute('aria-expanded', 'false'); 152 153 function setOpen(state) { 154 li.classList.toggle('menu-justify', false); 155 li.classList.toggle('is-open', state); 156 link.setAttribute('aria-expanded', state ? 'true' : 'false'); 157 } 158 159 // Keyboard: open on focus within, close on blur out.
160 on(li, 'focusin', function () { setOpen(true); }); 161 on(li, 'focusout', function () { 162 window.setTimeout(function () { 163 if (!li.contains(doc.activeElement)) { setOpen(false); } 164 }, 0); 165 }); 166 167 // Touch: first tap opens, second follows the link. 168 if (isTouch) { 169 on(link, 'click', function (e) { 170 if (!li.classList.contains('is-open')) { 171 e.preventDefault(); 172 setOpen(true); 173 } 174 }); 175 } 176 177 on(doc, 'keydown', function (e) { 178 if (e.key === 'Escape' && li.classList.contains('is-open')) { 179 setOpen(false); 180 link.focus(); 181 } 182 }); 183 }); 184 185 // Close any open panel when clicking elsewhere. 186 on(doc, 'click', function (e) { 187 Array.prototype.forEach.call(items, function (li) { 188 if (!li.contains(e.target)) { 189 li.classList.remove('is-open'); 190 var a = li.querySelector(':scope > a'); 191 if (a) { a.setAttribute('aria-expanded', 'false'); } 192 } 193 }); 194 }); 195 }()); 196 197 /* -------------------------------------------------- Header search */ 198 199 (function headerSearch() { 200 var toggler = doc.querySelector('.header-search-toggler'); 201 var panel = doc.getElementById('sp-header-search'); 202 if (!toggler || !panel) { return; } 203 204 on(toggler, 'click', function () { 205 var open = panel.hidden; 206 panel.hidden = !open; 207 toggler.setAttribute('aria-expanded', open ? 'true' : 'false'); 208 if (open) { 209 var field = panel.querySelector('input[type="search"], input[type="text"]'); 210 if (field) { field.focus(); } 211 } 212 }); 213 }()); 214 215 /* -------------------------------------------------- Article actions */ 216 217 (function articleActions() { 218 var copyBtn = doc.querySelector('.js-copy-link'); 219 var printBtn = doc.querySelector('.js-print'); 220 221 function toast(msg) { 222 var el = doc.createElement('div'); 223 el.className = 'lbvb-toast'; 224 el.setAttribute('role', 'status'); 225 el.textContent = msg; 226 doc.body.appendChild(el); 227 // next frame so the transition runs 228 window.requestAnimationFrame(function () { el.classList.add('is-visible'); }); 229 window.setTimeout(function () { 230 el.classList.remove('is-visible'); 231 window.setTimeout(function () { el.remove(); }, 250); 232 }, 2000); 233 } 234 235 on(copyBtn, 'click', function () { 236 var url = copyBtn.getAttribute('data-url') || window.location.href; 237 var done = function () { 238 copyBtn.classList.add('is-done'); 239 toast(copyBtn.getAttribute('data-done') || 'OK'); 240 window.setTimeout(function () { copyBtn.classList.remove('is-done'); }, 2000); 241 }; 242 243 if (navigator.clipboard && window.isSecureContext) { 244 navigator.clipboard.writeText(url).then(done).catch(function () { legacy(url, done); }); 245 } else { 246 legacy(url, done); 247 } 248 }); 249 250 function legacy(text, cb) { 251 var ta = doc.createElement('textarea'); 252 ta.value = text; 253 ta.setAttribute('readonly', ''); 254 ta.style.cssText = 'position:absolute;left:-9999px'; 255 doc.body.appendChild(ta); 256 ta.select(); 257 try { doc.execCommand('copy'); cb(); } catch (e) { /* nothing to do */ } 258 ta.remove(); 259 } 260 261 on(printBtn, 'click', function () { window.print(); }); 262 }()); 263 264 /* -------------------------------------------------- Bottom banner offset */ 265 266 (function bottomBannerOffset() { 267 // Consent banners dock at the bottom of the viewport and overlap the 268 // back-to-top button. Flag the body and publish the banner height so 269 // CSS can lift the button clear of it. 270 function measure() { 271 var banner = null; 272 var candidates = doc.querySelectorAll('body > div, body > section, body > aside'); 273 274 Array.prototype.forEach.call(candidates, function (el) { 275 if (banner) { return; } 276 var cs = window.getComputedStyle(el); 277 if (cs.position !== 'fixed' || cs.display === 'none' || cs.visibility === 'hidden') { return; } 278 var r = el.getBoundingClientRect(); 279 // Docked to the bottom, spanning most of the width, and not tall. 280 if (r.height > 0 && r.width > window.innerWidth * 0.6 && 281 Math.abs(r.bottom - window.innerHeight) < 4 && r.height < window.innerHeight * 0.6) { 282 banner = el; 283 } 284 }); 285 286 if (banner) { 287 doc.body.classList.add('has-bottom-banner'); 288 doc.documentElement.style.setProperty(
289 '--lbvb-bottom-banner', 290 Math.round(banner.getBoundingClientRect().height) + 'px' 291 ); 292 } else { 293 doc.body.classList.remove('has-bottom-banner'); 294 doc.documentElement.style.removeProperty('--lbvb-bottom-banner'); 295 } 296 } 297 298 measure(); 299 window.setTimeout(measure, 1200); // banners often inject late 300 window.addEventListener('resize', measure, { passive: true }); 301 302 if (window.MutationObserver) { 303 new MutationObserver(measure).observe(doc.body, { childList: true, subtree: false }); 304 } 305 }()); 306 307 /* -------------------------------------------------- Scroll to top */ 308 309 (function scrollUp() { 310 var btn = doc.querySelector('.sp-scroll-up'); 311 if (!btn) { return; } 312 313 var ticking = false; 314 315 function update() { 316 btn.style.display = window.pageYOffset > 300 ? 'flex' : 'none'; 317 ticking = false; 318 } 319 320 window.addEventListener('scroll', function () { 321 if (!ticking) { 322 window.requestAnimationFrame(update); 323 ticking = true; 324 } 325 }, { passive: true }); 326 327 on(btn, 'click', function (e) { 328 e.preventDefault(); 329 window.scrollTo({ top: 0, behavior: reduceMotion ? 'auto' : 'smooth' }); 330 }); 331 332 update(); 333 }()); 334}());
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.