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