@davidbirchall/core 1.0.6 → 1.0.8

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 (95) hide show
  1. package/.storybook/main.ts +18 -0
  2. package/.storybook/preview.ts +14 -0
  3. package/package.json +1 -4
  4. package/src/components/Badge/Badge.stories.ts +147 -0
  5. package/src/components/Badge/Badge.test.ts +57 -0
  6. package/src/components/Badge/Badge.vue +79 -0
  7. package/src/components/Button/Button.stories.ts +80 -0
  8. package/src/components/Button/Button.test.ts +145 -0
  9. package/src/components/Button/Button.vue +108 -0
  10. package/src/components/Button/types.ts +4 -0
  11. package/src/components/Calendar/Calendar.stories.ts +261 -0
  12. package/src/components/Calendar/Calendar.test.ts +119 -0
  13. package/src/components/Calendar/Calendar.vue +528 -0
  14. package/src/components/Calendar/types.ts +20 -0
  15. package/src/components/Card/Card.stories.ts +88 -0
  16. package/src/components/Card/Card.test.ts +173 -0
  17. package/src/components/Card/Card.vue +59 -0
  18. package/{dist/Card/types.d.ts → src/components/Card/types.ts} +1 -1
  19. package/src/components/Checkbox/Checkbox.stories.ts +126 -0
  20. package/src/components/Checkbox/Checkbox.test.ts +155 -0
  21. package/src/components/Checkbox/Checkbox.vue +121 -0
  22. package/src/components/Checkbox/types.ts +7 -0
  23. package/src/components/DataTable/DataTable.stories.ts +156 -0
  24. package/src/components/DataTable/DataTable.test.ts +185 -0
  25. package/src/components/DataTable/DataTable.vue +177 -0
  26. package/src/components/DataTable/types.ts +12 -0
  27. package/src/components/DatePicker/DatePicker.stories.ts +172 -0
  28. package/src/components/DatePicker/DatePicker.test.ts +87 -0
  29. package/src/components/DatePicker/DatePicker.vue +302 -0
  30. package/src/components/Dropdown/Dropdown.stories.ts +231 -0
  31. package/src/components/Dropdown/Dropdown.vue +314 -0
  32. package/src/components/Dropdown/types.ts +14 -0
  33. package/src/components/EmptyState/EmptyState.stories.ts +189 -0
  34. package/src/components/EmptyState/EmptyState.vue +215 -0
  35. package/src/components/EmptyState/types.ts +8 -0
  36. package/src/components/ErrorSummary/ErrorSummary.vue +78 -0
  37. package/src/components/ErrorSummary/types.ts +4 -0
  38. package/src/components/FormGroup/FormGroup.stories.ts +264 -0
  39. package/src/components/FormGroup/FormGroup.test.ts +63 -0
  40. package/src/components/FormGroup/FormGroup.vue +58 -0
  41. package/src/components/Heading/Heading.stories.ts +121 -0
  42. package/src/components/Heading/Heading.test.ts +184 -0
  43. package/src/components/Heading/Heading.vue +95 -0
  44. package/src/components/Heading/types.ts +6 -0
  45. package/src/components/Input/Input.stories.ts +172 -0
  46. package/src/components/Input/Input.test.ts +213 -0
  47. package/src/components/Input/Input.vue +121 -0
  48. package/src/components/Input/types.ts +11 -0
  49. package/src/components/Modal/Modal.stories.ts +341 -0
  50. package/src/components/Modal/Modal.test.ts +99 -0
  51. package/src/components/Modal/Modal.vue +278 -0
  52. package/src/components/ProgressBar/ProgressBar.stories.ts +313 -0
  53. package/src/components/ProgressBar/ProgressBar.test.ts +98 -0
  54. package/src/components/ProgressBar/ProgressBar.vue +117 -0
  55. package/src/components/Select/Select.stories.ts +177 -0
  56. package/src/components/Select/Select.test.ts +225 -0
  57. package/src/components/Select/Select.vue +147 -0
  58. package/src/components/Select/types.ts +16 -0
  59. package/src/components/StatCard/StatCard.stories.ts +274 -0
  60. package/src/components/StatCard/StatCard.vue +226 -0
  61. package/src/components/StatCard/types.ts +12 -0
  62. package/src/components/Tag/Tag.stories.ts +78 -0
  63. package/src/components/Tag/Tag.test.ts +50 -0
  64. package/src/components/Tag/Tag.vue +71 -0
  65. package/src/components/Tag/types.ts +4 -0
  66. package/src/components/TextArea/TextArea.stories.ts +171 -0
  67. package/src/components/TextArea/TextArea.test.ts +202 -0
  68. package/src/components/TextArea/TextArea.vue +122 -0
  69. package/src/components/TextArea/types.ts +11 -0
  70. package/src/components/index.ts +5 -0
  71. package/src/test/setup.ts +1 -0
  72. package/src/vite-env.d.ts +6 -0
  73. package/tsconfig.json +29 -0
  74. package/vite.config.ts +33 -0
  75. package/vitest.config.ts +28 -0
  76. package/dist/Button/types.d.ts +0 -4
  77. package/dist/Calendar/types.d.ts +0 -22
  78. package/dist/Checkbox/types.d.ts +0 -7
  79. package/dist/DataTable/types.d.ts +0 -11
  80. package/dist/Dropdown/types.d.ts +0 -13
  81. package/dist/EmptyState/types.d.ts +0 -8
  82. package/dist/ErrorSummary/types.d.ts +0 -4
  83. package/dist/Heading/types.d.ts +0 -6
  84. package/dist/Input/types.d.ts +0 -11
  85. package/dist/Select/types.d.ts +0 -15
  86. package/dist/StatCard/types.d.ts +0 -12
  87. package/dist/Tag/types.d.ts +0 -4
  88. package/dist/TextArea/types.d.ts +0 -11
  89. package/dist/core.css +0 -1
  90. package/dist/core.js +0 -24
  91. package/dist/core.js.map +0 -1
  92. package/dist/core.umd.cjs +0 -2
  93. package/dist/core.umd.cjs.map +0 -1
  94. package/dist/index.d.ts +0 -2
  95. package/dist/package.json +0 -27
@@ -0,0 +1,215 @@
1
+ <template>
2
+ <div class="empty-state" :class="`empty-state--${size}`">
3
+ <div v-if="icon || $slots.icon" class="empty-state__icon">
4
+ <slot name="icon">
5
+ <span class="empty-state__icon-text">{{ icon }}</span>
6
+ </slot>
7
+ </div>
8
+
9
+ <div class="empty-state__content">
10
+ <h3 v-if="title || $slots.title" class="empty-state__title">
11
+ <slot name="title">{{ title }}</slot>
12
+ </h3>
13
+
14
+ <p v-if="description || $slots.description" class="empty-state__description">
15
+ <slot name="description">{{ description }}</slot>
16
+ </p>
17
+
18
+ <div v-if="$slots.default" class="empty-state__body">
19
+ <slot />
20
+ </div>
21
+ </div>
22
+
23
+ <div v-if="actionText || $slots.action" class="empty-state__actions">
24
+ <slot name="action">
25
+ <button
26
+ v-if="actionText"
27
+ type="button"
28
+ class="empty-state__button"
29
+ :class="`empty-state__button--${actionVariant}`"
30
+ @click="handleAction"
31
+ >
32
+ {{ actionText }}
33
+ </button>
34
+ </slot>
35
+ </div>
36
+ </div>
37
+ </template>
38
+
39
+ <script setup lang="ts">
40
+
41
+ import type { EmptyStateProps } from './types'
42
+
43
+ withDefaults(defineProps<EmptyStateProps>(), {
44
+ title: '',
45
+ description: '',
46
+ icon: '',
47
+ actionText: '',
48
+ actionVariant: 'primary',
49
+ size: 'medium'
50
+ })
51
+
52
+ const emit = defineEmits<{
53
+ action: []
54
+ }>()
55
+
56
+ const handleAction = () => {
57
+ emit('action')
58
+ }
59
+ </script>
60
+
61
+ <style scoped>
62
+ .empty-state {
63
+ display: flex;
64
+ flex-direction: column;
65
+ align-items: center;
66
+ justify-content: center;
67
+ text-align: center;
68
+ padding: 3rem 1.5rem;
69
+ color: #6b7280;
70
+ }
71
+
72
+ .empty-state--small {
73
+ padding: 1.5rem 1rem;
74
+ }
75
+
76
+ .empty-state--large {
77
+ padding: 4rem 2rem;
78
+ }
79
+
80
+ .empty-state__icon {
81
+ margin-bottom: 1.5rem;
82
+ color: #9ca3af;
83
+ }
84
+
85
+ .empty-state--small .empty-state__icon {
86
+ margin-bottom: 1rem;
87
+ }
88
+
89
+ .empty-state__icon-text {
90
+ font-size: 3rem;
91
+ line-height: 1;
92
+ }
93
+
94
+ .empty-state--small .empty-state__icon-text {
95
+ font-size: 2rem;
96
+ }
97
+
98
+ .empty-state--large .empty-state__icon-text {
99
+ font-size: 4rem;
100
+ }
101
+
102
+ .empty-state__content {
103
+ max-width: 480px;
104
+ margin-bottom: 1.5rem;
105
+ }
106
+
107
+ .empty-state--small .empty-state__content {
108
+ max-width: 360px;
109
+ margin-bottom: 1rem;
110
+ }
111
+
112
+ .empty-state--large .empty-state__content {
113
+ max-width: 600px;
114
+ margin-bottom: 2rem;
115
+ }
116
+
117
+ .empty-state__title {
118
+ margin: 0 0 0.75rem 0;
119
+ font-size: 1.25rem;
120
+ font-weight: 600;
121
+ color: #374151;
122
+ }
123
+
124
+ .empty-state--small .empty-state__title {
125
+ font-size: 1rem;
126
+ margin-bottom: 0.5rem;
127
+ }
128
+
129
+ .empty-state--large .empty-state__title {
130
+ font-size: 1.5rem;
131
+ margin-bottom: 1rem;
132
+ }
133
+
134
+ .empty-state__description {
135
+ margin: 0;
136
+ font-size: 0.875rem;
137
+ line-height: 1.5;
138
+ color: #6b7280;
139
+ }
140
+
141
+ .empty-state--small .empty-state__description {
142
+ font-size: 0.8125rem;
143
+ }
144
+
145
+ .empty-state--large .empty-state__description {
146
+ font-size: 1rem;
147
+ }
148
+
149
+ .empty-state__body {
150
+ margin-top: 1rem;
151
+ }
152
+
153
+ .empty-state__actions {
154
+ display: flex;
155
+ gap: 0.75rem;
156
+ flex-wrap: wrap;
157
+ justify-content: center;
158
+ }
159
+
160
+ .empty-state__button {
161
+ padding: 0.625rem 1.25rem;
162
+ border: none;
163
+ border-radius: 6px;
164
+ font-size: 0.875rem;
165
+ font-weight: 500;
166
+ cursor: pointer;
167
+ transition: all 0.2s;
168
+ }
169
+
170
+ .empty-state--small .empty-state__button {
171
+ padding: 0.5rem 1rem;
172
+ font-size: 0.8125rem;
173
+ }
174
+
175
+ .empty-state--large .empty-state__button {
176
+ padding: 0.75rem 1.5rem;
177
+ font-size: 1rem;
178
+ }
179
+
180
+ .empty-state__button--primary {
181
+ background: #3b82f6;
182
+ color: white;
183
+ }
184
+
185
+ .empty-state__button--primary:hover {
186
+ background: #2563eb;
187
+ transform: translateY(-1px);
188
+ box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
189
+ }
190
+
191
+ .empty-state__button--primary:active {
192
+ transform: translateY(0);
193
+ }
194
+
195
+ .empty-state__button--secondary {
196
+ background: white;
197
+ color: #374151;
198
+ border: 1px solid #d1d5db;
199
+ }
200
+
201
+ .empty-state__button--secondary:hover {
202
+ background: #f9fafb;
203
+ border-color: #9ca3af;
204
+ }
205
+
206
+ @media (max-width: 768px) {
207
+ .empty-state {
208
+ padding: 2rem 1rem;
209
+ }
210
+
211
+ .empty-state--large {
212
+ padding: 3rem 1.5rem;
213
+ }
214
+ }
215
+ </style>
@@ -0,0 +1,8 @@
1
+ export interface EmptyStateProps {
2
+ title?: string
3
+ description?: string
4
+ icon?: string
5
+ actionText?: string
6
+ actionVariant?: 'primary' | 'secondary'
7
+ size?: 'small' | 'medium' | 'large'
8
+ }
@@ -0,0 +1,78 @@
1
+ <template>
2
+ <div v-if="errors.length" class="error-summary" role="alert" aria-live="assertive">
3
+ <div class="error-summary__header">
4
+ <span class="error-summary__icon">⚠️</span>
5
+ <div>
6
+ <h4 class="error-summary__title">{{ title }}</h4>
7
+ <p class="error-summary__subtitle">Please fix the following {{ errors.length }} issue<span v-if="errors.length !== 1">s</span>:</p>
8
+ </div>
9
+ </div>
10
+ <ul class="error-summary__list">
11
+ <li v-for="error in errors" :key="error.id" class="error-summary__item">
12
+ <a :href="`#${error.id}`" class="error-summary__link">
13
+ {{ error.message }}
14
+ </a>
15
+ </li>
16
+ </ul>
17
+ </div>
18
+ </template>
19
+
20
+ <script setup lang="ts">
21
+ import type { ErrorSummaryItem } from './types'
22
+
23
+ withDefaults(defineProps<{
24
+ title?: string
25
+ errors: ErrorSummaryItem[]
26
+ }>(), {
27
+ title: 'There is a problem'
28
+ })
29
+ </script>
30
+
31
+ <style scoped>
32
+ .error-summary {
33
+ border: 1px solid #fecaca;
34
+ background: #fef2f2;
35
+ border-radius: 0.75rem;
36
+ padding: 1rem 1.25rem;
37
+ margin-bottom: 1.25rem;
38
+ }
39
+
40
+ .error-summary__header {
41
+ display: flex;
42
+ gap: 0.75rem;
43
+ align-items: flex-start;
44
+ }
45
+
46
+ .error-summary__icon {
47
+ font-size: 1.25rem;
48
+ }
49
+
50
+ .error-summary__title {
51
+ margin: 0;
52
+ color: #991b1b;
53
+ font-size: 1rem;
54
+ font-weight: 700;
55
+ }
56
+
57
+ .error-summary__subtitle {
58
+ margin: 0.25rem 0 0;
59
+ color: #7f1d1d;
60
+ font-size: 0.875rem;
61
+ }
62
+
63
+ .error-summary__list {
64
+ margin: 0.75rem 0 0;
65
+ padding-left: 1.25rem;
66
+ color: #7f1d1d;
67
+ }
68
+
69
+ .error-summary__item + .error-summary__item {
70
+ margin-top: 0.35rem;
71
+ }
72
+
73
+ .error-summary__link {
74
+ color: #7f1d1d;
75
+ text-decoration: underline;
76
+ font-weight: 600;
77
+ }
78
+ </style>
@@ -0,0 +1,4 @@
1
+ export type ErrorSummaryItem = {
2
+ id: string
3
+ message: string
4
+ }
@@ -0,0 +1,264 @@
1
+ import type { Meta, StoryObj } from '@storybook/vue3'
2
+ import { ref } from 'vue'
3
+ import FormGroup from './FormGroup.vue'
4
+
5
+ const meta = {
6
+ title: 'Components/FormGroup',
7
+ component: FormGroup,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ label: {
11
+ control: 'text',
12
+ description: 'Form field label'
13
+ },
14
+ id: {
15
+ control: 'text',
16
+ description: 'Form field ID'
17
+ },
18
+ hint: {
19
+ control: 'text',
20
+ description: 'Helper text'
21
+ },
22
+ error: {
23
+ control: 'text',
24
+ description: 'Error message'
25
+ },
26
+ required: {
27
+ control: 'boolean',
28
+ description: 'Whether field is required'
29
+ }
30
+ },
31
+ args: {
32
+ label: 'Field Label',
33
+ id: 'field-id',
34
+ required: false
35
+ }
36
+ } satisfies Meta<typeof FormGroup>
37
+
38
+ export default meta
39
+ type Story = StoryObj<typeof meta>
40
+
41
+ export const Default: Story = {
42
+ args: {
43
+ label: 'Email Address',
44
+ id: 'email',
45
+ hint: 'We\'ll never share your email'
46
+ },
47
+ render: (args: any) => ({
48
+ components: { FormGroup },
49
+ setup() {
50
+ return { args }
51
+ },
52
+ template: `
53
+ <FormGroup v-bind="args">
54
+ <input
55
+ :id="args.id"
56
+ type="email"
57
+ placeholder="Enter your email"
58
+ style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;"
59
+ />
60
+ </FormGroup>
61
+ `
62
+ })
63
+ }
64
+
65
+ export const Required: Story = {
66
+ args: {
67
+ label: 'Password',
68
+ id: 'password',
69
+ required: true,
70
+ hint: 'Must be at least 8 characters'
71
+ },
72
+ render: (args: any) => ({
73
+ components: { FormGroup },
74
+ setup() {
75
+ return { args }
76
+ },
77
+ template: `
78
+ <FormGroup v-bind="args">
79
+ <input
80
+ :id="args.id"
81
+ type="password"
82
+ placeholder="Enter your password"
83
+ style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;"
84
+ />
85
+ </FormGroup>
86
+ `
87
+ })
88
+ }
89
+
90
+ export const WithError: Story = {
91
+ args: {
92
+ label: 'Username',
93
+ id: 'username',
94
+ error: 'This username is already taken',
95
+ required: true
96
+ },
97
+ render: (args: any) => ({
98
+ components: { FormGroup },
99
+ setup() {
100
+ return { args }
101
+ },
102
+ template: `
103
+ <FormGroup v-bind="args">
104
+ <input
105
+ :id="args.id"
106
+ type="text"
107
+ value="john_doe"
108
+ placeholder="Enter username"
109
+ style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;"
110
+ />
111
+ </FormGroup>
112
+ `
113
+ })
114
+ }
115
+
116
+ export const WithTextarea: Story = {
117
+ args: {
118
+ label: 'Description',
119
+ id: 'description',
120
+ hint: 'Brief description of your project'
121
+ },
122
+ render: (args: any) => ({
123
+ components: { FormGroup },
124
+ setup() {
125
+ return { args }
126
+ },
127
+ template: `
128
+ <FormGroup v-bind="args">
129
+ <textarea
130
+ :id="args.id"
131
+ rows="4"
132
+ placeholder="Enter description"
133
+ style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem; font-family: inherit;"
134
+ />
135
+ </FormGroup>
136
+ `
137
+ })
138
+ }
139
+
140
+ export const WithSelect: Story = {
141
+ args: {
142
+ label: 'Country',
143
+ id: 'country',
144
+ required: true
145
+ },
146
+ render: (args: any) => ({
147
+ components: { FormGroup },
148
+ setup() {
149
+ return { args }
150
+ },
151
+ template: `
152
+ <FormGroup v-bind="args">
153
+ <select
154
+ :id="args.id"
155
+ style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;"
156
+ >
157
+ <option value="">Select a country</option>
158
+ <option value="us">United States</option>
159
+ <option value="uk">United Kingdom</option>
160
+ <option value="ca">Canada</option>
161
+ </select>
162
+ </FormGroup>
163
+ `
164
+ })
165
+ }
166
+
167
+ export const CompleteForm: Story = {
168
+ render: () => ({
169
+ components: { FormGroup },
170
+ setup() {
171
+ const email = ref('')
172
+ const password = ref('')
173
+ const emailError = ref('')
174
+
175
+ const validateEmail = () => {
176
+ if (!email.value.includes('@')) {
177
+ emailError.value = 'Please enter a valid email'
178
+ } else {
179
+ emailError.value = ''
180
+ }
181
+ }
182
+
183
+ return { email, password, emailError, validateEmail }
184
+ },
185
+ template: `
186
+ <form style="max-width: 400px;">
187
+ <FormGroup
188
+ label="Email"
189
+ id="form-email"
190
+ :error="emailError"
191
+ required
192
+ hint="Enter your email address"
193
+ >
194
+ <input
195
+ id="form-email"
196
+ v-model="email"
197
+ @blur="validateEmail"
198
+ type="email"
199
+ placeholder="you@example.com"
200
+ style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;"
201
+ />
202
+ </FormGroup>
203
+
204
+ <FormGroup
205
+ label="Password"
206
+ id="form-password"
207
+ required
208
+ hint="Must be at least 8 characters"
209
+ >
210
+ <input
211
+ id="form-password"
212
+ v-model="password"
213
+ type="password"
214
+ placeholder="••••••••"
215
+ style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;"
216
+ />
217
+ </FormGroup>
218
+
219
+ <FormGroup
220
+ label="Bio"
221
+ id="form-bio"
222
+ hint="Tell us a bit about yourself"
223
+ >
224
+ <textarea
225
+ id="form-bio"
226
+ rows="3"
227
+ placeholder="I am..."
228
+ style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem; font-family: inherit;"
229
+ />
230
+ </FormGroup>
231
+
232
+ <button
233
+ type="submit"
234
+ style="width: 100%; padding: 0.625rem; background: #667eea; color: white; border: none; border-radius: 0.375rem; font-weight: 600; cursor: pointer;"
235
+ >
236
+ Submit
237
+ </button>
238
+ </form>
239
+ `
240
+ })
241
+ }
242
+
243
+ export const NoLabel: Story = {
244
+ args: {
245
+ id: 'search',
246
+ hint: 'Search for anything...'
247
+ },
248
+ render: (args: any) => ({
249
+ components: { FormGroup },
250
+ setup() {
251
+ return { args }
252
+ },
253
+ template: `
254
+ <FormGroup v-bind="args">
255
+ <input
256
+ :id="args.id"
257
+ type="search"
258
+ placeholder="Search..."
259
+ style="width: 100%; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;"
260
+ />
261
+ </FormGroup>
262
+ `
263
+ })
264
+ }
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import FormGroup from './FormGroup.vue'
4
+
5
+ describe('FormGroup', () => {
6
+ it('renders label when provided', () => {
7
+ const wrapper = mount(FormGroup, {
8
+ props: {
9
+ label: 'Email Address'
10
+ }
11
+ })
12
+ expect(wrapper.find('.form-group__label').text()).toContain('Email Address')
13
+ })
14
+
15
+ it('shows required indicator when required prop is true', () => {
16
+ const wrapper = mount(FormGroup, {
17
+ props: {
18
+ label: 'Name',
19
+ required: true
20
+ }
21
+ })
22
+ expect(wrapper.find('.form-group__required').exists()).toBe(true)
23
+ })
24
+
25
+ it('displays hint text when provided', () => {
26
+ const wrapper = mount(FormGroup, {
27
+ props: {
28
+ hint: 'This is a helpful hint'
29
+ }
30
+ })
31
+ expect(wrapper.find('.form-group__hint').text()).toBe('This is a helpful hint')
32
+ })
33
+
34
+ it('displays error message and adds error class', () => {
35
+ const wrapper = mount(FormGroup, {
36
+ props: {
37
+ error: 'This field is required'
38
+ }
39
+ })
40
+ expect(wrapper.find('.form-group__error').text()).toBe('This field is required')
41
+ expect(wrapper.classes()).toContain('form-group--error')
42
+ })
43
+
44
+ it('hides hint when error is present', () => {
45
+ const wrapper = mount(FormGroup, {
46
+ props: {
47
+ hint: 'Helpful hint',
48
+ error: 'Error message'
49
+ }
50
+ })
51
+ expect(wrapper.find('.form-group__hint').exists()).toBe(false)
52
+ expect(wrapper.find('.form-group__error').exists()).toBe(true)
53
+ })
54
+
55
+ it('renders slot content', () => {
56
+ const wrapper = mount(FormGroup, {
57
+ slots: {
58
+ default: '<input type="text" />'
59
+ }
60
+ })
61
+ expect(wrapper.find('input').exists()).toBe(true)
62
+ })
63
+ })
@@ -0,0 +1,58 @@
1
+ <template>
2
+ <div :class="['form-group', { 'form-group--error': error }]">
3
+ <label v-if="label" :for="id" class="form-group__label">
4
+ {{ label }}
5
+ </label>
6
+ <slot />
7
+ <small v-if="hint && !error" class="form-group__hint">{{ hint }}</small>
8
+ <small v-if="error" class="form-group__error">{{ error }}</small>
9
+ </div>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ interface Props {
14
+ label?: string
15
+ id?: string
16
+ hint?: string
17
+ error?: string
18
+ required?: boolean
19
+ }
20
+
21
+ withDefaults(defineProps<Props>(), {
22
+ label: '',
23
+ id: '',
24
+ hint: '',
25
+ error: '',
26
+ required: false
27
+ })
28
+ </script>
29
+
30
+ <style scoped>
31
+ .form-group {
32
+ margin-bottom: 1.5rem;
33
+ }
34
+
35
+ .form-group__label {
36
+ display: block;
37
+ font-weight: 600;
38
+ margin-bottom: 0.5rem;
39
+ color: #374151;
40
+ font-size: 0.9375rem;
41
+ }
42
+
43
+
44
+ .form-group__hint {
45
+ display: block;
46
+ margin-top: 0.25rem;
47
+ color: #6b7280;
48
+ font-size: 0.875rem;
49
+ }
50
+
51
+ .form-group__error {
52
+ display: block;
53
+ margin-top: 0.25rem;
54
+ color: #dc2626;
55
+ font-size: 0.875rem;
56
+ font-weight: 500;
57
+ }
58
+ </style>