picocrank 1.26.0 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint.config.js +12 -5
- package/package.json +8 -5
- package/styles/picocrank-extensions.css +46 -0
- package/vite.config.js +5 -0
- package/vue/components/Calendar.vue +125 -128
- package/vue/components/FormField.spec.js +350 -0
- package/vue/components/FormField.vue +236 -32
- package/vue/components/FormLayout.spec.js +56 -0
- package/vue/components/Login.vue +152 -41
- package/vue/components/Pagination.vue +5 -4
- package/vue/components/Tabs.spec.js +101 -0
- package/vue/components/Tabs.vue +115 -25
- package/vue/components/form-validation.spec.js +126 -0
- package/vue/examples/FormExample.vue +76 -48
- package/vue/examples/Login.vue +6 -1
- package/vue/examples/LoginExample.vue +98 -2
- package/vue/examples/PageStructure.vue +9 -9
- package/vue/examples/TableExample.vue +5 -5
- package/vue/examples/TabsExample.vue +41 -7
- package/vue/test/formTestUtils.js +279 -0
- package/vue/test/setup.js +5 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mountFormField, flushControlState } from '../test/formTestUtils.js'
|
|
3
|
+
|
|
4
|
+
describe('FormField', () => {
|
|
5
|
+
describe('labels and structure', () => {
|
|
6
|
+
it('renders a label associated with the control via for/id', () => {
|
|
7
|
+
const wrapper = mountFormField({
|
|
8
|
+
props: {
|
|
9
|
+
label: 'Email',
|
|
10
|
+
for: 'email',
|
|
11
|
+
},
|
|
12
|
+
slot: '<input id="email" type="email" />',
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const label = wrapper.find('label[for="email"]')
|
|
16
|
+
expect(label.exists()).toBe(true)
|
|
17
|
+
expect(label.text()).toContain('Email')
|
|
18
|
+
expect(wrapper.find('#email').exists()).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('renders a fake label when component-has-label is set', () => {
|
|
22
|
+
const wrapper = mountFormField({
|
|
23
|
+
props: {
|
|
24
|
+
label: 'Role',
|
|
25
|
+
componentHasLabel: true,
|
|
26
|
+
},
|
|
27
|
+
slot: '<div role="radiogroup">Options</div>',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(wrapper.find('label').exists()).toBe(false)
|
|
31
|
+
expect(wrapper.find('.fake-label').text()).toContain('Role')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('skips the label when label-skip is set', () => {
|
|
35
|
+
const wrapper = mountFormField({
|
|
36
|
+
props: {
|
|
37
|
+
label: 'Username',
|
|
38
|
+
labelSkip: true,
|
|
39
|
+
for: 'username',
|
|
40
|
+
},
|
|
41
|
+
slot: '<input id="username" aria-label="Username" />',
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
expect(wrapper.find('label').exists()).toBe(false)
|
|
45
|
+
expect(wrapper.find('.fake-label').exists()).toBe(false)
|
|
46
|
+
expect(wrapper.find('.form-field-label-skip').exists()).toBe(true)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('renders description below the control by default', () => {
|
|
50
|
+
const wrapper = mountFormField({
|
|
51
|
+
props: {
|
|
52
|
+
description: 'Helpful hint text.',
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const description = wrapper.find('.form-field-description')
|
|
57
|
+
expect(description.exists()).toBe(true)
|
|
58
|
+
expect(description.text()).toBe('Helpful hint text.')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders description above the control when description-above is set', () => {
|
|
62
|
+
const wrapper = mountFormField({
|
|
63
|
+
props: {
|
|
64
|
+
description: 'Shown first.',
|
|
65
|
+
descriptionAbove: true,
|
|
66
|
+
},
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const metaBlocks = wrapper.findAll('.form-field-meta')
|
|
70
|
+
expect(metaBlocks).toHaveLength(1)
|
|
71
|
+
expect(metaBlocks[0].find('.form-field-description').text()).toBe('Shown first.')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('renders a docs link when docs-url is provided', () => {
|
|
75
|
+
const wrapper = mountFormField({
|
|
76
|
+
props: {
|
|
77
|
+
docsUrl: 'https://example.com/docs',
|
|
78
|
+
docsUrlTitle: 'Read the docs',
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const link = wrapper.find('.form-field-docs-link')
|
|
83
|
+
expect(link.attributes('href')).toBe('https://example.com/docs')
|
|
84
|
+
expect(link.attributes('target')).toBe('_blank')
|
|
85
|
+
expect(link.attributes('rel')).toBe('noopener noreferrer')
|
|
86
|
+
expect(link.text()).toBe('Read the docs')
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('required indicator', () => {
|
|
91
|
+
it('shows a required asterisk when label-required is set', () => {
|
|
92
|
+
const wrapper = mountFormField({
|
|
93
|
+
props: {
|
|
94
|
+
labelRequired: true,
|
|
95
|
+
},
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const marker = wrapper.find('.form-field-required')
|
|
99
|
+
expect(marker.exists()).toBe(true)
|
|
100
|
+
expect(marker.attributes('aria-hidden')).toBe('true')
|
|
101
|
+
expect(marker.text()).toBe('*')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('shows a required asterisk when the slotted control is required', async () => {
|
|
105
|
+
const wrapper = mountFormField({
|
|
106
|
+
slot: '<input id="test-input" required />',
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
await flushControlState()
|
|
110
|
+
|
|
111
|
+
expect(wrapper.find('.form-field-required').exists()).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('error prop validation', () => {
|
|
116
|
+
it('shows a NotificationBlock when error is set', async () => {
|
|
117
|
+
const wrapper = mountFormField({
|
|
118
|
+
props: {
|
|
119
|
+
error: 'This field is required.',
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
await flushControlState()
|
|
124
|
+
|
|
125
|
+
const errorBlock = wrapper.find('.form-field-error')
|
|
126
|
+
expect(errorBlock.exists()).toBe(true)
|
|
127
|
+
expect(errorBlock.attributes('role')).toBe('alert')
|
|
128
|
+
expect(errorBlock.text()).toContain('This field is required.')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('does not show an error block when error is empty', async () => {
|
|
132
|
+
const wrapper = mountFormField({
|
|
133
|
+
props: {
|
|
134
|
+
error: '',
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
await flushControlState()
|
|
139
|
+
|
|
140
|
+
expect(wrapper.find('.form-field-error').exists()).toBe(false)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('marks the native control invalid when error is set', async () => {
|
|
144
|
+
const wrapper = mountFormField({
|
|
145
|
+
props: {
|
|
146
|
+
error: 'Invalid value.',
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
await flushControlState()
|
|
151
|
+
|
|
152
|
+
const input = wrapper.find('#test-input').element
|
|
153
|
+
expect(input.getAttribute('aria-invalid')).toBe('true')
|
|
154
|
+
expect(input.classList.contains('field-invalid')).toBe(true)
|
|
155
|
+
expect(input.validationMessage).toBe('Invalid value.')
|
|
156
|
+
expect(input.validity.customError).toBe(true)
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('links the control to the error via aria-describedby', async () => {
|
|
160
|
+
const wrapper = mountFormField({
|
|
161
|
+
props: {
|
|
162
|
+
error: 'Invalid value.',
|
|
163
|
+
},
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
await flushControlState()
|
|
167
|
+
|
|
168
|
+
const input = wrapper.find('#test-input').element
|
|
169
|
+
const errorId = wrapper.find('.form-field-error').attributes('id')
|
|
170
|
+
expect(input.getAttribute('aria-describedby')).toContain(errorId)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('clears native invalid state when error is removed', async () => {
|
|
174
|
+
const wrapper = mountFormField({
|
|
175
|
+
props: {
|
|
176
|
+
error: 'Invalid value.',
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
await flushControlState()
|
|
181
|
+
await wrapper.setProps({ error: '' })
|
|
182
|
+
await flushControlState()
|
|
183
|
+
|
|
184
|
+
const input = wrapper.find('#test-input').element
|
|
185
|
+
expect(input.hasAttribute('aria-invalid')).toBe(false)
|
|
186
|
+
expect(input.classList.contains('field-invalid')).toBe(false)
|
|
187
|
+
expect(input.validationMessage).toBe('')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('emits update:error when the user edits a field with an external error', async () => {
|
|
191
|
+
const wrapper = mountFormField({
|
|
192
|
+
props: {
|
|
193
|
+
error: 'Server rejected this value.',
|
|
194
|
+
},
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
await flushControlState()
|
|
198
|
+
await wrapper.find('#test-input').setValue('corrected')
|
|
199
|
+
await flushControlState()
|
|
200
|
+
|
|
201
|
+
expect(wrapper.emitted('update:error')).toContainEqual([''])
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('does not emit update:error on input when validate() supplies the error', async () => {
|
|
205
|
+
const wrapper = mountFormField({
|
|
206
|
+
props: {
|
|
207
|
+
validate: () => 'Always invalid.',
|
|
208
|
+
validateDeps: [],
|
|
209
|
+
},
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
await flushControlState()
|
|
213
|
+
await wrapper.find('#test-input').setValue('anything')
|
|
214
|
+
await flushControlState()
|
|
215
|
+
|
|
216
|
+
expect(wrapper.emitted('update:error')).toBeUndefined()
|
|
217
|
+
})
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
describe('validate function', () => {
|
|
221
|
+
it('shows an error from the validate callback', async () => {
|
|
222
|
+
const wrapper = mountFormField({
|
|
223
|
+
props: {
|
|
224
|
+
validate: () => 'Pick at least one option.',
|
|
225
|
+
validateDeps: [],
|
|
226
|
+
},
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
await flushControlState()
|
|
230
|
+
|
|
231
|
+
expect(wrapper.find('.form-field-error').text()).toContain('Pick at least one option.')
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('re-runs validate when validateDeps change', async () => {
|
|
235
|
+
const state = { value: 'a' }
|
|
236
|
+
const wrapper = mountFormField({
|
|
237
|
+
props: {
|
|
238
|
+
validateDeps: [state.value],
|
|
239
|
+
validate: () => (state.value === 'a' ? 'First value is invalid.' : ''),
|
|
240
|
+
},
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
await flushControlState()
|
|
244
|
+
expect(wrapper.find('.form-field-error').exists()).toBe(true)
|
|
245
|
+
|
|
246
|
+
state.value = 'b'
|
|
247
|
+
await wrapper.setProps({ validateDeps: ['b'] })
|
|
248
|
+
await flushControlState()
|
|
249
|
+
expect(wrapper.find('.form-field-error').exists()).toBe(false)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('prefers the error prop over validate()', async () => {
|
|
253
|
+
const wrapper = mountFormField({
|
|
254
|
+
props: {
|
|
255
|
+
error: 'External error.',
|
|
256
|
+
validate: () => 'Validation error.',
|
|
257
|
+
validateDeps: [],
|
|
258
|
+
},
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
await flushControlState()
|
|
262
|
+
|
|
263
|
+
expect(wrapper.find('.form-field-error').text()).toContain('External error.')
|
|
264
|
+
expect(wrapper.find('.form-field-error').text()).not.toContain('Validation error.')
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('ignores non-string validate results', async () => {
|
|
268
|
+
const wrapper = mountFormField({
|
|
269
|
+
props: {
|
|
270
|
+
validate: () => false,
|
|
271
|
+
validateDeps: [],
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
await flushControlState()
|
|
276
|
+
|
|
277
|
+
expect(wrapper.find('.form-field-error').exists()).toBe(false)
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
describe('component-has-label controls', () => {
|
|
282
|
+
it('does not apply native validity to composite controls', async () => {
|
|
283
|
+
const wrapper = mountFormField({
|
|
284
|
+
props: {
|
|
285
|
+
componentHasLabel: true,
|
|
286
|
+
error: 'Group error.',
|
|
287
|
+
},
|
|
288
|
+
slot: '<input id="test-input" />',
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
await flushControlState()
|
|
292
|
+
|
|
293
|
+
const input = wrapper.find('#test-input').element
|
|
294
|
+
expect(input.validationMessage).toBe('')
|
|
295
|
+
expect(input.hasAttribute('aria-invalid')).toBe(false)
|
|
296
|
+
})
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
describe('lifecycle', () => {
|
|
300
|
+
it('clears native validity on unmount', async () => {
|
|
301
|
+
const wrapper = mountFormField({
|
|
302
|
+
props: {
|
|
303
|
+
error: 'Invalid value.',
|
|
304
|
+
},
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
await flushControlState()
|
|
308
|
+
|
|
309
|
+
const input = wrapper.find('#test-input').element
|
|
310
|
+
expect(input.validationMessage).toBe('Invalid value.')
|
|
311
|
+
|
|
312
|
+
wrapper.unmount()
|
|
313
|
+
|
|
314
|
+
expect(input.validationMessage).toBe('')
|
|
315
|
+
expect(input.hasAttribute('aria-invalid')).toBe(false)
|
|
316
|
+
})
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
describe('disabled state', () => {
|
|
320
|
+
it('applies disabled styling class to the label', () => {
|
|
321
|
+
const wrapper = mountFormField({
|
|
322
|
+
props: {
|
|
323
|
+
disabled: true,
|
|
324
|
+
},
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
expect(wrapper.find('label.disabled').exists()).toBe(true)
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
describe('FormField htmlFor alias', () => {
|
|
333
|
+
it('accepts htmlFor as an alias for for', async () => {
|
|
334
|
+
const wrapper = mountFormField({
|
|
335
|
+
props: {
|
|
336
|
+
label: 'Name',
|
|
337
|
+
htmlFor: 'name-field',
|
|
338
|
+
for: '',
|
|
339
|
+
},
|
|
340
|
+
slot: '<input id="name-field" />',
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
expect(wrapper.find('label[for="name-field"]').exists()).toBe(true)
|
|
344
|
+
|
|
345
|
+
await wrapper.setProps({ error: 'Required.' })
|
|
346
|
+
await flushControlState()
|
|
347
|
+
|
|
348
|
+
expect(wrapper.find('#name-field').element.getAttribute('aria-invalid')).toBe('true')
|
|
349
|
+
})
|
|
350
|
+
})
|
|
@@ -1,39 +1,51 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<label v-if="!
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
2
|
+
<label v-if="showLabel && !componentHasLabel" :for="controlId" :class="{ disabled }">
|
|
3
|
+
<span class="form-field-label-text">{{ label }}</span><span v-if="showRequiredLabel" class="form-field-required" aria-hidden="true">*</span>
|
|
4
|
+
</label>
|
|
5
|
+
<span v-else-if="showLabel" class="fake-label" :class="{ disabled }">
|
|
6
|
+
<span class="form-field-label-text">{{ label }}</span><span v-if="showRequiredLabel" class="form-field-required" aria-hidden="true">*</span>
|
|
7
|
+
</span>
|
|
8
|
+
<div
|
|
9
|
+
ref="fieldControlRef"
|
|
10
|
+
class="form-field-control"
|
|
11
|
+
:class="{ 'form-field-label-skip': labelSkip }"
|
|
12
|
+
>
|
|
13
|
+
<div v-if="descriptionAbove && hasFieldMeta" class="form-field-meta">
|
|
14
|
+
<p v-if="description" class="subtle form-field-description">{{ description }}</p>
|
|
15
|
+
<a
|
|
16
|
+
v-if="docsUrl"
|
|
17
|
+
class="form-field-docs-link"
|
|
18
|
+
:href="docsUrl"
|
|
19
|
+
target="_blank"
|
|
20
|
+
rel="noopener noreferrer"
|
|
21
|
+
>{{ docsLinkTitle }}</a>
|
|
22
|
+
</div>
|
|
23
|
+
<slot />
|
|
24
|
+
<NotificationBlock
|
|
25
|
+
v-if="fieldError"
|
|
26
|
+
:id="errorId"
|
|
27
|
+
class="form-field-error"
|
|
28
|
+
type="bad"
|
|
29
|
+
label="Error"
|
|
30
|
+
:message="fieldError"
|
|
31
|
+
role="alert"
|
|
32
|
+
/>
|
|
33
|
+
<div v-if="!descriptionAbove && hasFieldMeta" class="form-field-meta">
|
|
34
|
+
<p v-if="description" class="subtle form-field-description">{{ description }}</p>
|
|
35
|
+
<a
|
|
36
|
+
v-if="docsUrl"
|
|
37
|
+
class="form-field-docs-link"
|
|
38
|
+
:href="docsUrl"
|
|
39
|
+
target="_blank"
|
|
40
|
+
rel="noopener noreferrer"
|
|
41
|
+
>{{ docsLinkTitle }}</a>
|
|
42
|
+
</div>
|
|
31
43
|
</div>
|
|
32
|
-
<slot v-else />
|
|
33
44
|
</template>
|
|
34
45
|
|
|
35
46
|
<script setup>
|
|
36
|
-
import { computed } from 'vue'
|
|
47
|
+
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
|
|
48
|
+
import NotificationBlock from './NotificationBlock.vue'
|
|
37
49
|
|
|
38
50
|
const props = defineProps({
|
|
39
51
|
label: {
|
|
@@ -64,7 +76,15 @@ const props = defineProps({
|
|
|
64
76
|
type: String,
|
|
65
77
|
default: '',
|
|
66
78
|
},
|
|
67
|
-
|
|
79
|
+
componentHasLabel: {
|
|
80
|
+
type: Boolean,
|
|
81
|
+
default: false,
|
|
82
|
+
},
|
|
83
|
+
labelSkip: {
|
|
84
|
+
type: Boolean,
|
|
85
|
+
default: false,
|
|
86
|
+
},
|
|
87
|
+
labelRequired: {
|
|
68
88
|
type: Boolean,
|
|
69
89
|
default: false,
|
|
70
90
|
},
|
|
@@ -72,11 +92,186 @@ const props = defineProps({
|
|
|
72
92
|
type: Boolean,
|
|
73
93
|
default: false,
|
|
74
94
|
},
|
|
95
|
+
error: {
|
|
96
|
+
type: String,
|
|
97
|
+
default: '',
|
|
98
|
+
},
|
|
99
|
+
validate: {
|
|
100
|
+
type: Function,
|
|
101
|
+
default: null,
|
|
102
|
+
},
|
|
103
|
+
validateDeps: {
|
|
104
|
+
type: Array,
|
|
105
|
+
default: () => [],
|
|
106
|
+
},
|
|
75
107
|
})
|
|
76
108
|
|
|
109
|
+
const emit = defineEmits(['update:error'])
|
|
110
|
+
|
|
77
111
|
const controlId = computed(() => props.htmlFor || props.for)
|
|
112
|
+
const showLabel = computed(() => !props.labelSkip)
|
|
78
113
|
const hasFieldMeta = computed(() => Boolean(props.description || props.docsUrl))
|
|
79
114
|
const docsLinkTitle = computed(() => props.docsUrlTitle || props.docsUrl)
|
|
115
|
+
const errorId = useId()
|
|
116
|
+
const FIELD_INVALID_CLASS = 'field-invalid'
|
|
117
|
+
const fieldControlRef = ref(null)
|
|
118
|
+
const controlRequired = ref(false)
|
|
119
|
+
let boundControl = null
|
|
120
|
+
|
|
121
|
+
function onControlInput() {
|
|
122
|
+
if (props.error.trim()) {
|
|
123
|
+
emit('update:error', '')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function bindControlListeners() {
|
|
128
|
+
unbindControlListeners()
|
|
129
|
+
const element = getControlElement()
|
|
130
|
+
if (!element) {
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
element.addEventListener('input', onControlInput)
|
|
134
|
+
element.addEventListener('change', onControlInput)
|
|
135
|
+
boundControl = element
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function unbindControlListeners() {
|
|
139
|
+
if (!boundControl) {
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
boundControl.removeEventListener('input', onControlInput)
|
|
143
|
+
boundControl.removeEventListener('change', onControlInput)
|
|
144
|
+
boundControl = null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const showRequiredLabel = computed(() => props.labelRequired || controlRequired.value)
|
|
148
|
+
|
|
149
|
+
const fieldError = computed(() => {
|
|
150
|
+
// Re-run when parent passes reactive deps (e.g. v-model values).
|
|
151
|
+
void props.validateDeps
|
|
152
|
+
|
|
153
|
+
if (props.error.trim()) {
|
|
154
|
+
return props.error.trim()
|
|
155
|
+
}
|
|
156
|
+
if (typeof props.validate === 'function') {
|
|
157
|
+
const result = props.validate()
|
|
158
|
+
return typeof result === 'string' ? result.trim() : ''
|
|
159
|
+
}
|
|
160
|
+
return ''
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
function getControlElement() {
|
|
164
|
+
if (!controlId.value) {
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
return document.getElementById(controlId.value)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function syncRequiredState() {
|
|
171
|
+
const linkedControl = getControlElement()
|
|
172
|
+
if (linkedControl) {
|
|
173
|
+
controlRequired.value = linkedControl.required === true
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const container = fieldControlRef.value
|
|
178
|
+
controlRequired.value = Boolean(
|
|
179
|
+
container?.querySelector('input[required], select[required], textarea[required]'),
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function syncControlState() {
|
|
184
|
+
syncNativeValidity()
|
|
185
|
+
syncRequiredState()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function syncNativeValidity() {
|
|
189
|
+
const element = getControlElement()
|
|
190
|
+
const canUseNativeValidity = Boolean(
|
|
191
|
+
controlId.value
|
|
192
|
+
&& !props.componentHasLabel
|
|
193
|
+
&& element
|
|
194
|
+
&& typeof element.setCustomValidity === 'function',
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
if (!canUseNativeValidity) {
|
|
198
|
+
clearNativeFieldState(element)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
element.setCustomValidity(fieldError.value || '')
|
|
203
|
+
if (fieldError.value) {
|
|
204
|
+
element.setAttribute('aria-invalid', 'true')
|
|
205
|
+
element.classList.add(FIELD_INVALID_CLASS)
|
|
206
|
+
setErrorDescribedBy(element)
|
|
207
|
+
} else {
|
|
208
|
+
element.removeAttribute('aria-invalid')
|
|
209
|
+
element.classList.remove(FIELD_INVALID_CLASS)
|
|
210
|
+
removeErrorDescribedBy(element)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function clearNativeFieldState(element) {
|
|
215
|
+
if (!element) {
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (typeof element.setCustomValidity === 'function') {
|
|
219
|
+
element.setCustomValidity('')
|
|
220
|
+
}
|
|
221
|
+
element.removeAttribute('aria-invalid')
|
|
222
|
+
element.classList.remove(FIELD_INVALID_CLASS)
|
|
223
|
+
removeErrorDescribedBy(element)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function setErrorDescribedBy(element) {
|
|
227
|
+
const existing = element.getAttribute('aria-describedby')
|
|
228
|
+
const ids = existing ? existing.split(/\s+/).filter(Boolean) : []
|
|
229
|
+
if (!ids.includes(errorId)) {
|
|
230
|
+
ids.push(errorId)
|
|
231
|
+
}
|
|
232
|
+
element.setAttribute('aria-describedby', ids.join(' '))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function removeErrorDescribedBy(element) {
|
|
236
|
+
const existing = element.getAttribute('aria-describedby')
|
|
237
|
+
if (!existing) {
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
const ids = existing.split(/\s+/).filter((id) => id !== errorId)
|
|
241
|
+
if (ids.length > 0) {
|
|
242
|
+
element.setAttribute('aria-describedby', ids.join(' '))
|
|
243
|
+
} else {
|
|
244
|
+
element.removeAttribute('aria-describedby')
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
watch(
|
|
249
|
+
() => [fieldError.value, controlId.value, props.componentHasLabel],
|
|
250
|
+
() => {
|
|
251
|
+
nextTick(syncControlState)
|
|
252
|
+
},
|
|
253
|
+
{ flush: 'post' },
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
watch(
|
|
257
|
+
controlId,
|
|
258
|
+
() => {
|
|
259
|
+
nextTick(bindControlListeners)
|
|
260
|
+
},
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
onMounted(() => {
|
|
264
|
+
nextTick(() => {
|
|
265
|
+
syncControlState()
|
|
266
|
+
bindControlListeners()
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
onBeforeUnmount(() => {
|
|
271
|
+
const element = boundControl || getControlElement()
|
|
272
|
+
unbindControlListeners()
|
|
273
|
+
clearNativeFieldState(element)
|
|
274
|
+
})
|
|
80
275
|
</script>
|
|
81
276
|
|
|
82
277
|
<style scoped>
|
|
@@ -107,4 +302,13 @@ label.disabled,
|
|
|
107
302
|
font-size: 0.9em;
|
|
108
303
|
align-self: flex-start;
|
|
109
304
|
}
|
|
305
|
+
|
|
306
|
+
.form-field-control :deep(.form-field-error) {
|
|
307
|
+
font-size: 0.9em;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.form-field-required {
|
|
311
|
+
color: var(--form-field-required-fg);
|
|
312
|
+
margin-left: 0.15em;
|
|
313
|
+
}
|
|
110
314
|
</style>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import FormLayout from './FormLayout.vue'
|
|
4
|
+
|
|
5
|
+
describe('FormLayout', () => {
|
|
6
|
+
it('renders a form element with default slot content', () => {
|
|
7
|
+
const wrapper = mount(FormLayout, {
|
|
8
|
+
attachTo: document.body,
|
|
9
|
+
slots: {
|
|
10
|
+
default: '<input id="name" />',
|
|
11
|
+
},
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
expect(wrapper.element.tagName).toBe('FORM')
|
|
15
|
+
expect(wrapper.find('#name').exists()).toBe(true)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('does not render an actions fieldset when the actions slot is empty', () => {
|
|
19
|
+
const wrapper = mount(FormLayout, {
|
|
20
|
+
slots: {
|
|
21
|
+
default: '<input id="name" />',
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
expect(wrapper.find('fieldset').exists()).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('renders an actions fieldset when the actions slot is provided', () => {
|
|
29
|
+
const wrapper = mount(FormLayout, {
|
|
30
|
+
slots: {
|
|
31
|
+
default: '<input id="name" />',
|
|
32
|
+
actions: '<button type="submit">Save</button>',
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const fieldset = wrapper.find('fieldset')
|
|
37
|
+
expect(fieldset.exists()).toBe(true)
|
|
38
|
+
expect(fieldset.find('button[type="submit"]').exists()).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('forwards submit events from the native form', async () => {
|
|
42
|
+
const onSubmit = vi.fn((event) => event.preventDefault())
|
|
43
|
+
const wrapper = mount(FormLayout, {
|
|
44
|
+
attrs: {
|
|
45
|
+
onSubmit,
|
|
46
|
+
},
|
|
47
|
+
slots: {
|
|
48
|
+
default: '<button type="submit">Save</button>',
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await wrapper.find('form').trigger('submit')
|
|
53
|
+
|
|
54
|
+
expect(onSubmit).toHaveBeenCalledTimes(1)
|
|
55
|
+
})
|
|
56
|
+
})
|