chameleon-backgrounds 2.1.3 → 3.0.0

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.
@@ -1,557 +1,743 @@
1
1
  (function webpackUniversalModuleDefinition(root, factory) {
2
- if(typeof exports === 'object' && typeof module === 'object')
3
- module.exports = factory();
4
- else if(typeof define === 'function' && define.amd)
5
- define([], factory);
6
- else if(typeof exports === 'object')
7
- exports["ChameleonBackgrounds"] = factory();
8
- else
9
- root["ChameleonBackgrounds"] = factory();
2
+ if (typeof exports === 'object' && typeof module === 'object')
3
+ module.exports = factory();
4
+ else if (typeof define === 'function' && define.amd)
5
+ define([], factory);
6
+ else if (typeof exports === 'object')
7
+ exports["ChameleonBackgrounds"] = factory();
8
+ else
9
+ root["ChameleonBackgrounds"] = factory();
10
10
  })(this, () => {
11
- return /******/ (() => { // webpackBootstrap
11
+ return /******/ (() => { // webpackBootstrap
12
12
  /******/ "use strict";
13
13
  /******/ // The require scope
14
14
  /******/ const __webpack_require__ = {};
15
- /******/
15
+ /******/
16
16
  /************************************************************************/
17
17
  /******/ /* webpack/runtime/define property getters */
18
18
  /******/ (() => {
19
19
  /******/ // define getter/value functions for harmony exports
20
20
  /******/ __webpack_require__.d = (exports, definition) => {
21
- /******/ if(Array.isArray(definition)) {
21
+ /******/ if (Array.isArray(definition)) {
22
22
  /******/ var i = 0;
23
- /******/ while(i < definition.length) {
23
+ /******/ while (i < definition.length) {
24
24
  /******/ var key = definition[i++];
25
25
  /******/ var binding = definition[i++];
26
- /******/ if(!__webpack_require__.o(exports, key)) {
27
- /******/ if(binding === 0) {
26
+ /******/ if (!__webpack_require__.o(exports, key)) {
27
+ /******/ if (binding === 0) {
28
28
  /******/ Object.defineProperty(exports, key, { enumerable: true, value: definition[i++] });
29
- /******/ } else {
29
+ /******/
30
+ } else {
30
31
  /******/ Object.defineProperty(exports, key, { enumerable: true, get: binding });
31
- /******/ }
32
- /******/ } else if(binding === 0) { i++; }
33
- /******/ }
34
- /******/ } else {
35
- /******/ for(var key in definition) {
36
- /******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
32
+ /******/
33
+ }
34
+ /******/
35
+ } else if (binding === 0) { i++; }
36
+ /******/
37
+ }
38
+ /******/
39
+ } else {
40
+ /******/ for (var key in definition) {
41
+ /******/ if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
37
42
  /******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
38
- /******/ }
39
- /******/ }
40
- /******/ }
41
- /******/ };
42
- /******/ })();
43
- /******/
43
+ /******/
44
+ }
45
+ /******/
46
+ }
47
+ /******/
48
+ }
49
+ /******/
50
+ };
51
+ /******/
52
+ })();
53
+ /******/
44
54
  /******/ /* webpack/runtime/hasOwnProperty shorthand */
45
55
  /******/ (() => {
46
56
  /******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
47
- /******/ })();
48
- /******/
57
+ /******/
58
+ })();
59
+ /******/
49
60
  /******/ /* webpack/runtime/make namespace object */
50
61
  /******/ (() => {
51
62
  /******/ // define __esModule on exports
52
63
  /******/ __webpack_require__.r = (exports) => {
53
- /******/ if(Symbol.toStringTag) {
64
+ /******/ if (Symbol.toStringTag) {
54
65
  /******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
55
- /******/ }
66
+ /******/
67
+ }
56
68
  /******/ Object.defineProperty(exports, '__esModule', { value: true });
57
- /******/ };
58
- /******/ })();
59
- /******/
60
- /************************************************************************/
61
- let __webpack_exports__ = {};
62
- /*!**************************************!*\
63
- !*** ./src/chameleon-backgrounds.js ***!
64
- \**************************************/
65
- __webpack_require__.r(__webpack_exports__);
69
+ /******/
70
+ };
71
+ /******/
72
+ })();
73
+ /******/
74
+ /************************************************************************/
75
+ let __webpack_exports__ = {};
76
+ /*!**************************************!*\
77
+ !*** ./src/chameleon-backgrounds.js ***!
78
+ \**************************************/
79
+ __webpack_require__.r(__webpack_exports__);
66
80
  /* harmony export */ __webpack_require__.d(__webpack_exports__, {
67
81
  /* harmony export */ ChameleonBackgrounds: () => (/* binding */ ChameleonBackgrounds),
68
82
  /* harmony export */ "default": () => (__WEBPACK_DEFAULT_EXPORT__)
69
- /* harmony export */ });
70
- /**
71
- * _____ _ _
72
- * / ____| | | |
73
- * | | | |__ __ _ _ __ ___ ___| | ___ ___ _ __
74
- * | | | '_ \ / _` | '_ ` _ \ / _ \ |/ _ \/ _ \| '_ \
75
- * | |____| | | | (_| | | | | | | __/ | __/ (_) | | | |
76
- * \_____|_| |_|\__,_|_| |_| |_|\___|_|\___|\___|_| |_|_
77
- * | _ \ | | | |
78
- * | |_) | __ _ ___| | ____ _ _ __ ___ _ _ _ __ __| |___
79
- * | _ < / _` |/ __| |/ / _` | '__/ _ \| | | | '_ \ / _` / __|
80
- * | |_) | (_| | (__| < (_| | | | (_) | |_| | | | | (_| \__ \
81
- * |____/ \__,_|\___|_|\_\__, |_| \___/ \__,_|_| |_|\__,_|___/
82
- * __/ |
83
- * |___/
84
- *
85
- * @module ChameleonBackgrounds
86
- * @version 2.1.3
87
- * @author Lennart van Ballegoij (https://weblenn.com/)
88
- * @license MIT
89
- * @see https://github.com/WebLenn/ChameleonBackgrounds
90
- *
91
- * A zero-dependency JavaScript library to dynamically load background
92
- * images with elegant fade-in transitions and slideshow support.
93
- */
94
-
95
- /**
96
- * @typedef {Object} ChameleonOptions
97
- * @property {string|HTMLElement} element - CSS selector or DOM element to attach to.
98
- * @property {'single'|'slider'} type - Background mode.
99
- * @property {string|string[]} src - Image URL(s). String for 'single', array for 'slider'.
100
- * @property {string} overlayColor - Overlay color (hex, rgb, rgba, hsl).
101
- * @property {string} [overlayImage] - Optional overlay pattern image URL.
102
- * @property {number} [minOverlay=0] - Minimum overlay opacity after fade (0–1).
103
- * @property {number} [transitionDuration=2000] - Fade duration in milliseconds.
104
- * @property {number} [sliderDuration=8000] - Time each slide is shown in milliseconds.
105
- * @property {boolean} [sliderLoop=false] - Whether the slider restarts after the last slide.
106
- * @property {'high'|'low'|'auto'} [fetchPriority='auto'] - fetchPriority for the first loaded image (e.g., 'high' for LCP images).
107
- */
108
-
109
- class ChameleonBackgrounds {
110
- /** @type {ChameleonOptions} */
111
- static #DEFAULTS = Object.freeze({
112
- element: 'body',
113
- type: 'single',
114
- src: '',
115
- overlayColor: '#0f1e25',
116
- overlayImage: null,
117
- minOverlay: 0,
118
- transitionDuration: 2000,
119
- sliderDuration: 8000,
120
- sliderLoop: false,
121
- fetchPriority: 'auto',
122
- });
123
-
124
- // Legacy snake_case → camelCase alias map
125
- static #ALIASES = Object.freeze({
126
- transition_duration: 'transitionDuration',
127
- slider_duration: 'sliderDuration',
128
- slider_loop: 'sliderLoop',
129
- min_overlay: 'minOverlay',
130
- overlay_color: 'overlayColor',
131
- overlay_image: 'overlayImage',
132
- fetch_priority: 'fetchPriority',
133
- });
134
-
135
- /** @type {ChameleonOptions} */
136
- #options;
137
-
138
- /** @type {HTMLElement} */
139
- #element;
140
-
141
- /** @type {string} */
142
- #uid;
143
-
144
- /** @type {string} */
145
- #originalHTML;
146
-
147
- /** @type {HTMLStyleElement|null} */
148
- #styleElement = null;
149
-
150
- /** @type {number|null} */
151
- #sliderIntervalId = null;
152
-
153
- /** @type {boolean} */
154
- #destroyed = false;
155
-
156
- /**
157
- * Create a new ChameleonBackgrounds instance.
158
- * @param {Partial<ChameleonOptions>} [options]
159
- */
160
- constructor(options = {}) {
161
- const normalized = ChameleonBackgrounds.#normalizeOptions(options);
162
- this.#options = { ...ChameleonBackgrounds.#DEFAULTS, ...normalized };
163
- this.#uid = ChameleonBackgrounds.#generateUID();
164
- this.#element = ChameleonBackgrounds.#resolveElement(this.#options.element);
165
- this.#originalHTML = this.#element.innerHTML;
166
-
167
- this.#init();
168
- }
169
-
170
- // ---------------------------------------------------------------------------
171
- // Public API
172
- // ---------------------------------------------------------------------------
173
-
174
- /**
175
- * Clean up all injected DOM, styles, and intervals.
176
- * Restores the target element to its original state.
177
- */
178
- destroy() {
179
- if (this.#destroyed) return;
180
- this.#destroyed = true;
181
-
182
- // Stop any running slider
183
- if (this.#sliderIntervalId !== null) {
184
- clearInterval(this.#sliderIntervalId);
185
- this.#sliderIntervalId = null;
186
- }
83
+ /* harmony export */
84
+ });
85
+ /**
86
+ * _____ _ _
87
+ * / ____| | | |
88
+ * | | | |__ __ _ _ __ ___ ___| | ___ ___ _ __
89
+ * | | | '_ \ / _` | '_ ` _ \ / _ \ |/ _ \/ _ \| '_ \
90
+ * | |____| | | | (_| | | | | | | __/ | __/ (_) | | | |
91
+ * \_____|_| |_|\__,_|_| |_| |_|\___|_|\___|\___|_| |_|_
92
+ * | _ \ | | | |
93
+ * | |_) | __ _ ___| | ____ _ _ __ ___ _ _ _ __ __| |___
94
+ * | _ < / _` |/ __| |/ / _` | '__/ _ \| | | | '_ \ / _` / __|
95
+ * | |_) | (_| | (__| < (_| | | | (_) | |_| | | | | (_| \__ \
96
+ * |____/ \__,_|\___|_|\_\__, |_| \___/ \__,_|_| |_|\__,_|___/
97
+ * __/ |
98
+ * |___/
99
+ *
100
+ * @module ChameleonBackgrounds
101
+ * @version 3.0.0
102
+ * @author Lennart van Ballegoij (https://weblenn.com/)
103
+ * @license MIT
104
+ * @see https://github.com/WebLenn/ChameleonBackgrounds
105
+ *
106
+ * A zero-dependency JavaScript library to dynamically load background
107
+ * images with elegant fade-in transitions and slideshow support.
108
+ */
109
+
110
+ /**
111
+ * @typedef {Object} ChameleonImageConfig
112
+ * @property {string} url - The fallback URL.
113
+ * @property {string} [srcset] - Optional srcset.
114
+ * @property {string} [sizes] - Optional sizes.
115
+ *
116
+ * @typedef {Object} ChameleonOptions
117
+ * @property {string|HTMLElement} element - CSS selector or DOM element to attach to.
118
+ * @property {'single'|'slider'} type - Background mode.
119
+ * @property {string|string[]|ChameleonImageConfig|ChameleonImageConfig[]} src - Image URL(s) or config object(s).
120
+ * @property {string} overlayColor - Overlay color (hex, rgb, rgba, hsl).
121
+ * @property {string} [overlayImage] - Optional overlay pattern image URL.
122
+ * @property {number} [minOverlay=0] - Minimum overlay opacity after fade (0–1).
123
+ * @property {number} [transitionDuration=2000] - Fade duration in milliseconds.
124
+ * @property {number} [sliderDuration=8000] - Time each slide is shown in milliseconds.
125
+ * @property {boolean} [sliderLoop=false] - Whether the slider restarts after the last slide.
126
+ * @property {'high'|'low'|'auto'} [fetchPriority='auto'] - fetchPriority for the first loaded image (e.g., 'high' for LCP images).
127
+ * @property {boolean} [lazyLoad=true] - Whether to wait until element is in viewport using IntersectionObserver.
128
+ * @property {'solid'|'crossfade'} [transitionMode='solid'] - Transition mode: 'solid' (fade to color) or 'crossfade' (fade between images).
129
+ */
130
+
131
+ class ChameleonBackgrounds {
132
+ /** @type {ChameleonOptions} */
133
+ static #DEFAULTS = Object.freeze({
134
+ element: 'body',
135
+ type: 'single',
136
+ src: '',
137
+ overlayColor: '#0f1e25',
138
+ overlayImage: null,
139
+ minOverlay: 0,
140
+ transitionDuration: 2000,
141
+ sliderDuration: 8000,
142
+ sliderLoop: false,
143
+ fetchPriority: 'auto',
144
+ lazyLoad: true,
145
+ transitionMode: 'solid',
146
+ respectReducedMotion: false,
147
+ });
148
+
149
+ // Legacy snake_case → camelCase alias map
150
+ static #ALIASES = Object.freeze({
151
+ transition_duration: 'transitionDuration',
152
+ slider_duration: 'sliderDuration',
153
+ slider_loop: 'sliderLoop',
154
+ min_overlay: 'minOverlay',
155
+ overlay_color: 'overlayColor',
156
+ overlay_image: 'overlayImage',
157
+ fetch_priority: 'fetchPriority',
158
+ lazy_load: 'lazyLoad',
159
+ transition_mode: 'transitionMode',
160
+ respect_reduced_motion: 'respectReducedMotion',
161
+ });
162
+
163
+ /** @type {boolean} */
164
+ static #globalStylesInjected = false;
165
+
166
+ /** @type {ChameleonOptions} */
167
+ #options;
168
+
169
+ /** @type {HTMLElement} */
170
+ #element;
171
+
172
+ /** @type {string} */
173
+ #uid;
174
+
175
+ /** @type {string} */
176
+ #originalHTML;
177
+
178
+ /** @type {number|null} */
179
+ #sliderIntervalId = null;
180
+
181
+ /** @type {number} */
182
+ #currentSlideIndex = 0;
183
+
184
+ /** @type {boolean} */
185
+ #isPaused = false;
186
+
187
+ /** @type {IntersectionObserver|null} */
188
+ #observer = null;
189
+
190
+ /** @type {boolean} */
191
+ #destroyed = false;
192
+
193
+ /**
194
+ * Create a new ChameleonBackgrounds instance.
195
+ * @param {Partial<ChameleonOptions>} [options]
196
+ */
197
+ constructor(options = {}) {
198
+ const normalized = ChameleonBackgrounds.#normalizeOptions(options);
199
+ this.#options = { ...ChameleonBackgrounds.#DEFAULTS, ...normalized };
200
+ this.#uid = ChameleonBackgrounds.#generateUID();
201
+ this.#element = ChameleonBackgrounds.#resolveElement(this.#options.element);
202
+ this.#originalHTML = this.#element.innerHTML;
203
+
204
+ this.#init();
205
+ }
187
206
 
188
- // Remove injected style element
189
- if (this.#styleElement?.parentNode) {
190
- this.#styleElement.parentNode.removeChild(this.#styleElement);
191
- this.#styleElement = null;
192
- }
207
+ // ---------------------------------------------------------------------------
208
+ // Public API
209
+ // ---------------------------------------------------------------------------
210
+
211
+ /**
212
+ * Clean up all injected DOM, styles, and intervals.
213
+ * Restores the target element to its original state.
214
+ */
215
+ destroy() {
216
+ if (this.#destroyed) return;
217
+ this.#destroyed = true;
193
218
 
194
- // Unwrap the original content instead of resetting innerHTML
195
- const wrapper = this.#element.querySelector(`#cbg-inner-${this.#uid}`);
196
- if (wrapper && wrapper.parentNode === this.#element) {
197
- while (wrapper.firstChild) {
198
- this.#element.insertBefore(wrapper.firstChild, wrapper);
219
+ // Stop any running slider
220
+ if (this.#sliderIntervalId !== null) {
221
+ clearInterval(this.#sliderIntervalId);
222
+ this.#sliderIntervalId = null;
223
+ }
224
+
225
+ if (this.#observer) {
226
+ this.#observer.disconnect();
227
+ this.#observer = null;
228
+ }
229
+
230
+ // Clean up dynamic classes on host
231
+ this.#element.classList.remove(`cbg-host-${this.#uid}`, 'cbg-host');
232
+
233
+ // Unwrap the original content instead of resetting innerHTML
234
+ const wrapper = this.#element.querySelector(`#cbg-inner-${this.#uid}`);
235
+ if (wrapper && wrapper.parentNode === this.#element) {
236
+ while (wrapper.firstChild) {
237
+ this.#element.insertBefore(wrapper.firstChild, wrapper);
238
+ }
239
+ this.#element.removeChild(wrapper);
240
+ }
241
+
242
+ // Remove loader
243
+ const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
244
+ if (loader) {
245
+ loader.remove();
246
+ }
247
+
248
+ // Remove temporary crossfade elements
249
+ const crossfadeElements = this.#element.querySelectorAll(`.cbg-crossfade-${this.#uid}`);
250
+ crossfadeElements.forEach(el => el.remove());
251
+
252
+ // Remove inline CSS Variables and background-image
253
+ this.#element.style.backgroundImage = '';
254
+ this.#element.style.removeProperty('--cbg-duration');
255
+ this.#element.style.removeProperty('--cbg-overlay-color');
256
+ this.#element.style.removeProperty('--cbg-overlay-image');
257
+ this.#element.style.removeProperty('--cbg-min-overlay');
199
258
  }
200
- this.#element.removeChild(wrapper);
201
- }
202
259
 
203
- // Remove loader
204
- const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
205
- if (loader) {
206
- loader.remove();
207
- }
260
+ /**
261
+ * The resolved options (read-only copy).
262
+ * @returns {ChameleonOptions}
263
+ */
264
+ get options() {
265
+ return { ...this.#options };
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // Initialization
270
+ // ---------------------------------------------------------------------------
271
+
272
+ #init() {
273
+ this.#injectGlobalStyles();
274
+ this.#updateCSSVariables();
275
+ this.#buildDOM();
208
276
 
209
- // Remove inline background-image
210
- this.#element.style.backgroundImage = '';
211
- }
212
-
213
- /**
214
- * The resolved options (read-only copy).
215
- * @returns {ChameleonOptions}
216
- */
217
- get options() {
218
- return { ...this.#options };
219
- }
220
-
221
- // ---------------------------------------------------------------------------
222
- // Initialization
223
- // ---------------------------------------------------------------------------
224
-
225
- #init() {
226
- this.#injectStyles();
227
- this.#buildDOM();
228
-
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());
235
- } else {
236
- if (document.readyState === 'complete') {
237
- this.#retrieveBackground();
238
- } else {
239
- window.addEventListener('load', () => this.#retrieveBackground(), { once: true });
277
+ // Check for prefers-reduced-motion to auto-pause slider if enabled
278
+ if (this.#options.respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
279
+ this.#isPaused = true;
280
+ }
281
+
282
+ if (this.#options.lazyLoad && 'IntersectionObserver' in window) {
283
+ this.#observer = new IntersectionObserver((entries) => {
284
+ entries.forEach((entry) => {
285
+ if (entry.isIntersecting) {
286
+ this.#observer.disconnect();
287
+ this.#observer = null;
288
+ this.#retrieveBackground();
289
+ }
290
+ });
291
+ });
292
+ this.#observer.observe(this.#element);
293
+ } else {
294
+ if (this.#element.matches('body')) {
295
+ queueMicrotask(() => this.#retrieveBackground());
296
+ } else {
297
+ if (document.readyState === 'complete') {
298
+ this.#retrieveBackground();
299
+ } else {
300
+ window.addEventListener('load', () => this.#retrieveBackground(), { once: true });
301
+ }
302
+ }
303
+ }
240
304
  }
241
- }
242
- }
243
-
244
- // ---------------------------------------------------------------------------
245
- // DOM Construction
246
- // ---------------------------------------------------------------------------
247
-
248
- /**
249
- * Inject a scoped <style> element into the <head>.
250
- */
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';
261
-
262
- const css = `
263
- ${typeof selector === 'string' ? selector : `.cbg-host-${uid}`} {
264
- position: relative;
305
+
306
+ // ---------------------------------------------------------------------------
307
+ // Play / Pause API
308
+ // ---------------------------------------------------------------------------
309
+
310
+ /**
311
+ * Pause the slideshow.
312
+ */
313
+ pause() {
314
+ if (this.#destroyed || this.#options.type !== 'slider') return;
315
+ this.#isPaused = true;
316
+ if (this.#sliderIntervalId !== null) {
317
+ clearInterval(this.#sliderIntervalId);
318
+ this.#sliderIntervalId = null;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Resume the slideshow.
324
+ */
325
+ play() {
326
+ if (this.#destroyed || this.#options.type !== 'slider') return;
327
+ this.#isPaused = false;
328
+ // Only restart if not currently running
329
+ if (this.#sliderIntervalId === null) {
330
+ this.#startSliderLoop();
331
+ }
265
332
  }
266
333
 
267
- #cbg-inner-${uid} {
334
+ // ---------------------------------------------------------------------------
335
+ // DOM Construction
336
+ // ---------------------------------------------------------------------------
337
+
338
+ /**
339
+ * Inject global styles once.
340
+ */
341
+ #injectGlobalStyles() {
342
+ if (ChameleonBackgrounds.#globalStylesInjected) return;
343
+ ChameleonBackgrounds.#globalStylesInjected = true;
344
+
345
+ const css = `
346
+ .cbg-host {
347
+ position: relative;
348
+ }
349
+ body.cbg-host {
350
+ /* body naturally acts as relative for background sizing if not explicitly positioned differently */
351
+ }
352
+ .cbg-inner {
268
353
  z-index: 2;
269
354
  position: relative;
270
355
  }
271
-
272
- .cbg-loader-${uid} {
356
+ .cbg-loader {
273
357
  height: 100%;
274
358
  width: 100%;
275
- position: ${position};
276
- background-image: ${overlayBg};
277
- background-color: ${this.#options.overlayColor};
359
+ position: absolute;
360
+ top: 0;
361
+ left: 0;
362
+ background-image: var(--cbg-overlay-image, none);
363
+ background-color: var(--cbg-overlay-color, transparent);
278
364
  opacity: 1;
279
365
  z-index: 1;
280
- transition: opacity ${duration}s ease;
366
+ transition: opacity var(--cbg-duration, 2s) ease;
367
+ pointer-events: none;
368
+ }
369
+ body.cbg-host > .cbg-loader {
370
+ position: fixed;
371
+ }
372
+ .cbg-crossfade-el {
373
+ position: absolute;
281
374
  top: 0;
282
375
  left: 0;
376
+ height: 100%;
377
+ width: 100%;
378
+ background-size: cover;
379
+ background-position: center;
380
+ background-repeat: no-repeat;
381
+ z-index: 0;
382
+ opacity: 0;
383
+ transition: opacity var(--cbg-duration, 2s) ease;
384
+ pointer-events: none;
385
+ }
386
+ body.cbg-host > .cbg-crossfade-el {
387
+ position: fixed;
283
388
  }
284
389
  `;
285
390
 
286
- const style = document.createElement('style');
287
- style.dataset.chameleonUid = uid;
288
- style.textContent = css;
289
- document.head.appendChild(style);
290
- this.#styleElement = style;
291
- }
292
-
293
- /**
294
- * Wrap existing content and insert the overlay loader.
295
- */
296
- #buildDOM() {
297
- const uid = this.#uid;
298
-
299
- const wrapper = document.createElement('div');
300
- wrapper.id = `cbg-inner-${uid}`;
301
-
302
- // Move all child nodes from the element into the wrapper
303
- while (this.#element.firstChild) {
304
- wrapper.appendChild(this.#element.firstChild);
305
- }
306
-
307
- const loader = document.createElement('div');
308
- loader.classList.add(`cbg-loader-${uid}`);
309
-
310
- // Append wrapper and loader
311
- this.#element.appendChild(wrapper);
312
- this.#element.appendChild(loader);
313
- }
314
-
315
- // ---------------------------------------------------------------------------
316
- // Background Loading
317
- // ---------------------------------------------------------------------------
391
+ const style = document.createElement('style');
392
+ style.id = 'chameleon-global-styles';
393
+ style.textContent = css;
394
+ document.head.appendChild(style);
395
+ }
318
396
 
319
- /**
320
- * Route to the correct loader based on type.
321
- */
322
- #retrieveBackground() {
323
- if (this.#destroyed) return;
397
+ /**
398
+ * Apply CSS custom properties to the host element.
399
+ */
400
+ #updateCSSVariables() {
401
+ this.#element.classList.add(`cbg-host-${this.#uid}`, 'cbg-host');
402
+ this.#element.style.setProperty('--cbg-duration', `${this.#options.transitionDuration / 1000}s`);
403
+ this.#element.style.setProperty('--cbg-overlay-color', this.#options.overlayColor);
404
+
405
+ if (this.#options.overlayImage) {
406
+ this.#element.style.setProperty('--cbg-overlay-image', `url(${this.#options.overlayImage})`);
407
+ } else {
408
+ this.#element.style.setProperty('--cbg-overlay-image', 'none');
409
+ }
324
410
 
325
- if (this.#options.type === 'single') {
326
- this.#loadBackground(typeof this.#options.src === 'string' ? this.#options.src : this.#options.src[0]);
327
- } else if (this.#options.type === 'slider') {
328
- this.#startSlider();
329
- }
330
- }
331
-
332
- /**
333
- * Preload an image and apply it as the element's background.
334
- * @param {string} src - Image URL to load.
335
- * @param {boolean} [isFirst=true] - Whether this is the first image (skips overlay reset).
336
- * @returns {Promise<void>}
337
- */
338
- #loadBackground(src, isFirst = true) {
339
- if (this.#destroyed) return Promise.resolve();
340
-
341
- return new Promise((resolve) => {
342
- const img = new Image();
343
- if (isFirst && 'fetchPriority' in img && this.#options.fetchPriority !== 'auto') {
344
- img.fetchPriority = this.#options.fetchPriority;
411
+ this.#element.style.setProperty('--cbg-min-overlay', String(this.#options.minOverlay));
345
412
  }
346
413
 
347
- img.onload = () => {
348
- if (this.#destroyed) return resolve();
414
+ /**
415
+ * Wrap existing content and insert the overlay loader.
416
+ */
417
+ #buildDOM() {
418
+ const uid = this.#uid;
349
419
 
350
- this.#element.style.backgroundImage = `url(${src})`;
351
- this.#element.style.backgroundSize = 'cover';
420
+ const wrapper = document.createElement('div');
421
+ wrapper.id = `cbg-inner-${uid}`;
422
+ wrapper.classList.add('cbg-inner');
352
423
 
353
- const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
354
- if (loader) {
355
- loader.style.opacity = String(this.#options.minOverlay);
424
+ // Move all child nodes from the element into the wrapper
425
+ while (this.#element.firstChild) {
426
+ wrapper.appendChild(this.#element.firstChild);
356
427
  }
357
428
 
358
- resolve();
359
- };
360
-
361
- img.onerror = () => {
362
- console.warn(`[ChameleonBackgrounds] Failed to load image: ${src}`);
363
- resolve();
364
- };
429
+ const loader = document.createElement('div');
430
+ loader.classList.add('cbg-loader', `cbg-loader-${uid}`);
365
431
 
366
- img.src = src;
367
- });
368
- }
369
-
370
- /**
371
- /**
372
- * Reload background with a fade-out → swap → fade-in cycle.
373
- * @param {string} [src] - Optional new image URL. If omitted, reloads using current options.
374
- * @returns {Promise<void>}
375
- */
376
- reloadBackground(src) {
377
- if (this.#destroyed) return Promise.resolve();
378
-
379
- if (src !== undefined) {
380
- if (this.#options.type === 'slider' && typeof src === 'string') {
381
- // If the user passes a comma-separated string, split it. Otherwise wrap it.
382
- this.#options.src = src.includes(',') ? src.split(',').map(s => s.trim()) : [src];
383
- } else {
384
- this.#options.src = src;
432
+ // Append wrapper and loader
433
+ this.#element.appendChild(wrapper);
434
+ this.#element.appendChild(loader);
385
435
  }
386
- }
387
-
388
- // Stop any running slider
389
- if (this.#sliderIntervalId !== null) {
390
- clearInterval(this.#sliderIntervalId);
391
- this.#sliderIntervalId = null;
392
- }
393
436
 
394
- const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
395
- if (loader) {
396
- loader.style.opacity = '1';
397
- }
437
+ // ---------------------------------------------------------------------------
438
+ // Background Loading
439
+ // ---------------------------------------------------------------------------
398
440
 
399
- return new Promise((resolve) => {
400
- setTimeout(() => {
401
- if (this.#destroyed) return resolve();
441
+ /**
442
+ * Route to the correct loader based on type.
443
+ */
444
+ #retrieveBackground() {
445
+ if (this.#destroyed) return;
402
446
 
403
447
  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);
448
+ const singleSrc = Array.isArray(this.#options.src) ? this.#options.src[0] : this.#options.src;
449
+ this.#loadBackground(singleSrc);
406
450
  } else if (this.#options.type === 'slider') {
407
451
  this.#startSlider();
408
- resolve();
409
452
  }
410
- }, this.#options.transitionDuration);
411
- });
412
- }
413
-
414
- /**
415
- * Update options on the fly without destroying the instance.
416
- * @param {Object} newOptions - The new options to merge.
417
- */
418
- reloadOptions(newOptions) {
419
- if (this.#destroyed) return;
420
-
421
- const normalized = ChameleonBackgrounds.#normalizeOptions(newOptions);
422
- this.#options = { ...this.#options, ...normalized };
423
-
424
- // Remove old styles
425
- if (this.#styleElement?.parentNode) {
426
- this.#styleElement.parentNode.removeChild(this.#styleElement);
427
- this.#styleElement = null;
428
- }
453
+ }
429
454
 
430
- // Inject updated styles
431
- this.#injectStyles();
432
- }
455
+ /**
456
+ * Preload an image and apply it as the element's background.
457
+ * Handles both string URLs and responsive image objects ({ url, srcset, sizes }).
458
+ * @param {string|ChameleonImageConfig} srcConfig - Image configuration or URL.
459
+ * @param {boolean} [isFirst=true] - Whether this is the first image (skips overlay reset).
460
+ * @param {boolean} [isCrossfade=false] - If true, handles true crossfading without solid color drop.
461
+ * @returns {Promise<void>}
462
+ */
463
+ #loadBackground(srcConfig, isFirst = true, isCrossfade = false) {
464
+ if (this.#destroyed) return Promise.resolve();
465
+
466
+ return new Promise((resolve) => {
467
+ const img = new Image();
468
+
469
+ let urlStr = srcConfig;
470
+ if (typeof srcConfig === 'object' && srcConfig.url) {
471
+ urlStr = srcConfig.url;
472
+ if (srcConfig.srcset) img.srcset = srcConfig.srcset;
473
+ if (srcConfig.sizes) img.sizes = srcConfig.sizes;
474
+ }
433
475
 
434
- // ---------------------------------------------------------------------------
435
- // Slider
436
- // ---------------------------------------------------------------------------
476
+ if (isFirst && 'fetchPriority' in img && this.#options.fetchPriority !== 'auto') {
477
+ img.fetchPriority = this.#options.fetchPriority;
478
+ }
437
479
 
438
- /**
439
- * Start the background slideshow.
440
- */
441
- #startSlider() {
442
- if (this.#destroyed) return;
480
+ // Append to DOM to ensure viewport-based `sizes` calculate correctly in all browsers
481
+ img.style.position = 'absolute';
482
+ img.style.visibility = 'hidden';
483
+ img.style.width = '0';
484
+ img.style.height = '0';
485
+ this.#element.appendChild(img);
486
+
487
+ img.onload = () => {
488
+ img.remove();
489
+ if (this.#destroyed) return resolve();
490
+
491
+ // `img.currentSrc` retrieves the actual srcset chosen file, otherwise fallback to `urlStr`
492
+ const finalUrl = img.currentSrc || urlStr;
493
+
494
+ if (isCrossfade && !isFirst) {
495
+ // --- Crossfade Transition Mode ---
496
+ const crossfadeDiv = document.createElement('div');
497
+ crossfadeDiv.classList.add('cbg-crossfade-el', `cbg-crossfade-${this.#uid}`);
498
+ crossfadeDiv.style.backgroundImage = `url("${finalUrl}")`;
499
+
500
+ // Insert right behind the loader (or directly at start of host)
501
+ this.#element.insertBefore(crossfadeDiv, this.#element.firstChild);
502
+
503
+ // Trigger layout so the transition works
504
+ void crossfadeDiv.offsetWidth;
505
+
506
+ crossfadeDiv.style.opacity = '1';
507
+
508
+ setTimeout(() => {
509
+ if (this.#destroyed) return resolve();
510
+ this.#element.style.backgroundImage = `url("${finalUrl}")`;
511
+ this.#element.style.backgroundSize = 'cover';
512
+ this.#element.style.backgroundRepeat = 'no-repeat';
513
+ crossfadeDiv.remove();
514
+ resolve();
515
+ }, this.#options.transitionDuration);
516
+
517
+ } else {
518
+ // --- Solid Transition Mode or First Load ---
519
+ this.#element.style.backgroundImage = `url("${finalUrl}")`;
520
+ this.#element.style.backgroundSize = 'cover';
521
+ this.#element.style.backgroundRepeat = 'no-repeat';
522
+
523
+ const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
524
+ if (loader) {
525
+ loader.style.opacity = String(this.#options.minOverlay);
526
+ }
527
+ resolve();
528
+ }
529
+ };
530
+
531
+ img.onerror = () => {
532
+ img.remove();
533
+ console.warn(`[ChameleonBackgrounds] Failed to load image:`, srcConfig);
534
+ resolve(); // resolve anyway to keep logic flowing
535
+ };
536
+
537
+ img.src = urlStr;
538
+ });
539
+ }
443
540
 
444
- const sources = this.#options.src;
445
- if (!Array.isArray(sources) || sources.length === 0) {
446
- console.warn('[ChameleonBackgrounds] Slider mode requires an array of image URLs in `src`.');
447
- return;
448
- }
541
+ /**
542
+ /**
543
+ * Reload background with a fade-out swap fade-in cycle.
544
+ * @param {string} [src] - Optional new image URL. If omitted, reloads using current options.
545
+ * @returns {Promise<void>}
546
+ */
547
+ reloadBackground(src) {
548
+ if (this.#destroyed) return Promise.resolve();
549
+
550
+ if (src !== undefined) {
551
+ if (this.#options.type === 'slider' && typeof src === 'string') {
552
+ // If the user passes a comma-separated string, split it.
553
+ this.#options.src = src.includes(',') ? src.split(',').map(s => s.trim()) : [src];
554
+ } else {
555
+ this.#options.src = src;
556
+ }
557
+ }
449
558
 
450
- // Load the first slide immediately
451
- let index = 0;
452
- this.#loadBackground(sources[index]).then(() => {
453
- if (this.#destroyed) return;
559
+ // Stop any running slider
560
+ if (this.#sliderIntervalId !== null) {
561
+ clearInterval(this.#sliderIntervalId);
562
+ this.#sliderIntervalId = null;
563
+ }
454
564
 
455
- index = 1;
456
- if (sources.length === 1) return; // only one slide, nothing to rotate
565
+ // For solid mode, we fade the loader up. For crossfade, we don't.
566
+ const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
567
+ if (loader && this.#options.transitionMode === 'solid') {
568
+ loader.style.opacity = '1';
569
+ }
457
570
 
458
- const interval = this.#options.sliderDuration + this.#options.transitionDuration * 2;
571
+ return new Promise((resolve) => {
572
+ const waitTime = this.#options.transitionMode === 'solid' ? this.#options.transitionDuration : 0;
573
+ setTimeout(() => {
574
+ if (this.#destroyed) return resolve();
575
+
576
+ if (this.#options.type === 'single') {
577
+ const singleSrc = Array.isArray(this.#options.src) ? this.#options.src[0] : this.#options.src;
578
+ this.#loadBackground(singleSrc, false, this.#options.transitionMode === 'crossfade').then(resolve);
579
+ } else if (this.#options.type === 'slider') {
580
+ this.#startSlider();
581
+ resolve();
582
+ }
583
+ }, waitTime);
584
+ });
585
+ }
459
586
 
460
- this.#sliderIntervalId = setInterval(() => {
461
- if (this.#destroyed) {
462
- clearInterval(this.#sliderIntervalId);
587
+ /**
588
+ * Update options on the fly without destroying the instance.
589
+ * @param {Object} newOptions - The new options to merge.
590
+ */
591
+ reloadOptions(newOptions) {
592
+ if (this.#destroyed) return;
593
+
594
+ const normalized = ChameleonBackgrounds.#normalizeOptions(newOptions);
595
+ this.#options = { ...this.#options, ...normalized };
596
+
597
+ this.#updateCSSVariables();
598
+ // Restart slider if needed
599
+ if (this.#options.type === 'slider' && !this.#isPaused && !this.#sliderIntervalId) {
600
+ this.#startSlider();
601
+ }
602
+ }
603
+
604
+ // ---------------------------------------------------------------------------
605
+ // Slider
606
+ // ---------------------------------------------------------------------------
607
+
608
+ /**
609
+ * Start the background slideshow.
610
+ */
611
+ #startSlider() {
612
+ if (this.#destroyed) return;
613
+
614
+ const sources = this.#options.src;
615
+ if (!Array.isArray(sources) || sources.length === 0) {
616
+ console.warn('[ChameleonBackgrounds] Slider mode requires an array of image URLs in `src`.');
463
617
  return;
464
618
  }
465
619
 
466
- this.#cycleSliderSlide(sources[index]);
467
- index++;
620
+ // Load the first slide immediately
621
+ this.#currentSlideIndex = 0;
622
+ this.#loadBackground(sources[this.#currentSlideIndex]).then(() => {
623
+ if (this.#destroyed) return;
468
624
 
469
- if (index >= sources.length) {
470
- if (this.#options.sliderLoop) {
471
- index = 0;
472
- } else {
625
+ this.#currentSlideIndex = 1;
626
+ if (sources.length === 1) return; // only one slide, nothing to rotate
627
+
628
+ this.#startSliderLoop();
629
+ });
630
+ }
631
+
632
+ #startSliderLoop() {
633
+ const sources = this.#options.src;
634
+
635
+ // Parse as integers to prevent string concatenation bugs if users passed strings
636
+ const sDuration = parseInt(this.#options.sliderDuration, 10) || 8000;
637
+ const tDuration = parseInt(this.#options.transitionDuration, 10) || 2000;
638
+ const interval = sDuration + tDuration * (this.#options.transitionMode === 'solid' ? 2 : 1);
639
+
640
+ this.#sliderIntervalId = setInterval(() => {
641
+ if (this.#destroyed || this.#isPaused) {
473
642
  clearInterval(this.#sliderIntervalId);
474
643
  this.#sliderIntervalId = null;
644
+ return;
645
+ }
646
+
647
+ this.#cycleSliderSlide(sources[this.#currentSlideIndex]);
648
+ this.#currentSlideIndex++;
649
+
650
+ if (this.#currentSlideIndex >= sources.length) {
651
+ if (this.#options.sliderLoop) {
652
+ this.#currentSlideIndex = 0;
653
+ } else {
654
+ clearInterval(this.#sliderIntervalId);
655
+ this.#sliderIntervalId = null;
656
+ }
475
657
  }
658
+ }, interval);
659
+ }
660
+
661
+ /**
662
+ * Cycle to a specific slide without resetting the slider instance.
663
+ * @param {string|ChameleonImageConfig} src - The image URL/config to load.
664
+ */
665
+ #cycleSliderSlide(src) {
666
+ if (this.#destroyed) return;
667
+
668
+ if (this.#options.transitionMode === 'solid') {
669
+ const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
670
+ if (loader) {
671
+ loader.style.opacity = '1';
672
+ }
673
+
674
+ setTimeout(() => {
675
+ if (this.#destroyed) return;
676
+ this.#loadBackground(src, false, false);
677
+ }, this.#options.transitionDuration);
678
+ } else {
679
+ // Crossfade mode triggers immediately
680
+ this.#loadBackground(src, false, true);
476
681
  }
477
- }, interval);
478
- });
479
- }
480
-
481
- /**
482
- * Cycle to a specific slide without resetting the slider instance.
483
- * @param {string} src - The image URL to load.
484
- */
485
- #cycleSliderSlide(src) {
486
- if (this.#destroyed) return;
487
-
488
- const loader = this.#element.querySelector(`.cbg-loader-${this.#uid}`);
489
- if (loader) {
490
- loader.style.opacity = '1';
491
- }
682
+ }
492
683
 
493
- setTimeout(() => {
494
- if (this.#destroyed) return;
495
- this.#loadBackground(src, false);
496
- }, this.#options.transitionDuration);
497
- }
498
-
499
- // ---------------------------------------------------------------------------
500
- // Static Helpers
501
- // ---------------------------------------------------------------------------
502
-
503
- /**
504
- * Convert legacy snake_case option keys to camelCase.
505
- * @param {Object} opts
506
- * @returns {Object}
507
- */
508
- static #normalizeOptions(opts) {
509
- const result = {};
510
- for (const [key, value] of Object.entries(opts)) {
511
- const alias = ChameleonBackgrounds.#ALIASES[key];
512
- result[alias ?? key] = value;
513
- }
514
- return result;
515
- }
516
-
517
- /**
518
- * Resolve a CSS selector string or HTMLElement to an HTMLElement.
519
- * @param {string|HTMLElement} el
520
- * @returns {HTMLElement}
521
- */
522
- static #resolveElement(el) {
523
- if (el instanceof HTMLElement) return el;
524
- if (typeof el === 'string') {
525
- if (el === 'body') return document.body;
526
- const found = document.querySelector(el);
527
- if (!found) throw new Error(`[ChameleonBackgrounds] Element not found: ${el}`);
528
- return /** @type {HTMLElement} */ (found);
529
- }
530
- throw new TypeError(`[ChameleonBackgrounds] Invalid element: ${el}`);
531
- }
532
-
533
- /**
534
- * Generate a short unique ID for scoping styles.
535
- * @returns {string}
536
- */
537
- static #generateUID() {
538
- // Prefer crypto.randomUUID where available, otherwise fall back
539
- if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
540
- return crypto.randomUUID().slice(0, 8);
684
+ // ---------------------------------------------------------------------------
685
+ // Static Helpers
686
+ // ---------------------------------------------------------------------------
687
+
688
+ /**
689
+ * Convert legacy snake_case option keys to camelCase.
690
+ * @param {Object} opts
691
+ * @returns {Object}
692
+ */
693
+ static #normalizeOptions(opts) {
694
+ const result = {};
695
+ for (const [key, value] of Object.entries(opts)) {
696
+ const alias = ChameleonBackgrounds.#ALIASES[key];
697
+ result[alias ?? key] = value;
698
+ }
699
+ return result;
700
+ }
701
+
702
+ /**
703
+ * Resolve a CSS selector string or HTMLElement to an HTMLElement.
704
+ * @param {string|HTMLElement} el
705
+ * @returns {HTMLElement}
706
+ */
707
+ static #resolveElement(el) {
708
+ if (el instanceof HTMLElement) return el;
709
+ if (typeof el === 'string') {
710
+ if (el === 'body') return document.body;
711
+ const found = document.querySelector(el);
712
+ if (!found) throw new Error(`[ChameleonBackgrounds] Element not found: ${el}`);
713
+ return /** @type {HTMLElement} */ (found);
714
+ }
715
+ throw new TypeError(`[ChameleonBackgrounds] Invalid element: ${el}`);
716
+ }
717
+
718
+ /**
719
+ * Generate a short unique ID for scoping styles.
720
+ * @returns {string}
721
+ */
722
+ static #generateUID() {
723
+ // Prefer crypto.randomUUID where available, otherwise fall back
724
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
725
+ return crypto.randomUUID().slice(0, 8);
726
+ }
727
+ // Fallback: random alphanumeric string
728
+ return Array.from({ length: 8 }, () =>
729
+ 'abcdefghijklmnopqrstuvwxyz0123456789'.charAt(Math.floor(Math.random() * 36))
730
+ ).join('');
731
+ }
541
732
  }
542
- // Fallback: random alphanumeric string
543
- return Array.from({ length: 8 }, () =>
544
- 'abcdefghijklmnopqrstuvwxyz0123456789'.charAt(Math.floor(Math.random() * 36))
545
- ).join('');
546
- }
547
- }
548
733
 
549
734
  /* harmony default export */ const __WEBPACK_DEFAULT_EXPORT__ = (ChameleonBackgrounds);
550
735
 
551
736
 
552
- __webpack_exports__ = __webpack_exports__["default"];
737
+ __webpack_exports__ = __webpack_exports__["default"];
553
738
  /******/ return __webpack_exports__;
554
- /******/ })()
555
- ;
739
+ /******/
740
+ })()
741
+ ;
556
742
  });
557
743
  //# sourceMappingURL=chameleon-backgrounds.js.map