1/** 2 * JazzyCarousel â Lightweight accessible carousel 3 * 4 * Follows the W3C APG Carousel pattern: 5 * https://www.w3.org/WAI/ARIA/apg/patterns/carousel/ 6 * 7 * CSS-driven transitions with minimal JS for state management. 8 * No jQuery dependency. Supports slide + fade modes, drag/swipe, 9 * arrows, dots, autoplay, and responsive slide counts. 10 * 11 * Usage: 12 * <ul data-jazzy-carousel="slide"> 13 * <li>â¦</li> 14 * <li>â¦</li> 15 * </ul> 16 * 17 * All direct element children become slides automatically. 18 * To exclude a child (e.g. a nav), add data-jazzy-carousel-exclude. 19 * 20 * Options (data attributes or JS config): 21 * data-jazzy-carousel="fade|slide" Transition mode (default: "slide") 22 * data-jazzy-autoplay="5000" Autoplay interval in ms (0 = off) 23 * data-jazzy-loop="true" Loop/wrap around (default: false) 24 * data-jazzy-slides="3" Slides visible at once (default: 1) 25 * data-jazzy-gap="40" Gap between slides in px (default: 0; omit to use CSS gap) 26 * data-jazzy-peek="40" Peek amount in px â shrinks slides to reveal next card (default: 0) 27 * data-jazzy-arrows="false" Disable auto-generated arrows 28 * data-jazzy-dots="true" Show dot indicators 29 * data-jazzy-drag="false" Disable drag/swipe (default: true) 30 * data-jazzy-adaptive-height="true" Match the carousel height to the active slide â fade mode only (default: false) 31 * data-jazzy-responsive='[{"max":950,"slides":2},{"max":650,"mode":"slide","slides":1,"autoplay":0,"drag":true}]' 32 * data-jazzy-dots-target=".media" Place dots inside a specific element (CSS selector, resolved from closest section/module) 33 * 34 * External arrows: Place buttons with [data-jazzy-carousel-prev] / [data-jazzy-carousel-next] 35 * anywhere in the DOM and set data-jazzy-carousel-id on both the carousel 36 * and the buttons to link them. Or place them as siblings/ancestors. 37 * 38 * Events emitted on the carousel element: 39 * jazzycarousel:change { detail: { index, previousIndex, slide } } 40 * 41 * Thumbnail navigation (nav-for): 42 * Place buttons with [data-slide-index="0"], [data-slide-index="1"], etc. 43 * inside a container with [data-jazzy-carousel-nav="carousel-id"]. 44 * Clicking a nav button calls goTo(index). The carousel's change event 45 * syncs the .active class on nav buttons automatically. 46 * 47 * Index sync: 48 * Any element with [data-jazzy-carousel-sync="carousel-id"] will have its 49 * data-jazzy-dialog-slide attribute updated to the current slide index 50 * on every change. Useful for expand/lightbox buttons. 51 * 52 * @since 1.0.0 53 */ 54(function () { 55 'use strict'; 56 57 var instances = []; 58 var DRAG_THRESHOLD = 10; 59 60 function JazzyCarousel(el, opts) { 61 this.el = el; 62 63 // Store the base options (from data attributes / caller) separately 64 // so responsive re-init can always merge against the original values. 65 this._baseOptions = Object.assign({ 66 mode: 'slide', 67 autoplay: 0, 68 loop: false, 69 slidesToShow: 1, 70 slidesToScroll: 1, 71 initialSlide: 0, 72 gap: 0, 73 peek: 0, 74 arrows: true, 75 dots: false, 76 drag: true, 77 adaptiveHeight: false, 78 responsive: [] 79 }, opts); 80 81 // Honor prefers-reduced-motion: disable autoplay, use instant transitions. 82 this._reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 83 if (this._reducedMotion) { 84 this._baseOptions.autoplay = 0; 85 } 86 87 // Working options â start from base, then apply responsive overrides 88 // for the current viewport so page-load matches the correct breakpoint. 89 this.options = Object.assign({}, this._baseOptions); 90 this._applyResponsiveOverrides(); 91 92 this.currentIndex = this.options.initialSlide || 0; 93 this.autoplayTimer = null; 94 this.autoplayPaused = false; 95 this.slides = []; 96 this.track = null; 97 this.prevBtn = null; 98 this.nextBtn = null; 99 this.pauseBtn = null; 100 this.dotsContainer = null; 101 this.liveRegion = null; 102 this._currentSlidesToShow = this.options.slidesToShow; 103 this._currentMode = this.options.mode; 104 this._hoistedExcluded = []; 105 this._dragState = null; 106 this._justDragged = false;
107 this._heightObserver = null; // ResizeObserver â adaptive height (fade) only 108 this._heightRaf = 0; 109 this._externalBindings = []; // { el, event, handler } â cleaned up on destroy 110 111 this._init(); 112 } 113 114 JazzyCarousel.prototype._bindExternal = function (el, event, handler) { 115 el.addEventListener(event, handler); 116 this._externalBindings.push({ el: el, event: event, handler: handler }); 117 }; 118 119 /** 120 * Merge responsive overrides into this.options for the current viewport. 121 * Called once in the constructor (page load) and indirectly via 122 * _reinitWithOverrides on resize. 123 */ 124 JazzyCarousel.prototype._applyResponsiveOverrides = function () { 125 var overrides = this._getResponsiveOverrides(); 126 if (overrides.mode !== undefined) this.options.mode = overrides.mode; 127 if (overrides.slides !== undefined) this.options.slidesToShow = overrides.slides; 128 if (overrides.autoplay !== undefined) this.options.autoplay = overrides.autoplay; 129 if (overrides.drag !== undefined) this.options.drag = overrides.drag; 130 if (overrides.loop !== undefined) this.options.loop = overrides.loop; 131 if (overrides.gap !== undefined) this.options.gap = overrides.gap; 132 if (overrides.peek !== undefined) this.options.peek = overrides.peek; 133 if (overrides.arrows !== undefined) this.options.arrows = overrides.arrows; 134 if (overrides.dots !== undefined) this.options.dots = overrides.dots; 135 }; 136 137 // ---------------------------------------------------------------- 138 // Init 139 // ---------------------------------------------------------------- 140 141 JazzyCarousel.prototype._init = function () { 142 var self = this; 143 this.el.classList.add('jazzy-carousel'); 144 this.el.classList.add('jazzy-' + this.options.mode); 145 if (this.options.adaptiveHeight) this.el.classList.add('jazzy-adaptive-height'); 146 147 // Collect slides â all direct element children except carousel-generated 148 // elements and anything with data-jazzy-carousel-exclude. 149 this.slides = Array.from(this.el.children).filter(function (child) { 150 if (child.nodeType !== 1) return false; 151 if (child.hasAttribute('data-jazzy-carousel-exclude')) return false; 152 if (child.classList.contains('jazzy-pause')) return false; 153 if (child.classList.contains('jazzy-arrows')) return false; 154 if (child.classList.contains('jazzy-dots')) return false; 155 return true; 156 }); 157 if (!this.slides.length) return; 158 159 // If all slides fit, skip carousel entirely â just a plain list. 160 if (this.slides.length <= this._getEffectiveSlidesToShow()) { 161 this.el.classList.add('jazzy-carousel', 'jazzy-static'); 162 this.el._jazzyCarousel = this; 163 return; 164 } 165 166 // Slide mode needs a clipping wrapper for translateX overflow. 167 // Fade mode doesn't translate, so no wrapper needed. 168 if (this.options.mode === 'slide') { 169 this.wrapper = document.createElement('div'); 170 this.wrapper.className = 'jazzy-carousel-wrapper'; 171 this.el.parentNode.insertBefore(this.wrapper, this.el); 172 this.wrapper.appendChild(this.el); 173 174 // Hoist excluded elements (e.g. nav) out of the sliding track 175 // into the wrapper so they don't translate with the slides. 176 this._hoistExcluded(); 177 } 178 179 // The carousel element itself is the track (avoids invalid HTML in <ul>). 180 this.track = this.el; 181 this.track.setAttribute('aria-atomic', 'false'); 182 183 this.slides.forEach(function (slide, i) { 184 slide.setAttribute('role', 'group'); 185 slide.setAttribute('aria-roledescription', 'slide'); 186 slide.setAttribute('aria-label', (i + 1) + ' of ' + self.slides.length); 187 }); 188 189 // ARIA on container. 190 this.el.setAttribute('role', 'region'); 191 this.el.setAttribute('aria-roledescription', 'carousel'); 192 if (!this.el.hasAttribute('aria-label') && !this.el.hasAttribute('aria-labelledby')) { 193 // Prefer a nearby heading so each carousel region has a distinct name 194 // rather than every one announcing as the generic "Content carousel". 195 var labelSection = this.el.closest('section, .module'); 196 var labelHeading = labelSection && labelSection.querySelector('h1, h2, h3, h4, h5, h6'); 197 if (labelHeading && labelHeading.textContent.trim()) { 198 if (!labelHeading.id) labelHeading.id = 'jazzy-carousel-label-' + Math.random().toString(36).substring(2, 8); 199 this.el.setAttribute('aria-labelledby', labelHeading.id); 200 } else { 201 this.el.setAttribute('aria-label', 'Carousel'); 202 } 203 } 204 205 // Visually-hidden live region for slide-change announcements. 206 this.liveRegion = document.createElement('div'); 207 this.liveRegion.className = 'jazzy-carousel-live'; 208 this.liveRegion.setAttribute('aria-live', 'polite'); 209 this.liveRegion.setAttribute('aria-atomic', 'true'); 210 this.liveRegion.style.cssText = 'position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0,0,0,0);border:0;'; 211 (this.wrapper || this.el.parentNode).appendChild(this.liveRegion); 212 213 // Autoplay rotation control â must be first in tab order (APG requirement). 214 if (this.options.autoplay > 0 && this.slides.length > 1) { 215 this._createPauseButton(); 216 } 217 218 // Arrows â auto-generate or bind external. 219 if (this.options.arrows && this.slides.length > 1) { 220 this._createArrows(); 221 } 222 this._bindExternalArrows(); 223 224 // Dots. 225 if (this.options.dots && this.slides.length > 1) { 226 this._createDots(); 227 } 228 229 // Keyboard â arrow keys move focus between slides, scrolling as needed. 230 this.el.addEventListener('keydown', function (e) { 231 if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; 232 if (!self.el.contains(document.activeElement)) return; 233 e.preventDefault(); 234 235 // Find which slide currently has focus. 236 var focusedSlide = self.slides.find(function (s) { return s.contains(document.activeElement); }); 237 var focusedIndex = focusedSlide ? self.slides.indexOf(focusedSlide) : self.currentIndex; 238 var targetIndex = focusedIndex + (e.key === 'ArrowLeft' ? -1 : 1); 239 240 // Clamp or loop. 241 if (targetIndex < 0) { 242 targetIndex = self.options.loop ? self.slides.length - 1 : 0; 243 } else if (targetIndex >= self.slides.length) { 244 targetIndex = self.options.loop ? 0 : self.slides.length - 1; 245 } 246 247 // Scroll the carousel if the target slide isn't visible. 248 var slidesToShow = self._getEffectiveSlidesToShow(); 249 if (targetIndex < self.currentIndex || targetIndex >= self.currentIndex + slidesToShow) { 250 self._goTo(Math.min(targetIndex, Math.max(0, self.slides.length - slidesToShow))); 251 } 252
253 self._focusSlide(targetIndex); 254 }); 255 256 // Drag / swipe. 257 if (this.options.drag && this.slides.length > 1) { 258 this._initDrag(); 259 } 260 261 // Responsive. 262 this._boundResize = this._onResize.bind(this); 263 window.addEventListener('resize', this._boundResize); 264 265 // Initial layout. 266 this._updateLayout(true); 267 268 // Adaptive height: keep the container pinned to the active slide as its height 269 // changes â on a viewport/container resize that rewraps the copy, and as async 270 // content (images, late fonts) reflows. One ResizeObserver on the slides catches 271 // both; rAF-coalescing collapses a resize burst into a single final measure. 272 // Where ResizeObserver is unsupported, the window-resize path handles viewport 273 // changes and per-image load listeners handle the decode reflow. 274 if (this.options.adaptiveHeight && this.options.mode === 'fade') { 275 if (window.ResizeObserver) { 276 this._heightObserver = new ResizeObserver(function () { 277 if (self._heightRaf) return; 278 self._heightRaf = requestAnimationFrame(function () { 279 self._heightRaf = 0; 280 self._updateHeight(true); 281 }); 282 }); 283 this.slides.forEach(function (slide) { self._heightObserver.observe(slide); }); 284 } else { 285 this.slides.forEach(function (slide) { 286 slide.querySelectorAll('img').forEach(function (img) { 287 if (!img.complete) { 288 img.addEventListener('load', function () { self._updateHeight(true); }); 289 } 290 }); 291 }); 292 } 293 } 294 295 // Autoplay â starts running, pauses on hover/focus per APG. 296 // Does NOT auto-resume â only the pause/play button restarts rotation. 297 if (this.options.autoplay > 0 && this.slides.length > 1) { 298 this._setLiveRegion(false); 299 this._startAutoplay(); 300 301 this.el.addEventListener('mouseenter', function () { 302 if (!self.autoplayPaused) self._stopAutoplay(); 303 }); 304 this.el.addEventListener('mouseleave', function () { 305 if (!self.autoplayPaused) self._startAutoplay(); 306 }); 307 this.el.addEventListener('focusin', function () { 308 self._stopAutoplay(); 309 }); 310 // APG: rotation does NOT resume when focus leaves â user must re-activate. 311 } else { 312 this._setLiveRegion(true); 313 } 314 315 // Pause when tab hidden. 316 this._boundVisibility = function () { 317 if (document.hidden) self._stopAutoplay(); 318 else if (self.options.autoplay > 0 && !self.autoplayPaused) self._startAutoplay(); 319 }; 320 document.addEventListener('visibilitychange', this._boundVisibility); 321 322 // Watch for slides added/removed dynamically. 323 this._observer = new MutationObserver(function (mutations) { 324 var changed = false; 325 mutations.forEach(function (m) { 326 m.addedNodes.forEach(function (node) { 327 if (node.nodeType === 1 && !node.hasAttribute('data-jazzy-carousel-exclude') 328 && !node.classList.contains('jazzy-pause') 329 && !node.classList.contains('jazzy-arrows') 330 && !node.classList.contains('jazzy-dots')) changed = true; 331 }); 332 m.removedNodes.forEach(function (node) { 333 if (node.nodeType === 1 && self.slides.indexOf(node) !== -1) changed = true; 334 }); 335 }); 336 if (changed) self._refresh(); 337 }); 338 this._observer.observe(this.el, { childList: true }); 339 340 // Nav-for: thumbnail navigation + index sync. 341 this._bindNav(); 342 343 this.el._jazzyCarousel = this; 344 }; 345 346 JazzyCarousel.prototype._refresh = function () { 347 this.slides = Array.from(this.el.children).filter(function (child) { 348 if (child.nodeType !== 1) return false; 349 if (child.hasAttribute('data-jazzy-carousel-exclude')) return false; 350 if (child.classList.contains('jazzy-pause')) return false; 351 if (child.classList.contains('jazzy-arrows')) return false; 352 if (child.classList.contains('jazzy-dots')) return false; 353 return true; 354 }); 355 var self = this; 356 357 // Update ARIA labels on all slides. 358 this.slides.forEach(function (slide, i) { 359 slide.setAttribute('role', 'group'); 360 slide.setAttribute('aria-roledescription', 'slide'); 361 slide.setAttribute('aria-label', (i + 1) + ' of ' + self.slides.length); 362 }); 363 364 // Rebuild dots if present. 365 if (this.dotsContainer) { 366 this.dotsContainer.remove(); 367 this._createDots(); 368 } 369 370 // Re-point the adaptive-height observer at the current slide set.
371 if (this._heightObserver) { 372 this._heightObserver.disconnect(); 373 this.slides.forEach(function (slide) { self._heightObserver.observe(slide); }); 374 } 375 376 // Clamp index and re-layout. 377 var maxIndex = Math.max(0, this.slides.length - this._getEffectiveSlidesToShow()); 378 if (this.currentIndex > maxIndex) this.currentIndex = maxIndex; 379 this._updateLayout(true); 380 }; 381 382 // ---------------------------------------------------------------- 383 // Pause / Play (rotation control) 384 // ---------------------------------------------------------------- 385 386 JazzyCarousel.prototype._createPauseButton = function () { 387 var self = this; 388 this.pauseBtn = document.createElement('button'); 389 this.pauseBtn.type = 'button'; 390 this.pauseBtn.className = 'jazzy-pause'; 391 this._updatePauseLabel(false); 392 393 this.pauseBtn.addEventListener('click', function () { 394 if (self.autoplayPaused) { 395 self.autoplayPaused = false; 396 self._startAutoplay(); 397 self._setLiveRegion(false); 398 self._updatePauseLabel(false); 399 } else { 400 self.autoplayPaused = true; 401 self._stopAutoplay(); 402 self._setLiveRegion(true); 403 self._updatePauseLabel(true); 404 } 405 }); 406 407 // Wrapper (slide mode): insert in wrapper so it doesn't scroll. 408 // No wrapper (fade mode): insert inside the carousel element directly. 409 var pauseParent = this.wrapper || this.el; 410 pauseParent.insertBefore(this.pauseBtn, pauseParent.firstChild); 411 }; 412 413 JazzyCarousel.prototype._updatePauseLabel = function (isPaused) { 414 var label = isPaused ? 'Start slide rotation' : 'Stop slide rotation'; 415 this.pauseBtn.setAttribute('aria-label', label); 416 this.pauseBtn.innerHTML = isPaused 417 ? '<svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor"><polygon points="0,0 12,7 0,14"/></svg>' 418 : '<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor"><rect x="0" y="0" width="3" height="14"/><rect x="7" y="0" width="3" height="14"/></svg>'; 419 }; 420 421 JazzyCarousel.prototype._setLiveRegion = function (isPolite) { 422 // Toggle the announcing live region itself (not the track) â 'off' while 423 // autoplay rotates so auto-advances stay silent; 'polite' when paused / 424 // hovered / focused or for manual navigation. (js.md: off while rotating.) 425 if (this.liveRegion) { 426 this.liveRegion.setAttribute('aria-live', isPolite ? 'polite' : 'off'); 427 } 428 }; 429 430 // ---------------------------------------------------------------- 431 // Arrows 432 // ---------------------------------------------------------------- 433 434 JazzyCarousel.prototype._createArrows = function () { 435 var self = this; 436 var nav = document.createElement('nav'); 437 nav.className = 'jazzy-arrows'; 438 nav.setAttribute('aria-label', 'Slide navigation'); 439 440 this.prevBtn = document.createElement('button'); 441 this.prevBtn.type = 'button'; 442 this.prevBtn.className = 'jazzy-prev'; 443 this.prevBtn.setAttribute('aria-label', 'Previous slide'); 444 this.prevBtn.innerHTML = '<svg width="8" height="14" viewBox="0 0 8 14" fill="none"><path d="M7 1L1 7L7 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'; 445 446 this.nextBtn = document.createElement('button'); 447 this.nextBtn.type = 'button'; 448 this.nextBtn.className = 'jazzy-next'; 449 this.nextBtn.setAttribute('aria-label', 'Next slide'); 450 this.nextBtn.innerHTML = '<svg width="8" height="14" viewBox="0 0 8 14" fill="none"><path d="M1 1L7 7L1 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'; 451 452 this.prevBtn.addEventListener('click', function () { self.prev(); }); 453 this.nextBtn.addEventListener('click', function () { self.next(); }); 454 455 nav.appendChild(this.prevBtn); 456 nav.appendChild(this.nextBtn); 457 var arrowParent = this.wrapper || this.el.parentNode; 458 arrowParent.appendChild(nav); 459 }; 460 461 JazzyCarousel.prototype._bindExternalArrows = function () { 462 var self = this; 463 var id = this.el.getAttribute('data-jazzy-carousel-id') || this.el.id; 464 var scope = document; 465 466 var bindPrev = function (el) { self.prevBtn = el; self._bindExternal(el, 'click', function () { self.prev(); }); }; 467 var bindNext = function (el) { self.nextBtn = el; self._bindExternal(el, 'click', function () { self.next(); }); }; 468 469 // Strategy 1: match by carousel ID. 470 if (id) { 471 var extPrev = scope.querySelector('[data-jazzy-carousel-prev="' + id + '"]'); 472 var extNext = scope.querySelector('[data-jazzy-carousel-next="' + id + '"]'); 473 if (extPrev || extNext) { 474 if (extPrev) bindPrev(extPrev); 475 if (extNext) bindNext(extNext); 476 return; 477 } 478 } 479 480 // Strategy 2: search inside the carousel element. 481 var innerPrev = this.el.querySelector('[data-jazzy-carousel-prev]'); 482 var innerNext = this.el.querySelector('[data-jazzy-carousel-next]'); 483 if (innerPrev || innerNext) { 484 if (innerPrev) bindPrev(innerPrev); 485 if (innerNext) bindNext(innerNext); 486 return; 487 } 488 489 // Strategy 3: search siblings and closest parent container. 490 var parent = this.el.closest('.container') || this.el.closest('section') || this.el.parentElement; 491 if (parent) { 492 var sibPrev = parent.querySelector('[data-jazzy-carousel-prev]'); 493 var sibNext = parent.querySelector('[data-jazzy-carousel-next]'); 494 if (sibPrev) bindPrev(sibPrev); 495 if (sibNext) bindNext(sibNext); 496 } 497 }; 498 499 // ---------------------------------------------------------------- 500 // Dots (grouped picker pattern â APG role="group" with buttons) 501 // ---------------------------------------------------------------- 502 503 JazzyCarousel.prototype._createDots = function () { 504 var self = this; 505 this.dotsContainer = document.createElement('div'); 506 this.dotsContainer.className = 'jazzy-dots'; 507 this.dotsContainer.setAttribute('role', 'group'); 508 this.dotsContainer.setAttribute('aria-label', 'Choose slide'); 509 510 for (var i = 0; i < this.slides.length; i++) { 511 (function (index) { 512 var dot = document.createElement('button'); 513 dot.type = 'button'; 514 dot.className = 'jazzy-dot'; 515 dot.setAttribute('aria-label', self.slides[index].getAttribute('aria-label') || ('Slide ' + (index + 1))); 516 if (index === 0) dot.setAttribute('aria-current', 'true'); 517 dot.addEventListener('click', function () { 518 self.goTo(index); // current dot stays activatable; re-selecting is a harmless no-op 519 }); 520 self.dotsContainer.appendChild(dot); 521 })(i); 522 } 523 524 // data-jazzy-dots-target: CSS selector for where to place dots 525 // (resolved relative to the carousel's closest section/module ancestor). 526 var targetSelector = this.el.getAttribute('data-jazzy-dots-target'); 527 var dotsParent = null; 528
529 if (targetSelector) { 530 var scope = this.el.closest('section, .module') || this.el.parentNode; 531 dotsParent = scope.querySelector(targetSelector); 532 } 533 534 if (!dotsParent) { 535 dotsParent = this.wrapper || this.el.parentNode; 536 } 537 538 dotsParent.appendChild(this.dotsContainer); 539 }; 540 541 // ---------------------------------------------------------------- 542 // Drag / Swipe 543 // ---------------------------------------------------------------- 544 545 JazzyCarousel.prototype._initDrag = function () { 546 var self = this; 547 var track = this.track; 548 549 var isFade = self.options.mode === 'fade'; 550 551 function onStart(e) { 552 if (e.button && e.button !== 0) return; 553 self._stopAutoplay(); 554 var x = e.touches ? e.touches[0].clientX : e.clientX; 555 var y = e.touches ? e.touches[0].clientY : e.clientY; 556 self._dragState = { startX: x, startY: y, currentX: x, dragging: false, startOffset: isFade ? 0 : self._getCurrentOffset() }; 557 if (!isFade) track.style.transition = 'none'; 558 } 559 560 function onMove(e) { 561 if (!self._dragState) return; 562 var x = e.touches ? e.touches[0].clientX : e.clientX; 563 var y = e.touches ? e.touches[0].clientY : e.clientY; 564 self._dragState.currentX = x; 565 var delta = x - self._dragState.startX; 566 var deltaY = y - self._dragState.startY; 567 568 // Commit to a horizontal drag only when the gesture is horizontal-dominant â 569 // otherwise a mostly-vertical swipe gets preventDefault'd and blocks page scroll. 570 if (!self._dragState.dragging && Math.abs(delta) > DRAG_THRESHOLD && Math.abs(delta) > Math.abs(deltaY)) { 571 self._dragState.dragging = true; 572 self.el.classList.add('is-dragging'); 573 } 574 575 if (self._dragState.dragging) { 576 if (e.cancelable) e.preventDefault(); 577 // Slide mode: live translate. Fade mode: gesture tracked, no visual drag. 578 if (!isFade) { 579 track.style.transform = 'translate3d(' + (self._dragState.startOffset + delta) + 'px, 0, 0)'; 580 } 581 } 582 } 583 584 function onEnd(e) { 585 if (!self._dragState) return; 586 var delta = self._dragState.currentX - self._dragState.startX; 587 var wasDragging = self._dragState.dragging; 588 self._dragState = null; 589 self.el.classList.remove('is-dragging'); 590 if (!isFade) track.style.transition = ''; 591 592 if (wasDragging) { 593 self._justDragged = true; 594 requestAnimationFrame(function () { 595 requestAnimationFrame(function () { self._justDragged = false; }); 596 }); 597 598 var threshold; 599 if (isFade) { 600 threshold = 50; 601 } else { 602 var slidesToShow = self._getEffectiveSlidesToShow(); 603 var dragGap = self.options.gap || (parseFloat(getComputedStyle(track).gap) || 0); 604 var dragPeek = self.options.peek || 0; 605 var slideWidth = (track.offsetWidth - dragPeek - dragGap * (slidesToShow - 1)) / slidesToShow; 606 threshold = slideWidth * 0.2; 607 } 608 609 if (delta < -threshold) { 610 self.next(); 611 } else if (delta > threshold) { 612 self.prev(); 613 } else if (!isFade) { 614 self._goTo(self.currentIndex); 615 } 616 617 // Focus nearest clickable element for keyboard access. 618 var target = e.target || (e.changedTouches && document.elementFromPoint( 619 e.changedTouches[0].clientX, e.changedTouches[0].clientY 620 )); 621 if (target) { 622 var focusable = target.closest('a, button') || target; 623 if (focusable && focusable.focus) focusable.focus({ preventScroll: true }); 624 } 625 } 626 627 if (self.options.autoplay > 0 && !self.autoplayPaused) self._startAutoplay(); 628 } 629 630 track.addEventListener('mousedown', onStart); 631 track.addEventListener('touchstart', onStart, { passive: true }); 632 window.addEventListener('mousemove', onMove); 633 window.addEventListener('touchmove', onMove, { passive: false }); 634 window.addEventListener('mouseup', onEnd); 635 window.addEventListener('touchend', onEnd); 636 637 track.addEventListener('dragstart', function (e) { e.preventDefault(); }); 638 639 track.addEventListener('click', function (e) { 640 if (self._justDragged) { 641 e.preventDefault(); 642 e.stopPropagation(); 643 } 644 }, true); 645 }; 646 647 JazzyCarousel.prototype._getCurrentOffset = function () { 648 var transform = getComputedStyle(this.track).transform; 649 if (!transform || transform === 'none') return 0; 650 var matrix = transform.match(/matrix.*\((.+)\)/); 651 if (!matrix) return 0; 652 var values = matrix[1].split(', '); 653 return parseFloat(values[4]) || 0; 654 }; 655 656 // ---------------------------------------------------------------- 657 // Responsive 658 // ---------------------------------------------------------------- 659 660 /** 661 * Get the effective options for the current viewport width. 662 * 663 * Responsive entries can override any option, not just `slides`: 664 * data-jazzy-responsive='[{"max":650,"mode":"slide","slides":1,"autoplay":0,"drag":true}]' 665 * 666 * Returns the merged option set. The `slides` shorthand maps to `slidesToShow`. 667 */ 668 JazzyCarousel.prototype._getResponsiveOverrides = function () { 669 if (!this.options.responsive.length) return {}; 670 var width = window.innerWidth; 671 var overrides = {}; 672 var sorted = this.options.responsive.slice().sort(function (a, b) { return b.max - a.max; }); 673 for (var i = 0; i < sorted.length; i++) { 674 if (width <= sorted[i].max) { 675 overrides = sorted[i]; 676 } 677 } 678 return overrides; 679 }; 680 681 JazzyCarousel.prototype._getEffectiveSlidesToShow = function () { 682 var overrides = this._getResponsiveOverrides(); 683 return overrides.slides || this.options.slidesToShow; 684 }; 685 686 JazzyCarousel.prototype._onResize = function () { 687 // Check if mode changed â requires full destroy + re-init. 688 var overrides = this._getResponsiveOverrides(); 689 var effectiveMode = overrides.mode || this._baseOptions.mode; 690 691 if (effectiveMode !== this._currentMode) { 692 this._reinitWithOverrides(); 693 return; 694 } 695 696 var newCount = this._getEffectiveSlidesToShow(); 697 var changed = newCount !== this._currentSlidesToShow; 698 this._currentSlidesToShow = newCount; 699 this._updateLayout(true); 700 if (changed) this._goTo(Math.min(this.currentIndex, this.slides.length - newCount), true); 701 }; 702 703 /** 704 * Destroy and re-initialize with responsive overrides applied. 705 * Called when `mode` changes at a breakpoint boundary. 706 */ 707 JazzyCarousel.prototype._reinitWithOverrides = function () { 708 var el = this.el; 709 var baseOpts = Object.assign({}, this._baseOptions); 710 711 // Hoist excluded elements back before destroy so DOM is clean. 712 this._restoreExcluded(); 713 this.destroy(); 714 715 // Re-init with base options â the constructor applies responsive 716 // overrides for the current viewport automatically. 717 var inst = new JazzyCarousel(el, baseOpts); 718 instances.push(inst); 719 }; 720 721 // ---------------------------------------------------------------- 722 // Layout 723 // ---------------------------------------------------------------- 724 725 JazzyCarousel.prototype._updateLayout = function (instant) { 726 var slidesToShow = this._getEffectiveSlidesToShow(); 727 var gap = this.options.gap; 728 729 if (this.options.mode === 'slide') { 730 // If gap is set, apply it inline. Otherwise defer to CSS â 731 // read the computed gap so slide-width math stays correct. 732 if (gap) { 733 this.track.style.gap = gap + 'px'; 734 } else { 735 this.track.style.gap = ''; 736 var computed = parseFloat(getComputedStyle(this.track).gap) || 0; 737 gap = computed; 738 } 739 740 var peek = this.options.peek || 0; 741 var trackWidth = this.track.offsetWidth; 742 var slideWidth = (trackWidth - peek - gap * (slidesToShow - 1)) / slidesToShow; 743
744 this.slides.forEach(function (slide) { 745 slide.style.flex = '0 0 ' + slideWidth + 'px'; 746 }); 747 } 748 749 this._goTo(this.currentIndex, instant); 750 }; 751 752 // ---------------------------------------------------------------- 753 // Navigation 754 // ---------------------------------------------------------------- 755 756 // Adaptive height (fade mode) â pin the carousel to the active slide's own 757 // content height so shorter slides don't inherit the tallest one's. The CSS 758 // (align-self:start on slides + transition on the container) makes each slide 759 // measurable and animates the change; instant skips the animation (init/resize). 760 JazzyCarousel.prototype._updateHeight = function (instant) { 761 if (!this.options.adaptiveHeight || this.options.mode !== 'fade') return; 762 var active = this.slides[this.currentIndex]; 763 if (!active) return; 764 if (instant) { 765 this.el.style.transition = 'none'; 766 // Release the pinned height so the measure reflects the slide's natural 767 // flow (not the stale previous value) even if it has a fill-height child. 768 // No flash: transition is off and nothing paints until the final px lands. 769 this.el.style.height = 'auto'; 770 } 771 this.el.style.height = active.offsetHeight + 'px'; 772 if (instant) { 773 this.el.offsetHeight; // force reflow so the height lands without animating 774 this.el.style.transition = ''; 775 } 776 }; 777 778 JazzyCarousel.prototype._goTo = function (index, instant) { 779 var previousIndex = this.currentIndex; 780 var slidesToShow = this._getEffectiveSlidesToShow(); 781 var maxIndex = Math.max(0, this.slides.length - slidesToShow); 782 783 if (index < 0) { 784 index = this.options.loop ? maxIndex : 0; 785 } else if (index > maxIndex) { 786 index = this.options.loop ? 0 : maxIndex; 787 } 788 789 this.currentIndex = index; 790 791 // Reduced motion: force instant transitions. 792 if (this._reducedMotion) instant = true; 793 794 if (this.options.mode === 'fade') { 795 this.slides.forEach(function (slide, i) { 796 slide.classList.toggle('is-active', i === index); 797 slide.setAttribute('aria-hidden', i !== index ? 'true' : 'false'); 798 slide.inert = i !== index; 799 }); 800 } else { 801 var gap = this.options.gap || (parseFloat(getComputedStyle(this.track).gap) || 0); 802 var peek = this.options.peek || 0; 803 var trackWidth = this.track.offsetWidth; 804 var slideWidth = (trackWidth - peek - gap * (slidesToShow - 1)) / slidesToShow; 805 var offset = index * (slideWidth + gap); 806 807 if (instant) this.track.style.transition = 'none'; 808 this.track.style.transform = 'translate3d(' + (-offset) + 'px, 0, 0)'; 809 if (instant) { 810 this.track.offsetHeight; 811 this.track.style.transition = ''; 812 } 813 814 this.slides.forEach(function (slide, i) { 815 var visible = i >= index && i < index + slidesToShow; 816 slide.setAttribute('aria-hidden', !visible ? 'true' : 'false'); 817 slide.inert = !visible; 818 }); 819 } 820 821 this._updateHeight(instant); 822 823 // Update arrows. Disabling the arrow that currently holds focus would eject 824 // focus to <body>, so move focus to the opposite (now-enabled) arrow first. 825 if (!this.options.loop) { 826 var prevDisabled = index === 0; 827 var nextDisabled = index >= maxIndex; 828 var focusedNext = this.nextBtn && document.activeElement === this.nextBtn; 829 var focusedPrev = this.prevBtn && document.activeElement === this.prevBtn; 830 if (this.prevBtn) this.prevBtn.disabled = prevDisabled; 831 if (this.nextBtn) this.nextBtn.disabled = nextDisabled; 832 if (focusedNext && nextDisabled && this.prevBtn && !prevDisabled) { 833 this.prevBtn.focus(); 834 } else if (focusedPrev && prevDisabled && this.nextBtn && !nextDisabled) { 835 this.nextBtn.focus(); 836 } 837 } 838 839 // Update dots â current slide dot gets aria-current (still activatable, not disabled). 840 if (this.dotsContainer) { 841 var dots = this.dotsContainer.querySelectorAll('.jazzy-dot'); 842 dots.forEach(function (dot, i) { 843 dot.classList.toggle('is-active', i === index); 844 if (i === index) { 845 dot.setAttribute('aria-current', 'true'); 846 } else { 847 dot.removeAttribute('aria-current'); 848 } 849 }); 850 } 851 852 if (previousIndex !== index) { 853 // Announce slide change to screen readers (text must change for SR to pick it up). 854 if (this.liveRegion) { 855 this.liveRegion.textContent = 'Slide ' + (index + 1) + ' of ' + this.slides.length; 856 } 857 858 this.el.dispatchEvent(new CustomEvent('jazzycarousel:change', { 859 detail: { index: index, previousIndex: previousIndex, slide: this.slides[index] } 860 })); 861 } 862 }; 863 864 JazzyCarousel.prototype.prev = function () { this._goTo(this.currentIndex - this.options.slidesToScroll); }; 865 JazzyCarousel.prototype.next = function () { this._goTo(this.currentIndex + this.options.slidesToScroll); }; 866 JazzyCarousel.prototype.goTo = function (index) { this._goTo(index); }; 867 868 // ---------------------------------------------------------------- 869 // Nav-for: thumbnail navigation + index sync 870 // ---------------------------------------------------------------- 871 872 JazzyCarousel.prototype._bindNav = function () { 873 var self = this; 874 var carouselId = this.el.id; 875 if (!carouselId) return; 876 877 // Thumbnail nav: [data-jazzy-carousel-nav="carousel-id"] 878 var navContainer = document.querySelector('[data-jazzy-carousel-nav="' + carouselId + '"]'); 879 if (navContainer) {
880 var navItems = navContainer.querySelectorAll('[data-slide-index]'); 881 882 // Click â goTo 883 navItems.forEach(function (item) { 884 self._bindExternal(item, 'click', function () { 885 var index = parseInt(item.getAttribute('data-slide-index'), 10); 886 self.goTo(index); 887 navItems.forEach(function (n) { n.classList.remove('active'); }); 888 item.classList.add('active'); 889 }); 890 }); 891 892 // Carousel change â sync active class 893 self._bindExternal(this.el, 'jazzycarousel:change', function (e) { 894 var idx = e.detail.index; 895 navItems.forEach(function (n, i) { 896 n.classList.toggle('active', i === idx); 897 }); 898 }); 899 } 900 901 // Index sync: [data-jazzy-carousel-sync="carousel-id"] 902 var syncEls = document.querySelectorAll('[data-jazzy-carousel-sync="' + carouselId + '"]'); 903 if (syncEls.length) { 904 self._bindExternal(this.el, 'jazzycarousel:change', function (e) { 905 syncEls.forEach(function (el) { 906 el.setAttribute('data-jazzy-dialog-slide', e.detail.index); 907 }); 908 }); 909 } 910 }; 911 912 JazzyCarousel.prototype._focusCurrentSlide = function () { 913 this._focusSlide(this.currentIndex); 914 }; 915 916 JazzyCarousel.prototype._focusSlide = function (index) { 917 var slide = this.slides[index]; 918 if (!slide) return; 919 920 var focusable = slide.querySelector('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'); 921 if (focusable) { 922 focusable.focus({ preventScroll: true }); 923 return; 924 } 925 926 if (!slide.hasAttribute('tabindex')) { 927 slide.setAttribute('tabindex', '-1'); 928 slide.addEventListener('blur', function onBlur() { 929 slide.removeAttribute('tabindex'); 930 slide.removeEventListener('blur', onBlur); 931 }); 932 } 933 slide.focus({ preventScroll: true }); 934 }; 935 936 // ---------------------------------------------------------------- 937 // Autoplay 938 // ---------------------------------------------------------------- 939 940 JazzyCarousel.prototype._startAutoplay = function () { 941 var self = this; 942 this._stopAutoplay(); 943 this.autoplayTimer = setInterval(function () { self.next(); }, this.options.autoplay); 944 }; 945 946 JazzyCarousel.prototype._stopAutoplay = function () { 947 if (this.autoplayTimer) { clearInterval(this.autoplayTimer); this.autoplayTimer = null; } 948 }; 949 950 // ---------------------------------------------------------------- 951 // Excluded-element hoisting (slide mode) 952 // ---------------------------------------------------------------- 953 954 /** 955 * Move [data-jazzy-carousel-exclude] elements from the track to the 956 * wrapper so they don't translate with the slides. Remembers each 957 * element's original position for _restoreExcluded(). 958 */ 959 JazzyCarousel.prototype._hoistExcluded = function () { 960 if (!this.wrapper) return; 961 var self = this; 962 var excluded = Array.from(this.el.querySelectorAll('[data-jazzy-carousel-exclude]')); 963 excluded.forEach(function (node) { 964 self._hoistedExcluded.push({ node: node, parent: node.parentNode, next: node.nextSibling }); 965 self.wrapper.insertBefore(node, self.el); 966 }); 967 }; 968 969 /** 970 * Restore hoisted excluded elements back to their original positions 971 * inside the track. Called before destroy so the DOM returns to its 972 * pre-init state. 973 */ 974 JazzyCarousel.prototype._restoreExcluded = function () { 975 this._hoistedExcluded.forEach(function (record) { 976 if (record.next && record.next.parentNode === record.parent) { 977 record.parent.insertBefore(record.node, record.next); 978 } else { 979 record.parent.appendChild(record.node); 980 } 981 }); 982 this._hoistedExcluded = []; 983 }; 984 985 // ---------------------------------------------------------------- 986 // Destroy 987 // ---------------------------------------------------------------- 988 989 JazzyCarousel.prototype.destroy = function () { 990 this._stopAutoplay(); 991 if (this._observer) this._observer.disconnect(); 992 if (this._heightObserver) this._heightObserver.disconnect(); 993 if (this._heightRaf) cancelAnimationFrame(this._heightRaf); 994 window.removeEventListener('resize', this._boundResize); 995 document.removeEventListener('visibilitychange', this._boundVisibility); 996 997 // Unbind external listeners (arrows, nav, sync) to prevent leaks on re-init.
998 this._externalBindings.forEach(function (b) { 999 b.el.removeEventListener(b.event, b.handler); 1000 }); 1001 this._externalBindings = []; 1002 1003 this.el.classList.remove('jazzy-carousel', 'jazzy-static', 'jazzy-fade'); 1004 this.el.removeAttribute('role'); 1005 this.el.removeAttribute('aria-roledescription'); 1006 1007 this.slides.forEach(function (slide) { slide.style.flex = ''; }); 1008 this.el.style.gap = ''; 1009 this.el.style.transform = ''; 1010 1011 // Remove generated elements from wrapper before unwrapping. 1012 if (this.pauseBtn) this.pauseBtn.remove(); 1013 if (this.dotsContainer) this.dotsContainer.remove(); 1014 if (this.liveRegion) this.liveRegion.remove(); 1015 var arrowsParent = this.wrapper || this.el.parentNode; 1016 if (arrowsParent) { 1017 var arrows = arrowsParent.querySelector('.jazzy-arrows'); 1018 if (arrows) arrows.remove(); 1019 } 1020 1021 // Restore hoisted excluded elements before unwrapping. 1022 this._restoreExcluded(); 1023 1024 // Unwrap from clipping container. 1025 if (this.wrapper && this.wrapper.parentNode) { 1026 this.wrapper.parentNode.insertBefore(this.el, this.wrapper); 1027 this.wrapper.remove(); 1028 } 1029 1030 delete this.el._jazzyCarousel; 1031 }; 1032 1033 // ---------------------------------------------------------------- 1034 // Static API 1035 // ---------------------------------------------------------------- 1036 1037 JazzyCarousel.init = function (el, opts) { 1038 if (el._jazzyCarousel) return el._jazzyCarousel; 1039 var instance = new JazzyCarousel(el, opts); 1040 instances.push(instance); 1041 return instance; 1042 }; 1043 1044 // ---------------------------------------------------------------- 1045 // Auto-init from data attributes 1046 // ---------------------------------------------------------------- 1047 1048 function autoInit() { 1049 document.querySelectorAll('[data-jazzy-carousel]').forEach(function (el) { 1050 if (el._jazzyCarousel) return; 1051 1052 var mode = el.getAttribute('data-jazzy-carousel') || 'slide'; 1053 var responsive = el.getAttribute('data-jazzy-responsive'); 1054 1055 JazzyCarousel.init(el, { 1056 mode: mode, 1057 autoplay: parseInt(el.getAttribute('data-jazzy-autoplay') || '0', 10), 1058 loop: el.getAttribute('data-jazzy-loop') === 'true', 1059 slidesToShow: parseInt(el.getAttribute('data-jazzy-slides') || '1', 10), 1060 slidesToScroll: parseInt(el.getAttribute('data-jazzy-slides-to-scroll') || '1', 10), 1061 initialSlide: parseInt(el.getAttribute('data-jazzy-initial-slide') || '0', 10), 1062 gap: parseInt(el.getAttribute('data-jazzy-gap') || '0', 10), 1063 peek: parseInt(el.getAttribute('data-jazzy-peek') || '0', 10), 1064 arrows: el.getAttribute('data-jazzy-arrows') !== 'false', 1065 dots: el.getAttribute('data-jazzy-dots') === 'true', 1066 drag: el.getAttribute('data-jazzy-drag') !== 'false', 1067 adaptiveHeight: el.getAttribute('data-jazzy-adaptive-height') === 'true', 1068 responsive: responsive ? JSON.parse(responsive) : [] 1069 }); 1070 }); 1071 } 1072 1073 if (document.readyState === 'loading') { 1074 document.addEventListener('DOMContentLoaded', autoInit); 1075 } else { 1076 autoInit(); 1077 } 1078 1079 // Watch for AJAX-loaded content â auto-init new carousels added to the DOM. 1080 if (typeof MutationObserver !== 'undefined') { 1081 new MutationObserver(function (mutations) { 1082 var found = false; 1083 mutations.forEach(function (m) { 1084 m.addedNodes.forEach(function (node) { 1085 if (node.nodeType !== 1) return; 1086 if (node.hasAttribute('data-jazzy-carousel') || node.querySelector('[data-jazzy-carousel]')) { 1087 found = true; 1088 } 1089 }); 1090 }); 1091 if (found) autoInit(); 1092 }).observe(document.body, { childList: true, subtree: true }); 1093 } 1094 1095 window.JazzyCarousel = JazzyCarousel; 1096})();
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.