@codeforamerica/marcomms-design-system 1.13.0 → 1.14.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,177 +1,579 @@
1
- // TODO: Add mouse click and drag support to scroll carousel
2
1
  import { LitElement, html, css } from "lit";
3
2
  import { commonStyles } from "../shared/common";
3
+ import "./icon";
4
4
 
5
5
  class Carousel extends LitElement {
6
+ // Configuration constants
7
+ static SWIPE_THRESHOLD = 50;
8
+ static SHADOW_SPACE = 32;
9
+ static PROGRESS_UPDATE_INTERVAL = 100;
10
+ static AUTOPLAY_DURATION = 10000;
11
+ static DESKTOP_BREAKPOINT = 768;
12
+ static ITEMS_PER_VIEW_DESKTOP = 3;
13
+ static ITEMS_PER_VIEW_MOBILE = 1;
14
+
6
15
  static properties = {
7
- atScrollStart: { type: Boolean },
8
- atScrollEnd: { type: Boolean },
16
+ currentPage: { type: Number, state: true },
17
+ totalSlides: { type: Number, state: true },
18
+ progressPercentage: { type: Number, state: true },
19
+ isPaused: { type: Boolean, state: true },
20
+ prefersReducedMotion: { type: Boolean, state: true },
21
+ itemsPerView: { type: Number },
22
+ autoPlay: { type: Boolean },
23
+ autoPlayDuration: { type: Number },
9
24
  };
25
+
10
26
  static styles = [
11
27
  commonStyles,
12
28
  css`
13
29
  :host {
14
- --slide-width-mobile: 75vw;
15
- --slide-width-desktop: var(--column-span-3);
30
+ --carousel-fade-duration: 300ms;
31
+ --carousel-control-size: var(--spacing-layout-2);
32
+ --carousel-dot-size: var(--spacing-component-3);
33
+ --carousel-dot-gap: var(--spacing-component-3);
34
+ --carousel-dot-color: var(--black-20);
35
+ --carousel-dot-active-color: var(--purple-60);
36
+ --carousel-gap: var(--spacing-layout-1);
37
+ }
16
38
 
17
- display: block;
18
- scrollbar-color: transparent transparent;
19
- scrollbar-width: 0px;
20
- scroll-behavior: smooth;
39
+ .carousel-container {
40
+ display: flex;
41
+ flex-direction: column;
42
+ gap: var(--spacing-component-3);
43
+ }
44
+
45
+ .carousel-slides {
46
+ width: 100%;
21
47
  overflow: hidden;
22
- -webkit-overflow-scrolling: touch;
48
+ transition: height 400ms ease-in-out;
49
+ height: auto;
50
+ }
51
+
52
+ .carousel-track {
53
+ display: flex;
54
+ gap: var(--carousel-gap);
55
+ }
56
+
57
+ ::slotted(*) {
58
+ flex: 0 0 calc((100% - (var(--items-per-view, 3) - 1) * var(--carousel-gap)) / var(--items-per-view, 3));
59
+ display: none;
60
+ opacity: 0;
61
+ transition: opacity var(--carousel-fade-duration) ease-in-out;
62
+ }
63
+
64
+ ::slotted([data-slide-active]) {
65
+ display: block;
66
+ opacity: 1;
23
67
  }
24
68
 
25
- :host(:focus) {
26
- outline: none !important;
69
+ @media (prefers-reduced-motion: reduce) {
70
+ .carousel-slides {
71
+ transition: none;
72
+ }
73
+
74
+ ::slotted(*) {
75
+ transition: none;
76
+ }
27
77
  }
28
78
 
29
- ::-webkit-scrollbar {
30
- background: transparent;
31
- width: 0px;
79
+ .carousel-controls {
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: center;
83
+ gap: var(--spacing-component-3);
32
84
  }
33
85
 
34
- .carousel {
86
+ .carousel-button {
87
+ background-color: transparent;
88
+ border: 0;
89
+ border-radius: 50%;
90
+ color: var(--text-color);
91
+ cursor: pointer;
35
92
  display: flex;
36
- gap: var(--spacing-layout-1);
37
- overflow-x: scroll;
38
- scroll-snap-type: x mandatory;
39
- padding-block-start: var(--spacing-layout-half);
40
- padding-block-end: var(--spacing-layout-1);
93
+ align-items: center;
94
+ justify-content: center;
95
+ font-size: 1.5rem;
96
+ height: var(--carousel-control-size);
97
+ width: var(--carousel-control-size);
98
+ transition: all 0.2s ease-in-out;
99
+ flex-shrink: 0;
41
100
  }
42
101
 
43
- .controls {
44
- margin-block-start: calc(-1 * var(--spacing-layout-half));
45
- margin-block-end: var(--spacing-layout-1);
46
- text-align: center;
102
+ .carousel-button:hover {
103
+ background-color: var(--purple-20);
104
+ border-color: var(--purple-60);
105
+ color: var(--purple-80);
47
106
  }
48
107
 
49
- .controls > * + * {
50
- margin-inline-start: var(--spacing-layout-half);
108
+ .carousel-button:focus-visible {
109
+ outline: var(--focus-outline);
110
+ outline-offset: 2px;
111
+ }
112
+
113
+ .carousel-button cfa-icon {
114
+ --size: var(--font-size-base);
115
+ color: var(--purple-80);
116
+ }
117
+
118
+ .carousel-pagination {
119
+ display: flex;
120
+ align-items: center;
121
+ gap: var(--carousel-dot-gap);
122
+ flex-wrap: wrap;
123
+ justify-content: center;
51
124
  }
52
125
 
53
- .controls button {
54
- background: transparent;
126
+ .carousel-dot {
127
+ background-color: var(--carousel-dot-color);
55
128
  border: none;
56
129
  border-radius: 50%;
57
- color: var(--purple-80);
58
130
  cursor: pointer;
59
- font-size: var(--font-size-large, 1.25rem);
60
- height: var(--spacing-layout-2);
61
- transition: background-color 0.2s ease-in-out;
62
- width: var(--spacing-layout-2);
131
+ height: var(--carousel-dot-size);
132
+ min-width: var(--carousel-dot-size);
133
+ padding: 0;
134
+ position: relative;
135
+ transition: all 0.3s ease-in-out;
63
136
  }
64
137
 
65
- .controls button:hover,
66
- .controls button:focus {
67
- background-color: var(--white, #fff);
138
+ .carousel-dot:hover {
139
+ background-color: var(--gray-60);
68
140
  }
69
141
 
70
- .controls button:focus {
142
+ .carousel-dot:focus-visible {
71
143
  outline: var(--focus-outline);
144
+ outline-offset: 3px;
72
145
  }
73
146
 
74
- .controls button.disabled {
75
- color: var(--black-20);
76
- cursor: default;
147
+ .carousel-dot[aria-current="true"] {
148
+ background-color: var(--carousel-dot-color);
149
+ border-radius: var(--carousel-dot-size);
150
+ min-width: calc(var(--carousel-dot-size) * 4);
151
+ overflow: hidden;
77
152
  }
78
153
 
79
- .controls button.disabled:hover,
80
- .controls button.disabled:focus {
81
- background: transparent;
82
- outline: none;
154
+ .carousel-dot-progress {
155
+ background-color: var(--carousel-dot-active-color);
156
+ height: 100%;
157
+ width: 0%;
158
+ transition: width 100ms linear;
83
159
  }
84
160
 
85
- ::slotted(*) {
86
- flex: 0 0 var(--slide-width-mobile);
87
- margin-block: var(--spacing-layout-1);
88
- scroll-snap-align: center;
161
+ @media (prefers-reduced-motion: reduce) {
162
+ .carousel-dot-progress {
163
+ transition: none;
164
+ }
89
165
  }
90
166
 
91
- @media (min-width: 1024px) {
92
- ::slotted(*) {
93
- flex: 0 0 var(--slide-width-desktop);
167
+ @media (max-width: 768px) {
168
+ /* Note: 768px matches Carousel.DESKTOP_BREAKPOINT - keep in sync */
169
+ .carousel-button {
170
+ font-size: 1.25rem;
171
+ height: calc(var(--carousel-control-size) * 0.85);
172
+ width: calc(var(--carousel-control-size) * 0.85);
94
173
  }
95
174
 
96
- .spacer {
97
- flex: 0 0
98
- calc((100vw - var(--column-span-12)) / 2 - var(--outer-margin));
175
+ .carousel-dot {
176
+ height: calc(var(--carousel-dot-size) * 0.8);
177
+ min-width: calc(var(--carousel-dot-size) * 0.8);
99
178
  }
100
179
  }
101
180
  `,
102
181
  ];
182
+
103
183
  constructor() {
104
184
  super();
105
- this.atScrollStart = true;
106
- this.atScrollEnd = false;
185
+ this.currentPage = 0;
186
+ this.totalSlides = 0;
187
+ this.progressPercentage = 0;
188
+ this.isPaused = true;
189
+ this.prefersReducedMotion = window.matchMedia(
190
+ "(prefers-reduced-motion: reduce)"
191
+ ).matches;
192
+ this.itemsPerView = Carousel.ITEMS_PER_VIEW_DESKTOP;
193
+ this.autoPlay = true;
194
+ // Clamp autoPlayDuration to positive value
195
+ this.autoPlayDuration = Math.max(100, Carousel.AUTOPLAY_DURATION);
196
+ this.autoPlayTimer = null;
197
+ this.autoPlayTimeout = null;
198
+ this.touchStartX = null;
199
+ this.touchEndX = null;
200
+ this._initialized = false;
201
+ this._resizeTimerId = null;
202
+ this._mouseEnterListener = null;
203
+ this._mouseLeaveListener = null;
107
204
  }
205
+
108
206
  connectedCallback() {
109
207
  super.connectedCallback();
110
208
 
111
- // Scroll slot element with focus into view
112
- this.addEventListener("focusin", (event) => {
113
- if (event.target.slot === "") {
114
- event.target.scrollIntoView({
115
- behavior: "smooth",
116
- block: "nearest",
117
- inline: "center",
118
- });
209
+ this.style.setProperty("--items-per-view", this.itemsPerView);
210
+
211
+ this.addEventListener("keydown", (e) => this._handleKeydown(e));
212
+ this.addEventListener("touchstart", (e) => this._handleTouchStart(e), false);
213
+ this.addEventListener("touchend", (e) => this._handleTouchEnd(e), false);
214
+
215
+ // Store debounced resize listener for proper cleanup
216
+ this._resizeListener = () => {
217
+ if (this._resizeTimerId) {
218
+ clearTimeout(this._resizeTimerId);
219
+ }
220
+ this._resizeTimerId = setTimeout(() => {
221
+ this.updateItemsPerView();
222
+ this.updateCarouselHeight();
223
+ }, 150);
224
+ };
225
+ window.addEventListener("resize", this._resizeListener);
226
+ }
227
+
228
+ disconnectedCallback() {
229
+ super.disconnectedCallback();
230
+ this.stopAutoPlay();
231
+ if (this._resizeListener) {
232
+ window.removeEventListener("resize", this._resizeListener);
233
+ }
234
+ if (this._resizeTimerId) {
235
+ clearTimeout(this._resizeTimerId);
236
+ }
237
+ // Clean up mouse listeners if they were attached
238
+ const slidesContainer = this.shadowRoot?.querySelector(".carousel-slides");
239
+ if (slidesContainer && this._mouseEnterListener && this._mouseLeaveListener) {
240
+ slidesContainer.removeEventListener("mouseenter", this._mouseEnterListener);
241
+ slidesContainer.removeEventListener("mouseleave", this._mouseLeaveListener);
242
+ }
243
+ }
244
+
245
+ updated(changedProperties) {
246
+ if (changedProperties.has("itemsPerView")) {
247
+ this.style.setProperty("--items-per-view", this.itemsPerView);
248
+ }
249
+ if (changedProperties.has("currentPage")) {
250
+ this.updateItemVisibility();
251
+ }
252
+ }
253
+
254
+ firstUpdated() {
255
+ const slot = this.shadowRoot.querySelector("slot");
256
+ const slidesContainer = this.shadowRoot.querySelector(".carousel-slides");
257
+
258
+ if (slot && !this._initialized) {
259
+ this._initialized = true;
260
+ const items = slot.assignedElements();
261
+ this.totalSlides = items.length;
262
+ this.updateItemsPerView();
263
+ this.updateItemVisibility();
264
+ this.startAutoPlay();
265
+
266
+ // Recalculate height after images load with timeout guard
267
+ this._waitForImages(items, 5000).then(() => {
268
+ this.updateCarouselHeight();
269
+ });
270
+ }
271
+
272
+ // Only pause when hovering over the carousel items, not the controls
273
+ // Store listeners for cleanup on disconnect
274
+ if (slidesContainer && !this._mouseEnterListener) {
275
+ this._mouseEnterListener = () => {
276
+ this.stopAutoPlay();
277
+ };
278
+ this._mouseLeaveListener = () => {
279
+ if (!this.isPaused) {
280
+ this._scheduleNextPage();
281
+ }
282
+ };
283
+ slidesContainer.addEventListener("mouseenter", this._mouseEnterListener);
284
+ slidesContainer.addEventListener("mouseleave", this._mouseLeaveListener);
285
+ }
286
+ }
287
+
288
+ _waitForImages(items, timeout = 3000) {
289
+ return Promise.race([
290
+ Promise.all(
291
+ Array.from(items).map(item =>
292
+ Promise.all(
293
+ Array.from(item.querySelectorAll('img')).map(img =>
294
+ img.complete
295
+ ? Promise.resolve()
296
+ : new Promise(resolve => {
297
+ img.addEventListener('load', resolve, { once: true });
298
+ img.addEventListener('error', resolve, { once: true });
299
+ })
300
+ )
301
+ )
302
+ )
303
+ ),
304
+ new Promise((_, reject) =>
305
+ setTimeout(() => reject(new Error('Image load timeout')), timeout)
306
+ )
307
+ ]).catch(() => {
308
+ // If timeout or error, continue anyway
309
+ return Promise.resolve();
310
+ });
311
+ }
312
+
313
+ get totalPages() {
314
+ return Math.ceil(this.totalSlides / this.itemsPerView);
315
+ }
316
+
317
+ updateItemsPerView() {
318
+ const isDesktop = window.innerWidth >= Carousel.DESKTOP_BREAKPOINT;
319
+ const newItemsPerView = isDesktop
320
+ ? Carousel.ITEMS_PER_VIEW_DESKTOP
321
+ : Carousel.ITEMS_PER_VIEW_MOBILE;
322
+
323
+ if (newItemsPerView !== this.itemsPerView) {
324
+ this.itemsPerView = newItemsPerView;
325
+ this.style.setProperty("--items-per-view", this.itemsPerView);
326
+ // Reset to first page if current page is out of bounds
327
+ if (this.currentPage >= this.totalPages) {
328
+ this.currentPage = 0;
329
+ }
330
+ this.requestUpdate();
331
+ }
332
+ }
333
+
334
+ updateItemVisibility() {
335
+ const slot = this.shadowRoot.querySelector("slot");
336
+ if (!slot) return;
337
+
338
+ const items = slot.assignedElements();
339
+ if (items.length === 0) return;
340
+
341
+ const startIdx = this.currentPage * this.itemsPerView;
342
+ const endIdx = startIdx + this.itemsPerView;
343
+
344
+ items.forEach((item, idx) => {
345
+ if (idx >= startIdx && idx < endIdx) {
346
+ item.setAttribute("data-slide-active", "");
347
+ } else {
348
+ item.removeAttribute("data-slide-active");
349
+ }
350
+ });
351
+
352
+ // Update carousel height to match current page content
353
+ this.updateCarouselHeight();
354
+ }
355
+
356
+ updateCarouselHeight() {
357
+ const slot = this.shadowRoot.querySelector("slot");
358
+ const slidesContainer = this.shadowRoot.querySelector(".carousel-slides");
359
+
360
+ if (!slot || !slidesContainer) return;
361
+
362
+ const items = slot.assignedElements();
363
+ if (items.length === 0) return;
364
+
365
+ const startIdx = this.currentPage * this.itemsPerView;
366
+ const endIdx = startIdx + this.itemsPerView;
367
+
368
+ // Get max height of current page items
369
+ let maxHeight = 0;
370
+ for (let i = startIdx; i < endIdx && i < items.length; i++) {
371
+ const item = items[i];
372
+ maxHeight = Math.max(maxHeight, item.offsetHeight);
373
+ }
374
+
375
+ // Use requestAnimationFrame to ensure layout is ready
376
+ // Add extra space for box-shadows to display without clipping
377
+ requestAnimationFrame(() => {
378
+ if (maxHeight > 0) {
379
+ slidesContainer.style.height = (maxHeight + Carousel.SHADOW_SPACE) + "px";
119
380
  }
120
381
  });
121
382
  }
122
- scrollPrevious() {
123
- const carousel = this.shadowRoot.querySelector(".carousel");
124
- const slotItem = this.shadowRoot
125
- .querySelector("slot")
126
- .assignedElements()[0];
127
- const slotItemWidth = slotItem.clientWidth;
128
- carousel.scrollBy({ left: -slotItemWidth, behavior: "smooth" });
129
- }
130
- scrollNext() {
131
- const carousel = this.shadowRoot.querySelector(".carousel");
132
- const slotItem = this.shadowRoot
133
- .querySelector("slot")
134
- .assignedElements()[0];
135
- const slotItemWidth = slotItem.clientWidth;
136
- carousel.scrollBy({ left: slotItemWidth, behavior: "smooth" });
137
- }
138
- handleScroll(event) {
139
- // Check if at start of scroll
140
- if (event.target.scrollLeft === 0) {
141
- this.atScrollStart = true;
142
- } else {
143
- this.atScrollStart = false;
144
- }
145
- // Check if at end of scroll
146
- if (
147
- Math.round(event.target.scrollLeft + event.target.clientWidth) >=
148
- Math.round(event.target.scrollWidth)
149
- ) {
150
- this.atScrollEnd = true;
151
- } else {
152
- this.atScrollEnd = false;
383
+
384
+ startAutoPlay() {
385
+ if (!this.autoPlay || this.prefersReducedMotion || this.totalSlides === 0) {
386
+ this.isPaused = true;
387
+ return;
388
+ }
389
+ this.isPaused = false;
390
+ this.progressPercentage = 0;
391
+ this._scheduleNextPage();
392
+ }
393
+
394
+ play() {
395
+ if (!this.prefersReducedMotion) {
396
+ this.startAutoPlay();
397
+ }
398
+ }
399
+
400
+ pause() {
401
+ this.isPaused = true;
402
+ this.stopAutoPlay();
403
+ }
404
+
405
+ stopAutoPlay() {
406
+ if (this.autoPlayTimer) {
407
+ clearInterval(this.autoPlayTimer);
408
+ this.autoPlayTimer = null;
409
+ }
410
+ if (this.autoPlayTimeout) {
411
+ clearTimeout(this.autoPlayTimeout);
412
+ this.autoPlayTimeout = null;
413
+ }
414
+ }
415
+
416
+ _scheduleNextPage() {
417
+ this.stopAutoPlay();
418
+
419
+ // Progress bar animation - calculate increment dynamically
420
+ const totalUpdates = this.autoPlayDuration / Carousel.PROGRESS_UPDATE_INTERVAL;
421
+ const increment = 100 / totalUpdates;
422
+
423
+ this.autoPlayTimer = setInterval(() => {
424
+ this.progressPercentage = Math.min(100, this.progressPercentage + increment);
425
+ }, Carousel.PROGRESS_UPDATE_INTERVAL);
426
+
427
+ // Advance to next page after configured duration
428
+ this.autoPlayTimeout = setTimeout(() => {
429
+ if (!this.isPaused) {
430
+ this.nextPage();
431
+ this.startAutoPlay();
432
+ }
433
+ }, this.autoPlayDuration);
434
+ }
435
+
436
+ nextPage() {
437
+ this.currentPage = (this.currentPage + 1) % this.totalPages;
438
+ this.progressPercentage = 0;
439
+ this.stopAutoPlay();
440
+ this.requestUpdate();
441
+ this.play();
442
+ }
443
+
444
+ previousPage() {
445
+ this.currentPage =
446
+ (this.currentPage - 1 + this.totalPages) % this.totalPages;
447
+ this.progressPercentage = 0;
448
+ this.stopAutoPlay();
449
+ this.requestUpdate();
450
+ this.play();
451
+ }
452
+
453
+ goToPage(pageIndex) {
454
+ if (pageIndex >= 0 && pageIndex < this.totalPages) {
455
+ // Only update if page is different
456
+ if (pageIndex !== this.currentPage) {
457
+ this.currentPage = pageIndex;
458
+ this.progressPercentage = 0;
459
+ this.stopAutoPlay();
460
+ this.requestUpdate();
461
+ this.play();
462
+ }
463
+ }
464
+ }
465
+
466
+ _handleKeydown(event) {
467
+ if (event.key === "ArrowRight") {
468
+ event.preventDefault();
469
+ this.nextPage();
470
+ this.pause();
471
+ } else if (event.key === "ArrowLeft") {
472
+ event.preventDefault();
473
+ this.previousPage();
474
+ this.pause();
475
+ }
476
+ }
477
+
478
+ _handleTouchStart(event) {
479
+ if (event.changedTouches && event.changedTouches.length > 0) {
480
+ this.touchStartX = event.changedTouches[0].screenX;
481
+ }
482
+ }
483
+
484
+ _handleTouchEnd(event) {
485
+ if (event.changedTouches && event.changedTouches.length > 0) {
486
+ this.touchEndX = event.changedTouches[0].screenX;
487
+ this._handleSwipe();
153
488
  }
154
489
  }
490
+
491
+ _handleSwipe() {
492
+ // Validate that touch coordinates were captured
493
+ if (this.touchStartX === null || this.touchEndX === null) {
494
+ return;
495
+ }
496
+
497
+ const diff = this.touchStartX - this.touchEndX;
498
+
499
+ if (Math.abs(diff) > Carousel.SWIPE_THRESHOLD) {
500
+ if (diff > 0) {
501
+ // Swiped left - go to next page
502
+ this.nextPage();
503
+ this.pause();
504
+ } else {
505
+ // Swiped right - go to previous page
506
+ this.previousPage();
507
+ this.pause();
508
+ }
509
+ }
510
+ }
511
+
155
512
  render() {
156
513
  return html`
157
- <div class="carousel" @scroll=${this.handleScroll}>
158
- <div class="spacer"></div>
159
- <slot></slot>
160
- <div class="spacer"></div>
161
- </div>
162
- <div class="controls">
163
- <button
164
- class="previous ${this.atScrollStart ? "disabled" : ""}"
165
- @click="${this.scrollPrevious}"
166
- >
167
- <cfa-icon>arrow_back</cfa-icon>
168
- </button>
169
- <button
170
- class="next ${this.atScrollEnd ? "disabled" : ""}"
171
- @click="${this.scrollNext}"
172
- >
173
- <cfa-icon>arrow_forward</cfa-icon>
174
- </button>
514
+ <div
515
+ class="carousel-container"
516
+ role="region"
517
+ aria-label="Content carousel"
518
+ >
519
+ <div class="carousel-slides">
520
+ <div class="carousel-track">
521
+ <slot @slotchange=${() => {
522
+ // Only update if not already initialized in firstUpdated
523
+ if (!this._initialized) {
524
+ const slot = this.shadowRoot.querySelector("slot");
525
+ if (slot) {
526
+ const items = slot.assignedElements();
527
+ this.totalSlides = items.length;
528
+ this.updateItemVisibility();
529
+ this.requestUpdate();
530
+ }
531
+ }
532
+ }}></slot>
533
+ </div>
534
+ </div>
535
+
536
+ ${this.totalSlides > 0
537
+ ? html`
538
+ <div class="carousel-controls">
539
+ <button
540
+ class="carousel-button"
541
+ @click=${() => this.previousPage()}
542
+ aria-label="Previous page"
543
+ >
544
+ <cfa-icon>arrow_back</cfa-icon>
545
+ </button>
546
+
547
+ <div class="carousel-pagination">
548
+ ${Array.from({ length: this.totalPages }).map(
549
+ (_, idx) => html`
550
+ <button
551
+ class="carousel-dot"
552
+ @click=${() => this.goToPage(idx)}
553
+ aria-label="Go to page ${idx + 1} of ${this.totalPages}"
554
+ aria-current=${idx === this.currentPage ? "true" : "false"}
555
+ >
556
+ ${idx === this.currentPage && !this.prefersReducedMotion
557
+ ? html`<div
558
+ class="carousel-dot-progress"
559
+ style="width: ${this.progressPercentage}%"
560
+ ></div>`
561
+ : ""}
562
+ </button>
563
+ `
564
+ )}
565
+ </div>
566
+
567
+ <button
568
+ class="carousel-button"
569
+ @click=${() => this.nextPage()}
570
+ aria-label="Next page"
571
+ >
572
+ <cfa-icon>arrow_forward</cfa-icon>
573
+ </button>
574
+ </div>
575
+ `
576
+ : ""}
175
577
  </div>
176
578
  `;
177
579
  }