@energysage/es-ds-components 5.7.0 → 5.7.2
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 +22 -0
- package/app/components/es-carousel.vue +377 -418
- package/app/components/es-sticky-bar.vue +19 -6
- package/nuxt.config.ts +0 -1
- package/package.json +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [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)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* critical, high, and moderate vulnerabilities ([#1775](https://github.com/EnergySage/es-ds/issues/1775)) ([5453306](https://github.com/EnergySage/es-ds/commit/54533067655059bba89ece29613af3846ea058f7))
|
|
9
|
+
* 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))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Dependencies
|
|
13
|
+
|
|
14
|
+
* The following workspace dependencies were updated
|
|
15
|
+
* devDependencies
|
|
16
|
+
* @energysage/es-ds-styles bumped from ^3.5.8 to ^3.5.9
|
|
17
|
+
|
|
18
|
+
## [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)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Bug Fixes
|
|
22
|
+
|
|
23
|
+
* floating sticky bar no longer causes mobile layout shift ([#1773](https://github.com/EnergySage/es-ds/issues/1773)) ([077eb77](https://github.com/EnergySage/es-ds/commit/077eb77f062df07a2e8714e8146c842e11c1b2db))
|
|
24
|
+
|
|
3
25
|
## [5.7.0](https://github.com/EnergySage/es-ds/compare/es-ds-components-v5.6.2...es-ds-components-v5.7.0) (2026-06-11)
|
|
4
26
|
|
|
5
27
|
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/*
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
|
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
|
|
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,352 @@ const BREAKPOINTS = {
|
|
|
73
76
|
XXL: parseSassBreakpoint(sassBreakpoints.xxl!),
|
|
74
77
|
};
|
|
75
78
|
|
|
76
|
-
// lower breakpoint values propagate to higher breakpoints unless overridden
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
94
|
-
// to
|
|
95
|
-
//
|
|
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
|
|
115
|
+
const arrowPadding = `${ARROW_BUTTON_PADDING / BASE_FONT_SIZE}rem`;
|
|
110
116
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const
|
|
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
|
-
//
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
:
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
);
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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`.)
|
|
149
158
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
159
|
+
It is a plugin, added at creation time. stopOnInteraction is false so it matches the previous
|
|
160
|
+
behavior of running until the user presses Esc.
|
|
161
|
+
*/
|
|
162
|
+
const autoplayEnabled = props.autoPlay && !prefersReducedMotion.value;
|
|
163
|
+
const autoplayPlugins = autoplayEnabled ? [Autoplay({ delay: props.autoPlayInterval, stopOnInteraction: false })] : [];
|
|
164
|
+
|
|
165
|
+
// `emblaOptions` is passed as a ref, not unwrapped: the composable then watches it and reinitializes
|
|
166
|
+
// the carousel itself when the options change, skipping the work when the new options are equivalent.
|
|
167
|
+
const [emblaRef, emblaApi] = emblaCarouselVue(emblaOptions, autoplayPlugins);
|
|
168
|
+
|
|
169
|
+
// reactive state derived from the Embla API
|
|
170
|
+
const scrollSnaps = ref<number[]>([]);
|
|
171
|
+
const selectedIndex = ref(0);
|
|
172
|
+
const canScrollPrev = ref(false);
|
|
173
|
+
const canScrollNext = ref(false);
|
|
174
|
+
|
|
175
|
+
// remembers that the user explicitly stopped autoplay, so that it stays stopped across reinits
|
|
176
|
+
const autoplayStopped = ref(false);
|
|
177
|
+
|
|
178
|
+
const updateNavState = () => {
|
|
179
|
+
const api = emblaApi.value;
|
|
180
|
+
if (!api) return;
|
|
181
|
+
selectedIndex.value = api.selectedScrollSnap();
|
|
182
|
+
canScrollPrev.value = api.canScrollPrev();
|
|
183
|
+
canScrollNext.value = api.canScrollNext();
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const onSelect = () => {
|
|
187
|
+
updateNavState();
|
|
188
|
+
emit('update', selectedIndex.value);
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const onReInit = () => {
|
|
192
|
+
const api = emblaApi.value;
|
|
193
|
+
if (!api) return;
|
|
194
|
+
scrollSnaps.value = api.scrollSnapList();
|
|
195
|
+
updateNavState();
|
|
196
|
+
|
|
197
|
+
// Embla re-creates and restarts its plugins on every reInit, which it does on its own whenever
|
|
198
|
+
// one of the `breakpoints` media queries changes (a phone rotating, a window resize across a
|
|
199
|
+
// breakpoint). Without this, motion the user deliberately stopped would silently resume.
|
|
200
|
+
if (autoplayStopped.value) {
|
|
201
|
+
api.plugins()?.autoplay?.stop();
|
|
185
202
|
}
|
|
203
|
+
};
|
|
186
204
|
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
});
|
|
205
|
+
const scrollPrev = () => emblaApi.value?.scrollPrev();
|
|
206
|
+
const scrollNext = () => emblaApi.value?.scrollNext();
|
|
207
|
+
const scrollTo = (index: number) => emblaApi.value?.scrollTo(index);
|
|
233
208
|
|
|
234
|
-
const
|
|
209
|
+
const stopAutoplay = () => {
|
|
210
|
+
autoplayStopped.value = true;
|
|
211
|
+
emblaApi.value?.plugins()?.autoplay?.stop();
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// stop carousel when user presses Escape key, in lieu of a pause button
|
|
215
|
+
// https://www.w3.org/WAI/WCAG22/Techniques/general/G187.html
|
|
216
|
+
const onEscapeKeyup = (e: KeyboardEvent) => {
|
|
217
|
+
if (e.key === 'Escape') {
|
|
218
|
+
stopAutoplay();
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/*
|
|
223
|
+
Dots come from Embla's measured scroll-snap list, which only exists after the carousel
|
|
224
|
+
initializes on the client. To keep the dots (and the arrow spacing) from popping in after
|
|
225
|
+
hydration, we render an estimated dot count during SSR / before mount using the same math Embla
|
|
226
|
+
uses, then reconcile with the real snap list once mounted. Per-breakpoint estimates are handed
|
|
227
|
+
to CSS (see the `.before-mount` rules below) so the count is already correct at every breakpoint
|
|
228
|
+
before hydration.
|
|
229
|
+
|
|
230
|
+
That CSS can only hide surplus dots up to `$num-dots-supported` in the style block. Beyond that
|
|
231
|
+
a breakpoint would briefly show the largest breakpoint's dot count until hydration corrects it.
|
|
232
|
+
The limit is set well past what this component should ever show — more than a handful of dots is
|
|
233
|
+
an anti-pattern — so in practice it isn't reachable.
|
|
234
|
+
*/
|
|
235
235
|
const isMounted = ref(false);
|
|
236
|
-
const key = ref('');
|
|
237
236
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
// `items` is a required prop, so it has no default. it is still read defensively here because this
|
|
238
|
+
// runs during setup, where a consumer that omitted it (from JS, or a dynamic component) would
|
|
239
|
+
// otherwise throw before Vue's "missing required prop" warning is any help.
|
|
240
|
+
const estimateSnaps = (visible: number, scroll: number) =>
|
|
241
|
+
Math.max(1, Math.ceil(((props.items?.length ?? 0) - visible) / scroll) + 1);
|
|
242
|
+
const estSnapsXs = computed(() => estimateSnaps(numVisibleXs.value, numScrollXs.value));
|
|
243
|
+
const estSnapsSm = computed(() => estimateSnaps(numVisibleSm.value, numScrollSm.value));
|
|
244
|
+
const estSnapsMd = computed(() => estimateSnaps(numVisibleMd.value, numScrollMd.value));
|
|
245
|
+
const estSnapsLg = computed(() => estimateSnaps(numVisibleLg.value, numScrollLg.value));
|
|
246
|
+
const estSnapsXl = computed(() => estimateSnaps(numVisibleXl.value, numScrollXl.value));
|
|
247
|
+
const estSnapsXxl = computed(() => estimateSnaps(numVisibleXxl.value, numScrollXxl.value));
|
|
248
|
+
const maxEstimatedSnaps = computed(() =>
|
|
249
|
+
Math.max(
|
|
250
|
+
estSnapsXs.value,
|
|
251
|
+
estSnapsSm.value,
|
|
252
|
+
estSnapsMd.value,
|
|
253
|
+
estSnapsLg.value,
|
|
254
|
+
estSnapsXl.value,
|
|
255
|
+
estSnapsXxl.value,
|
|
256
|
+
),
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
// number of dots to render: the measured snap count once mounted, otherwise the (max) estimate
|
|
260
|
+
const dotCount = computed(() => (isMounted.value ? scrollSnaps.value.length : maxEstimatedSnaps.value));
|
|
261
|
+
const dotIndices = computed(() => Array.from({ length: dotCount.value }, (_, i) => i));
|
|
262
|
+
|
|
263
|
+
// show the controls row when there's something to put in it
|
|
264
|
+
const showDotsRow = computed(() => props.showDots && dotCount.value > 1);
|
|
265
|
+
const showControls = computed(() => props.showArrows || showDotsRow.value);
|
|
266
|
+
|
|
267
|
+
/*
|
|
268
|
+
The dots are a single tab stop, with the arrow keys moving between them (the roving tabindex
|
|
269
|
+
approach from the ARIA APG tabs pattern). This is bound to the dots list rather than the
|
|
270
|
+
carousel as a whole, so it can never intercept arrow keys meant for content inside a slide.
|
|
271
|
+
*/
|
|
272
|
+
const onDotsKeydown = (e: KeyboardEvent) => {
|
|
273
|
+
let target: number;
|
|
274
|
+
if (e.key === 'ArrowRight') {
|
|
275
|
+
target = selectedIndex.value + 1;
|
|
276
|
+
} else if (e.key === 'ArrowLeft') {
|
|
277
|
+
target = selectedIndex.value - 1;
|
|
278
|
+
} else if (e.key === 'Home') {
|
|
279
|
+
target = 0;
|
|
280
|
+
} else if (e.key === 'End') {
|
|
281
|
+
target = dotCount.value - 1;
|
|
282
|
+
} else {
|
|
283
|
+
return;
|
|
242
284
|
}
|
|
285
|
+
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
// clamp rather than wrap, so the carousel doesn't jump end-to-end on a single keypress
|
|
288
|
+
const index = Math.min(dotCount.value - 1, Math.max(0, target));
|
|
289
|
+
scrollTo(index);
|
|
290
|
+
(e.currentTarget as HTMLElement).querySelectorAll('button')[index]?.focus();
|
|
243
291
|
};
|
|
244
292
|
|
|
245
293
|
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
294
|
isMounted.value = true;
|
|
261
295
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
296
|
+
const api = emblaApi.value;
|
|
297
|
+
if (api) {
|
|
298
|
+
onReInit();
|
|
299
|
+
api.on('select', onSelect);
|
|
300
|
+
api.on('reInit', onReInit);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (autoplayEnabled) {
|
|
304
|
+
document.addEventListener('keyup', onEscapeKeyup);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
onBeforeUnmount(() => {
|
|
309
|
+
const api = emblaApi.value;
|
|
310
|
+
if (api) {
|
|
311
|
+
api.off('select', onSelect);
|
|
312
|
+
api.off('reInit', onReInit);
|
|
313
|
+
}
|
|
314
|
+
document.removeEventListener('keyup', onEscapeKeyup);
|
|
269
315
|
});
|
|
270
316
|
</script>
|
|
271
317
|
|
|
272
318
|
<template>
|
|
273
|
-
<
|
|
274
|
-
:key="key"
|
|
275
|
-
:autoplay-interval="autoplayInterval"
|
|
276
|
-
:circular="isMounted && circular"
|
|
319
|
+
<div
|
|
277
320
|
class="es-carousel"
|
|
278
|
-
:class="
|
|
279
|
-
'es-carousel--brand': variant === 'brand',
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
:
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
class
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
321
|
+
:class="[
|
|
322
|
+
{ 'es-carousel--brand': variant === 'brand', 'before-mount': !isMounted },
|
|
323
|
+
!isMounted
|
|
324
|
+
? [
|
|
325
|
+
`num-dots-${estSnapsXs}`,
|
|
326
|
+
`num-dots-sm-${estSnapsSm}`,
|
|
327
|
+
`num-dots-md-${estSnapsMd}`,
|
|
328
|
+
`num-dots-lg-${estSnapsLg}`,
|
|
329
|
+
`num-dots-xl-${estSnapsXl}`,
|
|
330
|
+
`num-dots-xxl-${estSnapsXxl}`,
|
|
331
|
+
]
|
|
332
|
+
: [],
|
|
333
|
+
]"
|
|
334
|
+
role="region"
|
|
335
|
+
aria-roledescription="carousel"
|
|
336
|
+
:aria-label="ariaLabel">
|
|
337
|
+
<div
|
|
338
|
+
ref="emblaRef"
|
|
339
|
+
class="es-carousel__viewport">
|
|
340
|
+
<div
|
|
341
|
+
class="es-carousel__container d-flex"
|
|
342
|
+
:aria-live="autoPlay ? 'off' : 'polite'">
|
|
343
|
+
<div
|
|
344
|
+
v-for="(item, index) in items"
|
|
345
|
+
:key="index"
|
|
346
|
+
class="es-carousel__slide"
|
|
347
|
+
role="group"
|
|
348
|
+
aria-roledescription="slide"
|
|
349
|
+
:aria-label="`${index + 1} of ${items.length}`">
|
|
350
|
+
<slot
|
|
351
|
+
name="item"
|
|
352
|
+
:item="item" />
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
</div>
|
|
356
|
+
|
|
357
|
+
<div
|
|
358
|
+
v-if="showControls"
|
|
359
|
+
class="es-carousel__controls d-flex align-items-center justify-content-center">
|
|
360
|
+
<button
|
|
361
|
+
v-if="showArrows"
|
|
362
|
+
type="button"
|
|
363
|
+
class="es-carousel__arrow es-carousel__arrow--prev"
|
|
364
|
+
aria-label="Previous slide"
|
|
365
|
+
:disabled="!canScrollPrev"
|
|
366
|
+
@click="scrollPrev">
|
|
367
|
+
<icon-chevron-left />
|
|
368
|
+
</button>
|
|
369
|
+
|
|
370
|
+
<ul
|
|
371
|
+
v-if="showDotsRow"
|
|
372
|
+
class="es-carousel__dots d-flex align-items-center"
|
|
373
|
+
role="list"
|
|
374
|
+
@keydown="onDotsKeydown">
|
|
375
|
+
<li
|
|
376
|
+
v-for="index in dotIndices"
|
|
377
|
+
:key="index"
|
|
378
|
+
class="es-carousel__dot">
|
|
379
|
+
<button
|
|
380
|
+
type="button"
|
|
381
|
+
class="d-block"
|
|
382
|
+
:class="{ 'es-carousel__dot--active': index === selectedIndex }"
|
|
383
|
+
:tabindex="index === selectedIndex ? 0 : -1"
|
|
384
|
+
:aria-label="`Go to slide ${index + 1}`"
|
|
385
|
+
:aria-current="index === selectedIndex ? 'true' : undefined"
|
|
386
|
+
@click="scrollTo(index)" />
|
|
387
|
+
</li>
|
|
388
|
+
</ul>
|
|
389
|
+
<span
|
|
390
|
+
v-else-if="showArrows"
|
|
391
|
+
class="es-carousel__arrow-spacer"
|
|
392
|
+
aria-hidden="true" />
|
|
393
|
+
|
|
394
|
+
<button
|
|
395
|
+
v-if="showArrows"
|
|
396
|
+
type="button"
|
|
397
|
+
class="es-carousel__arrow es-carousel__arrow--next"
|
|
398
|
+
aria-label="Next slide"
|
|
399
|
+
:disabled="!canScrollNext"
|
|
400
|
+
@click="scrollNext">
|
|
401
|
+
<icon-chevron-right />
|
|
402
|
+
</button>
|
|
403
|
+
</div>
|
|
404
|
+
</div>
|
|
347
405
|
</template>
|
|
348
406
|
|
|
349
407
|
<style lang="scss" scoped>
|
|
408
|
+
@use 'sass:map';
|
|
350
409
|
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
351
410
|
@use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
|
|
352
|
-
@use 'sass:map';
|
|
353
411
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
generate CSS classes for each breakpoint that hide the dots that should be hidden before mount
|
|
412
|
+
/*
|
|
413
|
+
Before hydration we render the (max) estimated number of dots and hide the surplus at each
|
|
414
|
+
breakpoint, so the correct count shows at every breakpoint until Embla's measured snap list takes
|
|
415
|
+
over on mount. The `num-dots{infix}-{n}` class carries each breakpoint's estimated count.
|
|
359
416
|
*/
|
|
360
|
-
|
|
417
|
+
/* keep in sync with the note on the dot estimate in the script block above */
|
|
418
|
+
$num-dots-supported: 20;
|
|
361
419
|
.es-carousel.before-mount {
|
|
362
420
|
@each $breakpoint in map.keys(variables.$grid-breakpoints) {
|
|
363
421
|
@include breakpoints.media-breakpoint-up($breakpoint) {
|
|
364
422
|
$infix: breakpoints.breakpoint-infix($breakpoint, variables.$grid-breakpoints);
|
|
365
423
|
@for $i from 1 through $num-dots-supported {
|
|
366
|
-
&.num-dots#{$infix}-#{$i}
|
|
424
|
+
&.num-dots#{$infix}-#{$i} .es-carousel__dot:nth-child(#{$i}) ~ .es-carousel__dot {
|
|
367
425
|
display: none;
|
|
368
426
|
}
|
|
369
427
|
}
|
|
@@ -371,192 +429,91 @@ $num-dots-supported: 8;
|
|
|
371
429
|
}
|
|
372
430
|
}
|
|
373
431
|
|
|
374
|
-
/*
|
|
375
|
-
.es-
|
|
376
|
-
|
|
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) {
|
|
432
|
+
/* the viewport clips the slides; negative margins pull its edges back out to align with page content */
|
|
433
|
+
.es-carousel__viewport {
|
|
434
|
+
overflow: hidden;
|
|
394
435
|
margin-left: v-bind(negativeMargin);
|
|
395
436
|
margin-right: v-bind(negativeMargin);
|
|
396
437
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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));
|
|
438
|
+
/* peek: reveal a cut-off of the next slide by padding the viewport's right edge */
|
|
439
|
+
@include breakpoints.media-breakpoint-down(sm) {
|
|
440
|
+
padding-right: v-bind(peekMobile);
|
|
421
441
|
}
|
|
422
442
|
|
|
423
443
|
@include breakpoints.media-breakpoint-up(lg) {
|
|
424
|
-
|
|
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));
|
|
444
|
+
padding-right: v-bind(peekDesktop);
|
|
433
445
|
}
|
|
434
446
|
}
|
|
435
447
|
|
|
436
|
-
/*
|
|
437
|
-
|
|
438
|
-
|
|
448
|
+
/* the flex track that Embla translates */
|
|
449
|
+
.es-carousel__container {
|
|
450
|
+
/* allow vertical page scroll to pass through when dragging horizontally */
|
|
451
|
+
touch-action: pan-y pinch-zoom;
|
|
452
|
+
}
|
|
439
453
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
454
|
+
/* each slide: width is driven by numVisible at each breakpoint; side padding creates the slide gap */
|
|
455
|
+
.es-carousel__slide {
|
|
456
|
+
flex: 0 0 calc(100% / v-bind(numVisibleXs));
|
|
457
|
+
min-width: 0;
|
|
458
|
+
padding: 0 v-bind(sidePadding);
|
|
444
459
|
|
|
445
460
|
@include breakpoints.media-breakpoint-up(sm) {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
&:not(:disabled):not(.disabled):active {
|
|
449
|
-
transform: translateX(v-bind(prevArrowTranslateSm)) translateY(1px);
|
|
450
|
-
}
|
|
461
|
+
flex: 0 0 calc(100% / v-bind(numVisibleSm));
|
|
451
462
|
}
|
|
452
463
|
|
|
453
464
|
@include breakpoints.media-breakpoint-up(md) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
&:not(:disabled):not(.disabled):active {
|
|
457
|
-
transform: translateX(v-bind(prevArrowTranslateMd)) translateY(1px);
|
|
458
|
-
}
|
|
465
|
+
flex: 0 0 calc(100% / v-bind(numVisibleMd));
|
|
459
466
|
}
|
|
460
467
|
|
|
461
468
|
@include breakpoints.media-breakpoint-up(lg) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
&:not(:disabled):not(.disabled):active {
|
|
465
|
-
transform: translateX(v-bind(prevArrowTranslateLg)) translateY(1px);
|
|
466
|
-
}
|
|
469
|
+
flex: 0 0 calc(100% / v-bind(numVisibleLg));
|
|
467
470
|
}
|
|
468
471
|
|
|
469
472
|
@include breakpoints.media-breakpoint-up(xl) {
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
&:not(:disabled):not(.disabled):active {
|
|
473
|
-
transform: translateX(v-bind(prevArrowTranslateXl)) translateY(1px);
|
|
474
|
-
}
|
|
473
|
+
flex: 0 0 calc(100% / v-bind(numVisibleXl));
|
|
475
474
|
}
|
|
476
475
|
|
|
477
476
|
@include breakpoints.media-breakpoint-up(xxl) {
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
&:not(:disabled):not(.disabled):active {
|
|
481
|
-
transform: translateX(v-bind(prevArrowTranslateXxl)) translateY(1px);
|
|
482
|
-
}
|
|
477
|
+
flex: 0 0 calc(100% / v-bind(numVisibleXxl));
|
|
483
478
|
}
|
|
484
479
|
}
|
|
485
480
|
|
|
486
|
-
/* next arrow
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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));
|
|
481
|
+
/* controls row: prev arrow | dots | next arrow, centered below the carousel */
|
|
482
|
+
.es-carousel__controls {
|
|
483
|
+
gap: v-bind(dotSpacing);
|
|
484
|
+
margin-top: v-bind(controlsMarginTop);
|
|
485
|
+
}
|
|
529
486
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
487
|
+
/* keeps the arrows a sensible distance apart when there are no dots between them */
|
|
488
|
+
.es-carousel__arrow-spacer {
|
|
489
|
+
display: inline-block;
|
|
490
|
+
width: 2rem;
|
|
534
491
|
}
|
|
535
492
|
|
|
536
|
-
/* prev/next arrow
|
|
537
|
-
|
|
538
|
-
background:
|
|
539
|
-
border:
|
|
540
|
-
bottom: v-bind(arrowPositionBottom);
|
|
493
|
+
/* prev/next arrow buttons */
|
|
494
|
+
.es-carousel__arrow {
|
|
495
|
+
background: none;
|
|
496
|
+
border: none;
|
|
541
497
|
box-shadow: none;
|
|
542
498
|
color: variables.$gray-900;
|
|
543
|
-
height:
|
|
544
|
-
left: 50%;
|
|
499
|
+
line-height: 0;
|
|
545
500
|
padding: v-bind(arrowPadding);
|
|
546
501
|
|
|
547
502
|
&:hover {
|
|
548
503
|
color: variables.$gray-700;
|
|
549
504
|
}
|
|
550
|
-
&:focus {
|
|
551
|
-
|
|
505
|
+
&:focus-visible {
|
|
506
|
+
outline: 2px solid variables.$blue-600;
|
|
507
|
+
outline-offset: 2px;
|
|
552
508
|
}
|
|
553
|
-
&:not(:disabled):
|
|
554
|
-
background: unset;
|
|
555
|
-
box-shadow: none;
|
|
509
|
+
&:not(:disabled):active {
|
|
556
510
|
color: variables.$gray-700;
|
|
511
|
+
/* keep the subtle "press" shift used elsewhere in the design system */
|
|
512
|
+
transform: translateY(1px);
|
|
557
513
|
}
|
|
558
514
|
&:disabled {
|
|
559
515
|
color: variables.$gray-400;
|
|
516
|
+
cursor: default;
|
|
560
517
|
}
|
|
561
518
|
|
|
562
519
|
svg {
|
|
@@ -566,39 +523,32 @@ $num-dots-supported: 8;
|
|
|
566
523
|
}
|
|
567
524
|
}
|
|
568
525
|
|
|
569
|
-
/*
|
|
570
|
-
.es-carousel--brand {
|
|
571
|
-
:
|
|
572
|
-
color: variables.$blue-600;
|
|
526
|
+
/* brand variant: blue arrows */
|
|
527
|
+
.es-carousel--brand .es-carousel__arrow {
|
|
528
|
+
color: variables.$blue-600;
|
|
573
529
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
}
|
|
530
|
+
&:hover {
|
|
531
|
+
color: variables.$blue-700;
|
|
532
|
+
}
|
|
533
|
+
&:not(:disabled):active {
|
|
534
|
+
color: variables.$blue-800;
|
|
535
|
+
}
|
|
536
|
+
&:disabled {
|
|
537
|
+
color: variables.$gray-400;
|
|
583
538
|
}
|
|
584
539
|
}
|
|
585
540
|
|
|
586
|
-
/* dots
|
|
587
|
-
|
|
541
|
+
/* dots */
|
|
542
|
+
.es-carousel__dots {
|
|
588
543
|
gap: v-bind(dotSpacing);
|
|
544
|
+
list-style: none;
|
|
545
|
+
margin-bottom: 0;
|
|
589
546
|
padding-left: 0;
|
|
590
|
-
margin-top: v-bind(dotsMarginTop);
|
|
591
547
|
}
|
|
592
548
|
|
|
593
|
-
|
|
594
|
-
:deep(.es-carousel-dot) {
|
|
595
|
-
list-style-type: none;
|
|
549
|
+
.es-carousel__dot {
|
|
596
550
|
margin-bottom: 0;
|
|
597
551
|
|
|
598
|
-
&[data-p-highlight='true'] button {
|
|
599
|
-
background-color: variables.$orange-800;
|
|
600
|
-
}
|
|
601
|
-
|
|
602
552
|
button {
|
|
603
553
|
background-color: variables.$gray-100;
|
|
604
554
|
border: none;
|
|
@@ -610,6 +560,15 @@ $num-dots-supported: 8;
|
|
|
610
560
|
&:hover {
|
|
611
561
|
opacity: 0.8;
|
|
612
562
|
}
|
|
563
|
+
/* the arrow keys move focus between the dots, so the focused dot has to be obvious */
|
|
564
|
+
&:focus-visible {
|
|
565
|
+
outline: 2px solid variables.$blue-600;
|
|
566
|
+
outline-offset: 2px;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
button.es-carousel__dot--active {
|
|
571
|
+
background-color: variables.$orange-800;
|
|
613
572
|
}
|
|
614
573
|
}
|
|
615
574
|
</style>
|
|
@@ -7,12 +7,12 @@ import {
|
|
|
7
7
|
} from '../utils/menu-bar';
|
|
8
8
|
|
|
9
9
|
interface IProps {
|
|
10
|
-
|
|
10
|
+
floatStartingAtBreakpoint?: 'lg' | 'xl' | 'xxl' | '';
|
|
11
11
|
transparentColor?: string;
|
|
12
12
|
transparentStartingAtBreakpoint?: 'lg' | 'xl' | 'xxl' | '';
|
|
13
13
|
}
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
withDefaults(defineProps<IProps>(), {
|
|
15
|
+
floatStartingAtBreakpoint: '',
|
|
16
16
|
transparentColor: 'transparent',
|
|
17
17
|
transparentStartingAtBreakpoint: '',
|
|
18
18
|
});
|
|
@@ -28,7 +28,7 @@ const emitter = useEsdsEvents();
|
|
|
28
28
|
// - fixed-visible: bar is pinned to the top of the viewport, visible
|
|
29
29
|
// - fixed-hidden: bar is pinned but translated off screen above the viewport
|
|
30
30
|
type BarState = 'static' | 'absolute' | 'fixed-visible' | 'fixed-hidden';
|
|
31
|
-
const barState = ref<BarState>(
|
|
31
|
+
const barState = ref<BarState>('static');
|
|
32
32
|
|
|
33
33
|
// ref to the bar element, used to read its rendered height
|
|
34
34
|
const bar = ref<HTMLElement | null>(null);
|
|
@@ -189,6 +189,7 @@ onUnmounted(() => {
|
|
|
189
189
|
class="es-sticky-bar"
|
|
190
190
|
:class="[
|
|
191
191
|
`es-sticky-bar--${barState}`,
|
|
192
|
+
floatStartingAtBreakpoint && `es-sticky-bar--float-from-${floatStartingAtBreakpoint}`,
|
|
192
193
|
transparentStartingAtBreakpoint && `es-sticky-bar--transparent-from-${transparentStartingAtBreakpoint}`,
|
|
193
194
|
{
|
|
194
195
|
'es-sticky-bar--no-transition': suppressTransition,
|
|
@@ -200,10 +201,11 @@ onUnmounted(() => {
|
|
|
200
201
|
@mouseout="isHovered = false">
|
|
201
202
|
<slot />
|
|
202
203
|
</div>
|
|
203
|
-
<!-- holds the bar's space in normal flow when the bar is absolutely or fixed positioned
|
|
204
|
+
<!-- holds the bar's space in normal flow when the bar is absolutely or fixed positioned;
|
|
205
|
+
hidden at/above the float breakpoint, where the bar overlaps page content instead -->
|
|
204
206
|
<div
|
|
205
207
|
v-if="barState !== 'static'"
|
|
206
|
-
:class="
|
|
208
|
+
:class="floatStartingAtBreakpoint && `d-${floatStartingAtBreakpoint}-none`"
|
|
207
209
|
:style="{ height: `${barHeight}px` }"
|
|
208
210
|
aria-hidden="true" />
|
|
209
211
|
</template>
|
|
@@ -235,6 +237,17 @@ $shadow: 0 0 6px 0 rgba(34, 38, 51, 0.2);
|
|
|
235
237
|
}
|
|
236
238
|
}
|
|
237
239
|
|
|
240
|
+
// while still in the pre-mount static state, take the bar out of flow at/above the float
|
|
241
|
+
// breakpoint so it overlaps page content from the first paint; below the breakpoint the
|
|
242
|
+
// bar stays in flow until mount (when the spacer appears), avoiding a layout shift
|
|
243
|
+
@each $bp in (lg, xl, xxl) {
|
|
244
|
+
&--float-from-#{$bp}.es-sticky-bar--static {
|
|
245
|
+
@include breakpoints.media-breakpoint-up($bp) {
|
|
246
|
+
position: absolute;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
238
251
|
@media not (prefers-reduced-motion) {
|
|
239
252
|
transition:
|
|
240
253
|
background 0.2s ease-in-out,
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@energysage/es-ds-components",
|
|
3
|
-
"version": "5.7.
|
|
3
|
+
"version": "5.7.2",
|
|
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.
|
|
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",
|