tide-design-system 2.5.0 → 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.
Files changed (43) hide show
  1. package/.storybook/main.ts +2 -0
  2. package/README.md +3 -1
  3. package/dist/css/reset.css +5 -1
  4. package/dist/css/utilities-base.css +6 -6
  5. package/dist/css/utilities-responsive.css +24 -24
  6. package/dist/css/variables.css +3 -0
  7. package/dist/style.css +1 -1
  8. package/dist/tide-design-system.cjs +2 -2
  9. package/dist/tide-design-system.esm.d.ts +51 -9
  10. package/dist/tide-design-system.esm.js +1621 -1481
  11. package/dist/utilities/validation.ts +1 -1
  12. package/docs/assets/full-bleed.gif +0 -0
  13. package/docs/assets/layout-grid-default.webp +0 -0
  14. package/docs/assets/layout-grid-fluid.webp +0 -0
  15. package/docs/assets/layout-grid.webp +0 -0
  16. package/docs/configuation.md +47 -0
  17. package/docs/grid-layout.md +83 -0
  18. package/index.ts +4 -0
  19. package/package.json +1 -1
  20. package/src/assets/css/reset.css +5 -1
  21. package/src/assets/css/utilities-base.css +6 -6
  22. package/src/assets/css/utilities-responsive.css +24 -24
  23. package/src/assets/css/variables.css +3 -0
  24. package/src/components/TideAlert.vue +1 -1
  25. package/src/components/TideCarousel.vue +104 -40
  26. package/src/components/TideInputSelect.vue +1 -1
  27. package/src/components/TideInputSelectDeprecated.vue +1 -1
  28. package/src/components/TideInputText.vue +2 -2
  29. package/src/components/TideInputTextDeprecated.vue +2 -2
  30. package/src/components/TideInputTextarea.vue +2 -2
  31. package/src/components/TideInputTextareaDeprecated.vue +2 -2
  32. package/src/components/TideLink.vue +6 -0
  33. package/src/components/TideMenuItem.vue +1 -1
  34. package/src/components/TideModal.vue +1 -1
  35. package/src/components/TideRating.vue +93 -0
  36. package/src/components/TideSheet.vue +1 -1
  37. package/src/components/TideTabs.vue +58 -0
  38. package/src/stories/TideCarousel.stories.ts +47 -25
  39. package/src/stories/TideRating.stories.ts +120 -0
  40. package/src/stories/TideTabs.stories.ts +115 -0
  41. package/src/types/Formatted.ts +1 -1
  42. package/src/utilities/validation.ts +1 -1
  43. package/tests/utilities-format.spec.ts +40 -0
@@ -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,
@@ -236,7 +236,7 @@
236
236
  padding-inline: var(--modal-padding-x);
237
237
  }
238
238
  .tide-modal-content {
239
- grid-template-columns: var(--modal-padding-x) 1fr var(--modal-padding-x);
239
+ grid-template-columns: var(--modal-padding-x) var(--tide-safe-fr) var(--modal-padding-x);
240
240
  }
241
241
  :where(.tide-modal-content):deep(> :where(*)) {
242
242
  grid-column: 2;
@@ -0,0 +1,93 @@
1
+ <script lang="ts" setup>
2
+ import { computed } from 'vue';
3
+
4
+ import { CSS } from '@/types/Styles';
5
+
6
+ type Props = {
7
+ description?: string;
8
+ maxRating?: number;
9
+ showRating?: boolean;
10
+ title?: string;
11
+ };
12
+
13
+ const props = withDefaults(defineProps<Props>(), {
14
+ description: undefined,
15
+ maxRating: 10,
16
+ showRating: false,
17
+ title: undefined,
18
+ });
19
+
20
+ const ratingValue = defineModel<number>({ required: true });
21
+
22
+ const segments = computed(() => Array.from({ length: props.maxRating }, (_, index) => index + 1));
23
+
24
+ const handleClick = (segment: number) => {
25
+ ratingValue.value = segment;
26
+ };
27
+ </script>
28
+
29
+ <template>
30
+ <div :class="['tide-rating', CSS.WIDTH.FULL]">
31
+ <section :class="[CSS.DISPLAY.FLEX, CSS.AXIS2.CENTER, CSS.AXIS1.BETWEEN, CSS.PADDING.BOTTOM.HALF]">
32
+ <div :class="[CSS.FONT.ROLE.LABEL_2]">
33
+ {{ props.title }}
34
+ </div>
35
+ <div
36
+ :class="[CSS.FONT.ROLE.LABEL_3, CSS.FONT.COLOR.SURFACE.VARIANT]"
37
+ v-if="props.showRating"
38
+ >
39
+ {{ ratingValue }}/{{ props.maxRating }}
40
+ </div>
41
+ </section>
42
+ <section
43
+ :class="[CSS.FONT.ROLE.BODY_2, CSS.PADDING.BOTTOM.ONE, CSS.OVERFLOW.XY.HIDDEN, CSS.ELLIPSIS]"
44
+ v-if="props.description"
45
+ >
46
+ {{ props.description }}
47
+ </section>
48
+
49
+ <section :class="['tide-rating-bar', CSS.DISPLAY.FLEX, CSS.GAP.QUARTER, CSS.FLEX.GROW.ON, CSS.FLEX.SHRINK.ON]">
50
+ <button
51
+ :class="[
52
+ 'tide-rating-segment',
53
+ CSS.BG.SURFACE.VARIANT,
54
+ CSS.POSITION.RELATIVE,
55
+ CSS.OVERFLOW.XY.HIDDEN,
56
+ CSS.FLEX.GROW.ON,
57
+ CSS.FLEX.SHRINK.ON,
58
+ CSS.FLEX.BASIS.ZERO,
59
+ CSS.BORDER.FULL.ZERO,
60
+ segment === 1 && ['tide-rating-segment-first'],
61
+ segment === segments.length && ['tide-rating-segment-last'],
62
+ segment <= ratingValue && [CSS.BG.SURFACE.GRADIENT],
63
+ ]"
64
+ :key="segment"
65
+ @click="handleClick(segment)"
66
+ type="button"
67
+ v-for="segment in segments"
68
+ />
69
+ </section>
70
+ </div>
71
+ </template>
72
+
73
+ <style scoped>
74
+ .tide-rating-bar {
75
+ height: 28px;
76
+ }
77
+
78
+ .tide-rating-segment {
79
+ clip-path: polygon(12% 0, 100% 0, 88% 100%, 0 100%);
80
+ }
81
+
82
+ .tide-rating-segment-first {
83
+ border-top-left-radius: 9999px;
84
+ border-bottom-left-radius: 9999px;
85
+ clip-path: polygon(0 0, 100% 0, 88% 100%, 0 100%);
86
+ }
87
+
88
+ .tide-rating-segment-last {
89
+ border-top-right-radius: 9999px;
90
+ border-bottom-right-radius: 9999px;
91
+ clip-path: polygon(12% 0, 100% 0, 100% 100%, 0 100%);
92
+ }
93
+ </style>
@@ -170,7 +170,7 @@
170
170
  }
171
171
 
172
172
  .tide-sheet-content {
173
- grid-template-columns: var(--sheet-padding-x) 1fr var(--sheet-padding-x);
173
+ grid-template-columns: var(--sheet-padding-x) var(--tide-safe-fr) var(--sheet-padding-x);
174
174
  }
175
175
 
176
176
  :where(.tide-sheet-content):deep(> :where(*)) {
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import TideCarousel from '@/components/TideCarousel.vue';
3
+ import TideLink from '@/components/TideLink.vue';
4
+ import { ELEMENT } from '@/types/Element';
5
+ import { CSS } from '@/types/Styles';
6
+
7
+ import type { Tab } from '@/types/Tab';
8
+
9
+ type Props = {
10
+ tabs: Tab[];
11
+ };
12
+
13
+ defineProps<Props>();
14
+
15
+ const currentTab = defineModel<number>({ required: true });
16
+
17
+ const handleClick = (index: number) => {
18
+ currentTab.value = index;
19
+ };
20
+ </script>
21
+
22
+ <template>
23
+ <div :class="['tide-tabs', CSS.DISPLAY.FLEX, CSS.BORDER.BOTTOM.TWO, CSS.BORDER.COLOR.LOW]">
24
+ <TideCarousel :is-floating="true">
25
+ <li
26
+ :key="tab.label"
27
+ v-for="(tab, index) in tabs"
28
+ >
29
+ <TideLink
30
+ :class="[
31
+ index === currentTab ? CSS.FONT.COLOR.SURFACE.BRAND : CSS.FONT.COLOR.SURFACE.VARIANT,
32
+ CSS.PADDING.BOTTOM.ONE,
33
+ CSS.FONT.ROLE.LABEL_1,
34
+ CSS.WHITESPACE_WRAP.OFF,
35
+ ]"
36
+ :element="ELEMENT.BUTTON"
37
+ :label="tab.label"
38
+ :subtle="true"
39
+ @click="handleClick(index)"
40
+ />
41
+ <div :class="['rounded-border', index === currentTab && 'active']" />
42
+ </li>
43
+ </TideCarousel>
44
+ </div>
45
+ </template>
46
+
47
+ <style scoped>
48
+ .rounded-border {
49
+ border-radius: var(--tide-radius-full) var(--tide-radius-full) 0 0;
50
+ border-top-width: 4px;
51
+ border-top-style: solid;
52
+ border-color: transparent;
53
+ }
54
+
55
+ .active {
56
+ border-color: var(--tide-on-surface-brand);
57
+ }
58
+ </style>
@@ -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
+ };
@@ -0,0 +1,120 @@
1
+ import { action } from '@storybook/addon-actions';
2
+ import { ref, watchEffect } from 'vue';
3
+
4
+ import TideRating from '@/components/TideRating.vue';
5
+ import { argTypeBooleanUnrequired, change, doSomething, parameters } from '@/utilities/storybook';
6
+
7
+ import type { StoryContext } from '@storybook/vue3';
8
+
9
+ type Args = InstanceType<typeof TideRating>['$props'] & {
10
+ handleChange: string;
11
+ vModel: number;
12
+ };
13
+
14
+ const maxRating = 10;
15
+
16
+ const render = (args: Args, context: StoryContext) => ({
17
+ components: { TideRating },
18
+ methods: {
19
+ doSomething,
20
+ handleChange: (index: number) => {
21
+ action('Current rating')(index);
22
+ context.updateArgs({ ...args, vModel: index });
23
+
24
+ try {
25
+ const callback = eval(args.handleChange);
26
+
27
+ if (callback) {
28
+ callback();
29
+ }
30
+ } catch {
31
+ alert('Please specify a valid handler in the "change" control.');
32
+ }
33
+ },
34
+ },
35
+ setup: () => {
36
+ const ratingValue = ref<number>(args.vModel);
37
+
38
+ watchEffect(() => {
39
+ ratingValue.value = args.vModel;
40
+ });
41
+
42
+ return {
43
+ args,
44
+ ratingValue,
45
+ };
46
+ },
47
+ template: '<TideRating v-model="ratingValue" @update:modelValue="handleChange" v-bind="args" />',
48
+ });
49
+
50
+ export default {
51
+ argTypes: {
52
+ description: {
53
+ control: 'text',
54
+ description: 'Determines the text displayed below the title',
55
+ table: {
56
+ defaultValue: { summary: 'None' },
57
+ type: { summary: 'string' },
58
+ },
59
+ },
60
+ handleChange: {
61
+ ...change,
62
+ control: 'text',
63
+ description: 'JS code or function to execute on change event',
64
+ name: 'update:modelValue',
65
+ table: {
66
+ category: 'Events',
67
+ defaultValue: { summary: 'None' },
68
+ type: { summary: '() => void' },
69
+ },
70
+ },
71
+ maxRating: {
72
+ control: 'text',
73
+ description: 'Maximum rating value / Number of segments',
74
+ table: {
75
+ defaultValue: { summary: 10 },
76
+ type: { summary: 'number' },
77
+ },
78
+ },
79
+ showRating: {
80
+ ...argTypeBooleanUnrequired,
81
+ description: 'Determines whether to display the current rating value',
82
+ },
83
+ title: {
84
+ control: 'text',
85
+ description: 'Determines the title text displayed above the rating component',
86
+ table: {
87
+ defaultValue: { summary: 'None' },
88
+ type: { summary: 'string' },
89
+ },
90
+ },
91
+ vModel: {
92
+ control: {
93
+ type: 'select',
94
+ },
95
+ description: 'Data binding to Vue ref (selected rating index)',
96
+ isDynamic: true,
97
+ options: Array.from({ length: maxRating + 1 }, (_, i) => i),
98
+ table: {
99
+ category: 'Native',
100
+ defaultValue: { summary: 'None' },
101
+ type: { summary: 'Ref<number>' },
102
+ },
103
+ },
104
+ },
105
+ args: {
106
+ description: 'Description',
107
+ handleChange: 'doSomething',
108
+ maxRating: '10',
109
+ showRating: undefined,
110
+ title: 'Title',
111
+ vModel: 0,
112
+ },
113
+ component: TideRating,
114
+ parameters,
115
+ render,
116
+ tags: ['autodocs'],
117
+ title: 'Components/TideRating',
118
+ };
119
+
120
+ export const Demo = {};
@@ -0,0 +1,115 @@
1
+ import { action } from '@storybook/addon-actions';
2
+ import { ref, watchEffect } from 'vue';
3
+
4
+ import TideTabs from '@/components/TideTabs.vue';
5
+ import { change, disabledArgType, doSomething, parameters } from '@/utilities/storybook';
6
+
7
+ import type { Tab } from '@/types/Tab';
8
+ import type { StoryContext } from '@storybook/vue3';
9
+
10
+ type Args = InstanceType<typeof TideTabs>['$props'] & {
11
+ handleChange: string;
12
+ vModel: number;
13
+ };
14
+
15
+ const tabs: Tab[] = [
16
+ {
17
+ dataTrack: 'Tab 0 Click',
18
+ label: 'First Tab',
19
+ },
20
+ {
21
+ dataTrack: 'Tab 1 Click',
22
+ label: 'Second Tab',
23
+ },
24
+ {
25
+ dataTrack: 'Tab 2 Click',
26
+ label: 'Third Tab',
27
+ },
28
+ {
29
+ dataTrack: 'Tab 3 Click',
30
+ label: 'Fourth Tab',
31
+ },
32
+ ];
33
+
34
+ const render = (args: Args, context: StoryContext) => ({
35
+ components: { TideTabs },
36
+ methods: {
37
+ doSomething,
38
+ handleChange: (index: number) => {
39
+ action('Current tab')(index);
40
+ context.updateArgs({ ...args, vModel: index });
41
+
42
+ try {
43
+ const callback = eval(args.handleChange);
44
+
45
+ if (callback) {
46
+ callback();
47
+ }
48
+ } catch {
49
+ alert('Please specify a valid handler in the "change" control.');
50
+ }
51
+ },
52
+ },
53
+ setup: () => {
54
+ const currentTab = ref<number>(args.vModel);
55
+
56
+ watchEffect(() => {
57
+ currentTab.value = args.vModel;
58
+ });
59
+
60
+ return {
61
+ args,
62
+ currentTab,
63
+ };
64
+ },
65
+ template: `<TideTabs @update:modelValue="handleChange" v-bind="args" v-model="currentTab" />`,
66
+ });
67
+
68
+ export default {
69
+ argTypes: {
70
+ change: disabledArgType,
71
+ handleChange: {
72
+ ...change,
73
+ name: 'update:modelValue',
74
+ table: {
75
+ category: 'Events',
76
+ defaultValue: { summary: 'None' },
77
+ type: { summary: '(tabIndex: number) => void' },
78
+ },
79
+ },
80
+ tabs: {
81
+ control: 'object',
82
+ description: 'Sets labels and callback for each tab',
83
+ isCustom: true,
84
+ table: {
85
+ defaultValue: { summary: 'None' },
86
+ type: { summary: 'Tab[]' },
87
+ },
88
+ },
89
+ vModel: {
90
+ control: {
91
+ type: 'select',
92
+ },
93
+ description: 'Data binding to Vue ref (active tab index)',
94
+ isDynamic: true,
95
+ options: Object.keys(tabs).map((index) => parseInt(index)),
96
+ table: {
97
+ category: 'Native',
98
+ defaultValue: { summary: 'None' },
99
+ type: { summary: 'Ref<number>' },
100
+ },
101
+ },
102
+ },
103
+ args: {
104
+ handleChange: 'doSomething',
105
+ tabs,
106
+ vModel: 0,
107
+ },
108
+ component: TideTabs,
109
+ parameters,
110
+ render,
111
+ tags: ['autodocs'],
112
+ title: 'Components/TideTabs',
113
+ };
114
+
115
+ export const Demo = {};
@@ -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
+ });