@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.
- package/dist/index.js +293 -202
- package/package.json +1 -1
- package/src/components/carousel.js +594 -114
- package/src/components/carousel.stories.js +105 -1
- package/src/components/slide.js +3 -3
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
--
|
|
15
|
-
--
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
83
|
+
::slotted([data-slide-active]) {
|
|
84
|
+
display: block;
|
|
85
|
+
opacity: 1;
|
|
32
86
|
}
|
|
33
87
|
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
.
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
.
|
|
50
|
-
|
|
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
|
-
.
|
|
54
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
.
|
|
66
|
-
|
|
67
|
-
background-color: var(--white, #fff);
|
|
157
|
+
.carousel-dot:hover {
|
|
158
|
+
background-color: var(--gray-60);
|
|
68
159
|
}
|
|
69
160
|
|
|
70
|
-
.
|
|
161
|
+
.carousel-dot:focus-visible {
|
|
71
162
|
outline: var(--focus-outline);
|
|
163
|
+
outline-offset: 3px;
|
|
72
164
|
}
|
|
73
165
|
|
|
74
|
-
.
|
|
75
|
-
color: var(--
|
|
76
|
-
|
|
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
|
-
.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
180
|
+
@media (prefers-reduced-motion: reduce) {
|
|
181
|
+
.carousel-dot-progress {
|
|
182
|
+
transition: none;
|
|
183
|
+
}
|
|
89
184
|
}
|
|
90
185
|
|
|
91
|
-
@media (
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
.
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
106
|
-
this.
|
|
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
|
-
//
|
|
112
|
-
this.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
.
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
}
|