tide-design-system 2.5.2 → 2.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -62,7 +62,7 @@ export const getFieldValidationResult = ({
62
62
 
63
63
  // custom validator prop errors from have second highest precedence
64
64
  if (validators) {
65
- const validation = validateProperty(value.value, validators);
65
+ const validation = validateProperty(value.value ?? '', validators);
66
66
 
67
67
  if (!validation.valid) {
68
68
  return validation;
package/package.json CHANGED
@@ -63,7 +63,7 @@
63
63
  "main": "dist/tide-design-system.cjs",
64
64
  "module": "dist/tide-design-system.esm.js",
65
65
  "types": "dist/tide-design-system.esm.d.ts",
66
- "version": "2.5.2",
66
+ "version": "2.5.3",
67
67
  "dependencies": {
68
68
  "@floating-ui/vue": "^1.1.6"
69
69
  }
@@ -34,6 +34,7 @@
34
34
 
35
35
  const emit = defineEmits<Emits>();
36
36
 
37
+ const carouselRef = ref<HTMLDivElement | null>(null);
37
38
  const containerRef = ref<HTMLDivElement | null>(null);
38
39
  const contentWidth = ref<number>(0);
39
40
  const currentPageIndex = ref<number>(0);
@@ -46,26 +47,25 @@
46
47
  const slides = ref<HTMLElement[]>([]);
47
48
  const slidesInView = ref<number[]>([]);
48
49
  const slotObserver = ref<MutationObserver | null>(null);
50
+ const touchStart = ref<Touch | undefined>(undefined);
51
+ const slidesInViewCount = ref<number>(1);
49
52
 
50
53
  const currentPage = computed(() => currentPageIndex.value + 1);
51
54
  const dotContainerWidth = computed(() => dotCountVisible.value * dotWidth + (dotCountVisible.value - 1) * dotGap);
52
55
  const dotCountVisible = computed(() => (props.maxDots > totalPages.value ? totalPages.value : props.maxDots));
53
- const lastPageIndex = computed(() => totalPages.value - 1);
54
56
  const totalPages = computed(() => {
55
57
  if (!slides.value.length) return 0;
56
58
  if (!props.isScrollByPage) return slides.value.length;
57
59
 
58
- const gapWidth = cardGap * (slides.value.length - 1);
59
- const contentNoGap = contentWidth.value - gapWidth;
60
- const quotient = Math.round(contentNoGap / frameWidth.value);
61
- const remainder = contentNoGap % frameWidth.value;
60
+ const quotient = Math.floor(slides.value.length / slidesInViewCount.value);
61
+ const remainder = slides.value.length % slidesInViewCount.value;
62
62
 
63
63
  return remainder ? quotient + 1 : quotient;
64
64
  });
65
65
 
66
- const cardGap: number = 16;
67
66
  const dotGap: number = 8;
68
67
  const dotWidth: number = 8;
68
+ let scrollTimeout: number | undefined;
69
69
 
70
70
  const getDotClass = (dotIndex: number) => {
71
71
  let className = '';
@@ -76,17 +76,64 @@
76
76
  return className;
77
77
  };
78
78
 
79
+ const getIsElementWithinContainer = (element: HTMLElement, container: HTMLElement) => {
80
+ const containerRect = container.getBoundingClientRect();
81
+ const containerRight = containerRect.left + containerRect.width;
82
+ const elementRect = element.getBoundingClientRect();
83
+ const elementRight = elementRect.left + elementRect.width;
84
+
85
+ return elementRight <= containerRight && elementRect.left >= containerRect.left;
86
+ };
87
+
88
+ const handleTouchEnd = (event: TouchEvent) => {
89
+ if (!touchStart.value) return;
90
+
91
+ const touchEnd = event.changedTouches[0];
92
+ const deltaX = touchStart.value.clientX - touchEnd.clientX;
93
+ const deltaY = touchEnd.clientY - touchStart.value.clientY;
94
+
95
+ if (Math.abs(deltaX) > Math.abs(deltaY)) scrollByDelta(deltaX);
96
+ };
97
+
98
+ const handleTouchStart = (event: TouchEvent) => {
99
+ touchStart.value = event.touches[0];
100
+ };
101
+
102
+ const handleWheel = (event: WheelEvent) => {
103
+ const isShiftKeyDown = event.shiftKey;
104
+ const isWheel = Math.abs(event.deltaY) >= 80;
105
+
106
+ if (isWheel && !isShiftKeyDown) return;
107
+ if (event.shiftKey) event.preventDefault();
108
+
109
+ clearTimeout(scrollTimeout);
110
+
111
+ scrollTimeout = window.setTimeout(() => {
112
+ if (isWheel) {
113
+ scrollByDelta(event.deltaY);
114
+ } else {
115
+ const offset = slides.value[slidesInView.value[0]]?.offsetLeft || 0;
116
+
117
+ scrollToOffset(offset);
118
+ }
119
+ }, 100);
120
+ };
121
+
79
122
  const measureDom = () => {
80
123
  if (!containerRef.value) return;
81
124
 
82
125
  contentWidth.value = containerRef.value.scrollWidth;
83
126
  frameWidth.value = containerRef.value.clientWidth;
84
127
  showButtons.value = contentWidth.value > frameWidth.value;
128
+
129
+ slidesInViewCount.value = slides.value.filter((slide) =>
130
+ getIsElementWithinContainer(slide, containerRef.value as HTMLElement)
131
+ ).length;
85
132
  };
86
133
 
87
134
  const observeSlides = () => {
88
135
  const options = {
89
- root: containerRef?.value,
136
+ root: carouselRef?.value,
90
137
  rootMargin: '0px 1px 0px 0px',
91
138
  threshold: 1,
92
139
  };
@@ -131,12 +178,20 @@
131
178
  if (containerRef.value) slotObserver.value.observe(containerRef.value, { childList: true });
132
179
  };
133
180
 
181
+ const scrollByDelta = (delta: number) => {
182
+ const isScrollingLeft = delta < 0;
183
+
184
+ if (isScrollingLeft) {
185
+ props.isScrollByPage ? showPreviousPage() : showPreviousSlide();
186
+ } else {
187
+ props.isScrollByPage ? showNextPage() : showNextSlide();
188
+ }
189
+ };
190
+
134
191
  const scrollToOffset = (target: number) => {
135
192
  if (containerRef.value === null) return;
136
193
 
137
- const lastOffset: number = slides.value[slides.value.length - 1].offsetLeft;
138
- const placement = (target / lastOffset) * lastPageIndex.value;
139
- const isScrollingLeft = placement <= currentPageIndex.value;
194
+ const isScrollingLeft = target <= containerRef.value.scrollLeft;
140
195
 
141
196
  currentPageIndex.value = isScrollingLeft ? currentPageIndex.value - 1 : currentPageIndex.value + 1;
142
197
 
@@ -148,19 +203,21 @@
148
203
  };
149
204
 
150
205
  const showNextPage = () => {
151
- if (slidesInView.value.length === 0) return;
206
+ if (slidesInView.value.length === 0 || isLastSlide.value) return;
152
207
 
153
208
  const nextSlide: number = slidesInView.value[slidesInView.value.length - 1] + 1;
209
+ const offset = slides.value[nextSlide]?.offsetLeft;
154
210
 
155
- scrollToOffset(slides.value[nextSlide].offsetLeft);
211
+ scrollToOffset(offset);
156
212
  };
157
213
 
158
214
  const showPreviousPage = () => {
159
- if (slidesInView.value.length === 0) return;
215
+ if (slidesInView.value.length === 0 || isFirstSlide.value) return;
160
216
 
161
217
  const previousSlide: number = slidesInView.value[0] - slidesInView.value.length;
218
+ const offset = slides.value[previousSlide]?.offsetLeft || 0;
162
219
 
163
- scrollToOffset(slides.value[previousSlide]?.offsetLeft || 0);
220
+ scrollToOffset(offset);
164
221
  };
165
222
 
166
223
  const showNextSlide = () => {
@@ -284,51 +341,58 @@
284
341
  CSS.SNAP.ON,
285
342
  ]"
286
343
  ref="containerRef"
344
+ @touchend="handleTouchEnd"
345
+ @touchstart.passive="handleTouchStart"
346
+ @wheel="handleWheel"
287
347
  >
288
348
  <slot />
289
349
  </ul>
290
350
 
291
351
  <div
292
352
  :class="[
293
- 'tide-carousel-dots-container',
294
353
  CSS.POSITION.ABSOLUTE,
295
354
  CSS.POSITIONING.BOTTOM,
296
355
  CSS.DISPLAY.FLEX,
297
356
  CSS.AXIS1.CENTER,
298
357
  CSS.MARGIN.BOTTOM.HALF,
299
358
  CSS.WIDTH.FULL,
359
+ CSS.POINTER_EVENTS.OFF,
300
360
  ]"
301
- :style="{
302
- width: `${dotContainerWidth}px`,
303
- }"
304
- v-if="isScrollByPage && hasDots && totalPages > 1"
361
+ v-if="hasDots && totalPages > 1"
305
362
  >
306
363
  <div
307
- :class="[
308
- 'tide-carousel-dots',
309
- CSS.DISPLAY.FLEX,
310
- CSS.AXIS1.START,
311
- CSS.AXIS2.CENTER,
312
- CSS.GAP.HALF,
313
- CSS.OVERFLOW.X.SCROLL,
314
- CSS.POINTER_EVENTS.OFF,
315
- CSS.SCROLLBAR.OFF,
316
- ]"
317
- ref="dotsRef"
364
+ :class="['tide-carousel-dots-container']"
365
+ :style="{
366
+ width: `${dotContainerWidth}px`,
367
+ }"
318
368
  >
319
369
  <div
320
370
  :class="[
321
- 'tide-carousel-dot',
322
- CSS.FLEX.SHRINK.OFF,
323
- CSS.HEIGHT.ZERO,
324
- CSS.WIDTH.ZERO,
325
- CSS.BORDER.RADIUS.FULL,
326
- CSS.BG.SURFACE.DEFAULT,
327
- getDotClass(index),
371
+ 'tide-carousel-dots',
372
+ CSS.DISPLAY.FLEX,
373
+ CSS.AXIS1.START,
374
+ CSS.AXIS2.CENTER,
375
+ CSS.GAP.HALF,
376
+ CSS.OVERFLOW.X.SCROLL,
377
+ CSS.POINTER_EVENTS.OFF,
378
+ CSS.SCROLLBAR.OFF,
328
379
  ]"
329
- :key="index"
330
- v-for="(_, index) in totalPages"
331
- />
380
+ ref="dotsRef"
381
+ >
382
+ <div
383
+ :class="[
384
+ 'tide-carousel-dot',
385
+ CSS.FLEX.SHRINK.OFF,
386
+ CSS.HEIGHT.ZERO,
387
+ CSS.WIDTH.ZERO,
388
+ CSS.BORDER.RADIUS.FULL,
389
+ CSS.BG.SURFACE.DEFAULT,
390
+ getDotClass(index),
391
+ ]"
392
+ :key="index"
393
+ v-for="(_, index) in totalPages"
394
+ />
395
+ </div>
332
396
  </div>
333
397
  </div>
334
398
 
@@ -4,16 +4,19 @@
4
4
  import InternalBaseLink from '@/components/InternalBaseLink.vue';
5
5
  import TideIcon from '@/components/TideIcon.vue';
6
6
  import { ELEMENT } from '@/types/Element';
7
+ import { SIZE } from '@/types/Size';
7
8
  import { CSS } from '@/types/Styles';
8
9
  import { TARGET } from '@/types/Target';
9
10
 
10
11
  import type { Element } from '@/types/Element';
11
12
  import type { Icon } from '@/types/Icon';
13
+ import type { Size } from '@/types/Size';
12
14
 
13
15
  type Props = {
14
16
  element?: Element;
15
17
  href?: string;
16
18
  iconLeading?: Icon;
19
+ iconSize?: Size;
17
20
  iconTrailing?: Icon;
18
21
  isNewTab?: boolean;
19
22
  label: string;
@@ -24,6 +27,7 @@
24
27
  element: ELEMENT.LINK,
25
28
  href: undefined,
26
29
  iconLeading: undefined,
30
+ iconSize: SIZE.SMALL,
27
31
  iconTrailing: undefined,
28
32
  isNewTab: false,
29
33
  label: undefined,
@@ -49,6 +53,7 @@
49
53
  <TideIcon
50
54
  :class="[CSS.DISPLAY.INLINE_BLOCK, CSS.ALIGN.Y.MIDDLE, CSS.MARGIN.RIGHT.QUARTER]"
51
55
  :icon="props.iconLeading"
56
+ :size="iconSize"
52
57
  v-if="props.iconLeading"
53
58
  />
54
59
 
@@ -59,6 +64,7 @@
59
64
  <TideIcon
60
65
  :class="[CSS.DISPLAY.INLINE_BLOCK, CSS.ALIGN.Y.MIDDLE, CSS.MARGIN.LEFT.QUARTER]"
61
66
  :icon="props.iconTrailing"
67
+ :size="iconSize"
62
68
  v-if="props.iconTrailing"
63
69
  />
64
70
  </component>
@@ -50,7 +50,7 @@
50
50
  CSS.BORDER.RADIUS.ZERO,
51
51
  CSS.DISPLAY.FLEX,
52
52
  CSS.ELLIPSIS,
53
- CSS.FONT.ROLE.LABEL_2,
53
+ CSS.FONT.ROLE.LABEL_1,
54
54
  CSS.GAP.QUARTER,
55
55
  CSS.PADDING.X.ONE,
56
56
  CSS.PADDING.Y.HALF,
@@ -1,4 +1,5 @@
1
1
  import { action } from '@storybook/addon-actions';
2
+ import { computed } from 'vue';
2
3
 
3
4
  import TideCard from '@/components/TideCard.vue';
4
5
  import TideCarousel from '@/components/TideCarousel.vue';
@@ -8,6 +9,8 @@ import type { StoryContext } from '@storybook/vue3';
8
9
 
9
10
  type Args = InstanceType<typeof TideCarousel>['$props'] & {
10
11
  handleSlidesAddedToView: string;
12
+ isFullWidthCards: boolean | undefined;
13
+ maxDots: string | undefined;
11
14
  };
12
15
 
13
16
  const formatSnippet = (code: string, context: StoryContext) => {
@@ -19,7 +22,6 @@ const formatSnippet = (code: string, context: StoryContext) => {
19
22
  if (args.isFloating !== undefined) argsWithValues.push(`:is-floating="${args.isFloating}"`);
20
23
  if (args.isHideawayButtons !== undefined) argsWithValues.push(`:is-hideaway-buttons="${args.isHideawayButtons}"`);
21
24
  if (args.isScrollByPage !== undefined) argsWithValues.push(`:is-scroll-by-page="false"`);
22
- if (args.isTouchscreen !== undefined) argsWithValues.push(`:is-touchscreen="${args.isTouchscreen}"`);
23
25
  if (args.maxDots !== '') argsWithValues.push(`max-dots="${args.maxDots}"`);
24
26
  if (args.subtitle !== '') argsWithValues.push(`subtitle="${args.subtitle}"`);
25
27
  if (args.title !== '') argsWithValues.push(`title="${args.title}"`);
@@ -31,7 +33,7 @@ const formatSnippet = (code: string, context: StoryContext) => {
31
33
  // prettier-ignore
32
34
  `<TideCarousel ${argsWithValues.join(' ')}>` + lineBreak +
33
35
  slotContentMisc + tab +
34
- `<li class="tide-shrink-none" v-for="(_child, index) in new Array(12)">` + lineBreak + tab + tab +
36
+ `<li class="tide-shrink-none${args.isFullWidthCards ? ' tide-width-full' : ''}" v-for="(_child, index) in new Array(12)">` + lineBreak + tab + tab +
35
37
  args.default + lineBreak + tab +
36
38
  `</li>` + lineBreak +
37
39
  `</TideCarousel>`
@@ -66,19 +68,31 @@ const render = (args: Args) => ({
66
68
  }
67
69
  },
68
70
  },
69
- setup: () => ({ args }),
71
+ setup: () => {
72
+ const conditionalFullWidth = computed(() => (args.isFullWidthCards ? ' tide-width-full' : ''));
73
+ const argsFormatted = computed(() => ({
74
+ ...args,
75
+ maxDots: args.maxDots === '' ? undefined : args.maxDots,
76
+ }));
77
+
78
+ return {
79
+ argsFormatted,
80
+ conditionalFullWidth,
81
+ };
82
+ },
70
83
  // prettier-ignore
71
84
  template:
72
85
  `<TideCarousel
73
86
  @slides-added-to-view="handleSlidesAddedToView"
74
- v-bind="args"
87
+ v-bind="argsFormatted"
88
+ :key="argsFormatted.isFullWidthCards"
75
89
  >
76
- <template #misc>{{ args.misc }}</template>
90
+ <template #misc>{{ argsFormatted.misc }}</template>
77
91
  <li
78
- class="tide-shrink-none tide-border-1 sb-border-blue tide-padding-1 sb-bg-blue-light"
92
+ :class="['tide-shrink-none tide-border-1 sb-border-blue tide-padding-1 sb-bg-blue-light', conditionalFullWidth]"
79
93
  v-for="(_child, index) in new Array(12)"
80
94
  >
81
- {{ args.default.replace('#', index) }}
95
+ {{ argsFormatted.default.replace('#', index) }}
82
96
  </li>
83
97
  </TideCarousel>`,
84
98
  });
@@ -106,7 +120,7 @@ export default {
106
120
  },
107
121
  hasDots: {
108
122
  ...argTypeBooleanUnrequired,
109
- description: 'Determines whether to display the indicator dots overlay',
123
+ description: 'Determines whether to display the indicator dots overlay<br />(Only valid with full width cards)',
110
124
  table: {
111
125
  defaultValue: { summary: 'False' },
112
126
  },
@@ -118,6 +132,16 @@ export default {
118
132
  defaultValue: { summary: 'False' },
119
133
  },
120
134
  },
135
+ isFullWidthCards: {
136
+ ...argTypeBooleanUnrequired,
137
+ description: 'Preview 1 card per page implementation',
138
+ table: {
139
+ category: 'Demo',
140
+ defaultValue: { summary: 'None' },
141
+ disable: true,
142
+ type: { summary: 'boolean' },
143
+ },
144
+ },
121
145
  isHeadline1: {
122
146
  ...argTypeBooleanUnrequired,
123
147
  description: 'Determines font role used for title display',
@@ -133,23 +157,11 @@ export default {
133
157
  defaultValue: { summary: 'True' },
134
158
  },
135
159
  },
136
- isScrollByPage: {
137
- ...argTypeBooleanUnrequired,
138
- description: 'Determines pagination method',
139
- table: {
140
- defaultValue: { summary: 'None' },
141
- },
142
- },
143
- isTouchscreen: {
144
- ...argTypeBooleanUnrequired,
145
- description: 'Determines button and/or swipe control scheme',
146
- table: {
147
- defaultValue: { summary: 'None' },
148
- },
149
- },
160
+ isScrollByPage: disabledArgType,
150
161
  maxDots: {
151
162
  control: 'text',
152
163
  description: 'Determines the max number of indicator dots to display at a given time',
164
+ if: { arg: 'hasDots', eq: true },
153
165
  table: {
154
166
  defaultValue: { summary: 'None' },
155
167
  type: { summary: 'number' },
@@ -186,9 +198,8 @@ export default {
186
198
  handleSlidesAddedToView: 'doSomething',
187
199
  hasDots: undefined,
188
200
  isFloating: undefined,
201
+ isFullWidthCards: undefined,
189
202
  isHideawayButtons: undefined,
190
- isScrollByPage: undefined,
191
- isTouchscreen: undefined,
192
203
  maxDots: '',
193
204
  misc: '',
194
205
  subtitle: '',
@@ -201,4 +212,15 @@ export default {
201
212
  title: 'Components/TideCarousel',
202
213
  };
203
214
 
204
- export const Demo = {};
215
+ export const Demo = {
216
+ name: 'Default',
217
+ };
218
+
219
+ export const FullWidth = {
220
+ args: {
221
+ default: 'Full-width card #',
222
+ isFullWidthCards: true,
223
+ title: 'Demo',
224
+ },
225
+ name: 'Full-width cards ',
226
+ };
@@ -15,7 +15,7 @@ export const FORMAT_REGEX = {
15
15
  alphaNumberOrEmpty: /^$|^[a-z0-9]+$/i,
16
16
  alphaSpace: /^[a-zA-Z ]+$/g,
17
17
  email:
18
- /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
18
+ /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(?!(?:[a-zA-Z0-9-]+\.)*(?<tld>[a-zA-Z]{2,})\.\k<tld>$)((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}))$/,
19
19
  numberFormatted: /^$|(?=.)^\$?(([1-9][0-9]{0,2}(,[0-9]{3})*)|0)?(\.[0-9]{1,2})?$/,
20
20
  phone: /^(?:\d{3}-\d{3}-\d{4}|\d{1}-\d{3}-\d{3}-\d{4})?$/,
21
21
  price: /(?=.)^\$?(([1-9][0-9]{0,2}(,[0-9]{3})*)|0)?(\.[0-9]{1,2})?$/,
@@ -62,7 +62,7 @@ export const getFieldValidationResult = ({
62
62
 
63
63
  // custom validator prop errors from have second highest precedence
64
64
  if (validators) {
65
- const validation = validateProperty(value.value, validators);
65
+ const validation = validateProperty(value.value ?? '', validators);
66
66
 
67
67
  if (!validation.valid) {
68
68
  return validation;
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
 
3
+ import { FORMAT_REGEX } from '../src/types/Formatted';
3
4
  import {
4
5
  formatCamelCase,
5
6
  formatKebabCase,
@@ -428,3 +429,42 @@ describe('@/src/utilities/format.ts', () => {
428
429
  });
429
430
  });
430
431
  });
432
+
433
+ describe('@/src/types/Formatted.ts', () => {
434
+ describe('FORMAT_REGEX.email', () => {
435
+ it('accepts a standard email address.', () => {
436
+ const input = 'john.doe@gmail.com';
437
+ const output = true;
438
+
439
+ expect(FORMAT_REGEX.email.test(input)).toEqual(output);
440
+ });
441
+
442
+ it('accepts an email address with subdomains.', () => {
443
+ const input = 'john.doe@mail.sub.example.co.uk';
444
+ const output = true;
445
+
446
+ expect(FORMAT_REGEX.email.test(input)).toEqual(output);
447
+ });
448
+
449
+ it('rejects an email address that ends with a repeated TLD label (e.g. .com.com).', () => {
450
+ const input = 'john@gmail.com.com';
451
+ const output = false;
452
+
453
+ expect(FORMAT_REGEX.email.test(input)).toEqual(output);
454
+ });
455
+
456
+ it('rejects an email address that ends with a repeated TLD label (e.g. .net.net).', () => {
457
+ const input = 'john@company.net.net';
458
+ const output = false;
459
+
460
+ expect(FORMAT_REGEX.email.test(input)).toEqual(output);
461
+ });
462
+
463
+ it('rejects an email address missing a valid top-level domain.', () => {
464
+ const input = 'john@gmail';
465
+ const output = false;
466
+
467
+ expect(FORMAT_REGEX.email.test(input)).toEqual(output);
468
+ });
469
+ });
470
+ });