@4site/engrid-scripts 0.28.2 → 0.28.4

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
+ }
@@ -42,7 +42,12 @@ export declare class RememberMe {
42
42
  });
43
43
  private updateFieldData;
44
44
  private insertClearRememberMeLink;
45
+ private escapeHtml;
46
+ private buildClearLabelMarkup;
47
+ private getUsernameFromFieldData;
48
+ private getClearLabelPlainText;
45
49
  private getElementByFirstSelector;
50
+ private placeOnRightSide;
46
51
  private insertRememberMeOptin;
47
52
  private useRemote;
48
53
  private createIframe;
@@ -165,16 +165,26 @@ export class RememberMe {
165
165
  }
166
166
  }
167
167
  insertClearRememberMeLink() {
168
+ var _a;
168
169
  let clearRememberMeField = document.getElementById("clear-autofill-data");
170
+ const { html, hasInnerLink } = this.buildClearLabelMarkup();
169
171
  if (!clearRememberMeField) {
170
- clearRememberMeField = document.createElement("a");
172
+ clearRememberMeField = document.createElement(hasInnerLink ? "span" : "a");
171
173
  clearRememberMeField.setAttribute("id", "clear-autofill-data");
172
- clearRememberMeField.classList.add("label-tooltip");
173
- clearRememberMeField.setAttribute("style", "cursor: pointer;");
174
- clearRememberMeField.innerHTML = this.fieldClearLabel;
174
+ if (hasInnerLink) {
175
+ clearRememberMeField.classList.add("clear-autofill-data-wrapper");
176
+ }
177
+ else {
178
+ clearRememberMeField.classList.add("label-tooltip");
179
+ clearRememberMeField.setAttribute("style", "cursor: pointer;");
180
+ }
181
+ clearRememberMeField.innerHTML = html;
175
182
  const targetField = this.getElementByFirstSelector(this.fieldClearSelectorTarget);
176
183
  if (targetField) {
177
- if (this.fieldClearSelectorTargetLocation === "after") {
184
+ if (this.fieldClearSelectorTargetLocation === "rightSide") {
185
+ this.placeOnRightSide(targetField, clearRememberMeField);
186
+ }
187
+ else if (this.fieldClearSelectorTargetLocation === "after") {
178
188
  targetField.appendChild(clearRememberMeField);
179
189
  }
180
190
  else {
@@ -182,7 +192,10 @@ export class RememberMe {
182
192
  }
183
193
  }
184
194
  }
185
- clearRememberMeField.addEventListener("click", (e) => {
195
+ const clickTarget = hasInnerLink
196
+ ? (_a = clearRememberMeField.querySelector("#clear-autofill-data-link")) !== null && _a !== void 0 ? _a : clearRememberMeField
197
+ : clearRememberMeField;
198
+ const onClear = (e) => {
186
199
  e.preventDefault();
187
200
  this.clearFields(["supporter.country" /*, 'supporter.emailAddress'*/]);
188
201
  if (this.useRemote()) {
@@ -201,10 +214,54 @@ export class RememberMe {
201
214
  this.rememberMeOptIn = false;
202
215
  this._events.dispatchClear();
203
216
  window.dispatchEvent(new CustomEvent("RememberMe_Cleared"));
204
- });
217
+ };
218
+ clickTarget.addEventListener("click", onClear);
205
219
  this._events.dispatchLoad(true);
206
220
  window.dispatchEvent(new CustomEvent("RememberMe_Loaded", { detail: { withData: true } }));
207
221
  }
222
+ escapeHtml(value) {
223
+ const map = {
224
+ "&": "&amp;",
225
+ "<": "&lt;",
226
+ ">": "&gt;",
227
+ '"': "&quot;",
228
+ "'": "&#39;",
229
+ };
230
+ return value.replace(/[&<>"']/g, (c) => map[c]);
231
+ }
232
+ buildClearLabelMarkup() {
233
+ var _a;
234
+ const username = this.getUsernameFromFieldData();
235
+ const label = username
236
+ ? this.fieldClearLabel.replace(/\$username/g, () => this.escapeHtml(username))
237
+ : this.fieldClearLabel.replace(/[^\S\r\n]?\$username/g, "");
238
+ // Only the first non-empty {...} segment becomes the clickable clear link.
239
+ // Any additional {...} segments remain as literal text (braces included),
240
+ // and empty {} braces are left as-is. When no non-empty braces are present,
241
+ // the entire element is clickable (legacy behaviour).
242
+ const match = label.match(/\{([^}]+)\}/);
243
+ if (!match) {
244
+ return { html: label, hasInnerLink: false };
245
+ }
246
+ const before = label.slice(0, match.index);
247
+ const linkText = match[1];
248
+ const after = label.slice(((_a = match.index) !== null && _a !== void 0 ? _a : 0) + match[0].length);
249
+ const html = before +
250
+ `<a id="clear-autofill-data-link" class="label-tooltip" style="cursor: pointer;">${linkText}</a>` +
251
+ after;
252
+ return { html, hasInnerLink: true };
253
+ }
254
+ getUsernameFromFieldData() {
255
+ const value = this.fieldData["supporter.firstName"];
256
+ return value ? value.trim() : "";
257
+ }
258
+ getClearLabelPlainText() {
259
+ const username = this.getUsernameFromFieldData();
260
+ const label = username
261
+ ? this.fieldClearLabel.replace(/\$username/g, () => username)
262
+ : this.fieldClearLabel.replace(/[^\S\r\n]?\$username/g, "");
263
+ return label.replace(/\{([^}]*)\}/g, "$1");
264
+ }
208
265
  getElementByFirstSelector(selectorsString) {
209
266
  // iterate through the selectors until we find one that exists
210
267
  let targetField = null;
@@ -217,13 +274,76 @@ export class RememberMe {
217
274
  }
218
275
  return targetField;
219
276
  }
277
+ placeOnRightSide(targetField, elementToPlace) {
278
+ const wrapperClass = "rememberme-right-side-wrapper";
279
+ let wrapper;
280
+ if (targetField.parentElement &&
281
+ targetField.parentElement.classList.contains(wrapperClass)) {
282
+ wrapper = targetField.parentElement;
283
+ }
284
+ else {
285
+ // Read the target's computed styles BEFORE moving it into the flex
286
+ // wrapper. Once inside a flex container, margin collapsing no longer
287
+ // applies and the reported values can change. We transfer the original
288
+ // margin-top to the wrapper itself so the block retains its vertical
289
+ // spacing in the document flow, and zero out the target's own margin-top
290
+ // so it doesn't double-apply inside the flex row.
291
+ const targetStyle = window.getComputedStyle(targetField);
292
+ const targetMarginTop = targetStyle.marginTop;
293
+ const targetMarginBottom = targetStyle.marginBottom;
294
+ wrapper = document.createElement("div");
295
+ wrapper.classList.add(wrapperClass);
296
+ wrapper.style.display = "flex";
297
+ wrapper.style.alignItems = "center";
298
+ wrapper.style.gap = "8px";
299
+ wrapper.style.flexWrap = "wrap";
300
+ // Transfer vertical margins from the target to the wrapper so the
301
+ // surrounding layout is unchanged after the DOM move.
302
+ wrapper.style.marginTop = targetMarginTop;
303
+ wrapper.style.marginBottom = targetMarginBottom;
304
+ if (targetField.parentNode) {
305
+ targetField.parentNode.insertBefore(wrapper, targetField);
306
+ }
307
+ // Zero out the target's own margin-top/-bottom now that the wrapper
308
+ // owns them, so they don't double-apply inside the flex row.
309
+ targetField.style.marginTop = "0px";
310
+ targetField.style.marginBottom = "0px";
311
+ wrapper.appendChild(targetField);
312
+ }
313
+ // Read padding-left AFTER the wrapper exists but before any alignment
314
+ // logic, so it reflects the current computed value.
315
+ const targetPaddingLeft = window.getComputedStyle(targetField).paddingLeft;
316
+ wrapper.appendChild(elementToPlace);
317
+ const applyAlignment = () => {
318
+ const wrapped = elementToPlace.offsetTop > targetField.offsetTop;
319
+ if (wrapped) {
320
+ // When wrapped below, left-align with the target's content area.
321
+ elementToPlace.style.marginTop = "0px";
322
+ elementToPlace.style.marginLeft = targetPaddingLeft;
323
+ }
324
+ else {
325
+ // When side-by-side, both items are already vertically centred by the
326
+ // flex container — no extra margin-top needed on elementToPlace.
327
+ elementToPlace.style.marginTop = "0px";
328
+ elementToPlace.style.marginLeft = "0px";
329
+ }
330
+ };
331
+ // Defer the first alignment check to the next animation frame so the
332
+ // browser has had a chance to perform layout. Without this, offsetTop on
333
+ // both elements is still 0 (or stale) at call time.
334
+ requestAnimationFrame(applyAlignment);
335
+ if (typeof window.ResizeObserver === "function") {
336
+ const observer = new window.ResizeObserver(() => applyAlignment());
337
+ observer.observe(wrapper);
338
+ }
339
+ }
220
340
  insertRememberMeOptin() {
221
341
  let rememberMeOptInField = document.getElementById("remember-me-opt-in");
222
342
  if (!rememberMeOptInField) {
223
343
  const rememberMeLabel = this.rememberMeLabel;
224
344
  const rememberMeInfo = ENGrid.t("rememberMe.tooltip", {
225
345
  label: rememberMeLabel,
226
- clearLabel: this.fieldClearLabel,
346
+ clearLabel: this.getClearLabelPlainText(),
227
347
  });
228
348
  const rememberMeOptInFieldChecked = this.rememberMeOptIn ? "checked" : "";
229
349
  const rememberMeOptInField = document.createElement("div");
@@ -247,9 +367,14 @@ export class RememberMe {
247
367
  `;
248
368
  const targetField = this.getElementByFirstSelector(this.fieldOptInSelectorTarget);
249
369
  if (targetField && targetField.parentNode) {
250
- targetField.parentNode.insertBefore(rememberMeOptInField, this.fieldOptInSelectorTargetLocation == "before"
251
- ? targetField
252
- : targetField.nextSibling);
370
+ if (this.fieldOptInSelectorTargetLocation === "rightSide") {
371
+ this.placeOnRightSide(targetField, rememberMeOptInField);
372
+ }
373
+ else {
374
+ targetField.parentNode.insertBefore(rememberMeOptInField, this.fieldOptInSelectorTargetLocation == "before"
375
+ ? targetField
376
+ : targetField.nextSibling);
377
+ }
253
378
  const rememberMeCheckbox = document.getElementById("remember-me-checkbox");
254
379
  if (rememberMeCheckbox) {
255
380
  rememberMeCheckbox.addEventListener("change", () => {
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const AppVersion = "0.28.2";
1
+ export declare const AppVersion = "0.28.4";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const AppVersion = "0.28.2";
1
+ export const AppVersion = "0.28.4";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4site/engrid-scripts",
3
- "version": "0.28.2",
3
+ "version": "0.28.4",
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": "16aba8b38f4a978d1f965272f2df6bf6b98dd9f8",
28
+ "gitHead": "fb6e68d0a52142cba344b693972b38da7dcea795",
29
29
  "devDependencies": {
30
30
  "nodemon": "^2.0.20",
31
31
  "typescript": "^4.9.5"