@codeforamerica/marcomms-design-system 1.19.0 → 1.19.1

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.
Files changed (91) hide show
  1. package/package.json +2 -1
  2. package/src/components/accordion.js +141 -0
  3. package/src/components/accordion.stories.js +56 -0
  4. package/src/components/avatar.js +62 -0
  5. package/src/components/avatar.stories.js +27 -0
  6. package/src/components/bar.js +102 -0
  7. package/src/components/bar.stories.js +22 -0
  8. package/src/components/blob.js +128 -0
  9. package/src/components/blob.stories.js +73 -0
  10. package/src/components/box.js +55 -0
  11. package/src/components/box.stories.js +24 -0
  12. package/src/components/breadcrumbs.js +80 -0
  13. package/src/components/breadcrumbs.stories.js +27 -0
  14. package/src/components/button.js +163 -0
  15. package/src/components/button.scss +157 -0
  16. package/src/components/button.stories.js +49 -0
  17. package/src/components/callout.js +62 -0
  18. package/src/components/callout.stories.js +20 -0
  19. package/src/components/card.js +456 -0
  20. package/src/components/card.stories.js +227 -0
  21. package/src/components/carousel.js +662 -0
  22. package/src/components/carousel.stories.js +165 -0
  23. package/src/components/details.scss +71 -0
  24. package/src/components/details.stories.js +27 -0
  25. package/src/components/form-elements.scss +304 -0
  26. package/src/components/form-elements.stories.js +134 -0
  27. package/src/components/icon.js +44 -0
  28. package/src/components/icon.scss +32 -0
  29. package/src/components/icon.stories.js +38 -0
  30. package/src/components/label.js +63 -0
  31. package/src/components/label.stories.js +29 -0
  32. package/src/components/link-list.scss +80 -0
  33. package/src/components/link-list.stories.js +52 -0
  34. package/src/components/loader.scss +24 -0
  35. package/src/components/loader.stories.js +12 -0
  36. package/src/components/logo-card.js +93 -0
  37. package/src/components/logo-card.stories.js +48 -0
  38. package/src/components/nav.js +98 -0
  39. package/src/components/nav.stories.js +40 -0
  40. package/src/components/page-nav.js +171 -0
  41. package/src/components/page-nav.stories.js +112 -0
  42. package/src/components/pager.js +98 -0
  43. package/src/components/pager.stories.js +30 -0
  44. package/src/components/pagination.js +116 -0
  45. package/src/components/pagination.stories.js +30 -0
  46. package/src/components/person-card.js +240 -0
  47. package/src/components/person-card.stories.js +58 -0
  48. package/src/components/pill.js +33 -0
  49. package/src/components/pill.stories.js +25 -0
  50. package/src/components/placeholder.js +25 -0
  51. package/src/components/placeholder.stories.js +10 -0
  52. package/src/components/promo.js +82 -0
  53. package/src/components/promo.stories.js +37 -0
  54. package/src/components/pullquote.js +42 -0
  55. package/src/components/pullquote.stories.js +16 -0
  56. package/src/components/quote.js +111 -0
  57. package/src/components/quote.stories.js +23 -0
  58. package/src/components/reveal.js +83 -0
  59. package/src/components/reveal.stories.js +40 -0
  60. package/src/components/slide.js +122 -0
  61. package/src/components/slide.stories.js +49 -0
  62. package/src/components/social-icon.js +236 -0
  63. package/src/components/social-icon.stories.js +36 -0
  64. package/src/components/stat.js +92 -0
  65. package/src/components/stat.stories.js +28 -0
  66. package/src/components/tab-list.js +114 -0
  67. package/src/components/tab-list.stories.js +18 -0
  68. package/src/components/tab.js +95 -0
  69. package/src/components/tab.stories.js +29 -0
  70. package/src/components/tile.js +149 -0
  71. package/src/components/tile.stories.js +41 -0
  72. package/src/components/transcript.js +44 -0
  73. package/src/components/transcript.stories.js +103 -0
  74. package/src/core/base.scss +86 -0
  75. package/src/core/colors.mdx +100 -0
  76. package/src/core/grid.mdx +244 -0
  77. package/src/core/grid.scss +394 -0
  78. package/src/core/helpers.scss +111 -0
  79. package/src/core/layout.scss +103 -0
  80. package/src/core/layout.stories.js +145 -0
  81. package/src/core/reset.scss +53 -0
  82. package/src/core/shadows.mdx +108 -0
  83. package/src/core/tokens.scss +261 -0
  84. package/src/core/typography.mdx +79 -0
  85. package/src/core/typography.scss +411 -0
  86. package/src/core.js +10 -0
  87. package/src/index.js +43 -0
  88. package/src/shared/common.js +65 -0
  89. package/src/shared/layout.js +14 -0
  90. package/src/shared/typography.js +397 -0
  91. package/src/styles.scss +15 -0
@@ -0,0 +1,662 @@
1
+ import { LitElement, html, css } from "lit";
2
+ import { commonStyles } from "../shared/common";
3
+ import "./icon";
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
+ */
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
+
34
+ static properties = {
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 },
43
+ };
44
+
45
+ static styles = [
46
+ commonStyles,
47
+ css`
48
+ :host {
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
+ }
57
+
58
+ .carousel-container {
59
+ display: flex;
60
+ flex-direction: column;
61
+ gap: var(--spacing-component-3);
62
+ }
63
+
64
+ .carousel-slides {
65
+ width: 100%;
66
+ overflow: hidden;
67
+ transition: height 400ms ease-in-out;
68
+ height: auto;
69
+ }
70
+
71
+ .carousel-track {
72
+ display: flex;
73
+ gap: var(--carousel-gap);
74
+ }
75
+
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;
81
+ }
82
+
83
+ ::slotted([data-slide-active]) {
84
+ display: block;
85
+ opacity: 1;
86
+ }
87
+
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 {
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: center;
102
+ gap: var(--spacing-component-3);
103
+ }
104
+
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);
125
+ }
126
+
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);
135
+ }
136
+
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);
147
+ border: none;
148
+ border-radius: 50%;
149
+ cursor: pointer;
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;
155
+ }
156
+
157
+ .carousel-dot:hover {
158
+ background-color: var(--gray-60);
159
+ }
160
+
161
+ .carousel-dot:focus-visible {
162
+ outline: var(--focus-outline);
163
+ outline-offset: 3px;
164
+ }
165
+
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;
171
+ }
172
+
173
+ .carousel-dot-progress {
174
+ background-color: var(--carousel-dot-active-color);
175
+ height: 100%;
176
+ width: 0%;
177
+ transition: width 100ms linear;
178
+ }
179
+
180
+ @media (prefers-reduced-motion: reduce) {
181
+ .carousel-dot-progress {
182
+ transition: none;
183
+ }
184
+ }
185
+
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);
192
+ }
193
+
194
+ .carousel-dot {
195
+ height: calc(var(--carousel-dot-size) * 0.8);
196
+ min-width: calc(var(--carousel-dot-size) * 0.8);
197
+ }
198
+ }
199
+ `,
200
+ ];
201
+
202
+ constructor() {
203
+ super();
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;
223
+ }
224
+
225
+ connectedCallback() {
226
+ super.connectedCallback();
227
+
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;
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();
343
+ });
344
+ }
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();
553
+ }
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
+
590
+ render() {
591
+ return html`
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
+ : ""}
655
+ </div>
656
+ `;
657
+ }
658
+ }
659
+
660
+ if (!customElements.get("cfa-carousel")) {
661
+ customElements.define("cfa-carousel", Carousel);
662
+ }