@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.
@@ -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: 2000,
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
- // in order to enable full-width carousels with one item
71
- // but also ensure there is some space between items when more than one is visible
72
- // only apply horizontal padding to carousel items on breakpoints where more than one item is visible
73
- const DEFAULT_PADDING = '0.5rem';
74
- const paddingXs = computed(() => (numVisibleXs.value === 1 ? '0' : DEFAULT_PADDING));
75
- const paddingSm = computed(() => (numVisibleSm.value === 1 ? '0' : DEFAULT_PADDING));
76
- const paddingMd = computed(() => (numVisibleMd.value === 1 ? '0' : DEFAULT_PADDING));
77
- const paddingLg = computed(() => (numVisibleLg.value === 1 ? '0' : DEFAULT_PADDING));
78
- const paddingXl = computed(() => (numVisibleXl.value === 1 ? '0' : DEFAULT_PADDING));
79
- const paddingXxl = computed(() => (numVisibleXxl.value === 1 ? '0' : DEFAULT_PADDING));
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 && props.circular"
174
- :num-scroll="props.numScroll"
175
- :num-visible="props.numVisible"
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="props.showDots"
178
- :show-navigators="props.showArrows"
179
- :value="props.items"
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 mt-100',
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(paddingXs);
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: 0.7rem;
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: 0.875rem;
309
- width: 0.875rem;
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
@@ -71,6 +71,7 @@ export default defineNuxtConfig({
71
71
  'primevue/progressbar',
72
72
  'primevue/radiobutton',
73
73
  'primevue/rating',
74
+ 'primevue/selectbutton',
74
75
  'primevue/skeleton',
75
76
  'primevue/slider',
76
77
  'primevue/tabpanel',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@energysage/es-ds-components",
3
- "version": "3.0.0-alpha.10",
3
+ "version": "3.0.0-alpha.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "An EnergySage Vue component library",