PageSourceSearch

https://www.phreesia.com/wp-content/themes/studio-simpatico/assets/js/jazzy/carousel.js?ver=1.0.48

js phreesia.com collected 2026-10-02 04:13:14 UTC 41,379 bytes, 1,096 lines download raw bytes

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.