1/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2 CAROUSEL â widget behavior (Phase-2 widget system; moved out of main.js) 3 4 Self-contained, no dependencies. As a shortcode widget the carousel ALWAYS 5 loads: carousel.php un-gates it via the hzostudio_component_markers filter, 6 because [hzo_carousel] can render from surfaces the loader's post_content scan 7 can't see (header/footer parts run through do_shortcode, text widgets, template 8 fields). The carousel IIFE was removed from main.js so behavior lives here. 9 10 OPTION CONTRACT â every setting is a data-attribute on `.hzo-carousel`, so 11 the block pattern (author writes attributes) and the [hzo_carousel] shortcode 12 (carousel.php generates them from settings) drive the SAME behavior: 13 14 data-hzo-carousel-per-view slides visible at once (int, default 1) 15 â¦-per-view-md / -per-view-sm per-view at â¤1024 / â¤640 (responsive) 16 data-hzo-carousel-gap px between slides (int, default 0) 17 â¦-gap-md / -gap-sm gap at â¤1024 / â¤640 18 data-hzo-carousel-per-move slides advanced per step (int, default 1) 19 data-hzo-carousel-center center the active slide (bool) 20 data-hzo-carousel-loop wrap past the ends (bool, default true) 21 data-hzo-carousel-start initial slide index (int, default 0) 22 data-hzo-carousel-speed transition ms (int; default = CSS token) 23 data-hzo-carousel-effect "slide" | "fade" (fade also via --fade) 24 data-hzo-carousel-autoplay ms between advances (int; 0/absent = off) 25 data-hzo-carousel-pause-hover pause autoplay on hover/focus (default true) 26 data-hzo-carousel-stop-on-interaction stop autoplay after a manual nav (bool) 27 data-hzo-carousel-pagination "none" | "dots" | "fraction" | "progress" 28 data-hzo-carousel-dots legacy alias for pagination=dots (bool) 29 data-hzo-carousel-ring-dots dots with a countdown ring (needs autoplay) 30 data-hzo-carousel-arrows show prev/next (default true if present) 31 data-hzo-carousel-mousewheel advance on vertical wheel (bool) 32 data-hzo-carousel-wheel-release release the wheel to the page at the ends 33 (bool, default true â critical wheel UX) 34 data-hzo-carousel-drag pointer/touch swipe (bool, default true) 35 data-hzo-carousel-lazy add loading="lazy" to slide images (bool) 36 data-hzo-carousel-label accessible name (string) 37 38 A11y (WAI-ARIA APG Carousel): container is role=group + aria-roledescription 39 "carousel"; each slide role=group + aria-roledescription "slide" + "n of N"; 40 the viewport is aria-live=polite while NOT auto-rotating and off while it is; 41 the pause control is required whenever autoplay runs (WCAG 2.2.2) and is the 42 first stop; everything degrades to a plain swipeable scroller with no JS. 43 Reduced motion: no autoplay, no animated transforms. 44 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 45(function () { 46 'use strict'; 47 48 var prefersReduced = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); 49 50 function attr(root, name) { return root.getAttribute('data-hzo-carousel-' + name); } 51 function boolAttr(root, name, dflt) { 52 var v = attr(root, name); 53 if (v === null) { return dflt; } 54 return v !== 'false' && v !== '0' && v !== 'no'; 55 } 56 function intAttr(root, name, dflt) { 57 var v = parseInt(attr(root, name), 10); 58 return isNaN(v) ? dflt : v; 59 } 60 61 function setup(root) { 62 if (root.hasAttribute('data-hzo-carousel-ready')) { return; } // idempotent 63 var track = root.querySelector('.hzo-carousel__track'); 64 var slides = Array.prototype.slice.call(root.querySelectorAll('.hzo-carousel__slide')); 65 if (!track || !slides.length) { return; } 66 root.setAttribute('data-hzo-carousel-ready', ''); 67 68 var viewport = root.querySelector('.hzo-carousel__viewport'); 69 var prev = root.querySelector('.hzo-carousel__btn--prev'); 70 var next = root.querySelector('.hzo-carousel__btn--next'); 71 var toggle = root.querySelector('[data-hzo-carousel-toggle]'); 72 var n = slides.length; 73 74 // ââ Options ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 75 var fade = root.classList.contains('hzo-carousel--fade') || attr(root, 'effect') === 'fade'; 76 var perViewCfg = { 77 base: fade ? 1 : Math.max(1, intAttr(root, 'per-view', 1)), 78 md: fade ? 1 : intAttr(root, 'per-view-md', NaN), 79 sm: fade ? 1 : intAttr(root, 'per-view-sm', NaN) 80 }; 81 var gapCfg = { 82 base: fade ? 0 : Math.max(0, intAttr(root, 'gap', 0)), 83 md: fade ? 0 : intAttr(root, 'gap-md', NaN), 84 sm: fade ? 0 : intAttr(root, 'gap-sm', NaN) 85 }; 86 var perMove = Math.max(1, intAttr(root, 'per-move', 1)); 87 var center = !fade && boolAttr(root, 'center', false); 88 var loop = boolAttr(root, 'loop', true); 89 var speed = intAttr(root, 'speed', NaN); 90 var interval = intAttr(root, 'autoplay', 0); 91 var pauseHover = boolAttr(root, 'pause-hover', true); 92 var stopOnInteract = boolAttr(root, 'stop-on-interaction', false); 93 var wheelOn = boolAttr(root, 'mousewheel', false); 94 var wheelRelease = boolAttr(root, 'wheel-release', true); 95 var dragOn = boolAttr(root, 'drag', true); 96 var lazyOn = boolAttr(root, 'lazy', false); 97 var label = attr(root, 'label'); 98 if (label && !root.getAttribute('aria-label')) { root.setAttribute('aria-label', label); } 99 100 // Pagination mode: explicit attr wins; legacy data-hzo-carousel-dots / 101 // -ring-dots map to "dots"; default none. 102 var pagAttr = (attr(root, 'pagination') || '').toLowerCase(); 103 var ringReq = root.hasAttribute('data-hzo-carousel-ring-dots'); 104 if (!pagAttr) { 105 if (root.hasAttribute('data-hzo-carousel-dots') || ringReq) { pagAttr = 'dots'; } 106 else { pagAttr = 'none'; } 107 } 108 109 // Current per-view / gap resolve responsively (recomputed on resize). 110 function perView() { 111 var w = window.innerWidth; 112 if (w <= 640 && !isNaN(perViewCfg.sm)) { return Math.max(1, perViewCfg.sm); } 113 if (w <= 1024 && !isNaN(perViewCfg.md)) { return Math.max(1, perViewCfg.md); } 114 return perViewCfg.base; 115 } 116 function gapPx() { 117 var w = window.innerWidth; 118 if (w <= 640 && !isNaN(gapCfg.sm)) { return Math.max(0, gapCfg.sm); } 119 if (w <= 1024 && !isNaN(gapCfg.md)) { return Math.max(0, gapCfg.md); } 120 return gapCfg.base; 121 } 122 123 // ââ Geometry (translate-based; robust for any per-view/gap/per-move) ââ 124 // The CSS reads --hzo-cv-per-view / --hzo-cv-gap to size the slides; the 125 // JS measures the real pixel step from the rendered slide so fractional 126 // widths and box-sizing never drift. 127 function applyGeometry() { 128 root.style.setProperty('--hzo-cv-per-view', perView()); 129 root.style.setProperty('--hzo-cv-gap', gapPx() + 'px'); 130 } 131 var index = 0; // active slide index (snap unit) 132 133 function maxIndex() { 134 if (fade || center) { return n - 1; } 135 var pv = perView(); 136 return Math.max(0, n - pv); // last position that fills the viewport 137 } 138 139 function slideStep() { 140 // px advance for one slide = slide outer width + gap. 141 if (!slides[0]) { return 0; } 142 return slides[0].getBoundingClientRect().width + gapPx(); 143 } 144 145 function render(instant) { 146 if (fade) { 147 for (var f = 0; f < n; f++) { slides[f].classList.toggle('is-active', f === index); } 148 } else { 149 var step = slideStep(); 150 var offset = index * step; 151 if (center) { 152 var vpW = viewport ? viewport.getBoundingClientRect().width : 0; 153 var slW = slides[index] ? slides[index].getBoundingClientRect().width : 0; 154 offset -= (vpW - slW) / 2; 155 } 156 var xform = 'translate3d(' + (-offset) + 'px,0,0)'; 157 // Instant: disable the transition, apply the transform, force a 158 // reflow, then restore the transition (reverting to the CSS rule, 159 // which reads --hzo-cv-speed â so `speed` is never clobbered). 160 if (instant && !prefersReduced) {
161 track.style.transition = 'none'; 162 track.style.transform = xform; 163 void track.offsetWidth; 164 track.style.transition = ''; 165 } else { 166 track.style.transform = xform; 167 } 168 } 169 // Inert everything OUTSIDE the visible window so off-screen slide 170 // content (links/buttons clipped by overflow:hidden) stays out of tab 171 // order â APG carousel focus order / WCAG 2.4.3. Works for any 172 // per-view; fade shows exactly one. 173 if (fade) { 174 for (var i = 0; i < n; i++) { slides[i].inert = (i !== index); } 175 } else { 176 var pv = perView(); 177 var winStart = center ? index - Math.floor((pv - 1) / 2) : index; 178 var winEnd = winStart + pv - 1; 179 for (var j = 0; j < n; j++) { slides[j].inert = (j < winStart || j > winEnd); } 180 } 181 updateDots(); 182 updateArrows(); 183 updateStatus(); 184 } 185 186 // ââ Navigation âââââââââââââââââââââââââââââââââââââââââââââââââââââââ 187 function clamp(to) { return Math.max(0, Math.min(to, maxIndex())); } 188 function go(to, opts) { 189 opts = opts || {}; 190 var mx = maxIndex(); 191 var from = index; 192 if (loop) { 193 if (to > mx) { to = 0; } 194 else if (to < 0) { to = mx; } 195 } else { 196 to = clamp(to); 197 } 198 // A wrap jump (endâstart or startâend) more than one step away snaps 199 // instantly to avoid a long rewind blur; adjacent steps animate. 200 var instant = !fade && Math.abs(to - from) > Math.max(perMove, 1) && !prefersReduced; 201 index = to; 202 render(instant); 203 if (opts.user && stopOnInteract) { setPlaying(false); } 204 } 205 function stepBy(dir) { go(index + dir * perMove, { user: true }); } 206 207 if (prev) { prev.addEventListener('click', function () { stepBy(-1); }); } 208 if (next) { next.addEventListener('click', function () { stepBy(1); }); } 209 210 root.addEventListener('keydown', function (e) { 211 var t = e.target; 212 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) { return; } 213 if (e.key === 'ArrowLeft') { e.preventDefault(); stepBy(-1); } 214 else if (e.key === 'ArrowRight') { e.preventDefault(); stepBy(1); } 215 }); 216 217 function updateArrows() { 218 if (loop) { return; } // wrap-enabled â arrows never disable 219 if (prev) { prev.disabled = index <= 0; } 220 if (next) { next.disabled = index >= maxIndex(); } 221 } 222 223 // ââ Status: fraction pagination + a11y live region âââââââââââââââââââ 224 var fractionEl = null; 225 if (pagAttr === 'fraction') { 226 fractionEl = root.querySelector('.hzo-carousel__fraction'); 227 if (!fractionEl) { 228 fractionEl = document.createElement('div'); 229 fractionEl.className = 'hzo-carousel__fraction'; 230 root.appendChild(fractionEl); 231 } 232 } 233 var progressEl = null; 234 if (pagAttr === 'progress') { 235 progressEl = root.querySelector('.hzo-carousel__progress'); 236 if (!progressEl) { 237 progressEl = document.createElement('div'); 238 progressEl.className = 'hzo-carousel__progress'; 239 progressEl.innerHTML = '<span class="hzo-carousel__progress-bar"></span>'; 240 root.appendChild(progressEl); 241 } 242 } 243 function updateStatus() { 244 if (fractionEl) { fractionEl.textContent = (index + 1) + ' / ' + (maxIndex() + 1); } 245 if (progressEl) { 246 var bar = progressEl.firstChild; 247 var mx = maxIndex(); 248 if (bar) { bar.style.transform = 'scaleX(' + (mx ? (index / mx) : 1) + ')'; } 249 } 250 } 251 252 // ââ Dot indicators (+ optional countdown ring) âââââââââââââââââââââââ
253 var dotsWrap = root.querySelector('.hzo-carousel__dots'); 254 var dots = []; 255 var ringOn = false; 256 if (pagAttr === 'dots') { 257 ringOn = !!(interval && !prefersReduced && ringReq); 258 var stops = maxIndex() + 1; 259 if (stops > 1) { 260 if (!dotsWrap) { // don't inject an empty wrapper when there's nothing to page 261 dotsWrap = document.createElement('div'); 262 dotsWrap.className = 'hzo-carousel__dots'; 263 root.appendChild(dotsWrap); 264 } 265 dotsWrap.innerHTML = ''; 266 if (ringOn) { 267 dotsWrap.classList.add('hzo-carousel__dots--ring'); 268 dotsWrap.style.setProperty('--carousel-ring-duration', interval + 'ms'); 269 } 270 for (var d = 0; d < stops; d++) { 271 (function (di) { 272 var b = document.createElement('button'); 273 b.type = 'button'; 274 b.className = 'hzo-carousel__dot'; 275 b.setAttribute('aria-label', 'Go to slide ' + (di + 1)); 276 if (ringOn) { 277 b.innerHTML = '<svg class="hzo-carousel__dot-ring" viewBox="0 0 28 28" aria-hidden="true" focusable="false"><circle cx="14" cy="14" r="10"/></svg>'; 278 } 279 b.addEventListener('click', function () { go(di, { user: true }); }); 280 dotsWrap.appendChild(b); 281 dots.push(b); 282 })(d); 283 } 284 } 285 } 286 function updateDots() { 287 for (var k = 0; k < dots.length; k++) { 288 dots[k].setAttribute('aria-current', k === index ? 'true' : 'false'); 289 } 290 } 291 function restartRing() { 292 if (!ringOn || !dots[index]) { return; } 293 var svg = dots[index].querySelector('.hzo-carousel__dot-ring'); 294 if (svg) { svg.parentNode.replaceChild(svg.cloneNode(true), svg); } 295 } 296 297 // ââ Slides: roles + labels + lazy ââââââââââââââââââââââââââââââââââââ 298 for (var s = 0; s < n; s++) { 299 slides[s].setAttribute('role', 'group'); 300 slides[s].setAttribute('aria-roledescription', 'slide'); 301 if (!slides[s].getAttribute('aria-label')) { slides[s].setAttribute('aria-label', (s + 1) + ' of ' + n); } 302 if (lazyOn) { 303 var imgs = slides[s].querySelectorAll('img'); 304 for (var im = 0; im < imgs.length; im++) { 305 if (!imgs[im].getAttribute('loading')) { imgs[im].setAttribute('loading', 'lazy'); } 306 } 307 } 308 } 309 310 // ââ Drag / swipe (threshold-commit; no live follow) ââââââââââââââââââ 311 if (dragOn && viewport && window.PointerEvent) { 312 var sx = 0, sy = 0, sid = null, swiping = false; 313 viewport.addEventListener('pointerdown', function (e) { 314 if (e.pointerType === 'mouse' && e.button !== 0) { return; } 315 swiping = true; sid = e.pointerId; sx = e.clientX; sy = e.clientY; 316 }); 317 viewport.addEventListener('pointerup', function (e) { 318 if (!swiping || e.pointerId !== sid) { return; } 319 swiping = false; 320 var dx = e.clientX - sx, dy = e.clientY - sy; 321 if (Math.abs(dx) > 45 && Math.abs(dx) > Math.abs(dy)) { 322 stepBy(dx < 0 ? 1 : -1); 323 suspend(); arm(); // resync autoplay countdown 324 } 325 }); 326 viewport.addEventListener('pointercancel', function () { swiping = false; }); 327 } 328 329 // ââ Mousewheel (opt-in) with release-at-edges ââââââââââââââââââââââââ 330 // Wheel navigation is BOUNDED (never wraps) so the ends are reachable and, 331 // with release on (default), the page keeps scrolling once you pass the 332 // last/first slide. Release is INDEPENDENT of `loop` â a looping carousel
333 // has no wheel "edge", so tying release to !loop would trap page scroll 334 // forever (the default is loop=true). preventDefault fires ONLY when we 335 // actually consume the gesture to move a slide. 336 if (wheelOn && viewport) { 337 var wheelLock = false; 338 viewport.addEventListener('wheel', function (e) { 339 var d = Math.abs(e.deltaY) >= Math.abs(e.deltaX) ? e.deltaY : e.deltaX; 340 if (!d) { return; } 341 var dir = d > 0 ? 1 : -1; 342 var atEdge = (dir > 0 && index >= maxIndex()) || (dir < 0 && index <= 0); 343 if (atEdge) { 344 if (wheelRelease) { return; } // let the page scroll past the carousel 345 e.preventDefault(); // release off â swallow the wheel at the edge 346 return; 347 } 348 e.preventDefault(); 349 if (wheelLock) { return; } 350 wheelLock = true; 351 go(clamp(index + dir * perMove), { user: true }); // bounded (no wrap) 352 suspend(); arm(); 353 setTimeout(function () { wheelLock = false; }, 260); // one step per gesture 354 }, { passive: false }); 355 } 356 357 // ââ Autoplay (a11y-safe; pause on hover/focus; reduced-motion off) ââââ 358 var timer = null, playing = false; 359 function suspend() { 360 if (timer) { clearInterval(timer); timer = null; } 361 if (ringOn && dotsWrap) { dotsWrap.classList.add('is-paused'); } 362 } 363 function arm() { 364 if (playing && !timer && !prefersReduced && interval) { 365 timer = setInterval(function () { go(index + perMove); }, interval); 366 if (ringOn && dotsWrap) { dotsWrap.classList.remove('is-paused'); restartRing(); } 367 } 368 } 369 function setPlaying(on) { 370 playing = on; 371 if (on) { arm(); } else { suspend(); } 372 // Live region: announce slide changes only while NOT auto-rotating. 373 if (viewport) { viewport.setAttribute('aria-live', on ? 'off' : 'polite'); } 374 if (toggle) { 375 toggle.setAttribute('aria-pressed', on ? 'false' : 'true'); 376 toggle.setAttribute('aria-label', on ? 'Pause automatic slideshow' : 'Play automatic slideshow'); 377 toggle.textContent = on ? 'Pause' : 'Play'; // Label-in-Name (WCAG 2.5.3) 378 } 379 } 380 if (interval && !prefersReduced) { 381 if (pauseHover) { 382 root.addEventListener('mouseenter', suspend); 383 root.addEventListener('mouseleave', arm); 384 root.addEventListener('focusin', suspend); 385 root.addEventListener('focusout', function () { if (!root.contains(document.activeElement)) { arm(); } }); 386 } 387 if (toggle) { 388 toggle.hidden = false; 389 toggle.addEventListener('click', function () { setPlaying(!playing); }); 390 } 391 setPlaying(true); 392 } else { 393 if (toggle) { toggle.hidden = true; } 394 if (viewport) { viewport.setAttribute('aria-live', 'polite'); } 395 } 396 397 // ââ Re-resolve responsive per-view/gap + re-clamp on any width change ââ 398 var rt = null; 399 function reflow() { 400 if (rt) { clearTimeout(rt); } 401 rt = setTimeout(function () { 402 applyGeometry(); 403 index = clamp(index); 404 render(true); 405 }, 120); 406 } 407 window.addEventListener('resize', reflow); 408 // ResizeObserver catches CONTAINER-width changes with no window resize 409 // (sidebar/accordion collapse, parent flex/grid reflow) that would 410 // otherwise leave the px transform offset until the next nav. 411 if (window.ResizeObserver && viewport) { 412 new ResizeObserver(reflow).observe(viewport); 413 } 414 415 // ââ Init âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 416 applyGeometry(); 417 if (speed && !isNaN(speed) && !fade) { root.style.setProperty('--hzo-cv-speed', speed + 'ms'); } 418 index = clamp(intAttr(root, 'start', 0)); 419 render(true); 420 } 421 422 function init(scope) { 423 var roots = (scope || document).querySelectorAll('.hzo-carousel'); 424 for (var r = 0; r < roots.length; r++) { setup(roots[r]); } 425 } 426 427 if (document.readyState === 'loading') { 428 document.addEventListener('DOMContentLoaded', function () { init(); }); 429 } else { 430 init(); 431 } 432 // Expose on the shared engine namespace (readme.txt documents window.hzo as 433 // "the shared primitive") for dynamically-injected carousels (AJAX loops). 434 window.hzo = window.hzo || {}; 435 window.hzo.carousel = { init: init, setup: setup }; 436})();
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.