@energysage/es-ds-components 5.7.1 → 5.7.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [5.7.3](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.7.2...es-ds-components-v5.7.3) (2026-07-31)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * make EsCarousel autoplay stop upon interaction ([#1780](https://github.com/EnergySage/es-ds/issues/1780)) ([e5acf03](https://github.com/EnergySage/es-ds/commit/e5acf034f1af59d2ff25b5f50873c8a4312a1f5e))
9
+
10
+ ## [5.7.2](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.7.1...es-ds-components-v5.7.2) (2026-07-29)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * critical, high, and moderate vulnerabilities ([#1775](https://github.com/EnergySage/es-ds/issues/1775)) ([5453306](https://github.com/EnergySage/es-ds/commit/54533067655059bba89ece29613af3846ea058f7))
16
+ * EsCarousel autoplay no longer interrupts screen readers ([#1778](https://github.com/EnergySage/es-ds/issues/1778)) ([666ecf0](https://github.com/EnergySage/es-ds/commit/666ecf02383db3728195d8a6a44655d6fba1536a))
17
+
18
+
19
+ ### Dependencies
20
+
21
+ * The following workspace dependencies were updated
22
+ * devDependencies
23
+ * @energysage/es-ds-styles bumped from ^3.5.8 to ^3.5.9
24
+
3
25
  ## [5.7.1](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.7.0...es-ds-components-v5.7.1) (2026-06-12)
4
26
 
5
27
 
@@ -1,13 +1,18 @@
1
1
  <script setup lang="ts">
2
2
  /*
3
- TODO:
4
- - circular has quirky behavior when numVisible doesn't match numScroll
5
- - you can see this in the circular autoplay example
6
- - i'm not sure if this is fixable
7
- - prop to position the arrows at the bottom two corners of a full-width slide, like homepage
3
+ EsCarousel is built on Embla Carousel (https://www.embla-carousel.com/), replacing the
4
+ previous PrimeVue implementation. Embla is SSR-friendly and gives us full control over
5
+ the markup, which lets us make the component fully accessible.
6
+
7
+ TODO (design):
8
+ - Add a visible pause/play button when `autoPlay` is on. Today autoplay can only be
9
+ stopped via the Esc key, which is not reachable in mobile screen readers. A visible
10
+ control satisfies WCAG 2.2.2, but where it lives and how it looks needs a design pass.
8
11
  */
9
12
 
10
- import Carousel from 'primevue/carousel';
13
+ import emblaCarouselVue from 'embla-carousel-vue';
14
+ import Autoplay from 'embla-carousel-autoplay';
15
+ import type { EmblaOptionsType } from 'embla-carousel';
11
16
  import sassBreakpoints from '@energysage/es-ds-styles/scss/modules/breakpoints.module.scss';
12
17
  import type { EsCarouselBreakpointsInterface } from '../types';
13
18
 
@@ -15,15 +20,13 @@ import type { EsCarouselBreakpointsInterface } from '../types';
15
20
  // defined as a constant here so we can easily change it if we need to
16
21
  const BASE_FONT_SIZE = 16;
17
22
 
18
- // constants that contribute to dots and arrows positioning
23
+ // constants that contribute to dots and control sizing/spacing
19
24
  const DOT_SIZE = 14;
20
25
  const DOT_SPACING = 16;
21
26
  const ARROW_BUTTON_PADDING = 8;
22
27
 
23
- // the number of "dots" apart the arrows should be when dots are hidden
24
- const ARROW_SPACING_WHEN_NO_DOTS = 1;
25
-
26
28
  interface IProps {
29
+ ariaLabel?: string;
27
30
  arrowSize?: 'sm' | 'lg';
28
31
  autoPlay?: boolean;
29
32
  autoPlayInterval?: number;
@@ -42,13 +45,13 @@ interface IProps {
42
45
  variant?: 'default' | 'brand';
43
46
  }
44
47
  const props = withDefaults(defineProps<IProps>(), {
48
+ ariaLabel: 'Carousel',
45
49
  arrowSize: 'sm',
46
50
  autoPlay: false,
47
51
  autoPlayInterval: 4000,
48
52
  breakpoints: () => ({}),
49
53
  circular: false,
50
54
  controlGap: 24,
51
- items: () => [],
52
55
  numScroll: 1,
53
56
  numVisible: 1,
54
57
  peekDesktop: '',
@@ -73,297 +76,383 @@ const BREAKPOINTS = {
73
76
  XXL: parseSassBreakpoint(sassBreakpoints.xxl!),
74
77
  };
75
78
 
76
- // lower breakpoint values propagate to higher breakpoints unless overridden
77
- const numVisibleXs = computed(() => props.numVisible);
79
+ // lower breakpoint values propagate to higher breakpoints unless overridden.
80
+ // numVisible controls the slide width via CSS (flex-basis), see the style block below.
81
+ // the base value is floored at 1 because it is used as a divisor; the per-breakpoint values below
82
+ // don't need the same treatment, since a zero there falls through to the next-lowest breakpoint.
83
+ const numVisibleXs = computed(() => Math.max(1, props.numVisible));
78
84
  const numVisibleSm = computed(() => props.breakpoints?.sm?.numVisible || numVisibleXs.value);
79
85
  const numVisibleMd = computed(() => props.breakpoints?.md?.numVisible || numVisibleSm.value);
80
86
  const numVisibleLg = computed(() => props.breakpoints?.lg?.numVisible || numVisibleMd.value);
81
87
  const numVisibleXl = computed(() => props.breakpoints?.xl?.numVisible || numVisibleLg.value);
82
88
  const numVisibleXxl = computed(() => props.breakpoints?.xxl?.numVisible || numVisibleXl.value);
83
89
 
84
- // lower breakpoint values propagate to higher breakpoints unless overridden
85
- const numScrollXs = computed(() => props.numScroll);
90
+ // lower breakpoint values propagate to higher breakpoints unless overridden.
91
+ // numScroll drives Embla's `slidesToScroll` option per breakpoint. floored at 1 for the same
92
+ // reason as numVisible above: it is a divisor when estimating the number of dots.
93
+ const numScrollXs = computed(() => Math.max(1, props.numScroll));
86
94
  const numScrollSm = computed(() => props.breakpoints?.sm?.numScroll || numScrollXs.value);
87
95
  const numScrollMd = computed(() => props.breakpoints?.md?.numScroll || numScrollSm.value);
88
96
  const numScrollLg = computed(() => props.breakpoints?.lg?.numScroll || numScrollMd.value);
89
97
  const numScrollXl = computed(() => props.breakpoints?.xl?.numScroll || numScrollLg.value);
90
98
  const numScrollXxl = computed(() => props.breakpoints?.xxl?.numScroll || numScrollXl.value);
91
99
 
92
- // allow customizable spacing between slides
93
- // but since that's done as side padding around each slide, which moves them in from the container edge
94
- // to get slides to left and right align with page content
95
- // we apply a negative margin to either side to bring the container edges back out to match
100
+ // allow customizable spacing between slides.
101
+ // this is done as side padding around each slide, which moves them in from the container edge,
102
+ // so we apply a negative margin to either side to bring the container edges back out to match
103
+ // the surrounding page content.
96
104
  const sidePadding = computed(() => `${props.slideGap / 2 / BASE_FONT_SIZE}rem`);
97
105
  const negativeMargin = computed(() => `-${sidePadding.value}`);
98
106
 
99
- // size of dots and spacing between dots
107
+ // size of dots and spacing between dots/controls
100
108
  const dotSize = `${DOT_SIZE / BASE_FONT_SIZE}rem`;
101
109
  const dotSpacing = `${DOT_SPACING / BASE_FONT_SIZE}rem`;
110
+ const controlsMarginTop = computed(() => `${props.controlGap / BASE_FONT_SIZE}rem`);
102
111
 
103
- // arrow padding
104
- const arrowPadding = `${ARROW_BUTTON_PADDING / BASE_FONT_SIZE}rem`;
105
-
106
- // arrow size
112
+ // arrow icon size and padding
107
113
  const arrowSize = computed(() => (props.arrowSize === 'lg' ? 32 : 24));
108
114
  const arrowSizePx = computed(() => `${arrowSize.value}px`);
109
- const arrowButtonSize = computed(() => arrowSize.value + ARROW_BUTTON_PADDING * 2);
115
+ const arrowPadding = `${ARROW_BUTTON_PADDING / BASE_FONT_SIZE}rem`;
110
116
 
111
- // vertical positioning for the arrows
112
- const distanceFromCarouselBottomToCenterOfDots = computed(() => props.controlGap + DOT_SIZE / 2);
113
- const arrowPositionBottom = computed(
114
- () => `-${distanceFromCarouselBottomToCenterOfDots.value + arrowButtonSize.value / 2}px`,
115
- );
116
- const dotsMarginTop = computed(() => `${props.controlGap / BASE_FONT_SIZE}rem`);
117
+ /*
118
+ Whether the user prefers reduced motion. Read during setup rather than on mount, because it
119
+ gates the autoplay plugin below, which is created before the component mounts. The server can't
120
+ know the preference, so it renders as though there is none and the client settles it on load.
121
+ */
122
+ const prefersReducedMotion = ref(false);
123
+ if (import.meta.client) {
124
+ prefersReducedMotion.value = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
125
+ }
117
126
 
118
- // the number of dots visible at each breakpoint
119
- const numDotsXs = computed(() =>
120
- props.showDots
121
- ? Math.ceil((props.items.length - numVisibleXs.value) / numScrollXs.value) + 1
122
- : ARROW_SPACING_WHEN_NO_DOTS,
123
- );
124
- const numDotsSm = computed(() =>
125
- props.showDots
126
- ? Math.ceil((props.items.length - numVisibleSm.value) / numScrollSm.value) + 1
127
- : ARROW_SPACING_WHEN_NO_DOTS,
128
- );
129
- const numDotsMd = computed(() =>
130
- props.showDots
131
- ? Math.ceil((props.items.length - numVisibleMd.value) / numScrollMd.value) + 1
132
- : ARROW_SPACING_WHEN_NO_DOTS,
133
- );
134
- const numDotsLg = computed(() =>
135
- props.showDots
136
- ? Math.ceil((props.items.length - numVisibleLg.value) / numScrollLg.value) + 1
137
- : ARROW_SPACING_WHEN_NO_DOTS,
138
- );
139
- const numDotsXl = computed(() =>
140
- props.showDots
141
- ? Math.ceil((props.items.length - numVisibleXl.value) / numScrollXl.value) + 1
142
- : ARROW_SPACING_WHEN_NO_DOTS,
143
- );
144
- const numDotsXxl = computed(() =>
145
- props.showDots
146
- ? Math.ceil((props.items.length - numVisibleXxl.value) / numScrollXxl.value) + 1
147
- : ARROW_SPACING_WHEN_NO_DOTS,
148
- );
127
+ // build Embla's per-breakpoint slidesToScroll overrides from the resolved numScroll values.
128
+ // entries are keyed by min-width media queries, so the largest matching query wins at any width,
129
+ // which reproduces the "lower breakpoint propagates upward unless overridden" behavior.
130
+ const slidesToScrollBreakpoints = computed<EmblaOptionsType['breakpoints']>(() => ({
131
+ [`(min-width: ${BREAKPOINTS.SM}px)`]: { slidesToScroll: numScrollSm.value },
132
+ [`(min-width: ${BREAKPOINTS.MD}px)`]: { slidesToScroll: numScrollMd.value },
133
+ [`(min-width: ${BREAKPOINTS.LG}px)`]: { slidesToScroll: numScrollLg.value },
134
+ [`(min-width: ${BREAKPOINTS.XL}px)`]: { slidesToScroll: numScrollXl.value },
135
+ [`(min-width: ${BREAKPOINTS.XXL}px)`]: { slidesToScroll: numScrollXxl.value },
136
+ }));
137
+
138
+ const emblaOptions = computed<EmblaOptionsType>(() => {
139
+ const options: EmblaOptionsType = {
140
+ align: 'start',
141
+ loop: props.circular,
142
+ slidesToScroll: numScrollXs.value,
143
+ breakpoints: slidesToScrollBreakpoints.value,
144
+ };
145
+ // only set `duration` when reducing motion (instant transition); otherwise omit the key
146
+ // entirely so Embla uses its default scroll animation. Passing `duration: undefined` would
147
+ // override that default with undefined and collapse the tween to an instant jump.
148
+ if (prefersReducedMotion.value) {
149
+ options.duration = 0;
150
+ }
151
+ return options;
152
+ });
153
+
154
+ /*
155
+ Autoplay runs only when it is asked for AND the user has not requested reduced motion:
156
+ auto-advancing content is motion in its own right, so the preference should stop it entirely
157
+ rather than merely removing the slide animation. (Manual paging stays instant via `duration`.)
158
+
159
+ It is a plugin, added at creation time. `stopOnInteraction` is on so that dragging the slides
160
+ stops autoplay and leaves it stopped — with it off, the plugin restarts the timer on pointer-up.
161
+ The plugin's own stop doesn't record anything on our side though, so we listen for the same
162
+ events (see onMounted) and route them through `stopAutoplay`, which remembers the stop and keeps
163
+ it from being undone by a reinitialization.
164
+ */
165
+ const autoplayEnabled = props.autoPlay && !prefersReducedMotion.value;
166
+ const autoplayPlugins = autoplayEnabled ? [Autoplay({ delay: props.autoPlayInterval, stopOnInteraction: true })] : [];
167
+
168
+ // `emblaOptions` is passed as a ref, not unwrapped: the composable then watches it and reinitializes
169
+ // the carousel itself when the options change, skipping the work when the new options are equivalent.
170
+ const [emblaRef, emblaApi] = emblaCarouselVue(emblaOptions, autoplayPlugins);
171
+
172
+ // reactive state derived from the Embla API
173
+ const scrollSnaps = ref<number[]>([]);
174
+ const selectedIndex = ref(0);
175
+ const canScrollPrev = ref(false);
176
+ const canScrollNext = ref(false);
177
+
178
+ // remembers that the user explicitly stopped autoplay, so that it stays stopped across reinits
179
+ const autoplayStopped = ref(false);
180
+
181
+ const updateNavState = () => {
182
+ const api = emblaApi.value;
183
+ if (!api) return;
184
+ selectedIndex.value = api.selectedScrollSnap();
185
+ canScrollPrev.value = api.canScrollPrev();
186
+ canScrollNext.value = api.canScrollNext();
187
+ };
149
188
 
150
- // calculate the arrow position from center based on the number of dots
151
- const calculateArrowPosition = (numDots: number): number =>
152
- (numDots * DOT_SIZE) / 2 + (numDots * DOT_SPACING) / 2 + DOT_SPACING;
153
-
154
- // determine how much from center we need to move the arrow buttons, based on how many dots there are
155
- const arrowPositionXs = computed(() => calculateArrowPosition(numDotsXs.value));
156
- const arrowPositionSm = computed(() => calculateArrowPosition(numDotsSm.value));
157
- const arrowPositionMd = computed(() => calculateArrowPosition(numDotsMd.value));
158
- const arrowPositionLg = computed(() => calculateArrowPosition(numDotsLg.value));
159
- const arrowPositionXl = computed(() => calculateArrowPosition(numDotsXl.value));
160
- const arrowPositionXxl = computed(() => calculateArrowPosition(numDotsXxl.value));
161
-
162
- // prev button horizontal position
163
- const prevArrowTranslateXs = computed(() => `-${arrowPositionXs.value + arrowButtonSize.value}px`);
164
- const prevArrowTranslateSm = computed(() => `-${arrowPositionSm.value + arrowButtonSize.value}px`);
165
- const prevArrowTranslateMd = computed(() => `-${arrowPositionMd.value + arrowButtonSize.value}px`);
166
- const prevArrowTranslateLg = computed(() => `-${arrowPositionLg.value + arrowButtonSize.value}px`);
167
- const prevArrowTranslateXl = computed(() => `-${arrowPositionXl.value + arrowButtonSize.value}px`);
168
- const prevArrowTranslateXxl = computed(() => `-${arrowPositionXxl.value + arrowButtonSize.value}px`);
169
-
170
- // next arrow horizontal position
171
- const nextArrowTranslateXs = computed(() => `${arrowPositionXs.value}px`);
172
- const nextArrowTranslateSm = computed(() => `${arrowPositionSm.value}px`);
173
- const nextArrowTranslateMd = computed(() => `${arrowPositionMd.value}px`);
174
- const nextArrowTranslateLg = computed(() => `${arrowPositionLg.value}px`);
175
- const nextArrowTranslateXl = computed(() => `${arrowPositionXl.value}px`);
176
- const nextArrowTranslateXxl = computed(() => `${arrowPositionXxl.value}px`);
177
-
178
- // extra space to add below the carousel for the arrows when arrows are on but dots are off
179
- const arrowsOnlyBottomSpacing = computed(() => `${props.controlGap + arrowButtonSize.value}px`);
180
-
181
- const responsiveOptions = computed(() => {
182
- // if no special breakpoints are defined, don't pass in any responsive options
183
- if (!Object.keys(props.breakpoints).length) {
184
- return undefined;
189
+ const onSelect = () => {
190
+ updateNavState();
191
+ emit('update', selectedIndex.value);
192
+ };
193
+
194
+ const onReInit = () => {
195
+ const api = emblaApi.value;
196
+ if (!api) return;
197
+ scrollSnaps.value = api.scrollSnapList();
198
+ updateNavState();
199
+
200
+ // Embla re-creates and restarts its plugins on every reInit, which it does on its own whenever
201
+ // one of the `breakpoints` media queries changes (a phone rotating, a window resize across a
202
+ // breakpoint). Without this, motion the user deliberately stopped would silently resume.
203
+ if (autoplayStopped.value) {
204
+ api.plugins()?.autoplay?.stop();
185
205
  }
206
+ };
186
207
 
187
- return [
188
- // XXL breakpoint
189
- {
190
- // max width of XXL is infinite, so let's use 9999px
191
- breakpoint: '9999px',
192
- numScroll: numScrollXxl.value,
193
- numVisible: numVisibleXxl.value,
194
- },
195
- // XL breakpoint
196
- {
197
- // max width of XL is XXL minus one
198
- breakpoint: `${BREAKPOINTS.XXL - 1}px`,
199
- numScroll: numScrollXl.value,
200
- numVisible: numVisibleXl.value,
201
- },
202
- // LG breakpoint
203
- {
204
- // max width of LG is XL minus one
205
- breakpoint: `${BREAKPOINTS.XL - 1}px`,
206
- numScroll: numScrollLg.value,
207
- numVisible: numVisibleLg.value,
208
- },
209
- // MD breakpoint
210
- {
211
- // max width of MD is LG minus one
212
- breakpoint: `${BREAKPOINTS.LG - 1}px`,
213
- numScroll: numScrollMd.value,
214
- numVisible: numVisibleMd.value,
215
- },
216
- // SM breakpoint
217
- {
218
- // max width of SM is MD minus one
219
- breakpoint: `${BREAKPOINTS.MD - 1}px`,
220
- numScroll: numScrollSm.value,
221
- numVisible: numVisibleSm.value,
222
- },
223
- // XS breakpoint
224
- // (this is necessary to avoid weird behavior on mobile)
225
- {
226
- // max width of XS is SM minus one
227
- breakpoint: `${BREAKPOINTS.SM - 1}px`,
228
- numScroll: props.numScroll,
229
- numVisible: props.numVisible,
230
- },
231
- ];
232
- });
208
+ const stopAutoplay = () => {
209
+ autoplayStopped.value = true;
210
+ emblaApi.value?.plugins()?.autoplay?.stop();
211
+ };
212
+
213
+ /*
214
+ Paging the carousel yourself stops autoplay for good. Once someone has taken control, having the
215
+ carousel keep moving under them fights whatever they were trying to look at.
216
+
217
+ These three cover the arrows and the dots. Dragging and swiping are handled separately, by the
218
+ `pointerDown` listener in onMounted, since those never come through here.
219
+ */
220
+ const scrollPrev = () => {
221
+ stopAutoplay();
222
+ emblaApi.value?.scrollPrev();
223
+ };
224
+ const scrollNext = () => {
225
+ stopAutoplay();
226
+ emblaApi.value?.scrollNext();
227
+ };
228
+ const scrollTo = (index: number) => {
229
+ stopAutoplay();
230
+ emblaApi.value?.scrollTo(index);
231
+ };
232
+
233
+ // stop carousel when user presses Escape key, in lieu of a pause button
234
+ // https://www.w3.org/WAI/WCAG22/Techniques/general/G187.html
235
+ const onEscapeKeyup = (e: KeyboardEvent) => {
236
+ if (e.key === 'Escape') {
237
+ stopAutoplay();
238
+ }
239
+ };
233
240
 
234
- const autoplayInterval = ref(props.autoPlay ? props.autoPlayInterval : 0);
241
+ /*
242
+ Dots come from Embla's measured scroll-snap list, which only exists after the carousel
243
+ initializes on the client. To keep the dots (and the arrow spacing) from popping in after
244
+ hydration, we render an estimated dot count during SSR / before mount using the same math Embla
245
+ uses, then reconcile with the real snap list once mounted. Per-breakpoint estimates are handed
246
+ to CSS (see the `.before-mount` rules below) so the count is already correct at every breakpoint
247
+ before hydration.
248
+
249
+ That CSS can only hide surplus dots up to `$num-dots-supported` in the style block. Beyond that
250
+ a breakpoint would briefly show the largest breakpoint's dot count until hydration corrects it.
251
+ The limit is set well past what this component should ever show — more than a handful of dots is
252
+ an anti-pattern — so in practice it isn't reachable.
253
+ */
235
254
  const isMounted = ref(false);
236
- const key = ref('');
237
255
 
238
- const stopAutoplay = () => {
239
- if (autoplayInterval.value > 0) {
240
- autoplayInterval.value = 0;
241
- key.value = 'stopAutoplay';
256
+ // `items` is a required prop, so it has no default. it is still read defensively here because this
257
+ // runs during setup, where a consumer that omitted it (from JS, or a dynamic component) would
258
+ // otherwise throw before Vue's "missing required prop" warning is any help.
259
+ const estimateSnaps = (visible: number, scroll: number) =>
260
+ Math.max(1, Math.ceil(((props.items?.length ?? 0) - visible) / scroll) + 1);
261
+ const estSnapsXs = computed(() => estimateSnaps(numVisibleXs.value, numScrollXs.value));
262
+ const estSnapsSm = computed(() => estimateSnaps(numVisibleSm.value, numScrollSm.value));
263
+ const estSnapsMd = computed(() => estimateSnaps(numVisibleMd.value, numScrollMd.value));
264
+ const estSnapsLg = computed(() => estimateSnaps(numVisibleLg.value, numScrollLg.value));
265
+ const estSnapsXl = computed(() => estimateSnaps(numVisibleXl.value, numScrollXl.value));
266
+ const estSnapsXxl = computed(() => estimateSnaps(numVisibleXxl.value, numScrollXxl.value));
267
+ const maxEstimatedSnaps = computed(() =>
268
+ Math.max(
269
+ estSnapsXs.value,
270
+ estSnapsSm.value,
271
+ estSnapsMd.value,
272
+ estSnapsLg.value,
273
+ estSnapsXl.value,
274
+ estSnapsXxl.value,
275
+ ),
276
+ );
277
+
278
+ // number of dots to render: the measured snap count once mounted, otherwise the (max) estimate
279
+ const dotCount = computed(() => (isMounted.value ? scrollSnaps.value.length : maxEstimatedSnaps.value));
280
+ const dotIndices = computed(() => Array.from({ length: dotCount.value }, (_, i) => i));
281
+
282
+ // show the controls row when there's something to put in it
283
+ const showDotsRow = computed(() => props.showDots && dotCount.value > 1);
284
+ const showControls = computed(() => props.showArrows || showDotsRow.value);
285
+
286
+ /*
287
+ The dots are a single tab stop, with the arrow keys moving between them (the roving tabindex
288
+ approach from the ARIA APG tabs pattern). This is bound to the dots list rather than the
289
+ carousel as a whole, so it can never intercept arrow keys meant for content inside a slide.
290
+ */
291
+ const onDotsKeydown = (e: KeyboardEvent) => {
292
+ let target: number;
293
+ if (e.key === 'ArrowRight') {
294
+ target = selectedIndex.value + 1;
295
+ } else if (e.key === 'ArrowLeft') {
296
+ target = selectedIndex.value - 1;
297
+ } else if (e.key === 'Home') {
298
+ target = 0;
299
+ } else if (e.key === 'End') {
300
+ target = dotCount.value - 1;
301
+ } else {
302
+ return;
242
303
  }
304
+
305
+ e.preventDefault();
306
+ // clamp rather than wrap, so the carousel doesn't jump end-to-end on a single keypress
307
+ const index = Math.min(dotCount.value - 1, Math.max(0, target));
308
+ scrollTo(index);
309
+ (e.currentTarget as HTMLElement).querySelectorAll('button')[index]?.focus();
243
310
  };
244
311
 
245
312
  onMounted(() => {
246
- /**
247
- * avoids an unavoidable SSR issue with responsive carousels (SSR can't know the breakpoint
248
- * and therefore how many items need to be displayed per page) where circular carousels have
249
- * their last few items cloned and inserted before the first item in the list, meaning on initial
250
- * page render, you see those cloned last items listed first rather than the first item.
251
- *
252
- * then, upon hydration, the items change and the first item is then listed first.
253
- *
254
- * this workaround disables circular functionality for SSR, and swaps to the user-provided
255
- * setting for it on mount.
256
- *
257
- * this flag is also used to hide the extra dots from the mobile view on larger breakpoints
258
- * before hydration occurs and the number of dots is adjusted to the breakpoint.
259
- */
260
313
  isMounted.value = true;
261
314
 
262
- document.addEventListener('keyup', (e) => {
263
- if (e.key === 'Escape') {
264
- // Stop carousel when user presses Escape key, in lieu of pause button
265
- // https://www.w3.org/WAI/WCAG22/Techniques/general/G187.html
266
- stopAutoplay();
315
+ const api = emblaApi.value;
316
+ if (api) {
317
+ onReInit();
318
+ api.on('select', onSelect);
319
+ api.on('reInit', onReInit);
320
+ /*
321
+ The autoplay plugin stops itself on these two, but doesn't tell us, so a later
322
+ reinitialization would start it up again. Running our own handler on the same events
323
+ records the stop and keeps it stopped. Dragging and swiping arrive as `pointerDown`;
324
+ `slideFocusStart` is a slide receiving focus.
325
+ */
326
+ if (autoplayEnabled) {
327
+ api.on('pointerDown', stopAutoplay);
328
+ api.on('slideFocusStart', stopAutoplay);
267
329
  }
268
- });
330
+ }
331
+
332
+ if (autoplayEnabled) {
333
+ document.addEventListener('keyup', onEscapeKeyup);
334
+ }
335
+ });
336
+
337
+ onBeforeUnmount(() => {
338
+ const api = emblaApi.value;
339
+ if (api) {
340
+ api.off('select', onSelect);
341
+ api.off('reInit', onReInit);
342
+ api.off('pointerDown', stopAutoplay);
343
+ api.off('slideFocusStart', stopAutoplay);
344
+ }
345
+ document.removeEventListener('keyup', onEscapeKeyup);
269
346
  });
270
347
  </script>
271
348
 
272
349
  <template>
273
- <carousel
274
- :key="key"
275
- :autoplay-interval="autoplayInterval"
276
- :circular="isMounted && circular"
350
+ <div
277
351
  class="es-carousel"
278
- :class="{
279
- 'es-carousel--brand': variant === 'brand',
280
- 'arrows-only': showArrows && !showDots,
281
- 'before-mount': !isMounted,
282
- circular: circular,
283
- dots: showDots,
284
- [`num-dots-sm-${numDotsSm}`]: true,
285
- [`num-dots-md-${numDotsMd}`]: true,
286
- [`num-dots-lg-${numDotsLg}`]: true,
287
- [`num-dots-xl-${numDotsXl}`]: true,
288
- [`num-dots-xxl-${numDotsXxl}`]: true,
289
- }"
290
- :num-scroll="numScroll"
291
- :num-visible="numVisible"
292
- :responsive-options="responsiveOptions"
293
- :show-indicators="showDots"
294
- :show-navigators="showArrows"
295
- :value="items"
296
- :pt="{
297
- container: {
298
- class: 'es-carousel-container d-flex position-relative',
299
- },
300
- indicator: {
301
- class: 'es-carousel-dot',
302
- },
303
- indicators: {
304
- class: 'es-carousel-dots d-flex justify-content-center',
305
- },
306
- indicatorButton: {
307
- class: 'd-block',
308
- },
309
- itemsContent: {
310
- class: [
311
- 'w-100 overflow-hidden',
312
- {
313
- 'es-carousel-peek-desktop': peekDesktop,
314
- 'es-carousel-peek-mobile': peekMobile,
315
- },
316
- ],
317
- },
318
- itemsContainer: {
319
- class: 'd-flex',
320
- },
321
- item: {
322
- class: 'es-carousel-item',
323
- },
324
- itemCloned: {
325
- class: 'es-carousel-item',
326
- },
327
- previousButton: {
328
- class: 'es-carousel-arrow es-carousel-prev-arrow btn btn-outline-primary position-absolute px-sm-50',
329
- },
330
- nextButton: {
331
- class: 'es-carousel-arrow es-carousel-next-arrow btn btn-outline-primary position-absolute px-sm-50',
332
- },
333
- }"
334
- @update:page="(value: number) => emit('update', value)">
335
- <template #item="item">
336
- <slot
337
- name="item"
338
- :item="item.data" />
339
- </template>
340
- <template #previousicon>
341
- <icon-chevron-left />
342
- </template>
343
- <template #nexticon>
344
- <icon-chevron-right />
345
- </template>
346
- </carousel>
352
+ :class="[
353
+ { 'es-carousel--brand': variant === 'brand', 'before-mount': !isMounted },
354
+ !isMounted
355
+ ? [
356
+ `num-dots-${estSnapsXs}`,
357
+ `num-dots-sm-${estSnapsSm}`,
358
+ `num-dots-md-${estSnapsMd}`,
359
+ `num-dots-lg-${estSnapsLg}`,
360
+ `num-dots-xl-${estSnapsXl}`,
361
+ `num-dots-xxl-${estSnapsXxl}`,
362
+ ]
363
+ : [],
364
+ ]"
365
+ role="region"
366
+ aria-roledescription="carousel"
367
+ :aria-label="ariaLabel">
368
+ <div
369
+ ref="emblaRef"
370
+ class="es-carousel__viewport">
371
+ <div
372
+ class="es-carousel__container d-flex"
373
+ :aria-live="autoPlay ? 'off' : 'polite'">
374
+ <div
375
+ v-for="(item, index) in items"
376
+ :key="index"
377
+ class="es-carousel__slide"
378
+ role="group"
379
+ aria-roledescription="slide"
380
+ :aria-label="`${index + 1} of ${items.length}`">
381
+ <slot
382
+ name="item"
383
+ :item="item" />
384
+ </div>
385
+ </div>
386
+ </div>
387
+
388
+ <div
389
+ v-if="showControls"
390
+ class="es-carousel__controls d-flex align-items-center justify-content-center">
391
+ <button
392
+ v-if="showArrows"
393
+ type="button"
394
+ class="es-carousel__arrow es-carousel__arrow--prev"
395
+ aria-label="Previous slide"
396
+ :disabled="!canScrollPrev"
397
+ @click="scrollPrev">
398
+ <icon-chevron-left />
399
+ </button>
400
+
401
+ <ul
402
+ v-if="showDotsRow"
403
+ class="es-carousel__dots d-flex align-items-center"
404
+ role="list"
405
+ @keydown="onDotsKeydown">
406
+ <li
407
+ v-for="index in dotIndices"
408
+ :key="index"
409
+ class="es-carousel__dot">
410
+ <button
411
+ type="button"
412
+ class="d-block"
413
+ :class="{ 'es-carousel__dot--active': index === selectedIndex }"
414
+ :tabindex="index === selectedIndex ? 0 : -1"
415
+ :aria-label="`Go to slide ${index + 1}`"
416
+ :aria-current="index === selectedIndex ? 'true' : undefined"
417
+ @click="scrollTo(index)" />
418
+ </li>
419
+ </ul>
420
+ <span
421
+ v-else-if="showArrows"
422
+ class="es-carousel__arrow-spacer"
423
+ aria-hidden="true" />
424
+
425
+ <button
426
+ v-if="showArrows"
427
+ type="button"
428
+ class="es-carousel__arrow es-carousel__arrow--next"
429
+ aria-label="Next slide"
430
+ :disabled="!canScrollNext"
431
+ @click="scrollNext">
432
+ <icon-chevron-right />
433
+ </button>
434
+ </div>
435
+ </div>
347
436
  </template>
348
437
 
349
438
  <style lang="scss" scoped>
439
+ @use 'sass:map';
350
440
  @use '@energysage/es-ds-styles/scss/variables' as variables;
351
441
  @use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
352
- @use 'sass:map';
353
442
 
354
- /**
355
- solution for PrimeVue initially displaying the mobile breakpoint's number of dots on page load
356
- until hydration, when it then adjusts to the actual breakpoint and corrects the number of dots
357
-
358
- generate CSS classes for each breakpoint that hide the dots that should be hidden before mount
443
+ /*
444
+ Before hydration we render the (max) estimated number of dots and hide the surplus at each
445
+ breakpoint, so the correct count shows at every breakpoint until Embla's measured snap list takes
446
+ over on mount. The `num-dots{infix}-{n}` class carries each breakpoint's estimated count.
359
447
  */
360
- $num-dots-supported: 8;
448
+ /* keep in sync with the note on the dot estimate in the script block above */
449
+ $num-dots-supported: 20;
361
450
  .es-carousel.before-mount {
362
451
  @each $breakpoint in map.keys(variables.$grid-breakpoints) {
363
452
  @include breakpoints.media-breakpoint-up($breakpoint) {
364
453
  $infix: breakpoints.breakpoint-infix($breakpoint, variables.$grid-breakpoints);
365
454
  @for $i from 1 through $num-dots-supported {
366
- &.num-dots#{$infix}-#{$i} :deep(.es-carousel-dot:nth-child(#{$i}) ~ .es-carousel-dot) {
455
+ &.num-dots#{$infix}-#{$i} .es-carousel__dot:nth-child(#{$i}) ~ .es-carousel__dot {
367
456
  display: none;
368
457
  }
369
458
  }
@@ -371,192 +460,91 @@ $num-dots-supported: 8;
371
460
  }
372
461
  }
373
462
 
374
- /* prevent the prev arrow from looking disabled on circular carousels on first paint */
375
- .es-carousel.before-mount.circular :deep(.es-carousel-prev-arrow:disabled) {
376
- color: variables.$gray-900;
377
- }
378
- .es-carousel.es-carousel--brand.before-mount.circular :deep(.es-carousel-prev-arrow:disabled) {
379
- color: variables.$blue-900;
380
- }
381
-
382
- /* arrows are positioned absolutely, so when arrows are shown but dots are not, we need to reserve space for them */
383
- .es-carousel.arrows-only {
384
- padding-bottom: v-bind(arrowsOnlyBottomSpacing);
385
- }
386
-
387
- /* ensure there's enough space between the dots (when present) and the next content on the page */
388
- .es-carousel.dots {
389
- padding-bottom: 0.25rem;
390
- }
391
-
392
- /* make the carousel card edges align with page content */
393
- :deep(.es-carousel-container) {
463
+ /* the viewport clips the slides; negative margins pull its edges back out to align with page content */
464
+ .es-carousel__viewport {
465
+ overflow: hidden;
394
466
  margin-left: v-bind(negativeMargin);
395
467
  margin-right: v-bind(negativeMargin);
396
468
 
397
- > div.es-carousel-peek-desktop {
398
- @include breakpoints.media-breakpoint-up(lg) {
399
- padding-right: v-bind(peekDesktop);
400
- }
401
- }
402
-
403
- > div.es-carousel-peek-mobile {
404
- @include breakpoints.media-breakpoint-down(sm) {
405
- padding-right: v-bind(peekMobile);
406
- }
407
- }
408
- }
409
-
410
- /* card sizing, based on num visible at each breakpoint */
411
- :deep(.es-carousel-item) {
412
- flex: 1 0 calc(100% / v-bind(numVisibleXs));
413
- padding: 0 v-bind(sidePadding);
414
-
415
- @include breakpoints.media-breakpoint-up(sm) {
416
- flex: 1 0 calc(100% / v-bind(numVisibleSm));
417
- }
418
-
419
- @include breakpoints.media-breakpoint-up(md) {
420
- flex: 1 0 calc(100% / v-bind(numVisibleMd));
469
+ /* peek: reveal a cut-off of the next slide by padding the viewport's right edge */
470
+ @include breakpoints.media-breakpoint-down(sm) {
471
+ padding-right: v-bind(peekMobile);
421
472
  }
422
473
 
423
474
  @include breakpoints.media-breakpoint-up(lg) {
424
- flex: 1 0 calc(100% / v-bind(numVisibleLg));
425
- }
426
-
427
- @include breakpoints.media-breakpoint-up(xl) {
428
- flex: 1 0 calc(100% / v-bind(numVisibleXl));
429
- }
430
-
431
- @include breakpoints.media-breakpoint-up(xxl) {
432
- flex: 1 0 calc(100% / v-bind(numVisibleXxl));
475
+ padding-right: v-bind(peekDesktop);
433
476
  }
434
477
  }
435
478
 
436
- /* previous arrow horizontal positioning at each breakpoint */
437
- :deep(.es-carousel-prev-arrow) {
438
- transform: translateX(v-bind(prevArrowTranslateXs));
479
+ /* the flex track that Embla translates */
480
+ .es-carousel__container {
481
+ /* allow vertical page scroll to pass through when dragging horizontally */
482
+ touch-action: pan-y pinch-zoom;
483
+ }
439
484
 
440
- /* keep the "shift 1px down on click" transform from removing our transform */
441
- &:not(:disabled):not(.disabled):active {
442
- transform: translateX(v-bind(prevArrowTranslateXs)) translateY(1px);
443
- }
485
+ /* each slide: width is driven by numVisible at each breakpoint; side padding creates the slide gap */
486
+ .es-carousel__slide {
487
+ flex: 0 0 calc(100% / v-bind(numVisibleXs));
488
+ min-width: 0;
489
+ padding: 0 v-bind(sidePadding);
444
490
 
445
491
  @include breakpoints.media-breakpoint-up(sm) {
446
- transform: translateX(v-bind(prevArrowTranslateSm));
447
-
448
- &:not(:disabled):not(.disabled):active {
449
- transform: translateX(v-bind(prevArrowTranslateSm)) translateY(1px);
450
- }
492
+ flex: 0 0 calc(100% / v-bind(numVisibleSm));
451
493
  }
452
494
 
453
495
  @include breakpoints.media-breakpoint-up(md) {
454
- transform: translateX(v-bind(prevArrowTranslateMd));
455
-
456
- &:not(:disabled):not(.disabled):active {
457
- transform: translateX(v-bind(prevArrowTranslateMd)) translateY(1px);
458
- }
496
+ flex: 0 0 calc(100% / v-bind(numVisibleMd));
459
497
  }
460
498
 
461
499
  @include breakpoints.media-breakpoint-up(lg) {
462
- transform: translateX(v-bind(prevArrowTranslateLg));
463
-
464
- &:not(:disabled):not(.disabled):active {
465
- transform: translateX(v-bind(prevArrowTranslateLg)) translateY(1px);
466
- }
500
+ flex: 0 0 calc(100% / v-bind(numVisibleLg));
467
501
  }
468
502
 
469
503
  @include breakpoints.media-breakpoint-up(xl) {
470
- transform: translateX(v-bind(prevArrowTranslateXl));
471
-
472
- &:not(:disabled):not(.disabled):active {
473
- transform: translateX(v-bind(prevArrowTranslateXl)) translateY(1px);
474
- }
504
+ flex: 0 0 calc(100% / v-bind(numVisibleXl));
475
505
  }
476
506
 
477
507
  @include breakpoints.media-breakpoint-up(xxl) {
478
- transform: translateX(v-bind(prevArrowTranslateXxl));
479
-
480
- &:not(:disabled):not(.disabled):active {
481
- transform: translateX(v-bind(prevArrowTranslateXxl)) translateY(1px);
482
- }
508
+ flex: 0 0 calc(100% / v-bind(numVisibleXxl));
483
509
  }
484
510
  }
485
511
 
486
- /* next arrow horizontal positioning at each breakpoint */
487
- :deep(.es-carousel-next-arrow) {
488
- transform: translateX(v-bind(nextArrowTranslateXs));
489
-
490
- /* keep the "shift 1px down on click" transform from removing our transform */
491
- &:not(:disabled):not(.disabled):active {
492
- transform: translateX(v-bind(nextArrowTranslateXs)) translateY(1px);
493
- }
494
-
495
- @include breakpoints.media-breakpoint-up(sm) {
496
- transform: translateX(v-bind(nextArrowTranslateSm));
497
-
498
- &:not(:disabled):not(.disabled):active {
499
- transform: translateX(v-bind(nextArrowTranslateSm)) translateY(1px);
500
- }
501
- }
502
-
503
- @include breakpoints.media-breakpoint-up(md) {
504
- transform: translateX(v-bind(nextArrowTranslateMd));
505
-
506
- &:not(:disabled):not(.disabled):active {
507
- transform: translateX(v-bind(nextArrowTranslateMd)) translateY(1px);
508
- }
509
- }
510
-
511
- @include breakpoints.media-breakpoint-up(lg) {
512
- transform: translateX(v-bind(nextArrowTranslateLg));
513
-
514
- &:not(:disabled):not(.disabled):active {
515
- transform: translateX(v-bind(nextArrowTranslateLg)) translateY(1px);
516
- }
517
- }
518
-
519
- @include breakpoints.media-breakpoint-up(xl) {
520
- transform: translateX(v-bind(nextArrowTranslateXl));
521
-
522
- &:not(:disabled):not(.disabled):active {
523
- transform: translateX(v-bind(nextArrowTranslateXl)) translateY(1px);
524
- }
525
- }
526
-
527
- @include breakpoints.media-breakpoint-up(xxl) {
528
- transform: translateX(v-bind(nextArrowTranslateXxl));
512
+ /* controls row: prev arrow | dots | next arrow, centered below the carousel */
513
+ .es-carousel__controls {
514
+ gap: v-bind(dotSpacing);
515
+ margin-top: v-bind(controlsMarginTop);
516
+ }
529
517
 
530
- &:not(:disabled):not(.disabled):active {
531
- transform: translateX(v-bind(nextArrowTranslateXxl)) translateY(1px);
532
- }
533
- }
518
+ /* keeps the arrows a sensible distance apart when there are no dots between them */
519
+ .es-carousel__arrow-spacer {
520
+ display: inline-block;
521
+ width: 2rem;
534
522
  }
535
523
 
536
- /* prev/next arrow button styling */
537
- :deep(.es-carousel-arrow) {
538
- background: unset;
539
- border: unset;
540
- bottom: v-bind(arrowPositionBottom);
524
+ /* prev/next arrow buttons */
525
+ .es-carousel__arrow {
526
+ background: none;
527
+ border: none;
541
528
  box-shadow: none;
542
529
  color: variables.$gray-900;
543
- height: auto;
544
- left: 50%;
530
+ line-height: 0;
545
531
  padding: v-bind(arrowPadding);
546
532
 
547
533
  &:hover {
548
534
  color: variables.$gray-700;
549
535
  }
550
- &:focus {
551
- color: variables.$gray-900;
536
+ &:focus-visible {
537
+ outline: 2px solid variables.$blue-600;
538
+ outline-offset: 2px;
552
539
  }
553
- &:not(:disabled):not(.disabled):active {
554
- background: unset;
555
- box-shadow: none;
540
+ &:not(:disabled):active {
556
541
  color: variables.$gray-700;
542
+ /* keep the subtle "press" shift used elsewhere in the design system */
543
+ transform: translateY(1px);
557
544
  }
558
545
  &:disabled {
559
546
  color: variables.$gray-400;
547
+ cursor: default;
560
548
  }
561
549
 
562
550
  svg {
@@ -566,39 +554,32 @@ $num-dots-supported: 8;
566
554
  }
567
555
  }
568
556
 
569
- /* prev/next arrow button styling for the "brand" variant */
570
- .es-carousel--brand {
571
- :deep(.es-carousel-arrow) {
572
- color: variables.$blue-600;
557
+ /* brand variant: blue arrows */
558
+ .es-carousel--brand .es-carousel__arrow {
559
+ color: variables.$blue-600;
573
560
 
574
- &:hover {
575
- color: variables.$blue-700;
576
- }
577
- &:not(:disabled):not(.disabled):active {
578
- color: variables.$blue-800;
579
- }
580
- &:disabled {
581
- color: variables.$gray-400;
582
- }
561
+ &:hover {
562
+ color: variables.$blue-700;
563
+ }
564
+ &:not(:disabled):active {
565
+ color: variables.$blue-800;
566
+ }
567
+ &:disabled {
568
+ color: variables.$gray-400;
583
569
  }
584
570
  }
585
571
 
586
- /* dots container */
587
- :deep(.es-carousel-dots) {
572
+ /* dots */
573
+ .es-carousel__dots {
588
574
  gap: v-bind(dotSpacing);
575
+ list-style: none;
576
+ margin-bottom: 0;
589
577
  padding-left: 0;
590
- margin-top: v-bind(dotsMarginTop);
591
578
  }
592
579
 
593
- /* each individual dot */
594
- :deep(.es-carousel-dot) {
595
- list-style-type: none;
580
+ .es-carousel__dot {
596
581
  margin-bottom: 0;
597
582
 
598
- &[data-p-highlight='true'] button {
599
- background-color: variables.$orange-800;
600
- }
601
-
602
583
  button {
603
584
  background-color: variables.$gray-100;
604
585
  border: none;
@@ -610,6 +591,15 @@ $num-dots-supported: 8;
610
591
  &:hover {
611
592
  opacity: 0.8;
612
593
  }
594
+ /* the arrow keys move focus between the dots, so the focused dot has to be obvious */
595
+ &:focus-visible {
596
+ outline: 2px solid variables.$blue-600;
597
+ outline-offset: 2px;
598
+ }
599
+ }
600
+
601
+ button.es-carousel__dot--active {
602
+ background-color: variables.$orange-800;
613
603
  }
614
604
  }
615
605
  </style>
package/nuxt.config.ts CHANGED
@@ -68,7 +68,6 @@ export default defineNuxtConfig({
68
68
  'primevue/badgedirective',
69
69
  'primevue/breadcrumb',
70
70
  'primevue/button',
71
- 'primevue/carousel',
72
71
  'primevue/column',
73
72
  'primevue/datatable',
74
73
  'primevue/dialog',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energysage/es-ds-components",
3
- "version": "5.7.1",
3
+ "version": "5.7.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "An EnergySage Vue component library",
@@ -29,13 +29,16 @@
29
29
  },
30
30
  "license": "MIT",
31
31
  "devDependencies": {
32
- "@energysage/es-ds-styles": "^3.5.8",
32
+ "@energysage/es-ds-styles": "^3.5.9",
33
33
  "@nuxt/eslint": "^1.14.0",
34
34
  "@nuxtjs/google-fonts": "^3.2.0",
35
35
  "@phosphor-icons/vue": "^2.2.1",
36
36
  "@vuelidate/core": "^2.0.3",
37
37
  "@vuelidate/validators": "^2.0.4",
38
38
  "@vueuse/core": "^13.9.0",
39
+ "embla-carousel": "^8.6.0",
40
+ "embla-carousel-autoplay": "^8.6.0",
41
+ "embla-carousel-vue": "^8.6.0",
39
42
  "eslint": "^10.2.1",
40
43
  "eslint-config-prettier": "^10.0.1",
41
44
  "html-truncate": "^1.2.2",