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