picocrank 1.26.0 → 1.28.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/Header.spec.js +71 -0
- package/vue/components/Header.vue +27 -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/App.vue +10 -1
- 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 +37 -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
package/vue/components/Tabs.vue
CHANGED
|
@@ -18,11 +18,14 @@
|
|
|
18
18
|
v-for="(tab, index) in tabs"
|
|
19
19
|
:key="getTabKey(tab, index)"
|
|
20
20
|
:ref="(el) => setTabRef(getTabKey(tab, index), el)"
|
|
21
|
-
:class="['tab-button', { active: isTabSelected(tab, index) }]"
|
|
21
|
+
:class="['tab-button', { active: isTabSelected(tab, index), disabled: isTabDisabled(tab) }]"
|
|
22
|
+
:disabled="isTabDisabled(tab)"
|
|
22
23
|
:aria-selected="isTabSelected(tab, index) ? 'true' : 'false'"
|
|
24
|
+
:aria-disabled="isTabDisabled(tab) ? 'true' : undefined"
|
|
23
25
|
:aria-controls="`tab-panel-${getTabKey(tab, index)}`"
|
|
24
26
|
:id="`tab-${getTabKey(tab, index)}`"
|
|
25
|
-
:tabindex="
|
|
27
|
+
:tabindex="tabTabIndex(tab, index)"
|
|
28
|
+
:title="tab.disabledTitle || undefined"
|
|
26
29
|
role="tab"
|
|
27
30
|
type="button"
|
|
28
31
|
@click="onTabClick(getTabKey(tab, index))"
|
|
@@ -104,18 +107,66 @@ const emit = defineEmits(['tab-change'])
|
|
|
104
107
|
|
|
105
108
|
const tabRefs = new Map()
|
|
106
109
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
+
const activeTabId = ref(null)
|
|
111
|
+
const focusedTabId = ref(null)
|
|
112
|
+
|
|
113
|
+
function isTabDisabled(tab) {
|
|
114
|
+
return tab.disabled === true
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getFirstEnabledIndex() {
|
|
118
|
+
return props.tabs.findIndex((tab) => !isTabDisabled(tab))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getLastEnabledIndex() {
|
|
122
|
+
for (let index = props.tabs.length - 1; index >= 0; index -= 1) {
|
|
123
|
+
if (!isTabDisabled(props.tabs[index])) {
|
|
124
|
+
return index
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return -1
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getFirstEnabledTabKey() {
|
|
131
|
+
const index = getFirstEnabledIndex()
|
|
132
|
+
if (index < 0) {
|
|
133
|
+
return null
|
|
110
134
|
}
|
|
111
|
-
|
|
112
|
-
|
|
135
|
+
return getTabKey(props.tabs[index], index)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function resolveSelectableTabId(tabKey) {
|
|
139
|
+
if (tabKey !== null && tabKey !== undefined) {
|
|
140
|
+
const index = props.tabs.findIndex((tab, i) => getTabKey(tab, i) === tabKey)
|
|
141
|
+
if (index >= 0 && !isTabDisabled(props.tabs[index])) {
|
|
142
|
+
return tabKey
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return getFirstEnabledTabKey()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function tabTabIndex(tab, index) {
|
|
149
|
+
if (isTabDisabled(tab)) {
|
|
150
|
+
return -1
|
|
113
151
|
}
|
|
114
|
-
return
|
|
152
|
+
return isTabFocused(tab, index) ? 0 : -1
|
|
115
153
|
}
|
|
116
154
|
|
|
117
|
-
|
|
118
|
-
const
|
|
155
|
+
function findNextEnabledIndex(fromIndex, step) {
|
|
156
|
+
const length = props.tabs.length
|
|
157
|
+
if (length === 0) {
|
|
158
|
+
return fromIndex
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
for (let offset = 1; offset <= length; offset += 1) {
|
|
162
|
+
const index = (fromIndex + step * offset + length * 100) % length
|
|
163
|
+
if (!isTabDisabled(props.tabs[index])) {
|
|
164
|
+
return index
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return fromIndex
|
|
169
|
+
}
|
|
119
170
|
|
|
120
171
|
function getTabKey(tab, index) {
|
|
121
172
|
return tab.id ?? index
|
|
@@ -146,10 +197,14 @@ function focusTab(tabKey) {
|
|
|
146
197
|
}
|
|
147
198
|
|
|
148
199
|
function selectTab(tabKey, { emitChange = true } = {}) {
|
|
200
|
+
const index = tabIndexForKey(tabKey)
|
|
201
|
+
if (index >= 0 && isTabDisabled(props.tabs[index])) {
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
149
205
|
if (activeTabId.value !== tabKey) {
|
|
150
206
|
activeTabId.value = tabKey
|
|
151
207
|
if (emitChange) {
|
|
152
|
-
const index = tabIndexForKey(tabKey)
|
|
153
208
|
const activeTab = index >= 0 ? props.tabs[index] : null
|
|
154
209
|
emit('tab-change', activeTab, tabKey)
|
|
155
210
|
}
|
|
@@ -166,8 +221,11 @@ function moveFocusToIndex(index, { activate = props.activation === 'auto' } = {}
|
|
|
166
221
|
return
|
|
167
222
|
}
|
|
168
223
|
|
|
169
|
-
|
|
170
|
-
|
|
224
|
+
if (index < 0 || index >= props.tabs.length || isTabDisabled(props.tabs[index])) {
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const tabKey = getTabKey(props.tabs[index], index)
|
|
171
229
|
focusedTabId.value = tabKey
|
|
172
230
|
|
|
173
231
|
if (activate) {
|
|
@@ -177,6 +235,14 @@ function moveFocusToIndex(index, { activate = props.activation === 'auto' } = {}
|
|
|
177
235
|
nextTick(() => focusTab(tabKey))
|
|
178
236
|
}
|
|
179
237
|
|
|
238
|
+
function moveFocusByStep(fromIndex, step, { activate = props.activation === 'auto' } = {}) {
|
|
239
|
+
const nextIndex = findNextEnabledIndex(fromIndex, step)
|
|
240
|
+
if (nextIndex === fromIndex && isTabDisabled(props.tabs[fromIndex])) {
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
moveFocusToIndex(nextIndex, { activate })
|
|
244
|
+
}
|
|
245
|
+
|
|
180
246
|
function tabKeyFromElement(tabEl) {
|
|
181
247
|
const suffix = tabEl.id.slice('tab-'.length)
|
|
182
248
|
const index = props.tabs.findIndex((tab, i) => String(getTabKey(tab, i)) === suffix)
|
|
@@ -204,34 +270,34 @@ function onTabListKeydown(event) {
|
|
|
204
270
|
case 'ArrowRight':
|
|
205
271
|
if (!isVertical) {
|
|
206
272
|
event.preventDefault()
|
|
207
|
-
|
|
273
|
+
moveFocusByStep(index, 1)
|
|
208
274
|
}
|
|
209
275
|
break
|
|
210
276
|
case 'ArrowLeft':
|
|
211
277
|
if (!isVertical) {
|
|
212
278
|
event.preventDefault()
|
|
213
|
-
|
|
279
|
+
moveFocusByStep(index, -1)
|
|
214
280
|
}
|
|
215
281
|
break
|
|
216
282
|
case 'ArrowDown':
|
|
217
283
|
if (isVertical) {
|
|
218
284
|
event.preventDefault()
|
|
219
|
-
|
|
285
|
+
moveFocusByStep(index, 1)
|
|
220
286
|
}
|
|
221
287
|
break
|
|
222
288
|
case 'ArrowUp':
|
|
223
289
|
if (isVertical) {
|
|
224
290
|
event.preventDefault()
|
|
225
|
-
|
|
291
|
+
moveFocusByStep(index, -1)
|
|
226
292
|
}
|
|
227
293
|
break
|
|
228
294
|
case 'Home':
|
|
229
295
|
event.preventDefault()
|
|
230
|
-
moveFocusToIndex(
|
|
296
|
+
moveFocusToIndex(getFirstEnabledIndex())
|
|
231
297
|
break
|
|
232
298
|
case 'End':
|
|
233
299
|
event.preventDefault()
|
|
234
|
-
moveFocusToIndex(
|
|
300
|
+
moveFocusToIndex(getLastEnabledIndex())
|
|
235
301
|
break
|
|
236
302
|
case ' ':
|
|
237
303
|
case 'Enter':
|
|
@@ -260,20 +326,25 @@ function syncTabIdsAfterListChange() {
|
|
|
260
326
|
return
|
|
261
327
|
}
|
|
262
328
|
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
329
|
+
const activeIndex = tabIndexForKey(activeTabId.value)
|
|
330
|
+
const activeIsValid = activeIndex >= 0 && !isTabDisabled(props.tabs[activeIndex])
|
|
331
|
+
if (!activeIsValid) {
|
|
332
|
+
const firstKey = getFirstEnabledTabKey()
|
|
266
333
|
activeTabId.value = firstKey
|
|
267
334
|
focusedTabId.value = firstKey
|
|
268
335
|
return
|
|
269
336
|
}
|
|
270
337
|
|
|
271
|
-
|
|
338
|
+
const focusedIndex = tabIndexForKey(focusedTabId.value)
|
|
339
|
+
const focusedIsValid = focusedIndex >= 0 && !isTabDisabled(props.tabs[focusedIndex])
|
|
340
|
+
if (!focusedIsValid) {
|
|
272
341
|
focusedTabId.value = activeTabId.value
|
|
273
342
|
}
|
|
274
343
|
}
|
|
275
344
|
|
|
276
345
|
onMounted(() => {
|
|
346
|
+
activeTabId.value = resolveSelectableTabId(props.defaultTab)
|
|
347
|
+
focusedTabId.value = activeTabId.value
|
|
277
348
|
syncTabIdsAfterListChange()
|
|
278
349
|
})
|
|
279
350
|
|
|
@@ -357,7 +428,7 @@ defineExpose({
|
|
|
357
428
|
position: relative;
|
|
358
429
|
}
|
|
359
430
|
|
|
360
|
-
.tab-button:hover:not(.active) {
|
|
431
|
+
.tab-button:hover:not(.active):not(:disabled) {
|
|
361
432
|
color: var(--tab-hover-fg);
|
|
362
433
|
background-color: var(--tab-hover-bg);
|
|
363
434
|
}
|
|
@@ -368,6 +439,25 @@ defineExpose({
|
|
|
368
439
|
border-bottom-color: transparent;
|
|
369
440
|
}
|
|
370
441
|
|
|
442
|
+
.tab-button:disabled,
|
|
443
|
+
.tab-button.disabled {
|
|
444
|
+
cursor: not-allowed;
|
|
445
|
+
color: var(--disabled-text-color);
|
|
446
|
+
opacity: 0.72;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
.tab-button:disabled:hover,
|
|
450
|
+
.tab-button.disabled:hover {
|
|
451
|
+
color: var(--disabled-text-color);
|
|
452
|
+
background-color: transparent;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.tab-button:disabled.active,
|
|
456
|
+
.tab-button.disabled.active {
|
|
457
|
+
background-color: var(--tab-active-bg);
|
|
458
|
+
color: var(--disabled-text-color);
|
|
459
|
+
}
|
|
460
|
+
|
|
371
461
|
.tabs-vertical .tab-button {
|
|
372
462
|
width: 100%;
|
|
373
463
|
justify-content: flex-start;
|
|
@@ -426,7 +516,7 @@ defineExpose({
|
|
|
426
516
|
z-index: 1;
|
|
427
517
|
}
|
|
428
518
|
|
|
429
|
-
.tab-button:hover:not(.active):focus-visible {
|
|
519
|
+
.tab-button:hover:not(.active):not(:disabled):focus-visible {
|
|
430
520
|
color: var(--tab-hover-fg);
|
|
431
521
|
background-color: var(--tab-hover-bg);
|
|
432
522
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
flushControlState,
|
|
4
|
+
MIN_COMMENTS_LENGTH,
|
|
5
|
+
mountLoginValidationForm,
|
|
6
|
+
mountValidationForm,
|
|
7
|
+
} from '../test/formTestUtils.js'
|
|
8
|
+
|
|
9
|
+
describe('form validation integration', () => {
|
|
10
|
+
it('blocks submit and surfaces native required validation for empty email', async () => {
|
|
11
|
+
const { wrapper, submitted } = mountValidationForm()
|
|
12
|
+
|
|
13
|
+
await wrapper.find('button[type="submit"]').trigger('click')
|
|
14
|
+
await flushControlState()
|
|
15
|
+
|
|
16
|
+
expect(submitted.value).toBe(false)
|
|
17
|
+
expect(wrapper.find('#email').element.validationMessage).not.toBe('')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('shows computed email format errors as the user types', async () => {
|
|
21
|
+
const { wrapper } = mountValidationForm()
|
|
22
|
+
|
|
23
|
+
await wrapper.find('#email').setValue('not-an-email')
|
|
24
|
+
await flushControlState()
|
|
25
|
+
|
|
26
|
+
expect(wrapper.find('#email').element.getAttribute('aria-invalid')).toBe('true')
|
|
27
|
+
expect(wrapper.text()).toContain('Enter a valid email address.')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('clears email errors when a valid address is entered', async () => {
|
|
31
|
+
const { wrapper } = mountValidationForm()
|
|
32
|
+
|
|
33
|
+
await wrapper.find('#email').setValue('not-an-email')
|
|
34
|
+
await flushControlState()
|
|
35
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
36
|
+
await flushControlState()
|
|
37
|
+
|
|
38
|
+
expect(wrapper.find('#email').element.hasAttribute('aria-invalid')).toBe(false)
|
|
39
|
+
expect(wrapper.findAll('.form-field-error')).toHaveLength(0)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('shows comments length validation only when comments are non-empty', async () => {
|
|
43
|
+
const { wrapper } = mountValidationForm()
|
|
44
|
+
|
|
45
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
46
|
+
await wrapper.find('#comments').setValue('Too short')
|
|
47
|
+
await flushControlState()
|
|
48
|
+
|
|
49
|
+
expect(wrapper.text()).toContain(`Comments must be at least ${MIN_COMMENTS_LENGTH} characters.`)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('allows submit when email is valid and comments are empty', async () => {
|
|
53
|
+
const { wrapper, submitted } = mountValidationForm()
|
|
54
|
+
|
|
55
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
56
|
+
await wrapper.find('button[type="submit"]').trigger('click')
|
|
57
|
+
await flushControlState()
|
|
58
|
+
|
|
59
|
+
expect(submitted.value).toBe(true)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('blocks submit for conflicting accessibility selections via validate()', async () => {
|
|
63
|
+
const { wrapper, submitted, formData } = mountValidationForm()
|
|
64
|
+
|
|
65
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
66
|
+
formData.value.accessibility = ['high-contrast', 'low-contrast']
|
|
67
|
+
await flushControlState()
|
|
68
|
+
|
|
69
|
+
await wrapper.find('button[type="submit"]').trigger('click')
|
|
70
|
+
await flushControlState()
|
|
71
|
+
|
|
72
|
+
expect(submitted.value).toBe(false)
|
|
73
|
+
expect(wrapper.text()).toContain('High contrast and low contrast UI cannot both be selected.')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('submits when accessibility selections are compatible', async () => {
|
|
77
|
+
const { wrapper, submitted, formData } = mountValidationForm()
|
|
78
|
+
|
|
79
|
+
await wrapper.find('#email').setValue('user@example.com')
|
|
80
|
+
formData.value.accessibility = ['high-contrast']
|
|
81
|
+
await flushControlState()
|
|
82
|
+
|
|
83
|
+
await wrapper.find('button[type="submit"]').trigger('click')
|
|
84
|
+
await flushControlState()
|
|
85
|
+
|
|
86
|
+
expect(submitted.value).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('Login-style server validation', () => {
|
|
91
|
+
it('maps password errors to the password field', async () => {
|
|
92
|
+
const { wrapper } = mountLoginValidationForm()
|
|
93
|
+
|
|
94
|
+
wrapper.vm.applyServerErrors({ password: 'Incorrect password.' })
|
|
95
|
+
await flushControlState()
|
|
96
|
+
|
|
97
|
+
expect(wrapper.find('#password').element.getAttribute('aria-invalid')).toBe('true')
|
|
98
|
+
expect(wrapper.text()).toContain('Incorrect password.')
|
|
99
|
+
expect(wrapper.find('.login-form-error').exists()).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('maps server failures to a form-level NotificationBlock', async () => {
|
|
103
|
+
const { wrapper } = mountLoginValidationForm()
|
|
104
|
+
|
|
105
|
+
wrapper.vm.applyServerErrors({ form: 'Unable to reach authentication service.' })
|
|
106
|
+
await flushControlState()
|
|
107
|
+
|
|
108
|
+
expect(wrapper.find('.login-form-error').exists()).toBe(true)
|
|
109
|
+
expect(wrapper.find('.login-form-error').attributes('role')).toBe('alert')
|
|
110
|
+
expect(wrapper.text()).toContain('Unable to reach authentication service.')
|
|
111
|
+
expect(wrapper.find('#username').element.hasAttribute('aria-invalid')).toBe(false)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('clears field errors when the user edits the control', async () => {
|
|
115
|
+
const { wrapper, passwordError } = mountLoginValidationForm()
|
|
116
|
+
|
|
117
|
+
wrapper.vm.applyServerErrors({ password: 'Incorrect password.' })
|
|
118
|
+
await flushControlState()
|
|
119
|
+
|
|
120
|
+
await wrapper.find('#password').setValue('new-secret')
|
|
121
|
+
await flushControlState()
|
|
122
|
+
|
|
123
|
+
expect(passwordError.value).toBe('')
|
|
124
|
+
expect(wrapper.find('#password').element.hasAttribute('aria-invalid')).toBe(false)
|
|
125
|
+
})
|
|
126
|
+
})
|
package/vue/examples/App.vue
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
<Navigation ref="navigation">
|
|
3
3
|
<Navigation ref="topBarNavigation">
|
|
4
4
|
<Header
|
|
5
|
-
username="
|
|
5
|
+
:username="headerUsername"
|
|
6
|
+
:login-route="headerLoginRoute"
|
|
6
7
|
@toggleSidebar="toggleSidebar"
|
|
7
8
|
@logoClick="goToIndex"
|
|
8
9
|
@userClick="goToUserDetails"
|
|
@@ -85,6 +86,8 @@
|
|
|
85
86
|
const includeSupplementalThemes = ref(true);
|
|
86
87
|
const topBarEnabled = ref(false);
|
|
87
88
|
const breadcrumbsEnabled = ref(true);
|
|
89
|
+
const headerUsername = ref('tester');
|
|
90
|
+
const headerLoginRoute = { name: 'Login' };
|
|
88
91
|
|
|
89
92
|
const { discoverThemes } = initCustomTheme({ includeSupplementalThemes: true });
|
|
90
93
|
|
|
@@ -134,6 +137,10 @@
|
|
|
134
137
|
breadcrumbsEnabled.value = !breadcrumbsEnabled.value;
|
|
135
138
|
}
|
|
136
139
|
|
|
140
|
+
function setHeaderUsername(username) {
|
|
141
|
+
headerUsername.value = username;
|
|
142
|
+
}
|
|
143
|
+
|
|
137
144
|
async function dummyFetchResults(_query, { signal } = {}) {
|
|
138
145
|
await new Promise((resolve, reject) => {
|
|
139
146
|
const timer = setTimeout(resolve, 1500)
|
|
@@ -200,6 +207,8 @@
|
|
|
200
207
|
provide('toggleTopBarEnabled', toggleTopBarEnabled);
|
|
201
208
|
provide('breadcrumbsEnabled', breadcrumbsEnabled);
|
|
202
209
|
provide('toggleBreadcrumbsEnabled', toggleBreadcrumbsEnabled);
|
|
210
|
+
provide('headerUsername', headerUsername);
|
|
211
|
+
provide('setHeaderUsername', setHeaderUsername);
|
|
203
212
|
|
|
204
213
|
watch(quickSearchRef, (quickSearch) => {
|
|
205
214
|
if (quickSearch) {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
/>
|
|
23
23
|
</FormField>
|
|
24
24
|
|
|
25
|
-
<FormField label="Role"
|
|
25
|
+
<FormField label="Role" component-has-label>
|
|
26
26
|
<RadioGroup
|
|
27
27
|
v-model="formData.role"
|
|
28
28
|
name="role"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
/>
|
|
31
31
|
</FormField>
|
|
32
32
|
|
|
33
|
-
<FormField label="Term"
|
|
33
|
+
<FormField label="Term" component-has-label>
|
|
34
34
|
<RadioGroup
|
|
35
35
|
v-model="formData.term"
|
|
36
36
|
name="term"
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
|
|
53
53
|
<FormField
|
|
54
54
|
label="Compensation"
|
|
55
|
-
|
|
55
|
+
component-has-label
|
|
56
|
+
label-required
|
|
56
57
|
description-above
|
|
57
58
|
description="Select the incentive plan that applies to this role."
|
|
58
59
|
docs-url="https://github.com/jamesread/femtocrank"
|
|
@@ -68,11 +69,13 @@
|
|
|
68
69
|
|
|
69
70
|
<FormField
|
|
70
71
|
label="Accessibility requirements"
|
|
71
|
-
|
|
72
|
+
component-has-label
|
|
72
73
|
description-above
|
|
73
74
|
description="Choose any accommodations needed for this role."
|
|
74
75
|
docs-url="https://www.w3.org/WAI/fundamentals/accessibility-intro/"
|
|
75
76
|
docs-url-title="Web accessibility introduction"
|
|
77
|
+
:validate="validateAccessibilityRequirements"
|
|
78
|
+
:validate-deps="[formData.accessibility]"
|
|
76
79
|
>
|
|
77
80
|
<CheckGroup
|
|
78
81
|
v-model="formData.accessibility"
|
|
@@ -84,6 +87,7 @@
|
|
|
84
87
|
<FormField
|
|
85
88
|
label="Email"
|
|
86
89
|
for="email"
|
|
90
|
+
:error="emailError"
|
|
87
91
|
description="We'll use this address for account notifications only."
|
|
88
92
|
docs-url="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/email"
|
|
89
93
|
docs-url-title="HTML email input reference"
|
|
@@ -149,7 +153,7 @@
|
|
|
149
153
|
/>
|
|
150
154
|
</FormField>
|
|
151
155
|
|
|
152
|
-
<FormField label="Newsletters"
|
|
156
|
+
<FormField label="Newsletters" component-has-label>
|
|
153
157
|
<div>
|
|
154
158
|
<label>
|
|
155
159
|
<input
|
|
@@ -174,7 +178,8 @@
|
|
|
174
178
|
<FormField
|
|
175
179
|
label="Comments"
|
|
176
180
|
for="comments"
|
|
177
|
-
|
|
181
|
+
:error="commentsError"
|
|
182
|
+
description="Optional feedback or notes about this submission (minimum 50 characters when provided)."
|
|
178
183
|
>
|
|
179
184
|
<textarea
|
|
180
185
|
id="comments"
|
|
@@ -222,13 +227,39 @@
|
|
|
222
227
|
</template>
|
|
223
228
|
|
|
224
229
|
<script setup>
|
|
225
|
-
import { ref } from 'vue'
|
|
230
|
+
import { computed, ref } from 'vue'
|
|
226
231
|
import Section from '../components/Section.vue'
|
|
227
232
|
import FormLayout from '../components/FormLayout.vue'
|
|
228
233
|
import FormField from '../components/FormField.vue'
|
|
229
234
|
import RadioGroup from '../components/RadioGroup.vue'
|
|
230
235
|
import CheckGroup from '../components/CheckGroup.vue'
|
|
231
236
|
|
|
237
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
238
|
+
const MIN_COMMENTS_LENGTH = 50
|
|
239
|
+
|
|
240
|
+
function createDefaultFormData() {
|
|
241
|
+
return {
|
|
242
|
+
name: '',
|
|
243
|
+
role: '',
|
|
244
|
+
term: '',
|
|
245
|
+
salary: '0',
|
|
246
|
+
compensation: 'sales-target-incentive',
|
|
247
|
+
accessibility: ['screen-reader', 'high-contrast'],
|
|
248
|
+
email: 'not-an-email',
|
|
249
|
+
isAdmin: false,
|
|
250
|
+
website: '',
|
|
251
|
+
favouriteFood: '',
|
|
252
|
+
favouriteColour: '#dee3e7',
|
|
253
|
+
favouriteNumber: 50,
|
|
254
|
+
newsletter1: false,
|
|
255
|
+
newsletter2: false,
|
|
256
|
+
comments: 'Too short.',
|
|
257
|
+
readonlyText: 'This is a readonly textarea.',
|
|
258
|
+
disabledText: 'This is a disabled textarea.',
|
|
259
|
+
datetime: '',
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
232
263
|
// From femtocrank/tests/simple.html — Role (default), Term (boolean), Compensation (list)
|
|
233
264
|
const roleOptions = [
|
|
234
265
|
{ label: 'Sales', value: 'sales' },
|
|
@@ -251,36 +282,52 @@ const accessibilityOptions = [
|
|
|
251
282
|
{ label: 'Screen reader support', value: 'screen-reader' },
|
|
252
283
|
{ label: 'Keyboard navigation', value: 'keyboard-navigation' },
|
|
253
284
|
{ label: 'High contrast', value: 'high-contrast' },
|
|
285
|
+
{ label: 'Low contrast UI', value: 'low-contrast' },
|
|
254
286
|
{ label: 'Captions / transcripts', value: 'captions' },
|
|
255
287
|
]
|
|
256
288
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
289
|
+
function validateAccessibilityRequirements() {
|
|
290
|
+
const selected = formData.value.accessibility
|
|
291
|
+
if (selected.includes('high-contrast') && selected.includes('low-contrast')) {
|
|
292
|
+
return 'High contrast and low contrast UI cannot both be selected.'
|
|
293
|
+
}
|
|
294
|
+
return ''
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const formData = ref(createDefaultFormData())
|
|
298
|
+
|
|
299
|
+
const emailError = computed(() => {
|
|
300
|
+
const email = formData.value.email.trim()
|
|
301
|
+
if (!email) {
|
|
302
|
+
return 'Email is required.'
|
|
303
|
+
}
|
|
304
|
+
if (!EMAIL_PATTERN.test(email)) {
|
|
305
|
+
return 'Enter a valid email address.'
|
|
306
|
+
}
|
|
307
|
+
return ''
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
const commentsError = computed(() => {
|
|
311
|
+
const comments = formData.value.comments.trim()
|
|
312
|
+
if (comments.length > 0 && comments.length < MIN_COMMENTS_LENGTH) {
|
|
313
|
+
return `Comments must be at least ${MIN_COMMENTS_LENGTH} characters.`
|
|
314
|
+
}
|
|
315
|
+
return ''
|
|
276
316
|
})
|
|
277
317
|
|
|
278
318
|
const submitted = ref(false)
|
|
279
319
|
|
|
280
|
-
function handleSubmit() {
|
|
320
|
+
function handleSubmit(event) {
|
|
321
|
+
if (!event.target.reportValidity()) {
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (validateAccessibilityRequirements()) {
|
|
326
|
+
return
|
|
327
|
+
}
|
|
328
|
+
|
|
281
329
|
submitted.value = true
|
|
282
330
|
console.log('Form submitted:', formData.value)
|
|
283
|
-
// In a real application, you would send this data to a server
|
|
284
331
|
alert('Form submitted! Check the console and the form result below.')
|
|
285
332
|
}
|
|
286
333
|
|
|
@@ -290,26 +337,7 @@ function handleCancel() {
|
|
|
290
337
|
}
|
|
291
338
|
|
|
292
339
|
function resetForm() {
|
|
293
|
-
formData.value =
|
|
294
|
-
name: '',
|
|
295
|
-
role: '',
|
|
296
|
-
term: '',
|
|
297
|
-
salary: '0',
|
|
298
|
-
compensation: '',
|
|
299
|
-
accessibility: [],
|
|
300
|
-
email: '',
|
|
301
|
-
isAdmin: false,
|
|
302
|
-
website: '',
|
|
303
|
-
favouriteFood: '',
|
|
304
|
-
favouriteColour: '#dee3e7',
|
|
305
|
-
favouriteNumber: 50,
|
|
306
|
-
newsletter1: false,
|
|
307
|
-
newsletter2: false,
|
|
308
|
-
comments: '',
|
|
309
|
-
readonlyText: 'This is a readonly textarea.',
|
|
310
|
-
disabledText: 'This is a disabled textarea.',
|
|
311
|
-
datetime: ''
|
|
312
|
-
}
|
|
340
|
+
formData.value = createDefaultFormData()
|
|
313
341
|
submitted.value = false
|
|
314
342
|
}
|
|
315
343
|
</script>
|
package/vue/examples/Login.vue
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Section title="Login" classes="">
|
|
3
|
-
<Login
|
|
3
|
+
<Login
|
|
4
|
+
ref="loginRef"
|
|
4
5
|
@local-login="handleLocalLogin"
|
|
5
6
|
@oauth-login="handleOAuthLogin"
|
|
6
7
|
@tab-change="onTabChange"
|
|
@@ -9,9 +10,12 @@
|
|
|
9
10
|
</template>
|
|
10
11
|
|
|
11
12
|
<script setup>
|
|
13
|
+
import { ref } from 'vue';
|
|
12
14
|
import Section from '../components/Section.vue';
|
|
13
15
|
import Login from '../components/Login.vue';
|
|
14
16
|
|
|
17
|
+
const loginRef = ref(null);
|
|
18
|
+
|
|
15
19
|
// Handle local login
|
|
16
20
|
function handleLocalLogin(credentials) {
|
|
17
21
|
console.log('Local login attempt:', {
|
|
@@ -41,6 +45,7 @@ function handleLocalLogin(credentials) {
|
|
|
41
45
|
|
|
42
46
|
// Placeholder success
|
|
43
47
|
alert(`Local login would authenticate: ${credentials.username}`);
|
|
48
|
+
loginRef.value?.setLocalLoginLoading(false);
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
// Handle OAuth login
|