@energysage/es-ds-components 3.0.0-alpha.10 → 3.0.0-alpha.11
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/components/es-carousel.vue +314 -48
- package/components/es-segmented-control.vue +237 -0
- package/nuxt.config.ts +1 -0
- package/package.json +1 -1
|
@@ -1,40 +1,59 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
/*
|
|
3
3
|
TODO:
|
|
4
|
-
- responsive carousels briefly show mobile amount of dots on desktop, until hydration
|
|
5
|
-
- i think we may be able to fix this with CSS, because we know how many dots there should be
|
|
6
|
-
- (something PrimeVue Carousel should really be doing)
|
|
7
4
|
- circular has quirky behavior when numVisible doesn't match numScroll
|
|
8
5
|
- you can see this in the circular autoplay example
|
|
9
6
|
- i'm not sure if this is fixable
|
|
7
|
+
- figure out peek behavior
|
|
8
|
+
- prop to position the arrows at the bottom two corners of a full-width slide, like homepage
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
11
|
import Carousel from 'primevue/carousel';
|
|
13
12
|
import sassBreakpoints from '@energysage/es-ds-styles/scss/modules/breakpoints.module.scss';
|
|
14
13
|
import type { EsCarouselBreakpointsInterface } from '../types';
|
|
15
14
|
|
|
15
|
+
// this isn't set in a SASS variable so we can't import it, but it's
|
|
16
|
+
// defined as a constant here so we can easily change it if we need to
|
|
17
|
+
const BASE_FONT_SIZE = 16;
|
|
18
|
+
|
|
19
|
+
// constants that contribute to dots and arrows positioning
|
|
20
|
+
const DOT_SIZE = 14;
|
|
21
|
+
const DOT_SPACING = 16;
|
|
22
|
+
const ARROW_BUTTON_PADDING = 8;
|
|
23
|
+
|
|
24
|
+
// the number of "dots" apart the arrows should be when dots are hidden
|
|
25
|
+
const ARROW_SPACING_WHEN_NO_DOTS = 1;
|
|
26
|
+
|
|
16
27
|
interface IProps {
|
|
28
|
+
arrowSize?: 'sm' | 'lg';
|
|
17
29
|
autoPlay?: boolean;
|
|
18
30
|
autoPlayInterval?: number;
|
|
19
31
|
breakpoints?: EsCarouselBreakpointsInterface;
|
|
20
32
|
circular?: boolean;
|
|
33
|
+
controlGap?: number;
|
|
21
34
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
35
|
items: Array<any>;
|
|
23
36
|
numScroll?: number;
|
|
24
37
|
numVisible?: number;
|
|
25
38
|
showArrows?: boolean;
|
|
26
39
|
showDots?: boolean;
|
|
40
|
+
slideGap?: number;
|
|
41
|
+
variant?: 'default' | 'brand';
|
|
27
42
|
}
|
|
28
43
|
const props = withDefaults(defineProps<IProps>(), {
|
|
44
|
+
arrowSize: 'sm',
|
|
29
45
|
autoPlay: false,
|
|
30
|
-
autoPlayInterval:
|
|
46
|
+
autoPlayInterval: 4000,
|
|
31
47
|
breakpoints: () => ({}),
|
|
32
48
|
circular: false,
|
|
49
|
+
controlGap: 24,
|
|
33
50
|
items: () => [],
|
|
34
51
|
numScroll: 1,
|
|
35
52
|
numVisible: 1,
|
|
36
53
|
showArrows: true,
|
|
37
54
|
showDots: true,
|
|
55
|
+
slideGap: 16,
|
|
56
|
+
variant: 'default',
|
|
38
57
|
});
|
|
39
58
|
|
|
40
59
|
const emit = defineEmits<{
|
|
@@ -67,16 +86,82 @@ const numScrollLg = computed(() => props.breakpoints?.lg?.numScroll || numScroll
|
|
|
67
86
|
const numScrollXl = computed(() => props.breakpoints?.xl?.numScroll || numScrollLg.value);
|
|
68
87
|
const numScrollXxl = computed(() => props.breakpoints?.xxl?.numScroll || numScrollXl.value);
|
|
69
88
|
|
|
70
|
-
//
|
|
71
|
-
// but
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
const
|
|
89
|
+
// allow customizable spacing between slides
|
|
90
|
+
// but since that's done as side padding around each slide, which moves them in from the container edge
|
|
91
|
+
// to get slides to left and right align with page content
|
|
92
|
+
// we apply a negative margin to either side to bring the container edges back out to match
|
|
93
|
+
const sidePadding = computed(() => `${props.slideGap / 2 / BASE_FONT_SIZE}rem`);
|
|
94
|
+
const negativeMargin = computed(() => `-${sidePadding.value}`);
|
|
95
|
+
|
|
96
|
+
// size of dots and spacing between dots
|
|
97
|
+
const dotSize = `${DOT_SIZE / BASE_FONT_SIZE}rem`;
|
|
98
|
+
const dotSpacing = `${DOT_SPACING / BASE_FONT_SIZE}rem`;
|
|
99
|
+
|
|
100
|
+
// arrow padding
|
|
101
|
+
const arrowPadding = `${ARROW_BUTTON_PADDING / BASE_FONT_SIZE}rem`;
|
|
102
|
+
|
|
103
|
+
// arrow size
|
|
104
|
+
const arrowSize = computed(() => (props.arrowSize === 'lg' ? 32 : 24));
|
|
105
|
+
const arrowSizePx = computed(() => `${arrowSize.value}px`);
|
|
106
|
+
const arrowButtonSize = computed(() => arrowSize.value + ARROW_BUTTON_PADDING * 2);
|
|
107
|
+
|
|
108
|
+
// vertical positioning for the arrows
|
|
109
|
+
const distanceFromCarouselBottomToCenterOfDots = computed(() => props.controlGap + DOT_SIZE / 2);
|
|
110
|
+
const arrowPositionBottom = computed(
|
|
111
|
+
() => `-${distanceFromCarouselBottomToCenterOfDots.value + arrowButtonSize.value / 2}px`,
|
|
112
|
+
);
|
|
113
|
+
const dotsMarginTop = computed(() => `${props.controlGap / BASE_FONT_SIZE}rem`);
|
|
114
|
+
|
|
115
|
+
// the number of dots visible at each breakpoint
|
|
116
|
+
const numDotsXs = computed(() =>
|
|
117
|
+
props.showDots ? Math.ceil(props.items.length / numVisibleXs.value) : ARROW_SPACING_WHEN_NO_DOTS,
|
|
118
|
+
);
|
|
119
|
+
const numDotsSm = computed(() =>
|
|
120
|
+
props.showDots ? Math.ceil(props.items.length / numVisibleSm.value) : ARROW_SPACING_WHEN_NO_DOTS,
|
|
121
|
+
);
|
|
122
|
+
const numDotsMd = computed(() =>
|
|
123
|
+
props.showDots ? Math.ceil(props.items.length / numVisibleMd.value) : ARROW_SPACING_WHEN_NO_DOTS,
|
|
124
|
+
);
|
|
125
|
+
const numDotsLg = computed(() =>
|
|
126
|
+
props.showDots ? Math.ceil(props.items.length / numVisibleLg.value) : ARROW_SPACING_WHEN_NO_DOTS,
|
|
127
|
+
);
|
|
128
|
+
const numDotsXl = computed(() =>
|
|
129
|
+
props.showDots ? Math.ceil(props.items.length / numVisibleXl.value) : ARROW_SPACING_WHEN_NO_DOTS,
|
|
130
|
+
);
|
|
131
|
+
const numDotsXxl = computed(() =>
|
|
132
|
+
props.showDots ? Math.ceil(props.items.length / numVisibleXxl.value) : ARROW_SPACING_WHEN_NO_DOTS,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// calculate the arrow position from center based on the number of dots
|
|
136
|
+
const calculateArrowPosition = (numDots: number): number =>
|
|
137
|
+
(numDots * DOT_SIZE) / 2 + (numDots * DOT_SPACING) / 2 + DOT_SPACING;
|
|
138
|
+
|
|
139
|
+
// determine how much from center we need to move the arrow buttons, based on how many dots there are
|
|
140
|
+
const arrowPositionXs = computed(() => calculateArrowPosition(numDotsXs.value));
|
|
141
|
+
const arrowPositionSm = computed(() => calculateArrowPosition(numDotsSm.value));
|
|
142
|
+
const arrowPositionMd = computed(() => calculateArrowPosition(numDotsMd.value));
|
|
143
|
+
const arrowPositionLg = computed(() => calculateArrowPosition(numDotsLg.value));
|
|
144
|
+
const arrowPositionXl = computed(() => calculateArrowPosition(numDotsXl.value));
|
|
145
|
+
const arrowPositionXxl = computed(() => calculateArrowPosition(numDotsXxl.value));
|
|
146
|
+
|
|
147
|
+
// prev button horizontal position
|
|
148
|
+
const prevArrowTranslateXs = computed(() => `-${arrowPositionXs.value + arrowButtonSize.value}px`);
|
|
149
|
+
const prevArrowTranslateSm = computed(() => `-${arrowPositionSm.value + arrowButtonSize.value}px`);
|
|
150
|
+
const prevArrowTranslateMd = computed(() => `-${arrowPositionMd.value + arrowButtonSize.value}px`);
|
|
151
|
+
const prevArrowTranslateLg = computed(() => `-${arrowPositionLg.value + arrowButtonSize.value}px`);
|
|
152
|
+
const prevArrowTranslateXl = computed(() => `-${arrowPositionXl.value + arrowButtonSize.value}px`);
|
|
153
|
+
const prevArrowTranslateXxl = computed(() => `-${arrowPositionXxl.value + arrowButtonSize.value}px`);
|
|
154
|
+
|
|
155
|
+
// next arrow horizontal position
|
|
156
|
+
const nextArrowTranslateXs = computed(() => `${arrowPositionXs.value}px`);
|
|
157
|
+
const nextArrowTranslateSm = computed(() => `${arrowPositionSm.value}px`);
|
|
158
|
+
const nextArrowTranslateMd = computed(() => `${arrowPositionMd.value}px`);
|
|
159
|
+
const nextArrowTranslateLg = computed(() => `${arrowPositionLg.value}px`);
|
|
160
|
+
const nextArrowTranslateXl = computed(() => `${arrowPositionXl.value}px`);
|
|
161
|
+
const nextArrowTranslateXxl = computed(() => `${arrowPositionXxl.value}px`);
|
|
162
|
+
|
|
163
|
+
// extra space to add below the carousel for the arrows when arrows are on but dots are off
|
|
164
|
+
const arrowsOnlyBottomSpacing = computed(() => `${props.controlGap + arrowButtonSize.value}px`);
|
|
80
165
|
|
|
81
166
|
const responsiveOptions = computed(() => {
|
|
82
167
|
// if no special breakpoints are defined, don't pass in any responsive options
|
|
@@ -153,6 +238,9 @@ onMounted(() => {
|
|
|
153
238
|
*
|
|
154
239
|
* this workaround disables circular functionality for SSR, and swaps to the user-provided
|
|
155
240
|
* setting for it on mount.
|
|
241
|
+
*
|
|
242
|
+
* this flag is also used to hide the extra dots from the mobile view on larger breakpoints
|
|
243
|
+
* before hydration occurs and the number of dots is adjusted to the breakpoint.
|
|
156
244
|
*/
|
|
157
245
|
isMounted.value = true;
|
|
158
246
|
|
|
@@ -170,22 +258,38 @@ onMounted(() => {
|
|
|
170
258
|
<carousel
|
|
171
259
|
:key="key"
|
|
172
260
|
:autoplay-interval="autoplayInterval"
|
|
173
|
-
:circular="isMounted &&
|
|
174
|
-
|
|
175
|
-
:
|
|
261
|
+
:circular="isMounted && circular"
|
|
262
|
+
class="es-carousel"
|
|
263
|
+
:class="{
|
|
264
|
+
'es-carousel--brand': variant === 'brand',
|
|
265
|
+
'arrows-only': showArrows && !showDots,
|
|
266
|
+
'before-mount': !isMounted,
|
|
267
|
+
circular: circular,
|
|
268
|
+
dots: showDots,
|
|
269
|
+
[`num-dots-sm-${numDotsSm}`]: true,
|
|
270
|
+
[`num-dots-md-${numDotsMd}`]: true,
|
|
271
|
+
[`num-dots-lg-${numDotsLg}`]: true,
|
|
272
|
+
[`num-dots-xl-${numDotsXl}`]: true,
|
|
273
|
+
[`num-dots-xxl-${numDotsXxl}`]: true,
|
|
274
|
+
}"
|
|
275
|
+
:num-scroll="numScroll"
|
|
276
|
+
:num-visible="numVisible"
|
|
176
277
|
:responsive-options="responsiveOptions"
|
|
177
|
-
:show-indicators="
|
|
178
|
-
:show-navigators="
|
|
179
|
-
:value="
|
|
278
|
+
:show-indicators="showDots"
|
|
279
|
+
:show-navigators="showArrows"
|
|
280
|
+
:value="items"
|
|
180
281
|
:pt="{
|
|
181
282
|
container: {
|
|
182
|
-
class: 'd-flex',
|
|
283
|
+
class: 'es-carousel-container d-flex position-relative',
|
|
183
284
|
},
|
|
184
285
|
indicator: {
|
|
185
286
|
class: 'es-carousel-dot',
|
|
186
287
|
},
|
|
187
288
|
indicators: {
|
|
188
|
-
class: 'es-carousel-dots d-flex justify-content-center
|
|
289
|
+
class: 'es-carousel-dots d-flex justify-content-center',
|
|
290
|
+
},
|
|
291
|
+
indicatorButton: {
|
|
292
|
+
class: 'd-block',
|
|
189
293
|
},
|
|
190
294
|
itemsContent: {
|
|
191
295
|
class: 'w-100 overflow-hidden',
|
|
@@ -200,10 +304,10 @@ onMounted(() => {
|
|
|
200
304
|
class: 'es-carousel-item',
|
|
201
305
|
},
|
|
202
306
|
previousButton: {
|
|
203
|
-
class: 'es-carousel-arrow es-carousel-prev-arrow btn btn-outline-primary px-sm-50',
|
|
307
|
+
class: 'es-carousel-arrow es-carousel-prev-arrow btn btn-outline-primary position-absolute px-sm-50',
|
|
204
308
|
},
|
|
205
309
|
nextButton: {
|
|
206
|
-
class: 'es-carousel-arrow es-carousel-next-arrow btn btn-outline-primary px-sm-50',
|
|
310
|
+
class: 'es-carousel-arrow es-carousel-next-arrow btn btn-outline-primary position-absolute px-sm-50',
|
|
207
311
|
},
|
|
208
312
|
}"
|
|
209
313
|
@update:page="(value: number) => emit('update', value)">
|
|
@@ -224,53 +328,188 @@ onMounted(() => {
|
|
|
224
328
|
<style lang="scss" scoped>
|
|
225
329
|
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
226
330
|
@use '@energysage/es-ds-styles/scss/mixins/breakpoints' as breakpoints;
|
|
331
|
+
@use 'sass:map';
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
solution for PrimeVue initially displaying the mobile breakpoint's number of dots on page load
|
|
335
|
+
until hydration, when it then adjusts to the actual breakpoint and corrects the number of dots
|
|
336
|
+
|
|
337
|
+
generate CSS classes for each breakpoint that hide the dots that should be hidden before mount
|
|
338
|
+
*/
|
|
339
|
+
$num-dots-supported: 8;
|
|
340
|
+
.es-carousel.before-mount {
|
|
341
|
+
@each $breakpoint in map.keys(variables.$grid-breakpoints) {
|
|
342
|
+
@include breakpoints.media-breakpoint-up($breakpoint) {
|
|
343
|
+
$infix: breakpoints.breakpoint-infix($breakpoint, variables.$grid-breakpoints);
|
|
344
|
+
@for $i from 1 through $num-dots-supported {
|
|
345
|
+
&.num-dots#{$infix}-#{$i} :deep(.es-carousel-dot:nth-child(#{$i}) ~ .es-carousel-dot) {
|
|
346
|
+
display: none;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
227
352
|
|
|
353
|
+
/* prevent the prev arrow from looking disabled on circular carousels on first paint */
|
|
354
|
+
.es-carousel.before-mount.circular :deep(.es-carousel-prev-arrow:disabled) {
|
|
355
|
+
color: variables.$gray-900;
|
|
356
|
+
}
|
|
357
|
+
.es-carousel.es-carousel--brand.before-mount.circular :deep(.es-carousel-prev-arrow:disabled) {
|
|
358
|
+
color: variables.$blue-900;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* arrows are positioned absolutely, so when arrows are shown but dots are not, we need to reserve space for them */
|
|
362
|
+
.es-carousel.arrows-only {
|
|
363
|
+
padding-bottom: v-bind(arrowsOnlyBottomSpacing);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* ensure there's enough space between the dots (when present) and the next content on the page */
|
|
367
|
+
.es-carousel.dots {
|
|
368
|
+
padding-bottom: 0.25rem;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/* make the carousel card edges align with page content */
|
|
372
|
+
:deep(.es-carousel-container) {
|
|
373
|
+
margin-left: v-bind(negativeMargin);
|
|
374
|
+
margin-right: v-bind(negativeMargin);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* card sizing, based on num visible at each breakpoint */
|
|
228
378
|
:deep(.es-carousel-item) {
|
|
229
379
|
flex: 1 0 calc(100% / v-bind(numVisibleXs));
|
|
230
|
-
padding: 0 v-bind(
|
|
231
|
-
}
|
|
380
|
+
padding: 0 v-bind(sidePadding);
|
|
232
381
|
|
|
233
|
-
@include breakpoints.media-breakpoint-up(sm) {
|
|
234
|
-
:deep(.es-carousel-item) {
|
|
382
|
+
@include breakpoints.media-breakpoint-up(sm) {
|
|
235
383
|
flex: 1 0 calc(100% / v-bind(numVisibleSm));
|
|
236
|
-
padding: 0 v-bind(paddingSm);
|
|
237
384
|
}
|
|
238
|
-
}
|
|
239
385
|
|
|
240
|
-
@include breakpoints.media-breakpoint-up(md) {
|
|
241
|
-
:deep(.es-carousel-item) {
|
|
386
|
+
@include breakpoints.media-breakpoint-up(md) {
|
|
242
387
|
flex: 1 0 calc(100% / v-bind(numVisibleMd));
|
|
243
|
-
padding: 0 v-bind(paddingMd);
|
|
244
388
|
}
|
|
245
|
-
}
|
|
246
389
|
|
|
247
|
-
@include breakpoints.media-breakpoint-up(lg) {
|
|
248
|
-
:deep(.es-carousel-item) {
|
|
390
|
+
@include breakpoints.media-breakpoint-up(lg) {
|
|
249
391
|
flex: 1 0 calc(100% / v-bind(numVisibleLg));
|
|
250
|
-
padding: 0 v-bind(paddingLg);
|
|
251
392
|
}
|
|
252
|
-
}
|
|
253
393
|
|
|
254
|
-
@include breakpoints.media-breakpoint-up(xl) {
|
|
255
|
-
:deep(.es-carousel-item) {
|
|
394
|
+
@include breakpoints.media-breakpoint-up(xl) {
|
|
256
395
|
flex: 1 0 calc(100% / v-bind(numVisibleXl));
|
|
257
|
-
padding: 0 v-bind(paddingXl);
|
|
258
396
|
}
|
|
259
|
-
}
|
|
260
397
|
|
|
261
|
-
@include breakpoints.media-breakpoint-up(xxl) {
|
|
262
|
-
:deep(.es-carousel-item) {
|
|
398
|
+
@include breakpoints.media-breakpoint-up(xxl) {
|
|
263
399
|
flex: 1 0 calc(100% / v-bind(numVisibleXxl));
|
|
264
|
-
padding: 0 v-bind(paddingXxl);
|
|
265
400
|
}
|
|
266
401
|
}
|
|
267
402
|
|
|
403
|
+
/* previous arrow horizontal positioning at each breakpoint */
|
|
404
|
+
:deep(.es-carousel-prev-arrow) {
|
|
405
|
+
transform: translateX(v-bind(prevArrowTranslateXs));
|
|
406
|
+
|
|
407
|
+
/* keep the "shift 1px down on click" transform from removing our transform */
|
|
408
|
+
&:not(:disabled):not(.disabled):active {
|
|
409
|
+
transform: translateX(v-bind(prevArrowTranslateXs)) translateY(1px);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
@include breakpoints.media-breakpoint-up(sm) {
|
|
413
|
+
transform: translateX(v-bind(prevArrowTranslateSm));
|
|
414
|
+
|
|
415
|
+
&:not(:disabled):not(.disabled):active {
|
|
416
|
+
transform: translateX(v-bind(prevArrowTranslateSm)) translateY(1px);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
@include breakpoints.media-breakpoint-up(md) {
|
|
421
|
+
transform: translateX(v-bind(prevArrowTranslateMd));
|
|
422
|
+
|
|
423
|
+
&:not(:disabled):not(.disabled):active {
|
|
424
|
+
transform: translateX(v-bind(prevArrowTranslateMd)) translateY(1px);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
@include breakpoints.media-breakpoint-up(lg) {
|
|
429
|
+
transform: translateX(v-bind(prevArrowTranslateLg));
|
|
430
|
+
|
|
431
|
+
&:not(:disabled):not(.disabled):active {
|
|
432
|
+
transform: translateX(v-bind(prevArrowTranslateLg)) translateY(1px);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
@include breakpoints.media-breakpoint-up(xl) {
|
|
437
|
+
transform: translateX(v-bind(prevArrowTranslateXl));
|
|
438
|
+
|
|
439
|
+
&:not(:disabled):not(.disabled):active {
|
|
440
|
+
transform: translateX(v-bind(prevArrowTranslateXl)) translateY(1px);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
@include breakpoints.media-breakpoint-up(xxl) {
|
|
445
|
+
transform: translateX(v-bind(prevArrowTranslateXxl));
|
|
446
|
+
|
|
447
|
+
&:not(:disabled):not(.disabled):active {
|
|
448
|
+
transform: translateX(v-bind(prevArrowTranslateXxl)) translateY(1px);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* next arrow horizontal positioning at each breakpoint */
|
|
454
|
+
:deep(.es-carousel-next-arrow) {
|
|
455
|
+
transform: translateX(v-bind(nextArrowTranslateXs));
|
|
456
|
+
|
|
457
|
+
/* keep the "shift 1px down on click" transform from removing our transform */
|
|
458
|
+
&:not(:disabled):not(.disabled):active {
|
|
459
|
+
transform: translateX(v-bind(nextArrowTranslateXs)) translateY(1px);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
@include breakpoints.media-breakpoint-up(sm) {
|
|
463
|
+
transform: translateX(v-bind(nextArrowTranslateSm));
|
|
464
|
+
|
|
465
|
+
&:not(:disabled):not(.disabled):active {
|
|
466
|
+
transform: translateX(v-bind(nextArrowTranslateSm)) translateY(1px);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
@include breakpoints.media-breakpoint-up(md) {
|
|
471
|
+
transform: translateX(v-bind(nextArrowTranslateMd));
|
|
472
|
+
|
|
473
|
+
&:not(:disabled):not(.disabled):active {
|
|
474
|
+
transform: translateX(v-bind(nextArrowTranslateMd)) translateY(1px);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
@include breakpoints.media-breakpoint-up(lg) {
|
|
479
|
+
transform: translateX(v-bind(nextArrowTranslateLg));
|
|
480
|
+
|
|
481
|
+
&:not(:disabled):not(.disabled):active {
|
|
482
|
+
transform: translateX(v-bind(nextArrowTranslateLg)) translateY(1px);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
@include breakpoints.media-breakpoint-up(xl) {
|
|
487
|
+
transform: translateX(v-bind(nextArrowTranslateXl));
|
|
488
|
+
|
|
489
|
+
&:not(:disabled):not(.disabled):active {
|
|
490
|
+
transform: translateX(v-bind(nextArrowTranslateXl)) translateY(1px);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
@include breakpoints.media-breakpoint-up(xxl) {
|
|
495
|
+
transform: translateX(v-bind(nextArrowTranslateXxl));
|
|
496
|
+
|
|
497
|
+
&:not(:disabled):not(.disabled):active {
|
|
498
|
+
transform: translateX(v-bind(nextArrowTranslateXxl)) translateY(1px);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/* prev/next arrow button styling */
|
|
268
504
|
:deep(.es-carousel-arrow) {
|
|
269
505
|
background: unset;
|
|
270
506
|
border: unset;
|
|
507
|
+
bottom: v-bind(arrowPositionBottom);
|
|
271
508
|
box-shadow: none;
|
|
272
509
|
color: variables.$gray-900;
|
|
273
510
|
height: auto;
|
|
511
|
+
left: 50%;
|
|
512
|
+
padding: v-bind(arrowPadding);
|
|
274
513
|
|
|
275
514
|
&:hover {
|
|
276
515
|
color: variables.$gray-700;
|
|
@@ -286,13 +525,39 @@ onMounted(() => {
|
|
|
286
525
|
&:disabled {
|
|
287
526
|
color: variables.$gray-400;
|
|
288
527
|
}
|
|
528
|
+
|
|
529
|
+
svg {
|
|
530
|
+
/* use !important to override the inline style on svg icons */
|
|
531
|
+
height: v-bind(arrowSizePx) !important;
|
|
532
|
+
width: v-bind(arrowSizePx) !important;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/* prev/next arrow button styling for the "brand" variant */
|
|
537
|
+
.es-carousel--brand {
|
|
538
|
+
:deep(.es-carousel-arrow) {
|
|
539
|
+
color: variables.$blue-600;
|
|
540
|
+
|
|
541
|
+
&:hover {
|
|
542
|
+
color: variables.$blue-700;
|
|
543
|
+
}
|
|
544
|
+
&:not(:disabled):not(.disabled):active {
|
|
545
|
+
color: variables.$blue-800;
|
|
546
|
+
}
|
|
547
|
+
&:disabled {
|
|
548
|
+
color: variables.$gray-400;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
289
551
|
}
|
|
290
552
|
|
|
553
|
+
/* dots container */
|
|
291
554
|
:deep(.es-carousel-dots) {
|
|
292
|
-
gap:
|
|
555
|
+
gap: v-bind(dotSpacing);
|
|
293
556
|
padding-left: 0;
|
|
557
|
+
margin-top: v-bind(dotsMarginTop);
|
|
294
558
|
}
|
|
295
559
|
|
|
560
|
+
/* each individual dot */
|
|
296
561
|
:deep(.es-carousel-dot) {
|
|
297
562
|
list-style-type: none;
|
|
298
563
|
margin-bottom: 0;
|
|
@@ -305,8 +570,9 @@ onMounted(() => {
|
|
|
305
570
|
background-color: variables.$gray-100;
|
|
306
571
|
border: none;
|
|
307
572
|
border-radius: 50%;
|
|
308
|
-
height:
|
|
309
|
-
|
|
573
|
+
height: v-bind(dotSize);
|
|
574
|
+
padding: 0;
|
|
575
|
+
width: v-bind(dotSize);
|
|
310
576
|
|
|
311
577
|
&:hover {
|
|
312
578
|
opacity: 0.8;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import SelectButton from 'primevue/selectbutton';
|
|
3
|
+
|
|
4
|
+
interface SegmentedControlItem {
|
|
5
|
+
label: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
label: string;
|
|
11
|
+
options: SegmentedControlItem[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ButtonDimensionsInterface {
|
|
15
|
+
options: Partial<CSSStyleDeclaration>[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
19
|
+
disabled: false,
|
|
20
|
+
});
|
|
21
|
+
const model = defineModel<number>();
|
|
22
|
+
const segmentedControlRef = useTemplateRef('segmentedControl');
|
|
23
|
+
let resizeObserver: ResizeObserver | null = null;
|
|
24
|
+
|
|
25
|
+
// needed to avoid Content Layout Shift (CLS)
|
|
26
|
+
// as the inkbar is positioned via left and width attributes which are not set until mount
|
|
27
|
+
// meaning the active state would be initially missing and then pop in on mount, causing CLS
|
|
28
|
+
const isMounted = ref(false);
|
|
29
|
+
|
|
30
|
+
const buttonDimensions: ButtonDimensionsInterface = reactive({
|
|
31
|
+
options: props.options?.length ? props.options.map(() => ({})) : [],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const inkbarStyle: ComputedRef<Partial<CSSStyleDeclaration>> = computed(
|
|
35
|
+
() => buttonDimensions.options[model.value || 0],
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// assume the provided label is unique and convert to an id
|
|
39
|
+
// by lowercasing and replacing all whitespace with dashes
|
|
40
|
+
const labelId = computed(() => {
|
|
41
|
+
return props.label ? props.label.toLowerCase().replace(/\s/g, '-') : '';
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// return the left/width to use for the active state bubble, for each provided button
|
|
45
|
+
const getButtonDimensions = (buttons: HTMLCollection) => {
|
|
46
|
+
const result = [];
|
|
47
|
+
let runningLeftTotal = 0;
|
|
48
|
+
for (let i = 0; i < buttons.length; i += 1) {
|
|
49
|
+
const button = buttons[i] as unknown as HTMLElement;
|
|
50
|
+
result.push({
|
|
51
|
+
left: `${runningLeftTotal}px`,
|
|
52
|
+
width: `${button.offsetWidth}px`,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
runningLeftTotal += button.offsetWidth;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
onMounted(() => {
|
|
62
|
+
isMounted.value = true;
|
|
63
|
+
|
|
64
|
+
if (segmentedControlRef.value) {
|
|
65
|
+
// set the initial button dimensions
|
|
66
|
+
const buttons = segmentedControlRef.value.querySelectorAll(
|
|
67
|
+
'.es-segmented-control-button',
|
|
68
|
+
) as unknown as HTMLCollection;
|
|
69
|
+
buttonDimensions.options = getButtonDimensions(buttons);
|
|
70
|
+
|
|
71
|
+
// set up a resize observer for whenever the segmented control's button group changes width
|
|
72
|
+
// this will most commonly happen when changing between desktop and mobile breakpoints
|
|
73
|
+
// but could also happen if the label text of any options changes
|
|
74
|
+
resizeObserver = new ResizeObserver((groups) => {
|
|
75
|
+
groups.forEach((group) => {
|
|
76
|
+
const updatedButtons = group.target.children;
|
|
77
|
+
buttonDimensions.options = getButtonDimensions(updatedButtons);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// start the resize observer listening to the segmented control's button group
|
|
82
|
+
const group = segmentedControlRef.value.querySelector('.es-segmented-control-group');
|
|
83
|
+
if (group) {
|
|
84
|
+
resizeObserver.observe(group);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
onUnmounted(() => {
|
|
90
|
+
if (resizeObserver) {
|
|
91
|
+
resizeObserver.disconnect();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
</script>
|
|
95
|
+
|
|
96
|
+
<template>
|
|
97
|
+
<div
|
|
98
|
+
v-if="options?.length"
|
|
99
|
+
ref="segmentedControl"
|
|
100
|
+
:aria-hidden="props.disabled || undefined"
|
|
101
|
+
class="es-segmented-control d-flex position-relative"
|
|
102
|
+
:class="{
|
|
103
|
+
disabled: props.disabled,
|
|
104
|
+
'before-mount': !isMounted,
|
|
105
|
+
}">
|
|
106
|
+
<label
|
|
107
|
+
:id="labelId"
|
|
108
|
+
class="sr-only">
|
|
109
|
+
{{ label }}
|
|
110
|
+
</label>
|
|
111
|
+
<span
|
|
112
|
+
class="es-segmented-control-inkbar rounded-lg position-absolute"
|
|
113
|
+
:data-left="buttonDimensions.options[model || 0]?.left || 0"
|
|
114
|
+
:style="inkbarStyle as any" />
|
|
115
|
+
<select-button
|
|
116
|
+
v-model="model"
|
|
117
|
+
:allow-empty="false"
|
|
118
|
+
:aria-labelledby="labelId"
|
|
119
|
+
data-key="label"
|
|
120
|
+
:disabled="props.disabled"
|
|
121
|
+
:option-label="(option) => option.label"
|
|
122
|
+
:option-value="(option) => props.options.findIndex((innerOption) => option.label === innerOption.label)"
|
|
123
|
+
:options="props.options"
|
|
124
|
+
:pt="{
|
|
125
|
+
root: 'es-segmented-control-group bg-gray-100 d-flex rounded-lg',
|
|
126
|
+
button: 'es-segmented-control-button font-size-sm font-weight-bold position-relative px-200 px-lg-300 rounded-lg',
|
|
127
|
+
label: 'es-segmented-control-label position-relative',
|
|
128
|
+
}"
|
|
129
|
+
:unselectable="false" />
|
|
130
|
+
</div>
|
|
131
|
+
</template>
|
|
132
|
+
|
|
133
|
+
<style lang="scss" scoped>
|
|
134
|
+
@use '@energysage/es-ds-styles/scss/variables' as variables;
|
|
135
|
+
|
|
136
|
+
/* avoid Content Layout Shift (CLS) */
|
|
137
|
+
.es-segmented-control.before-mount {
|
|
138
|
+
.es-segmented-control-inkbar {
|
|
139
|
+
/* prevent inkbar with left and width of zero from showing up between first paint and mount */
|
|
140
|
+
opacity: 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
:deep(.es-segmented-control-button) {
|
|
144
|
+
position: relative;
|
|
145
|
+
|
|
146
|
+
/* temporarily replicate the inkbar's active state until mount */
|
|
147
|
+
&[data-p-highlight='true'] {
|
|
148
|
+
&::before {
|
|
149
|
+
border-radius: variables.$border-radius-lg;
|
|
150
|
+
content: '';
|
|
151
|
+
left: 0;
|
|
152
|
+
position: absolute;
|
|
153
|
+
width: 100%;
|
|
154
|
+
z-index: 3;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* active state bubble */
|
|
161
|
+
.es-segmented-control-inkbar,
|
|
162
|
+
.es-segmented-control.before-mount :deep(.es-segmented-control-button[data-p-highlight='true']::before) {
|
|
163
|
+
/* using style for bg instead of bg-white class so it can be overridden for disabled state */
|
|
164
|
+
background-color: variables.$white;
|
|
165
|
+
border: 2px solid variables.$warm-orange;
|
|
166
|
+
bottom: 2px;
|
|
167
|
+
top: 2px;
|
|
168
|
+
/* bring it ~2px away from each side to match the top/bottom */
|
|
169
|
+
transform: scaleX(0.98);
|
|
170
|
+
|
|
171
|
+
@media not (prefers-reduced-motion) {
|
|
172
|
+
transition:
|
|
173
|
+
left 250ms cubic-bezier(0.35, 0, 0.25, 1),
|
|
174
|
+
width 250ms cubic-bezier(0.35, 0, 0.25, 1);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* each individual option */
|
|
179
|
+
:deep(.es-segmented-control-button) {
|
|
180
|
+
color: variables.$gray-700;
|
|
181
|
+
cursor: pointer;
|
|
182
|
+
/*
|
|
183
|
+
need a non-standard padding to cause a specific height that matches
|
|
184
|
+
better with our "lg" 14px border radius to get the rounded pill look
|
|
185
|
+
*/
|
|
186
|
+
padding-bottom: 6px;
|
|
187
|
+
padding-top: 6px;
|
|
188
|
+
/* don't transition all properties, otherwise responsive bubble width updates will be delayed */
|
|
189
|
+
transition: color 250ms ease-in-out;
|
|
190
|
+
/* prevent text from highlighting when you click a bunch of times */
|
|
191
|
+
user-select: none;
|
|
192
|
+
white-space: nowrap;
|
|
193
|
+
z-index: 4;
|
|
194
|
+
|
|
195
|
+
&:hover {
|
|
196
|
+
color: variables.$gray-900;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
&:focus-visible {
|
|
200
|
+
box-shadow: 0 0 0 3px variables.$warm-orange inset;
|
|
201
|
+
outline: none;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/* bring the label above the active state bubble */
|
|
206
|
+
:deep(.es-segmented-control-label) {
|
|
207
|
+
z-index: 4;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* disabled state */
|
|
211
|
+
.es-segmented-control.disabled,
|
|
212
|
+
.es-segmented-control.before-mount.disabled {
|
|
213
|
+
cursor: default;
|
|
214
|
+
pointer-events: none;
|
|
215
|
+
user-select: none;
|
|
216
|
+
|
|
217
|
+
/* active bubble */
|
|
218
|
+
.es-segmented-control-inkbar,
|
|
219
|
+
:deep(.es-segmented-control-button[data-p-highlight='true']::before) {
|
|
220
|
+
background-color: variables.$gray-200;
|
|
221
|
+
border-color: variables.$gray-300;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/* text inside active bubble */
|
|
225
|
+
:deep(.es-segmented-control-button[data-p-highlight='true']) {
|
|
226
|
+
&,
|
|
227
|
+
&:hover {
|
|
228
|
+
color: variables.$gray-500;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* all other items */
|
|
233
|
+
:deep(.es-segmented-control-button) {
|
|
234
|
+
color: variables.$gray-500;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
</style>
|
package/nuxt.config.ts
CHANGED