@4site/engrid-scripts 0.28.1 → 0.28.3

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.
package/dist/index.d.ts CHANGED
@@ -34,6 +34,7 @@ export * from "./skip-link";
34
34
  export * from "./src-defer";
35
35
  export * from "./set-recurr-freq";
36
36
  export * from "./page-background";
37
+ export * from "./page-background-rotation";
37
38
  export * from "./neverbounce";
38
39
  export * from "./freshaddress";
39
40
  export * from "./progress-bar";
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ export * from "./skip-link";
34
34
  export * from "./src-defer";
35
35
  export * from "./set-recurr-freq";
36
36
  export * from "./page-background";
37
+ export * from "./page-background-rotation";
37
38
  export * from "./neverbounce";
38
39
  export * from "./freshaddress";
39
40
  export * from "./progress-bar";
@@ -0,0 +1,84 @@
1
+ type BackgroundRotationSlideOrder = "random" | "sequential" | "true-random";
2
+ interface EngridPageBackgroundRotationOptions {
3
+ enabled: boolean;
4
+ interval: number;
5
+ initialDelay: number;
6
+ transitionDuration: number;
7
+ transitionClass: string;
8
+ eachImageSelector: string;
9
+ backgroundImageSelector: string;
10
+ slideOrder: BackgroundRotationSlideOrder;
11
+ randomStart: boolean;
12
+ reducedMotion: boolean;
13
+ rotateOnMobile: boolean;
14
+ mobileBreakpoint: string;
15
+ controls: boolean;
16
+ }
17
+ export declare class PageBackgroundRotation {
18
+ private logger;
19
+ private defaultOptions;
20
+ private options;
21
+ private container;
22
+ private items;
23
+ private layers;
24
+ private imageUrls;
25
+ private imagesWarmed;
26
+ private warmingScheduled;
27
+ private firstImagePreloaded;
28
+ private currentIndex;
29
+ private randomBag;
30
+ private history;
31
+ private isPaused;
32
+ private pausedForReducedMotion;
33
+ private interactionPauses;
34
+ private previousButton;
35
+ private pauseButton;
36
+ private liveRegion;
37
+ private rotationTimer;
38
+ private initialDelayTimer;
39
+ private initialDelayElapsed;
40
+ private transitionTimer;
41
+ private mobileMediaQuery;
42
+ private reducedMotionMediaQuery;
43
+ constructor(options?: Partial<EngridPageBackgroundRotationOptions>);
44
+ private shouldRun;
45
+ private prepareItems;
46
+ private applyLayerImage;
47
+ private preloadFirstImage;
48
+ private scheduleImageWarming;
49
+ private warmRemainingImages;
50
+ private getItemLayer;
51
+ private getItemImage;
52
+ private getItemImageUrl;
53
+ private isStaticMode;
54
+ private reducedMotionPreferred;
55
+ private updateMode;
56
+ private startRotationTimer;
57
+ private stopRotationTimer;
58
+ private stopRotation;
59
+ private finishTransition;
60
+ private showStaticImage;
61
+ private setActiveItem;
62
+ private setFlowLayer;
63
+ private rotateToNextImage;
64
+ private goToImage;
65
+ private goToNextImage;
66
+ private goToPreviousImage;
67
+ private togglePause;
68
+ private createControls;
69
+ private pauseForInteraction;
70
+ private resumeFromInteraction;
71
+ private canRotate;
72
+ private updatePreviousButtonState;
73
+ private announceImage;
74
+ private createControlButton;
75
+ private updatePauseButton;
76
+ private pauseIcon;
77
+ private playIcon;
78
+ private getNextIndex;
79
+ private getNextFromRandomBag;
80
+ private getRandomIndex;
81
+ private getItemTheme;
82
+ private getItemAttribution;
83
+ }
84
+ export {};
@@ -0,0 +1,557 @@
1
+ // PageBackgroundRotation handles the rotation of background images within a page-backgroundImage block
2
+ // By default, this feature is not enabled, and must be enabled by importing and initializing it in the client theme's onLoad block
3
+ // Within the page-backgroundImage block, if there is a parent div with a class of 'background-rotation', then the background image will rotate every 5 seconds
4
+ // The image rotates on a cross-fade transition, and the next image is randomly selected from the list of child elements with a class of 'background-image-item' within the 'background-rotation' div
5
+ // The random selection of the next image is done in a way that ensures that the same image is not displayed twice in a row, and that all images are displayed before any image is repeated
6
+ // On mobile, the background image will not rotate, and a random image in the list will be displayed as a static background image
7
+ // The background image will also not rotate if the user has set a preference for reduced motion in their system settings, unless controls are present which will allow the user to "start" the process manually.
8
+ // Figattributes/figcaptions, if included on the image, will also need to be updated to reflect the new image being displayed
9
+ // Each image item can include a data-theme (default 'dark') attribute, which allow for client themes to style particular elements based on the background color.
10
+ // Options block:
11
+ /**
12
+ * Set via the default options, overridden by the options passed to the constructor, and overridden by a window-level variable called 'EngridPageBackgroundRotationOptions' if it exists. The options are as follows:
13
+ * enabled: Whether the background rotation is enabled (default: true)
14
+ * interval: The interval in milliseconds between image rotations (default: 5000)
15
+ * initialDelay: The delay in milliseconds before the first rotation, giving the first image time to load (default: 10000)
16
+ * transitionDuration: The duration of the cross-fade transition in milliseconds (default: 500)
17
+ * transitionClass: The CSS class to apply to the background image container during the transition (default: 'background-rotation-transition')
18
+ * eachImageSelector: The CSS selector for each individual background image (default: '.page-background-image-item')
19
+ * backgroundImageSelector: The CSS selector for the background image container (default: '.page-background-rotation')
20
+ * slideOrder: The order in which the images are displayed (default: 'random' [random-bag], other options: 'sequential', 'true-random')
21
+ * randomStart: Whether to start the rotation at a random image (default: true)
22
+ * reducedMotion: Whether to respect the user's preference for reduced motion (default: true)
23
+ * rotateOnMobile: Whether to rotate the background image on mobile devices (default: false)
24
+ * mobileBreakpoint: Where to consider the layout as being "mobile" (default: ‘(max-width: 499px)’)
25
+ * controls: Whether to add back, pause, and forward buttons for the rotation (default: false)
26
+ */
27
+ import { ENGrid } from "./engrid";
28
+ import { EngridLogger } from "./logger";
29
+ export class PageBackgroundRotation {
30
+ constructor(options = {}) {
31
+ var _a;
32
+ this.logger = new EngridLogger("PageBackgroundRotation", "white", "rebeccapurple", "🌄");
33
+ this.defaultOptions = {
34
+ enabled: true,
35
+ interval: 5000,
36
+ initialDelay: 10000,
37
+ transitionDuration: 500,
38
+ transitionClass: "background-rotation-transition",
39
+ eachImageSelector: ".page-background-image-item",
40
+ backgroundImageSelector: ".page-background-rotation",
41
+ slideOrder: "random",
42
+ randomStart: true,
43
+ reducedMotion: true,
44
+ rotateOnMobile: false,
45
+ mobileBreakpoint: "(max-width: 499px)",
46
+ controls: false,
47
+ };
48
+ this.container = null;
49
+ this.items = [];
50
+ this.layers = [];
51
+ this.imageUrls = [];
52
+ this.imagesWarmed = false;
53
+ this.warmingScheduled = false;
54
+ this.firstImagePreloaded = false;
55
+ this.currentIndex = -1;
56
+ this.randomBag = [];
57
+ this.history = [];
58
+ this.isPaused = false;
59
+ this.pausedForReducedMotion = false;
60
+ this.interactionPauses = new Set();
61
+ this.previousButton = null;
62
+ this.pauseButton = null;
63
+ this.liveRegion = null;
64
+ this.rotationTimer = null;
65
+ this.initialDelayTimer = null;
66
+ this.initialDelayElapsed = false;
67
+ this.transitionTimer = null;
68
+ this.reducedMotionMediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
69
+ this.options = Object.assign(Object.assign(Object.assign({}, this.defaultOptions), options), ((_a = window.EngridPageBackgroundRotationOptions) !== null && _a !== void 0 ? _a : {}));
70
+ this.mobileMediaQuery = window.matchMedia(this.options.mobileBreakpoint);
71
+ if (!this.shouldRun())
72
+ return;
73
+ this.container = document.querySelector(`.page-backgroundImage ${this.options.backgroundImageSelector}, .body-banner ${this.options.backgroundImageSelector}`);
74
+ this.items = Array.from(this.container.querySelectorAll(this.options.eachImageSelector));
75
+ this.container.style.setProperty("--background-rotation-transition-duration", `${this.options.transitionDuration}ms`);
76
+ document.body.style.setProperty("--background-rotation-transition-duration", `${this.options.transitionDuration}ms`);
77
+ this.prepareItems();
78
+ if (this.items.length === 1) {
79
+ this.showStaticImage();
80
+ return;
81
+ }
82
+ this.updateMode();
83
+ if (!this.options.rotateOnMobile) {
84
+ this.mobileMediaQuery.addEventListener("change", () => this.updateMode());
85
+ }
86
+ if (this.options.reducedMotion) {
87
+ this.reducedMotionMediaQuery.addEventListener("change", () => this.updateMode());
88
+ }
89
+ if (this.options.controls) {
90
+ this.createControls();
91
+ }
92
+ }
93
+ shouldRun() {
94
+ if (!this.options.enabled) {
95
+ this.logger.log("Background rotation is disabled");
96
+ return false;
97
+ }
98
+ const container = document.querySelector(`.page-backgroundImage ${this.options.backgroundImageSelector}, .body-banner ${this.options.backgroundImageSelector}`);
99
+ if (!container)
100
+ return false;
101
+ if (!container.querySelector(this.options.eachImageSelector)) {
102
+ this.logger.log("No background image items found to rotate");
103
+ return false;
104
+ }
105
+ return true;
106
+ }
107
+ prepareItems() {
108
+ this.items.forEach((item, index) => {
109
+ const layer = this.getItemLayer(item);
110
+ const imageUrl = this.getItemImageUrl(item);
111
+ if (!imageUrl) {
112
+ this.logger.log("Background image item has no image source", item);
113
+ }
114
+ this.imageUrls[index] = imageUrl;
115
+ layer.classList.add("background-rotation-layer");
116
+ layer.setAttribute("aria-hidden", "true");
117
+ this.layers[index] = layer;
118
+ });
119
+ }
120
+ // The inline background-image is what makes a layer fetch its image, so it is
121
+ // applied when the layer is first shown rather than for every layer up front
122
+ applyLayerImage(index) {
123
+ const layer = this.layers[index];
124
+ const imageUrl = this.imageUrls[index];
125
+ if (!layer || !imageUrl || layer.style.backgroundImage)
126
+ return;
127
+ layer.style.backgroundImage = `url('${imageUrl}')`;
128
+ }
129
+ // Fetches the first image ahead of other page assets so the background
130
+ // paints as early as possible; only ever applied to the first image shown
131
+ preloadFirstImage(index) {
132
+ if (this.firstImagePreloaded)
133
+ return;
134
+ this.firstImagePreloaded = true;
135
+ const imageUrl = this.imageUrls[index];
136
+ if (!imageUrl)
137
+ return;
138
+ const preload = document.createElement("link");
139
+ preload.rel = "preload";
140
+ preload.setAttribute("as", "image");
141
+ preload.href = imageUrl;
142
+ preload.setAttribute("fetchpriority", "high");
143
+ document.head.appendChild(preload);
144
+ }
145
+ // The remaining layers are applied once the page has settled, so a full set of
146
+ // viewport-sized images isn't competing with the form's own assets during load.
147
+ // Warming waits until the first image has finished loading AND a minimum 4s
148
+ // delay has passed, so it never competes with the first image's bandwidth.
149
+ // The layers are still applied ahead of the first rotation (10s initialDelay),
150
+ // so cross-fades don't start against an image that hasn't been fetched yet.
151
+ scheduleImageWarming(index) {
152
+ if (this.warmingScheduled || this.imagesWarmed)
153
+ return;
154
+ this.warmingScheduled = true;
155
+ const imageUrl = this.imageUrls[index];
156
+ const firstImageLoaded = new Promise((resolve) => {
157
+ if (!imageUrl) {
158
+ resolve();
159
+ return;
160
+ }
161
+ const probe = new Image();
162
+ probe.onload = () => resolve();
163
+ probe.onerror = () => resolve();
164
+ probe.src = imageUrl;
165
+ });
166
+ const minimumDelay = new Promise((resolve) => window.setTimeout(resolve, 4000));
167
+ Promise.all([firstImageLoaded, minimumDelay]).then(() => this.warmRemainingImages());
168
+ }
169
+ warmRemainingImages() {
170
+ if (this.imagesWarmed)
171
+ return;
172
+ this.imagesWarmed = true;
173
+ const warm = () => this.items.forEach((_, index) => this.applyLayerImage(index));
174
+ const requestIdle = window.requestIdleCallback;
175
+ if (requestIdle) {
176
+ requestIdle.call(window, warm, { timeout: 3000 });
177
+ }
178
+ else {
179
+ window.setTimeout(warm, 1000);
180
+ }
181
+ }
182
+ // The item is typically the <img> tag itself. If MediaAttribution has wrapped
183
+ // it in a <figure class="media-with-attribution">, the figure becomes the fade
184
+ // layer so its figattribution cross-fades in sync with the image
185
+ getItemLayer(item) {
186
+ var _a;
187
+ if (item instanceof HTMLImageElement &&
188
+ ((_a = item.parentElement) === null || _a === void 0 ? void 0 : _a.matches("figure.media-with-attribution"))) {
189
+ return item.parentElement;
190
+ }
191
+ return item;
192
+ }
193
+ getItemImage(item) {
194
+ if (item instanceof HTMLImageElement)
195
+ return item;
196
+ return item.querySelector("img");
197
+ }
198
+ getItemImageUrl(item) {
199
+ const img = this.getItemImage(item);
200
+ if (!img)
201
+ return null;
202
+ return img.getAttribute("data-src") || img.getAttribute("src");
203
+ }
204
+ isStaticMode() {
205
+ // With controls enabled a reduced-motion user can still advance the
206
+ // images on their own, so only treat reduced motion as static mode
207
+ // when there are no controls
208
+ if (this.reducedMotionPreferred() && !this.options.controls) {
209
+ return true;
210
+ }
211
+ if (!this.options.rotateOnMobile && this.mobileMediaQuery.matches) {
212
+ return true;
213
+ }
214
+ return false;
215
+ }
216
+ reducedMotionPreferred() {
217
+ return this.options.reducedMotion && this.reducedMotionMediaQuery.matches;
218
+ }
219
+ // Starts or stops the rotation based on the current viewport and motion
220
+ // preferences, called on page load and whenever they change
221
+ updateMode() {
222
+ if (this.isStaticMode()) {
223
+ this.stopRotation();
224
+ if (this.currentIndex === -1) {
225
+ this.showStaticImage();
226
+ }
227
+ else {
228
+ ENGrid.setBodyData("background-rotation", "static");
229
+ }
230
+ this.logger.log("Static background image mode");
231
+ return;
232
+ }
233
+ // A reduced-motion preference (with controls enabled) starts paused so
234
+ // the user can advance the images on their own; if the preference is
235
+ // removed again, only auto-resume when the pause wasn't user-initiated
236
+ if (this.reducedMotionPreferred()) {
237
+ this.stopRotationTimer();
238
+ this.isPaused = true;
239
+ this.pausedForReducedMotion = true;
240
+ this.updatePauseButton();
241
+ this.logger.log("Auto-rotation paused for reduced motion preference");
242
+ }
243
+ else if (this.pausedForReducedMotion) {
244
+ this.pausedForReducedMotion = false;
245
+ this.isPaused = false;
246
+ this.updatePauseButton();
247
+ }
248
+ if (this.rotationTimer !== null || this.initialDelayTimer !== null)
249
+ return;
250
+ const startIndex = this.currentIndex !== -1
251
+ ? this.currentIndex
252
+ : this.options.randomStart
253
+ ? this.getRandomIndex()
254
+ : 0;
255
+ // Preload before the layer's background-image is applied, so the
256
+ // high-priority fetch is the one that hits the network first
257
+ this.preloadFirstImage(startIndex);
258
+ this.setActiveItem(startIndex);
259
+ this.scheduleImageWarming(startIndex);
260
+ ENGrid.setBodyData("background-rotation", "active");
261
+ if (this.canRotate())
262
+ this.startRotationTimer();
263
+ this.logger.log(`Rotating ${this.items.length} background images every ${this.options.interval}ms`);
264
+ }
265
+ // The first rotation waits for initialDelay to give the first image (and the
266
+ // warming of the rest) time to load; later rotations use the normal interval.
267
+ // If paused before the first rotation, the full initial delay re-arms on resume
268
+ startRotationTimer() {
269
+ this.stopRotationTimer();
270
+ if (!this.initialDelayElapsed) {
271
+ this.initialDelayTimer = window.setTimeout(() => {
272
+ this.initialDelayTimer = null;
273
+ this.initialDelayElapsed = true;
274
+ this.rotateToNextImage();
275
+ this.rotationTimer = window.setInterval(() => this.rotateToNextImage(), this.options.interval);
276
+ }, this.options.initialDelay);
277
+ return;
278
+ }
279
+ this.rotationTimer = window.setInterval(() => this.rotateToNextImage(), this.options.interval);
280
+ }
281
+ stopRotationTimer() {
282
+ if (this.initialDelayTimer !== null) {
283
+ window.clearTimeout(this.initialDelayTimer);
284
+ this.initialDelayTimer = null;
285
+ }
286
+ if (this.rotationTimer !== null) {
287
+ window.clearInterval(this.rotationTimer);
288
+ this.rotationTimer = null;
289
+ }
290
+ }
291
+ stopRotation() {
292
+ this.stopRotationTimer();
293
+ this.finishTransition();
294
+ }
295
+ // Settles a cross-fade: only the current image keeps the class that makes it
296
+ // visible, and the in-flow layer moves to it. Runs when a transition ends, and
297
+ // again if the next transition starts first, so an interrupted fade can never
298
+ // leave a stale layer stacked on top of the current one
299
+ finishTransition() {
300
+ var _a;
301
+ if (this.transitionTimer !== null) {
302
+ window.clearTimeout(this.transitionTimer);
303
+ this.transitionTimer = null;
304
+ }
305
+ this.layers.forEach((layer, index) => {
306
+ layer.classList.remove("background-rotation-outgoing");
307
+ if (index === this.currentIndex)
308
+ return;
309
+ layer.classList.remove("active");
310
+ layer.setAttribute("aria-hidden", "true");
311
+ });
312
+ if (this.currentIndex !== -1) {
313
+ this.setFlowLayer(this.layers[this.currentIndex]);
314
+ }
315
+ (_a = this.container) === null || _a === void 0 ? void 0 : _a.classList.remove(this.options.transitionClass);
316
+ }
317
+ showStaticImage() {
318
+ const index = this.options.randomStart ? this.getRandomIndex() : 0;
319
+ this.preloadFirstImage(index);
320
+ this.setActiveItem(index);
321
+ ENGrid.setBodyData("background-rotation", "static");
322
+ }
323
+ setActiveItem(index, moveFlow = true) {
324
+ var _a;
325
+ const layer = this.layers[index];
326
+ if (!layer)
327
+ return;
328
+ this.applyLayerImage(index);
329
+ layer.classList.add("active");
330
+ layer.removeAttribute("aria-hidden");
331
+ this.currentIndex = index;
332
+ if (moveFlow)
333
+ this.setFlowLayer(layer);
334
+ const imageUrl = this.imageUrls[index];
335
+ if (imageUrl) {
336
+ document.body.style.setProperty("--background-rotation-image", `url('${imageUrl}')`);
337
+ }
338
+ setTimeout(() => {
339
+ ENGrid.setBodyData("background-rotation-theme", this.getItemTheme(this.items[index]));
340
+ }, 300);
341
+ this.logger.log("Active background image", index + 1, "of", this.items.length, (_a = this.getItemAttribution(this.items[index])) !== null && _a !== void 0 ? _a : "");
342
+ }
343
+ // Marks the single layer that stays in-flow to give the container its height
344
+ // at the <=499px breakpoint. Kept on the outgoing layer during a cross-fade
345
+ // so two in-flow layers never stack, and moved to the incoming layer once
346
+ // the transition ends (see finishTransition)
347
+ setFlowLayer(layer) {
348
+ this.layers.forEach((item) => item.classList.remove("background-rotation-flow"));
349
+ layer.classList.add("background-rotation-flow");
350
+ }
351
+ rotateToNextImage() {
352
+ this.goToImage(this.getNextIndex());
353
+ }
354
+ goToImage(nextIndex, addToHistory = true) {
355
+ if (nextIndex === this.currentIndex)
356
+ return;
357
+ if (addToHistory && this.currentIndex !== -1) {
358
+ this.history.push(this.currentIndex);
359
+ if (this.history.length > this.items.length * 2)
360
+ this.history.shift();
361
+ }
362
+ this.updatePreviousButtonState();
363
+ // Settle a fade that is still running before starting the next one
364
+ this.finishTransition();
365
+ const outgoingLayer = this.layers[this.currentIndex];
366
+ // The outgoing layer keeps .active (staying fully opaque) while the
367
+ // incoming layer fades in on top of it, so the composite is opaque at
368
+ // every point of the cross-fade — nothing behind the layers ever washes
369
+ // through. It loses .active in finishTransition, once it is fully covered.
370
+ outgoingLayer === null || outgoingLayer === void 0 ? void 0 : outgoingLayer.classList.add("background-rotation-outgoing");
371
+ outgoingLayer === null || outgoingLayer === void 0 ? void 0 : outgoingLayer.setAttribute("aria-hidden", "true");
372
+ this.container.classList.add(this.options.transitionClass);
373
+ this.setActiveItem(nextIndex, false);
374
+ this.transitionTimer = window.setTimeout(() => this.finishTransition(), this.options.transitionDuration);
375
+ }
376
+ goToNextImage() {
377
+ this.goToImage(this.getNextIndex());
378
+ this.announceImage();
379
+ if (this.canRotate())
380
+ this.startRotationTimer();
381
+ }
382
+ goToPreviousImage() {
383
+ const previousIndex = this.history.pop();
384
+ if (previousIndex === undefined) {
385
+ this.logger.log("No previous background image in the history");
386
+ return;
387
+ }
388
+ this.goToImage(previousIndex, false);
389
+ this.announceImage();
390
+ if (this.canRotate())
391
+ this.startRotationTimer();
392
+ }
393
+ togglePause() {
394
+ this.isPaused = !this.isPaused;
395
+ // Once the user touches the pause control the pause is theirs, so a later
396
+ // reduced-motion change no longer auto-resumes the rotation
397
+ this.pausedForReducedMotion = false;
398
+ if (this.isPaused) {
399
+ this.stopRotationTimer();
400
+ this.logger.log("Background rotation paused");
401
+ }
402
+ else {
403
+ if (this.canRotate())
404
+ this.startRotationTimer();
405
+ this.logger.log("Background rotation resumed");
406
+ }
407
+ this.updatePauseButton();
408
+ }
409
+ createControls() {
410
+ const controls = document.createElement("div");
411
+ controls.className = "background-rotation-controls";
412
+ controls.setAttribute("role", "group");
413
+ controls.setAttribute("aria-label", "Background image rotation controls");
414
+ // Pause the auto-rotation while the user is hovering or tabbing through the
415
+ // controls, so nobody has to chase a moving target
416
+ controls.addEventListener("mouseenter", () => this.pauseForInteraction("hover"));
417
+ controls.addEventListener("mouseleave", () => this.resumeFromInteraction("hover"));
418
+ controls.addEventListener("focusin", () => this.pauseForInteraction("focus"));
419
+ controls.addEventListener("focusout", (event) => {
420
+ if (!controls.contains(event.relatedTarget)) {
421
+ this.resumeFromInteraction("focus");
422
+ }
423
+ });
424
+ this.previousButton = this.createControlButton("background-rotation-prev", "Previous background image", '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18l-6-6 6-6"/></svg>');
425
+ this.previousButton.addEventListener("click", () => this.goToPreviousImage());
426
+ this.pauseButton = this.createControlButton("background-rotation-pause", "Pause background rotation", this.pauseIcon());
427
+ this.pauseButton.setAttribute("aria-pressed", "false");
428
+ this.pauseButton.addEventListener("click", () => this.togglePause());
429
+ const nextButton = this.createControlButton("background-rotation-next", "Next background image", '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18l6-6-6-6"/></svg>');
430
+ nextButton.addEventListener("click", () => this.goToNextImage());
431
+ this.liveRegion = document.createElement("div");
432
+ this.liveRegion.className = "engrid__sr-only";
433
+ this.liveRegion.setAttribute("aria-live", "polite");
434
+ this.liveRegion.setAttribute("aria-atomic", "true");
435
+ controls.append(this.previousButton, this.pauseButton, nextButton, this.liveRegion);
436
+ document.body.appendChild(controls);
437
+ this.updatePreviousButtonState();
438
+ // Reflect a pause that happened before the controls existed (e.g. the
439
+ // reduced-motion auto-pause in updateMode)
440
+ this.updatePauseButton();
441
+ }
442
+ // Auto-rotation pauses while the user interacts with the controls, separately
443
+ // from a user-initiated pause, and resumes when the interaction ends
444
+ pauseForInteraction(kind) {
445
+ this.interactionPauses.add(kind);
446
+ if (!this.isPaused)
447
+ this.stopRotationTimer();
448
+ }
449
+ resumeFromInteraction(kind) {
450
+ this.interactionPauses.delete(kind);
451
+ if (this.canRotate() &&
452
+ this.rotationTimer === null &&
453
+ this.initialDelayTimer === null &&
454
+ !this.isStaticMode()) {
455
+ this.startRotationTimer();
456
+ }
457
+ }
458
+ // Auto-rotation only runs when nothing is holding it: no user-initiated pause,
459
+ // and no hover or focus on the controls. Using a control implies one of those
460
+ // interactions, so the timer can't be restarted out from under the user
461
+ canRotate() {
462
+ return !this.isPaused && this.interactionPauses.size === 0;
463
+ }
464
+ updatePreviousButtonState() {
465
+ if (this.previousButton) {
466
+ this.previousButton.disabled = this.history.length === 0;
467
+ }
468
+ }
469
+ // Announce user-initiated image changes to screen readers. Auto-rotation is
470
+ // intentionally not announced to avoid interrupting every few seconds.
471
+ announceImage() {
472
+ var _a;
473
+ if (!this.liveRegion)
474
+ return;
475
+ const item = this.items[this.currentIndex];
476
+ const description = ((_a = this.getItemImage(item)) === null || _a === void 0 ? void 0 : _a.getAttribute("alt")) ||
477
+ this.getItemAttribution(item);
478
+ this.liveRegion.textContent = `Background image ${this.currentIndex + 1} of ${this.items.length}${description ? `: ${description}` : ""}`;
479
+ }
480
+ createControlButton(className, ariaLabel, icon) {
481
+ const button = document.createElement("button");
482
+ button.type = "button";
483
+ button.className = className;
484
+ button.setAttribute("aria-label", ariaLabel);
485
+ button.innerHTML = icon;
486
+ return button;
487
+ }
488
+ updatePauseButton() {
489
+ if (!this.pauseButton)
490
+ return;
491
+ this.pauseButton.innerHTML = this.isPaused
492
+ ? this.playIcon()
493
+ : this.pauseIcon();
494
+ this.pauseButton.setAttribute("aria-label", this.isPaused ? "Play background rotation" : "Pause background rotation");
495
+ this.pauseButton.setAttribute("aria-pressed", String(this.isPaused));
496
+ }
497
+ pauseIcon() {
498
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>';
499
+ }
500
+ playIcon() {
501
+ return '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>';
502
+ }
503
+ getNextIndex() {
504
+ switch (this.options.slideOrder) {
505
+ case "sequential":
506
+ return (this.currentIndex + 1) % this.items.length;
507
+ case "true-random":
508
+ return this.getRandomIndex(this.currentIndex);
509
+ case "random":
510
+ default:
511
+ return this.getNextFromRandomBag();
512
+ }
513
+ }
514
+ // "Random bag" selection: every image is displayed once before any image is
515
+ // repeated, and the current image is never repeated back-to-back
516
+ getNextFromRandomBag() {
517
+ if (this.randomBag.length === 0) {
518
+ this.randomBag = this.items
519
+ .map((_, index) => index)
520
+ .filter((index) => index !== this.currentIndex);
521
+ for (let i = this.randomBag.length - 1; i > 0; i--) {
522
+ const j = Math.floor(Math.random() * (i + 1));
523
+ [this.randomBag[i], this.randomBag[j]] = [
524
+ this.randomBag[j],
525
+ this.randomBag[i],
526
+ ];
527
+ }
528
+ }
529
+ return this.randomBag.pop();
530
+ }
531
+ getRandomIndex(excludeIndex = -1) {
532
+ if (this.items.length <= 1)
533
+ return 0;
534
+ let index = excludeIndex;
535
+ while (index === excludeIndex) {
536
+ index = Math.floor(Math.random() * this.items.length);
537
+ }
538
+ return index;
539
+ }
540
+ // Each item can set a data-theme="light" or data-theme="dark" (default)
541
+ // attribute to control the .body-title h1 text color shown over its image
542
+ getItemTheme(item) {
543
+ var _a;
544
+ return ((_a = item.getAttribute("data-theme")) === null || _a === void 0 ? void 0 : _a.toLowerCase()) === "light"
545
+ ? "light"
546
+ : "dark";
547
+ }
548
+ // Each item carries its own figattribution/figcaption (added by the MediaAttribution
549
+ // component or authored directly), so it cross-fades in sync with its image
550
+ getItemAttribution(item) {
551
+ var _a;
552
+ const attribution = item.matches("img")
553
+ ? (_a = item.parentElement) === null || _a === void 0 ? void 0 : _a.querySelector("figattribution, figcaption")
554
+ : item.querySelector("figattribution, figcaption");
555
+ return attribution ? attribution.textContent : null;
556
+ }
557
+ }
@@ -2,7 +2,6 @@ import { EnForm, RememberMeEvents } from "./events";
2
2
  export declare class RememberMe {
3
3
  _form: EnForm;
4
4
  _events: RememberMeEvents;
5
- private _frequency;
6
5
  private remoteUrl;
7
6
  private cookieName;
8
7
  private fieldNames;
@@ -15,7 +14,6 @@ export declare class RememberMe {
15
14
  private fieldDonationAmountRadioName;
16
15
  private fieldDonationAmountOtherName;
17
16
  private fieldDonationRecurrPayRadioName;
18
- private fieldDonationRecurrFreqRadioName;
19
17
  private fieldDonationAmountOtherCheckboxID;
20
18
  private fieldOptInSelectorTarget;
21
19
  private fieldOptInSelectorTargetLocation;
@@ -31,7 +29,6 @@ export declare class RememberMe {
31
29
  fieldDonationAmountRadioName?: string;
32
30
  fieldDonationAmountOtherName?: string;
33
31
  fieldDonationRecurrPayRadioName?: string;
34
- fieldDonationRecurrFreqRadioName?: string;
35
32
  fieldDonationAmountOtherCheckboxID?: string;
36
33
  fieldOptInSelectorTarget?: string;
37
34
  fieldOptInSelectorTargetLocation?: string;
@@ -104,25 +101,5 @@ export declare class RememberMe {
104
101
  * @param overwrite - A boolean indicating whether to overwrite the existing value of the fields. Defaults to false.
105
102
  */
106
103
  private writeFields;
107
- /**
108
- * SwapAmounts replaces the donationAmt radio DOM nodes ~1 second after page
109
- * load (triggered by DonationFrequency.load() setTimeout). When that happens
110
- * the selection the RememberMe just wrote gets wiped out.
111
- *
112
- * This method subscribes to the first onFrequencyChange event and, after a
113
- * short delay to let SwapAmounts finish its DOM update, re-applies only the
114
- * donation amount. It unsubscribes immediately so it only fires once.
115
- *
116
- * To avoid overwriting a manual donor interaction, the handler checks
117
- * whether the current amount selection is empty/wiped (as SwapAmounts does)
118
- * OR still matches what writeFields originally set. If the donor already
119
- * picked a different amount, we skip re-application.
120
- */
121
- private reapplyDonationAmtAfterSwap;
122
- /**
123
- * Returns the currently selected donation amount value, or null if nothing
124
- * is selected. Checks both predefined radio buttons and the "Other" text input.
125
- */
126
- private getCurrentSelectedAmount;
127
104
  private isJson;
128
105
  }
@@ -8,7 +8,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
8
8
  });
9
9
  };
10
10
  import * as cookie from "./cookie";
11
- import { EnForm, RememberMeEvents, DonationFrequency } from "./events";
11
+ import { EnForm, RememberMeEvents } from "./events";
12
12
  import { ENGrid } from "./engrid";
13
13
  const tippy = require("tippy.js").default;
14
14
  // localStorage key used to cache the per-device AES-GCM encryption key.
@@ -18,7 +18,6 @@ export class RememberMe {
18
18
  constructor(options) {
19
19
  this._form = EnForm.getInstance();
20
20
  this._events = RememberMeEvents.getInstance();
21
- this._frequency = DonationFrequency.getInstance();
22
21
  this.iframe = null;
23
22
  this.encryptData = options.encryptData ? options.encryptData : false;
24
23
  this.hide = options.hide ? options.hide : false;
@@ -41,10 +40,6 @@ export class RememberMe {
41
40
  options.fieldDonationRecurrPayRadioName
42
41
  ? options.fieldDonationRecurrPayRadioName
43
42
  : "transaction.recurrpay";
44
- this.fieldDonationRecurrFreqRadioName =
45
- options.fieldDonationRecurrFreqRadioName
46
- ? options.fieldDonationRecurrFreqRadioName
47
- : "transaction.recurrfreq";
48
43
  this.fieldDonationAmountOtherCheckboxID =
49
44
  options.fieldDonationAmountOtherCheckboxID
50
45
  ? options.fieldDonationAmountOtherCheckboxID
@@ -106,7 +101,6 @@ export class RememberMe {
106
101
  }
107
102
  else {
108
103
  this.insertClearRememberMeLink();
109
- this.reapplyDonationAmtAfterSwap();
110
104
  }
111
105
  }
112
106
  });
@@ -126,9 +120,6 @@ export class RememberMe {
126
120
  this.insertClearRememberMeLink();
127
121
  }
128
122
  this.writeFields();
129
- if (hasFieldData) {
130
- this.reapplyDonationAmtAfterSwap();
131
- }
132
123
  this._form.onSubmit.subscribe(() => {
133
124
  if (this.rememberMeOptIn) {
134
125
  this.readFields();
@@ -147,9 +138,6 @@ export class RememberMe {
147
138
  this.insertClearRememberMeLink();
148
139
  }
149
140
  this.writeFields();
150
- if (hasFieldData) {
151
- this.reapplyDonationAmtAfterSwap();
152
- }
153
141
  this._form.onSubmit.subscribe(() => {
154
142
  if (this.rememberMeOptIn) {
155
143
  this.readFields();
@@ -483,17 +471,6 @@ export class RememberMe {
483
471
  if (type === "radio" || type === "checkbox") {
484
472
  field = document.querySelector(fieldSelector + ":checked");
485
473
  }
486
- // When the donation amount radio is set to "Other", save the actual
487
- // custom value from the .other text input instead of "Other".
488
- if (this.fieldNames[i] === this.fieldDonationAmountRadioName &&
489
- field &&
490
- field.value.toLowerCase() === "other") {
491
- const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
492
- if (otherField && otherField.value) {
493
- this.fieldData[this.fieldNames[i]] = encodeURIComponent(otherField.value);
494
- continue;
495
- }
496
- }
497
474
  this.fieldData[this.fieldNames[i]] = encodeURIComponent(field.value);
498
475
  }
499
476
  else if (field.tagName === "SELECT") {
@@ -577,36 +554,17 @@ export class RememberMe {
577
554
  field.click();
578
555
  }
579
556
  }
580
- else if (this.fieldNames[i] === this.fieldDonationRecurrFreqRadioName) {
581
- // recurrfreq is a radio group — find the specific radio with the saved value and click it
582
- const savedValue = this.fieldData[this.fieldNames[i]];
583
- if (savedValue) {
584
- const freqRadio = document.querySelector(fieldSelector + "[value='" + CSS.escape(savedValue) + "']");
585
- if (freqRadio) {
586
- freqRadio.click();
587
- }
588
- }
589
- }
590
557
  else if (this.fieldDonationAmountRadioName === this.fieldNames[i]) {
591
- const savedAmt = this.fieldData[this.fieldNames[i]];
592
- const escapedAmt = CSS.escape(savedAmt);
593
- field = document.querySelector(fieldSelector + "[value='" + escapedAmt + "']");
558
+ field = document.querySelector(fieldSelector +
559
+ "[value='" +
560
+ this.fieldData[this.fieldNames[i]] +
561
+ "']");
594
562
  if (field) {
595
- // Saved value matches a predefined radio option — just click it
596
563
  field.click();
597
564
  }
598
565
  else {
599
- // No matching radio: the value is a custom amount.
600
- // Click the "Other" radio first so the text input becomes active,
601
- // then fill in the numeric value.
602
- const otherRadio = document.querySelector(fieldSelector + "[value='Other'], " +
603
- fieldSelector + "[value='other'], " +
604
- fieldSelector + "[value='OTHER']");
605
- if (otherRadio) {
606
- otherRadio.click();
607
- }
608
- const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
609
- this.setFieldValue(otherField, savedAmt, true);
566
+ field = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
567
+ this.setFieldValue(field, this.fieldData[this.fieldNames[i]], true);
610
568
  }
611
569
  }
612
570
  else {
@@ -619,74 +577,6 @@ export class RememberMe {
619
577
  }
620
578
  }
621
579
  }
622
- /**
623
- * SwapAmounts replaces the donationAmt radio DOM nodes ~1 second after page
624
- * load (triggered by DonationFrequency.load() setTimeout). When that happens
625
- * the selection the RememberMe just wrote gets wiped out.
626
- *
627
- * This method subscribes to the first onFrequencyChange event and, after a
628
- * short delay to let SwapAmounts finish its DOM update, re-applies only the
629
- * donation amount. It unsubscribes immediately so it only fires once.
630
- *
631
- * To avoid overwriting a manual donor interaction, the handler checks
632
- * whether the current amount selection is empty/wiped (as SwapAmounts does)
633
- * OR still matches what writeFields originally set. If the donor already
634
- * picked a different amount, we skip re-application.
635
- */
636
- reapplyDonationAmtAfterSwap() {
637
- const savedAmt = this.fieldData[this.fieldDonationAmountRadioName];
638
- if (!savedAmt)
639
- return;
640
- // Capture the amount that writeFields just set so we can detect manual changes
641
- const amountAtRegistration = this.getCurrentSelectedAmount();
642
- const handler = () => {
643
- // SwapAmounts calls _amount.load() after swapList — give it a tick to settle
644
- window.setTimeout(() => {
645
- const currentAmt = this.getCurrentSelectedAmount();
646
- // Only re-apply if the selection is now empty (DOM was swapped out)
647
- // or still matches what we originally wrote. If the donor manually
648
- // selected a different amount, respect their choice.
649
- const selectionWiped = currentAmt === null || currentAmt === "";
650
- const selectionUnchanged = currentAmt === amountAtRegistration;
651
- if (!selectionWiped && !selectionUnchanged) {
652
- return;
653
- }
654
- const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']";
655
- const escapedAmt = CSS.escape(savedAmt);
656
- let radio = document.querySelector(fieldSelector + "[value='" + escapedAmt + "']");
657
- if (radio) {
658
- radio.click();
659
- }
660
- else {
661
- // Custom amount: click "Other" radio then fill the text input
662
- const otherRadio = document.querySelector(fieldSelector + "[value='Other'], " +
663
- fieldSelector + "[value='other'], " +
664
- fieldSelector + "[value='OTHER']");
665
- if (otherRadio)
666
- otherRadio.click();
667
- const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
668
- this.setFieldValue(otherField, savedAmt, true);
669
- }
670
- }, 200);
671
- };
672
- // Subscribe once: fires on the first frequency change then auto-unsubscribes
673
- this._frequency.onFrequencyChange.one(handler);
674
- }
675
- /**
676
- * Returns the currently selected donation amount value, or null if nothing
677
- * is selected. Checks both predefined radio buttons and the "Other" text input.
678
- */
679
- getCurrentSelectedAmount() {
680
- const fieldSelector = "[name='" + this.fieldDonationAmountRadioName + "']";
681
- const checkedRadio = document.querySelector(fieldSelector + ":checked");
682
- if (!checkedRadio)
683
- return null;
684
- if (checkedRadio.value.toLowerCase() === "other") {
685
- const otherField = document.querySelector("input[name='" + this.fieldDonationAmountOtherName + "']");
686
- return otherField ? otherField.value : null;
687
- }
688
- return checkedRadio.value;
689
- }
690
580
  isJson(str) {
691
581
  try {
692
582
  JSON.parse(str);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const AppVersion = "0.28.1";
1
+ export declare const AppVersion = "0.28.3";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const AppVersion = "0.28.1";
1
+ export const AppVersion = "0.28.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4site/engrid-scripts",
3
- "version": "0.28.1",
3
+ "version": "0.28.3",
4
4
  "description": "Scripts for the ENGRID Project",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -25,7 +25,7 @@
25
25
  "strongly-typed-events": "^2.0.9"
26
26
  },
27
27
  "homepage": "https://github.com/4site-interactive-studios/engrid#readme",
28
- "gitHead": "32b7076b094f29226c5f6b90d80c5bbe87062abe",
28
+ "gitHead": "c1aaaea0b49ed3d1f27eaaa62814d7ba02b950f7",
29
29
  "devDependencies": {
30
30
  "nodemon": "^2.0.20",
31
31
  "typescript": "^4.9.5"