chameleon-backgrounds 2.1.3 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -83,7 +83,7 @@ __webpack_require__.r(__webpack_exports__);
83
83
  * |___/
84
84
  *
85
85
  * @module ChameleonBackgrounds
86
- * @version 2.1.3
86
+ * @version 3.0.1
87
87
  * @author Lennart van Ballegoij (https://weblenn.com/)
88
88
  * @license MIT
89
89
  * @see https://github.com/WebLenn/ChameleonBackgrounds
@@ -93,10 +93,15 @@ __webpack_require__.r(__webpack_exports__);
93
93
  */
94
94
 
95
95
  /**
96
+ * @typedef {Object} ChameleonImageConfig
97
+ * @property {string} url - The fallback URL.
98
+ * @property {string} [srcset] - Optional srcset.
99
+ * @property {string} [sizes] - Optional sizes.
100
+ *
96
101
  * @typedef {Object} ChameleonOptions
97
102
  * @property {string|HTMLElement} element - CSS selector or DOM element to attach to.
98
103
  * @property {'single'|'slider'} type - Background mode.
99
- * @property {string|string[]} src - Image URL(s). String for 'single', array for 'slider'.
104
+ * @property {string|string[]|ChameleonImageConfig|ChameleonImageConfig[]} src - Image URL(s) or config object(s).
100
105
  * @property {string} overlayColor - Overlay color (hex, rgb, rgba, hsl).
101
106
  * @property {string} [overlayImage] - Optional overlay pattern image URL.
102
107
  * @property {number} [minOverlay=0] - Minimum overlay opacity after fade (0–1).
@@ -104,6 +109,8 @@ __webpack_require__.r(__webpack_exports__);
104
109
  * @property {number} [sliderDuration=8000] - Time each slide is shown in milliseconds.
105
110
  * @property {boolean} [sliderLoop=false] - Whether the slider restarts after the last slide.
106
111
  * @property {'high'|'low'|'auto'} [fetchPriority='auto'] - fetchPriority for the first loaded image (e.g., 'high' for LCP images).
112
+ * @property {boolean} [lazyLoad=true] - Whether to wait until element is in viewport using IntersectionObserver.
113
+ * @property {'solid'|'crossfade'} [transitionMode='solid'] - Transition mode: 'solid' (fade to color) or 'crossfade' (fade between images).
107
114
  */
108
115
 
109
116
  class ChameleonBackgrounds {
@@ -119,6 +126,9 @@ class ChameleonBackgrounds {
119
126
  sliderDuration: 8000,
120
127
  sliderLoop: false,
121
128
  fetchPriority: 'auto',
129
+ lazyLoad: true,
130
+ transitionMode: 'solid',
131
+ respectReducedMotion: false,
122
132
  });
123
133
 
124
134
  // Legacy snake_case → camelCase alias map
@@ -130,8 +140,14 @@ class ChameleonBackgrounds {
130
140
  overlay_color: 'overlayColor',
131
141
  overlay_image: 'overlayImage',
132
142
  fetch_priority: 'fetchPriority',
143
+ lazy_load: 'lazyLoad',
144
+ transition_mode: 'transitionMode',
145
+ respect_reduced_motion: 'respectReducedMotion',
133
146
  });
134
147
 
148
+ /** @type {boolean} */
149
+ static #globalStylesInjected = false;
150
+
135
151
  /** @type {ChameleonOptions} */
136
152
  #options;
137
153
 
@@ -144,12 +160,24 @@ class ChameleonBackgrounds {
144
160
  /** @type {string} */
145
161
  #originalHTML;
146
162
 
147
- /** @type {HTMLStyleElement|null} */
148
- #styleElement = null;
149
-
150
163
  /** @type {number|null} */
151
164
  #sliderIntervalId = null;
152
165
 
166
+ /** @type {number} */
167
+ #currentSlideIndex = 0;
168
+
169
+ /** @type {boolean} */
170
+ #hasLoadedFirstBackground = false;
171
+
172
+ /** @type {number} */
173
+ #transitionId = 0;
174
+
175
+ /** @type {boolean} */
176
+ #isPaused = false;
177
+
178
+ /** @type {IntersectionObserver|null} */
179
+ #observer = null;
180
+
153
181
  /** @type {boolean} */
154
182
  #destroyed = false;
155
183
 
@@ -181,16 +209,18 @@ class ChameleonBackgrounds {
181
209
 
182
210
  // Stop any running slider
183
211
  if (this.#sliderIntervalId !== null) {
184
- clearInterval(this.#sliderIntervalId);
212
+ clearTimeout(this.#sliderIntervalId);
185
213
  this.#sliderIntervalId = null;
186
214
  }
187
215
 
188
- // Remove injected style element
189
- if (this.#styleElement?.parentNode) {
190
- this.#styleElement.parentNode.removeChild(this.#styleElement);
191
- this.#styleElement = null;
216
+ if (this.#observer) {
217
+ this.#observer.disconnect();
218
+ this.#observer = null;
192
219
  }
193
220
 
221
+ // Clean up dynamic classes on host
222
+ this.#element.classList.remove(`cbg-host-${this.#uid}`, 'cbg-host');
223
+
194
224
  // Unwrap the original content instead of resetting innerHTML
195
225
  const wrapper = this.#element.querySelector(`#cbg-inner-${this.#uid}`);
196
226
  if (wrapper && wrapper.parentNode === this.#element) {
@@ -206,8 +236,16 @@ class ChameleonBackgrounds {
206
236
  loader.remove();
207
237
  }
208
238
 
209
- // Remove inline background-image
239
+ // Remove temporary crossfade elements
240
+ const crossfadeElements = this.#element.querySelectorAll(`.cbg-crossfade-${this.#uid}`);
241
+ crossfadeElements.forEach(el => el.remove());
242
+
243
+ // Remove inline CSS Variables and background-image
210
244
  this.#element.style.backgroundImage = '';
245
+ this.#element.style.removeProperty('--cbg-duration');
246
+ this.#element.style.removeProperty('--cbg-overlay-color');
247
+ this.#element.style.removeProperty('--cbg-overlay-image');
248
+ this.#element.style.removeProperty('--cbg-min-overlay');
211
249
  }
212
250
 
213
251
  /**
@@ -223,71 +261,145 @@ class ChameleonBackgrounds {
223
261
  // ---------------------------------------------------------------------------
224
262
 
225
263
  #init() {
226
- this.#injectStyles();
264
+ this.#injectGlobalStyles();
265
+ this.#updateCSSVariables();
227
266
  this.#buildDOM();
228
267
 
229
- // If the element is NOT <body>, wait for window load to start loading images.
230
- // For <body>, the loader is already visible, so we start immediately to avoid
231
- // the re-execution issue the legacy lib had with body scripts.
232
- if (this.#element.matches('body')) {
233
- // Use a microtask so the DOM changes above settle first.
234
- queueMicrotask(() => this.#retrieveBackground());
268
+ // Check for prefers-reduced-motion to auto-pause slider if enabled
269
+ if (this.#options.respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
270
+ this.#isPaused = true;
271
+ }
272
+
273
+ if (this.#options.lazyLoad && 'IntersectionObserver' in window) {
274
+ this.#observer = new IntersectionObserver((entries) => {
275
+ entries.forEach((entry) => {
276
+ if (entry.isIntersecting) {
277
+ this.#observer.disconnect();
278
+ this.#observer = null;
279
+ this.#retrieveBackground();
280
+ }
281
+ });
282
+ });
283
+ this.#observer.observe(this.#element);
235
284
  } else {
236
- if (document.readyState === 'complete') {
237
- this.#retrieveBackground();
285
+ if (this.#element.matches('body')) {
286
+ queueMicrotask(() => this.#retrieveBackground());
238
287
  } else {
239
- window.addEventListener('load', () => this.#retrieveBackground(), { once: true });
288
+ if (document.readyState === 'complete') {
289
+ this.#retrieveBackground();
290
+ } else {
291
+ window.addEventListener('load', () => this.#retrieveBackground(), { once: true });
292
+ }
240
293
  }
241
294
  }
242
295
  }
243
296
 
297
+ // ---------------------------------------------------------------------------
298
+ // Play / Pause API
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /**
302
+ * Pause the slideshow.
303
+ */
304
+ pause() {
305
+ if (this.#destroyed || this.#options.type !== 'slider') return;
306
+ this.#isPaused = true;
307
+ if (this.#sliderIntervalId !== null) {
308
+ clearTimeout(this.#sliderIntervalId);
309
+ this.#sliderIntervalId = null;
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Resume the slideshow.
315
+ */
316
+ play() {
317
+ if (this.#destroyed || this.#options.type !== 'slider') return;
318
+ this.#isPaused = false;
319
+ // Only restart if not currently running
320
+ if (this.#sliderIntervalId === null) {
321
+ this.#startSliderLoop();
322
+ }
323
+ }
324
+
244
325
  // ---------------------------------------------------------------------------
245
326
  // DOM Construction
246
327
  // ---------------------------------------------------------------------------
247
328
 
248
329
  /**
249
- * Inject a scoped <style> element into the <head>.
330
+ * Inject global styles once.
250
331
  */
251
- #injectStyles() {
252
- const uid = this.#uid;
253
- const selector = this.#options.element === 'body' || this.#element.matches('body')
254
- ? 'body'
255
- : this.#options.element;
256
- const duration = this.#options.transitionDuration / 1000; // ms → s
257
- const position = this.#element.matches('body') ? 'fixed' : 'absolute';
258
- const overlayBg = this.#options.overlayImage
259
- ? `url(${this.#options.overlayImage})`
260
- : 'none';
332
+ #injectGlobalStyles() {
333
+ if (ChameleonBackgrounds.#globalStylesInjected) return;
334
+ ChameleonBackgrounds.#globalStylesInjected = true;
261
335
 
262
336
  const css = `
263
- ${typeof selector === 'string' ? selector : `.cbg-host-${uid}`} {
337
+ .cbg-host {
264
338
  position: relative;
265
339
  }
266
-
267
- #cbg-inner-${uid} {
340
+ body.cbg-host {
341
+ /* body naturally acts as relative for background sizing if not explicitly positioned differently */
342
+ }
343
+ .cbg-inner {
268
344
  z-index: 2;
269
345
  position: relative;
270
346
  }
271
-
272
- .cbg-loader-${uid} {
347
+ .cbg-loader {
273
348
  height: 100%;
274
349
  width: 100%;
275
- position: ${position};
276
- background-image: ${overlayBg};
277
- background-color: ${this.#options.overlayColor};
350
+ position: absolute;
351
+ top: 0;
352
+ left: 0;
353
+ background-image: var(--cbg-overlay-image, none);
354
+ background-color: var(--cbg-overlay-color, transparent);
278
355
  opacity: 1;
279
356
  z-index: 1;
280
- transition: opacity ${duration}s ease;
357
+ transition: opacity var(--cbg-duration, 2s) ease;
358
+ pointer-events: none;
359
+ }
360
+ body.cbg-host > .cbg-loader {
361
+ position: fixed;
362
+ }
363
+ .cbg-crossfade-el {
364
+ position: absolute;
281
365
  top: 0;
282
366
  left: 0;
367
+ height: 100%;
368
+ width: 100%;
369
+ background-size: cover;
370
+ background-position: center;
371
+ background-repeat: no-repeat;
372
+ z-index: 0;
373
+ opacity: 0;
374
+ transition: opacity var(--cbg-duration, 2s) ease;
375
+ pointer-events: none;
376
+ }
377
+ body.cbg-host > .cbg-crossfade-el {
378
+ position: fixed;
283
379
  }
284
380
  `;
285
381
 
286
382
  const style = document.createElement('style');
287
- style.dataset.chameleonUid = uid;
383
+ style.id = 'chameleon-global-styles';
288
384
  style.textContent = css;
289
385
  document.head.appendChild(style);
290
- this.#styleElement = style;
386
+ }
387
+
388
+ /**
389
+ * Apply CSS custom properties to the host element.
390
+ */
391
+ #updateCSSVariables() {
392
+ this.#element.classList.add(`cbg-host-${this.#uid}`, 'cbg-host');
393
+ this.#element.style.setProperty('--cbg-duration', `${this.#options.transitionDuration / 1000}s`);
394
+ this.#element.style.setProperty('--cbg-overlay-color', this.#options.overlayColor);
395
+
396
+ if (this.#options.overlayImage) {
397
+ this.#element.style.setProperty('--cbg-overlay-image', `url(${this.#options.overlayImage})`);
398
+ } else {
399
+ this.#element.style.setProperty('--cbg-overlay-image', 'none');
400
+ }
401
+
402
+ this.#element.style.setProperty('--cbg-min-overlay', String(this.#options.minOverlay));
291
403
  }
292
404
 
293
405
  /**
@@ -298,6 +410,7 @@ class ChameleonBackgrounds {
298
410
 
299
411
  const wrapper = document.createElement('div');
300
412
  wrapper.id = `cbg-inner-${uid}`;
413
+ wrapper.classList.add('cbg-inner');
301
414
 
302
415
  // Move all child nodes from the element into the wrapper
303
416
  while (this.#element.firstChild) {
@@ -305,7 +418,7 @@ class ChameleonBackgrounds {
305
418
  }
306
419
 
307
420
  const loader = document.createElement('div');
308
- loader.classList.add(`cbg-loader-${uid}`);
421
+ loader.classList.add('cbg-loader', `cbg-loader-${uid}`);
309
422
 
310
423
  // Append wrapper and loader
311
424
  this.#element.appendChild(wrapper);
@@ -323,7 +436,8 @@ class ChameleonBackgrounds {
323
436
  if (this.#destroyed) return;
324
437
 
325
438
  if (this.#options.type === 'single') {
326
- this.#loadBackground(typeof this.#options.src === 'string' ? this.#options.src : this.#options.src[0]);
439
+ const singleSrc = Array.isArray(this.#options.src) ? this.#options.src[0] : this.#options.src;
440
+ this.#loadBackground(singleSrc);
327
441
  } else if (this.#options.type === 'slider') {
328
442
  this.#startSlider();
329
443
  }
@@ -331,39 +445,90 @@ class ChameleonBackgrounds {
331
445
 
332
446
  /**
333
447
  * Preload an image and apply it as the element's background.
334
- * @param {string} src - Image URL to load.
448
+ * Handles both string URLs and responsive image objects ({ url, srcset, sizes }).
449
+ * @param {string|ChameleonImageConfig} srcConfig - Image configuration or URL.
335
450
  * @param {boolean} [isFirst=true] - Whether this is the first image (skips overlay reset).
451
+ * @param {boolean} [isCrossfade=false] - If true, handles true crossfading without solid color drop.
336
452
  * @returns {Promise<void>}
337
453
  */
338
- #loadBackground(src, isFirst = true) {
454
+ #loadBackground(srcConfig, isFirst = true, isCrossfade = false) {
339
455
  if (this.#destroyed) return Promise.resolve();
340
456
 
341
457
  return new Promise((resolve) => {
342
458
  const img = new Image();
459
+
460
+ let urlStr = srcConfig;
461
+ if (typeof srcConfig === 'object' && srcConfig.url) {
462
+ urlStr = srcConfig.url;
463
+ if (srcConfig.srcset) img.srcset = srcConfig.srcset;
464
+ if (srcConfig.sizes) img.sizes = srcConfig.sizes;
465
+ }
466
+
343
467
  if (isFirst && 'fetchPriority' in img && this.#options.fetchPriority !== 'auto') {
344
468
  img.fetchPriority = this.#options.fetchPriority;
345
469
  }
346
470
 
471
+ // Append to DOM to ensure viewport-based `sizes` calculate correctly in all browsers
472
+ img.style.position = 'absolute';
473
+ img.style.visibility = 'hidden';
474
+ img.style.width = '0';
475
+ img.style.height = '0';
476
+ this.#element.appendChild(img);
477
+
347
478
  img.onload = () => {
479
+ img.remove();
348
480
  if (this.#destroyed) return resolve();
349
481
 
350
- this.#element.style.backgroundImage = `url(${src})`;
351
- this.#element.style.backgroundSize = 'cover';
352
-
353
- const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
354
- if (loader) {
355
- loader.style.opacity = String(this.#options.minOverlay);
482
+ // `img.currentSrc` retrieves the actual srcset chosen file, otherwise fallback to `urlStr`
483
+ const finalUrl = img.currentSrc || urlStr;
484
+
485
+ if (isCrossfade && !isFirst) {
486
+ // --- Crossfade Transition Mode ---
487
+ const crossfadeDiv = document.createElement('div');
488
+ crossfadeDiv.classList.add('cbg-crossfade-el', `cbg-crossfade-${this.#uid}`);
489
+ crossfadeDiv.style.backgroundImage = `url("${finalUrl}")`;
490
+
491
+ // Insert right behind the loader (or directly at start of host)
492
+ this.#element.insertBefore(crossfadeDiv, this.#element.firstChild);
493
+
494
+ // Trigger layout so the transition works
495
+ void crossfadeDiv.offsetWidth;
496
+
497
+ crossfadeDiv.style.opacity = '1';
498
+
499
+ setTimeout(() => {
500
+ if (this.#destroyed) return resolve();
501
+ this.#element.style.backgroundImage = `url("${finalUrl}")`;
502
+ this.#element.style.backgroundSize = 'cover';
503
+ this.#element.style.backgroundRepeat = 'no-repeat';
504
+ crossfadeDiv.remove();
505
+ resolve();
506
+ }, this.#options.transitionDuration);
507
+
508
+ } else {
509
+ // --- Solid Transition Mode or First Load ---
510
+ this.#element.style.backgroundImage = `url("${finalUrl}")`;
511
+ this.#element.style.backgroundSize = 'cover';
512
+ this.#element.style.backgroundRepeat = 'no-repeat';
513
+
514
+ const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
515
+ if (loader) {
516
+ loader.style.opacity = String(this.#options.minOverlay);
517
+ }
518
+ this.#hasLoadedFirstBackground = true;
519
+ setTimeout(() => {
520
+ resolve();
521
+ }, this.#options.transitionDuration);
356
522
  }
357
-
358
- resolve();
359
523
  };
360
524
 
361
525
  img.onerror = () => {
362
- console.warn(`[ChameleonBackgrounds] Failed to load image: ${src}`);
363
- resolve();
526
+ img.remove();
527
+ console.warn(`[ChameleonBackgrounds] Failed to load image:`, srcConfig);
528
+ resolve(); // resolve anyway to keep logic flowing
364
529
  };
365
530
 
366
- img.src = src;
531
+ img.src = urlStr;
367
532
  });
368
533
  }
369
534
 
@@ -376,9 +541,11 @@ class ChameleonBackgrounds {
376
541
  reloadBackground(src) {
377
542
  if (this.#destroyed) return Promise.resolve();
378
543
 
544
+ this.#transitionId++;
545
+
379
546
  if (src !== undefined) {
380
547
  if (this.#options.type === 'slider' && typeof src === 'string') {
381
- // If the user passes a comma-separated string, split it. Otherwise wrap it.
548
+ // If the user passes a comma-separated string, split it.
382
549
  this.#options.src = src.includes(',') ? src.split(',').map(s => s.trim()) : [src];
383
550
  } else {
384
551
  this.#options.src = src;
@@ -387,28 +554,17 @@ class ChameleonBackgrounds {
387
554
 
388
555
  // Stop any running slider
389
556
  if (this.#sliderIntervalId !== null) {
390
- clearInterval(this.#sliderIntervalId);
557
+ clearTimeout(this.#sliderIntervalId);
391
558
  this.#sliderIntervalId = null;
392
559
  }
393
560
 
394
- const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
395
- if (loader) {
396
- loader.style.opacity = '1';
561
+ if (this.#options.type === 'single') {
562
+ const singleSrc = Array.isArray(this.#options.src) ? this.#options.src[0] : this.#options.src;
563
+ return this.#cycleSliderSlide(singleSrc, this.#transitionId);
564
+ } else if (this.#options.type === 'slider') {
565
+ this.#startSlider(this.#transitionId);
566
+ return Promise.resolve();
397
567
  }
398
-
399
- return new Promise((resolve) => {
400
- setTimeout(() => {
401
- if (this.#destroyed) return resolve();
402
-
403
- if (this.#options.type === 'single') {
404
- const singleSrc = typeof this.#options.src === 'string' ? this.#options.src : this.#options.src[0];
405
- this.#loadBackground(singleSrc, false).then(resolve);
406
- } else if (this.#options.type === 'slider') {
407
- this.#startSlider();
408
- resolve();
409
- }
410
- }, this.#options.transitionDuration);
411
- });
412
568
  }
413
569
 
414
570
  /**
@@ -421,14 +577,9 @@ class ChameleonBackgrounds {
421
577
  const normalized = ChameleonBackgrounds.#normalizeOptions(newOptions);
422
578
  this.#options = { ...this.#options, ...normalized };
423
579
 
424
- // Remove old styles
425
- if (this.#styleElement?.parentNode) {
426
- this.#styleElement.parentNode.removeChild(this.#styleElement);
427
- this.#styleElement = null;
428
- }
429
-
430
- // Inject updated styles
431
- this.#injectStyles();
580
+ this.#updateCSSVariables();
581
+ // In v1/v2, reloadOptions did not restart the slider; the user called reloadBackground.
582
+ // We intentionally do not auto-restart here to prevent concurrent race conditions.
432
583
  }
433
584
 
434
585
  // ---------------------------------------------------------------------------
@@ -437,8 +588,9 @@ class ChameleonBackgrounds {
437
588
 
438
589
  /**
439
590
  * Start the background slideshow.
591
+ * @param {number} tid - The transition ID
440
592
  */
441
- #startSlider() {
593
+ #startSlider(tid = this.#transitionId) {
442
594
  if (this.#destroyed) return;
443
595
 
444
596
  const sources = this.#options.src;
@@ -447,53 +599,86 @@ class ChameleonBackgrounds {
447
599
  return;
448
600
  }
449
601
 
450
- // Load the first slide immediately
451
- let index = 0;
452
- this.#loadBackground(sources[index]).then(() => {
453
- if (this.#destroyed) return;
602
+ this.#currentSlideIndex = 0;
603
+
604
+ if (this.#hasLoadedFirstBackground) {
605
+ // Instance already has a background, crossfade smoothly
606
+ this.#cycleSliderSlide(sources[this.#currentSlideIndex], tid).then(() => {
607
+ if (this.#destroyed || this.#transitionId !== tid) return;
608
+ this.#currentSlideIndex = 1;
609
+ if (sources.length > 1) this.#startSliderLoop();
610
+ });
611
+ } else {
612
+ // First load, load immediately without solid color flash
613
+ this.#loadBackground(sources[this.#currentSlideIndex], true, this.#options.transitionMode === 'crossfade').then(() => {
614
+ if (this.#destroyed || this.#transitionId !== tid) return;
615
+ this.#hasLoadedFirstBackground = true;
616
+ this.#currentSlideIndex = 1;
617
+ if (sources.length > 1) this.#startSliderLoop();
618
+ });
619
+ }
620
+ }
621
+
622
+ #startSliderLoop() {
623
+ if (this.#destroyed || this.#isPaused) return;
454
624
 
455
- index = 1;
456
- if (sources.length === 1) return; // only one slide, nothing to rotate
625
+ // Parse as integers to prevent string concatenation bugs if users passed strings
626
+ const sDuration = parseInt(this.#options.sliderDuration, 10) || 8000;
457
627
 
458
- const interval = this.#options.sliderDuration + this.#options.transitionDuration * 2;
628
+ this.#sliderIntervalId = setTimeout(() => {
629
+ if (this.#destroyed || this.#isPaused) {
630
+ this.#sliderIntervalId = null;
631
+ return;
632
+ }
459
633
 
460
- this.#sliderIntervalId = setInterval(() => {
461
- if (this.#destroyed) {
462
- clearInterval(this.#sliderIntervalId);
634
+ const tid = this.#transitionId;
635
+ this.#cycleSliderSlide(this.#options.src[this.#currentSlideIndex], tid).then(() => {
636
+ if (this.#destroyed || this.#isPaused || this.#transitionId !== tid) {
637
+ this.#sliderIntervalId = null;
463
638
  return;
464
639
  }
465
640
 
466
- this.#cycleSliderSlide(sources[index]);
467
- index++;
641
+ this.#currentSlideIndex++;
468
642
 
469
- if (index >= sources.length) {
643
+ if (this.#currentSlideIndex >= this.#options.src.length) {
470
644
  if (this.#options.sliderLoop) {
471
- index = 0;
645
+ this.#currentSlideIndex = 0;
472
646
  } else {
473
- clearInterval(this.#sliderIntervalId);
474
647
  this.#sliderIntervalId = null;
648
+ return;
475
649
  }
476
650
  }
477
- }, interval);
478
- });
651
+
652
+ this.#startSliderLoop();
653
+ });
654
+ }, sDuration);
479
655
  }
480
656
 
481
657
  /**
482
658
  * Cycle to a specific slide without resetting the slider instance.
483
- * @param {string} src - The image URL to load.
659
+ * @param {string|ChameleonImageConfig} src - The image URL/config to load.
660
+ * @param {number} tid - The transition ID
661
+ * @returns {Promise<void>}
484
662
  */
485
- #cycleSliderSlide(src) {
486
- if (this.#destroyed) return;
663
+ #cycleSliderSlide(src, tid = this.#transitionId) {
664
+ if (this.#destroyed) return Promise.resolve();
487
665
 
488
- const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
489
- if (loader) {
490
- loader.style.opacity = '1';
491
- }
666
+ return new Promise(resolve => {
667
+ if (this.#options.transitionMode === 'solid') {
668
+ const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
669
+ if (loader) {
670
+ loader.style.opacity = '1';
671
+ }
492
672
 
493
- setTimeout(() => {
494
- if (this.#destroyed) return;
495
- this.#loadBackground(src, false);
496
- }, this.#options.transitionDuration);
673
+ setTimeout(() => {
674
+ if (this.#destroyed || this.#transitionId !== tid) return resolve();
675
+ this.#loadBackground(src, false, false).then(resolve);
676
+ }, this.#options.transitionDuration);
677
+ } else {
678
+ // Crossfade mode triggers immediately
679
+ this.#loadBackground(src, false, true).then(resolve);
680
+ }
681
+ });
497
682
  }
498
683
 
499
684
  // ---------------------------------------------------------------------------