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
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<Section title="
|
|
3
|
-
<Login
|
|
2
|
+
<Section title="Simple login" subtitle="Username and password only" classes="narrow">
|
|
3
|
+
<Login ref="simpleLoginRef" local-only @local-login="handleSimpleLocalLogin" />
|
|
4
|
+
</Section>
|
|
5
|
+
|
|
6
|
+
<Section
|
|
7
|
+
title="Simulated server response"
|
|
8
|
+
subtitle="Choose what the server returns when you submit the simple login form above"
|
|
9
|
+
classes="narrow"
|
|
10
|
+
>
|
|
11
|
+
<FormField label="Response" component-has-label description="Field errors appear on username or password; server and network errors appear below the form.">
|
|
12
|
+
<RadioGroup
|
|
13
|
+
v-model="simulatedResponse"
|
|
14
|
+
name="login-sim-response"
|
|
15
|
+
variant="list"
|
|
16
|
+
:options="simulatedResponseOptions"
|
|
17
|
+
/>
|
|
18
|
+
</FormField>
|
|
19
|
+
|
|
20
|
+
<FormField label="Timing" component-has-label>
|
|
21
|
+
<label class="simulate-delay-label">
|
|
22
|
+
<input v-model="simulateDelay" type="checkbox" />
|
|
23
|
+
<span>Simulate slow response (1.5s)</span>
|
|
24
|
+
</label>
|
|
25
|
+
</FormField>
|
|
26
|
+
</Section>
|
|
27
|
+
|
|
28
|
+
<Section title="Login example" subtitle="Local, OAuth2, and custom authentication tabs" classes="narrow">
|
|
29
|
+
<Login
|
|
30
|
+
ref="fullLoginRef"
|
|
4
31
|
:oauth-providers="oauthProviders"
|
|
5
32
|
:custom-tabs="customTabs"
|
|
6
33
|
@local-login="handleLocalLogin"
|
|
@@ -56,6 +83,67 @@
|
|
|
56
83
|
import { ref } from 'vue';
|
|
57
84
|
import Section from '../components/Section.vue';
|
|
58
85
|
import Login from '../components/Login.vue';
|
|
86
|
+
import FormField from '../components/FormField.vue';
|
|
87
|
+
import RadioGroup from '../components/RadioGroup.vue';
|
|
88
|
+
|
|
89
|
+
const simpleLoginRef = ref(null);
|
|
90
|
+
const fullLoginRef = ref(null);
|
|
91
|
+
const simulatedResponse = ref('success');
|
|
92
|
+
const simulateDelay = ref(false);
|
|
93
|
+
|
|
94
|
+
const simulatedResponseOptions = [
|
|
95
|
+
{ value: 'success', label: 'Success' },
|
|
96
|
+
{ value: 'wrong-password', label: 'Wrong password' },
|
|
97
|
+
{ value: 'username-not-found', label: 'Username not found' },
|
|
98
|
+
{ value: 'account-locked', label: 'Account locked' },
|
|
99
|
+
{ value: 'server-error', label: 'Server error (500)' },
|
|
100
|
+
{ value: 'network-error', label: 'Network unavailable' },
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
const simulatedResponseMessages = {
|
|
104
|
+
'wrong-password': 'Incorrect password.',
|
|
105
|
+
'username-not-found': 'No account found for that username.',
|
|
106
|
+
'account-locked': 'This account is locked. Contact support.',
|
|
107
|
+
'server-error': 'Sign-in failed. Please try again later.',
|
|
108
|
+
'network-error': 'Could not reach the server. Check your connection.',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const SIMULATED_DELAY_MS = 1500;
|
|
112
|
+
|
|
113
|
+
function resolveSimulatedLoginErrors(responseKey) {
|
|
114
|
+
const message = simulatedResponseMessages[responseKey];
|
|
115
|
+
if (!message) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
if (responseKey === 'wrong-password') {
|
|
119
|
+
return { password: message };
|
|
120
|
+
}
|
|
121
|
+
if (responseKey === 'username-not-found') {
|
|
122
|
+
return { username: message };
|
|
123
|
+
}
|
|
124
|
+
return { form: message };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function handleSimpleLocalLogin(credentials) {
|
|
128
|
+
try {
|
|
129
|
+
if (simulateDelay.value) {
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, SIMULATED_DELAY_MS));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const errors = resolveSimulatedLoginErrors(simulatedResponse.value);
|
|
134
|
+
if (errors) {
|
|
135
|
+
simpleLoginRef.value?.setLocalLoginErrors(errors);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
simpleLoginRef.value?.resetLocalForm();
|
|
140
|
+
alert(`Signed in as ${credentials.username}`);
|
|
141
|
+
} catch {
|
|
142
|
+
simpleLoginRef.value?.setLocalLoginErrors({
|
|
143
|
+
form: 'Sign-in failed. Please try again later.',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
59
147
|
|
|
60
148
|
// Custom tabs configuration
|
|
61
149
|
const customTabs = ref([
|
|
@@ -151,6 +239,7 @@ function handleLocalLogin(credentials) {
|
|
|
151
239
|
|
|
152
240
|
// Placeholder success
|
|
153
241
|
alert(`Local login would authenticate: ${credentials.username}`);
|
|
242
|
+
fullLoginRef.value?.setLocalLoginLoading(false);
|
|
154
243
|
}
|
|
155
244
|
|
|
156
245
|
// Handle OAuth login
|
|
@@ -220,6 +309,13 @@ function handleSSOLogin(provider) {
|
|
|
220
309
|
</script>
|
|
221
310
|
|
|
222
311
|
<style scoped>
|
|
312
|
+
.simulate-delay-label {
|
|
313
|
+
display: flex;
|
|
314
|
+
align-items: center;
|
|
315
|
+
gap: 0.5em;
|
|
316
|
+
cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
|
|
223
319
|
.magic-link-form,
|
|
224
320
|
.sso-providers {
|
|
225
321
|
display: flex;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
<Section title="Header" subtitle="Branding, search, and top navigation">
|
|
13
13
|
<FormLayout @submit.prevent>
|
|
14
|
-
<FormField label="Logo and title"
|
|
14
|
+
<FormField label="Logo and title" component-has-label>
|
|
15
15
|
<RadioGroup
|
|
16
16
|
name="ps-branding"
|
|
17
17
|
variant="boolean"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
/>
|
|
23
23
|
</FormField>
|
|
24
24
|
|
|
25
|
-
<FormField label="QuickSearch"
|
|
25
|
+
<FormField label="QuickSearch" component-has-label>
|
|
26
26
|
<RadioGroup
|
|
27
27
|
name="ps-quicksearch"
|
|
28
28
|
variant="boolean"
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
/>
|
|
34
34
|
</FormField>
|
|
35
35
|
|
|
36
|
-
<FormField label="Dummy background search"
|
|
36
|
+
<FormField label="Dummy background search" component-has-label :disabled="!quickSearchEnabled">
|
|
37
37
|
<div>
|
|
38
38
|
<RadioGroup
|
|
39
39
|
name="ps-dummy-search"
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
</div>
|
|
52
52
|
</FormField>
|
|
53
53
|
|
|
54
|
-
<FormField label="Dark/Light mode button"
|
|
54
|
+
<FormField label="Dark/Light mode button" component-has-label>
|
|
55
55
|
<RadioGroup
|
|
56
56
|
name="ps-theme-toggle"
|
|
57
57
|
variant="boolean"
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
/>
|
|
63
63
|
</FormField>
|
|
64
64
|
|
|
65
|
-
<FormField label="Theme switcher"
|
|
65
|
+
<FormField label="Theme switcher" component-has-label>
|
|
66
66
|
<RadioGroup
|
|
67
67
|
name="ps-theme-switcher"
|
|
68
68
|
variant="boolean"
|
|
@@ -73,7 +73,7 @@
|
|
|
73
73
|
/>
|
|
74
74
|
</FormField>
|
|
75
75
|
|
|
76
|
-
<FormField label="Top bar"
|
|
76
|
+
<FormField label="Top bar" component-has-label>
|
|
77
77
|
<div>
|
|
78
78
|
<RadioGroup
|
|
79
79
|
name="ps-topbar"
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
</div>
|
|
93
93
|
</FormField>
|
|
94
94
|
|
|
95
|
-
<FormField label="Breadcrumbs"
|
|
95
|
+
<FormField label="Breadcrumbs" component-has-label>
|
|
96
96
|
<RadioGroup
|
|
97
97
|
name="ps-breadcrumbs"
|
|
98
98
|
variant="boolean"
|
|
@@ -102,12 +102,29 @@
|
|
|
102
102
|
@update:model-value="setBreadcrumbsEnabled"
|
|
103
103
|
/>
|
|
104
104
|
</FormField>
|
|
105
|
+
|
|
106
|
+
<FormField label="Header username" component-has-label>
|
|
107
|
+
<div>
|
|
108
|
+
<RadioGroup
|
|
109
|
+
name="ps-header-username"
|
|
110
|
+
variant="list"
|
|
111
|
+
aria-label="Header username"
|
|
112
|
+
:model-value="headerUsername"
|
|
113
|
+
:options="headerUsernameOptions"
|
|
114
|
+
@update:model-value="setHeaderUsernameValue"
|
|
115
|
+
/>
|
|
116
|
+
<p class="subtle">
|
|
117
|
+
When no username is set, the header shows a Login link if
|
|
118
|
+
<code>loginRoute</code> is configured.
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
</FormField>
|
|
105
122
|
</FormLayout>
|
|
106
123
|
</Section>
|
|
107
124
|
|
|
108
125
|
<Section title="Sidebar" subtitle="Primary navigation panel">
|
|
109
126
|
<FormLayout @submit.prevent>
|
|
110
|
-
<FormField label="Sidebar navigation"
|
|
127
|
+
<FormField label="Sidebar navigation" component-has-label>
|
|
111
128
|
<div>
|
|
112
129
|
<RadioGroup
|
|
113
130
|
name="ps-sidebar"
|
|
@@ -124,7 +141,7 @@
|
|
|
124
141
|
</div>
|
|
125
142
|
</FormField>
|
|
126
143
|
|
|
127
|
-
<FormField label="Example link states"
|
|
144
|
+
<FormField label="Example link states" component-has-label>
|
|
128
145
|
<div>
|
|
129
146
|
<RadioGroup
|
|
130
147
|
name="ps-example-links"
|
|
@@ -174,6 +191,11 @@ const onOffOptions = [
|
|
|
174
191
|
{ label: 'Off', value: false },
|
|
175
192
|
]
|
|
176
193
|
|
|
194
|
+
const headerUsernameOptions = [
|
|
195
|
+
{ label: 'No username', value: '' },
|
|
196
|
+
{ label: 'tester', value: 'tester' },
|
|
197
|
+
]
|
|
198
|
+
|
|
177
199
|
const sidebarEnabled = inject('sidebarEnabled')
|
|
178
200
|
const toggleSidebarEnabled = inject('toggleSidebarEnabled')
|
|
179
201
|
const brandingEnabled = inject('brandingEnabled')
|
|
@@ -190,6 +212,8 @@ const topBarEnabled = inject('topBarEnabled')
|
|
|
190
212
|
const toggleTopBarEnabled = inject('toggleTopBarEnabled')
|
|
191
213
|
const breadcrumbsEnabled = inject('breadcrumbsEnabled')
|
|
192
214
|
const toggleBreadcrumbsEnabled = inject('toggleBreadcrumbsEnabled')
|
|
215
|
+
const headerUsername = inject('headerUsername')
|
|
216
|
+
const setHeaderUsername = inject('setHeaderUsername')
|
|
193
217
|
const navigation = inject('navigation', null)
|
|
194
218
|
|
|
195
219
|
const exampleLinksEnabled = ref(false)
|
|
@@ -228,6 +252,10 @@ function setBreadcrumbsEnabled(want) {
|
|
|
228
252
|
setFlag(breadcrumbsEnabled, toggleBreadcrumbsEnabled, want)
|
|
229
253
|
}
|
|
230
254
|
|
|
255
|
+
function setHeaderUsernameValue(username) {
|
|
256
|
+
setHeaderUsername(username)
|
|
257
|
+
}
|
|
258
|
+
|
|
231
259
|
function setSidebarEnabled(want) {
|
|
232
260
|
setFlag(sidebarEnabled, toggleSidebarEnabled, want)
|
|
233
261
|
}
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
</template>
|
|
51
51
|
|
|
52
52
|
<FormLayout>
|
|
53
|
-
<FormField label="Data"
|
|
53
|
+
<FormField label="Data" component-has-label description="Connect or disconnect row data for the table above. Saved column layouts persist in localStorage for this table.">
|
|
54
54
|
<RadioGroup
|
|
55
55
|
v-model="dataConnected"
|
|
56
56
|
name="table-data-connection"
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
|
|
62
62
|
<FormField
|
|
63
63
|
label="Wide table"
|
|
64
|
-
|
|
64
|
+
component-has-label
|
|
65
65
|
description="Adds extra columns (email, department, role, status) to the table."
|
|
66
66
|
>
|
|
67
67
|
<label>
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
</label>
|
|
71
71
|
</FormField>
|
|
72
72
|
|
|
73
|
-
<FormField label="Features"
|
|
73
|
+
<FormField label="Features" component-has-label description="Toggle core table behaviour.">
|
|
74
74
|
<CheckGroup
|
|
75
75
|
v-model="enabledFeatures"
|
|
76
76
|
name="table-features"
|
|
@@ -80,7 +80,7 @@
|
|
|
80
80
|
|
|
81
81
|
<FormField
|
|
82
82
|
label="Responsive layout"
|
|
83
|
-
|
|
83
|
+
component-has-label
|
|
84
84
|
description="Column priorities hide lower-priority columns as the table narrows. Horizontal scroll keeps every column visible with optional sticky leading columns. Browser layout leaves column sizing to the viewport."
|
|
85
85
|
>
|
|
86
86
|
<RadioGroup
|
|
@@ -93,7 +93,7 @@
|
|
|
93
93
|
<FormField
|
|
94
94
|
v-if="layout === 'scroll'"
|
|
95
95
|
label="Sticky columns"
|
|
96
|
-
|
|
96
|
+
component-has-label
|
|
97
97
|
description="Pin the first columns while scrolling horizontally."
|
|
98
98
|
>
|
|
99
99
|
<RadioGroup
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Section
|
|
3
3
|
title="Horizontal Tabs"
|
|
4
|
-
subtitle="A flexible tabbed control component with support for multiple tab panes"
|
|
4
|
+
subtitle="A flexible tabbed control component with support for multiple tab panes and disabled tabs"
|
|
5
5
|
:padding="horizontalTabsSectionPadding"
|
|
6
6
|
>
|
|
7
7
|
<template #toolbar>
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
<input v-model="horizontalTabsPadding" type="checkbox" />
|
|
14
14
|
Tab padding
|
|
15
15
|
</label>
|
|
16
|
+
<label class="tabs-padding-toggle">
|
|
17
|
+
<input v-model="detailsTabEnabled" type="checkbox" />
|
|
18
|
+
Enable Details tab
|
|
19
|
+
</label>
|
|
16
20
|
</template>
|
|
17
21
|
|
|
18
22
|
<Tabs
|
|
@@ -26,6 +30,8 @@
|
|
|
26
30
|
<p>
|
|
27
31
|
This is the Overview tab. It contains general information about the current topic.
|
|
28
32
|
You can place any content here, including forms, tables, images, or other components.
|
|
33
|
+
The <strong>Details</strong> tab starts disabled — use the toolbar checkbox to
|
|
34
|
+
enable it when elevated permissions are granted.
|
|
29
35
|
</p>
|
|
30
36
|
<ul>
|
|
31
37
|
<li>First item in the overview</li>
|
|
@@ -97,7 +103,7 @@
|
|
|
97
103
|
|
|
98
104
|
<Section
|
|
99
105
|
title="Vertical Tabs"
|
|
100
|
-
subtitle="Side tab strip with arrow-key navigation up and down"
|
|
106
|
+
subtitle="Side tab strip with arrow-key navigation up and down; disabled tabs are skipped"
|
|
101
107
|
:padding="verticalTabsSectionPadding"
|
|
102
108
|
>
|
|
103
109
|
<template #toolbar>
|
|
@@ -109,6 +115,10 @@
|
|
|
109
115
|
<input v-model="verticalTabsPadding" type="checkbox" />
|
|
110
116
|
Tab padding
|
|
111
117
|
</label>
|
|
118
|
+
<label class="tabs-padding-toggle">
|
|
119
|
+
<input v-model="advancedTabEnabled" type="checkbox" />
|
|
120
|
+
Enable Advanced tab
|
|
121
|
+
</label>
|
|
112
122
|
</template>
|
|
113
123
|
|
|
114
124
|
<Tabs
|
|
@@ -123,6 +133,7 @@
|
|
|
123
133
|
<p>
|
|
124
134
|
Vertical tabs place the tab list beside the panel. Use them when labels are
|
|
125
135
|
longer or when the layout mirrors a settings sidebar.
|
|
136
|
+
<strong>Advanced</strong> starts disabled — use the toolbar checkbox to enable it.
|
|
126
137
|
</p>
|
|
127
138
|
</div>
|
|
128
139
|
</template>
|
|
@@ -186,24 +197,47 @@
|
|
|
186
197
|
</template>
|
|
187
198
|
|
|
188
199
|
<script setup>
|
|
189
|
-
import { ref, reactive } from 'vue';
|
|
200
|
+
import { computed, ref, reactive } from 'vue';
|
|
190
201
|
import { HomeIcon, Settings01Icon, UserIcon, ViewIcon } from '@hugeicons/core-free-icons';
|
|
191
202
|
import Section from '../components/Section.vue';
|
|
192
203
|
import Tabs from '../components/Tabs.vue';
|
|
193
204
|
import FormLayout from '../components/FormLayout.vue';
|
|
194
205
|
import FormField from '../components/FormField.vue';
|
|
195
206
|
|
|
196
|
-
const
|
|
207
|
+
const detailsTabEnabled = ref(false);
|
|
208
|
+
const advancedTabEnabled = ref(false);
|
|
209
|
+
|
|
210
|
+
const basicTabs = computed(() => [
|
|
197
211
|
{ id: 0, label: 'Overview', icon: HomeIcon },
|
|
198
|
-
{
|
|
212
|
+
{
|
|
213
|
+
id: 1,
|
|
214
|
+
label: 'Details',
|
|
215
|
+
icon: ViewIcon,
|
|
216
|
+
...(detailsTabEnabled.value
|
|
217
|
+
? {}
|
|
218
|
+
: {
|
|
219
|
+
disabled: true,
|
|
220
|
+
disabledTitle: 'Details requires elevated permissions',
|
|
221
|
+
}),
|
|
222
|
+
},
|
|
199
223
|
{ id: 2, label: 'Settings', icon: Settings01Icon },
|
|
200
224
|
]);
|
|
201
225
|
|
|
202
|
-
const verticalTabs =
|
|
226
|
+
const verticalTabs = computed(() => [
|
|
203
227
|
{ id: 'general', label: 'General', icon: HomeIcon },
|
|
204
228
|
{ id: 'appearance', label: 'Appearance', icon: Settings01Icon },
|
|
205
229
|
{ id: 'notifications', label: 'Notifications', icon: UserIcon },
|
|
206
|
-
{
|
|
230
|
+
{
|
|
231
|
+
id: 'advanced',
|
|
232
|
+
label: 'Advanced',
|
|
233
|
+
icon: Settings01Icon,
|
|
234
|
+
...(advancedTabEnabled.value
|
|
235
|
+
? {}
|
|
236
|
+
: {
|
|
237
|
+
disabled: true,
|
|
238
|
+
disabledTitle: 'Advanced settings are not available for this account',
|
|
239
|
+
}),
|
|
240
|
+
},
|
|
207
241
|
]);
|
|
208
242
|
|
|
209
243
|
const horizontalTabsSectionPadding = ref(true);
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils'
|
|
2
|
+
import { computed, defineComponent, ref } from 'vue'
|
|
3
|
+
import FormField from '../components/FormField.vue'
|
|
4
|
+
import FormLayout from '../components/FormLayout.vue'
|
|
5
|
+
import NotificationBlock from '../components/NotificationBlock.vue'
|
|
6
|
+
|
|
7
|
+
export const routerLinkStub = {
|
|
8
|
+
name: 'RouterLink',
|
|
9
|
+
template: '<a><slot /></a>',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const defaultMountOptions = {
|
|
13
|
+
attachTo: document.body,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function mountFormField(options = {}) {
|
|
17
|
+
const {
|
|
18
|
+
props = {},
|
|
19
|
+
slot = '<input id="test-input" />',
|
|
20
|
+
global = {},
|
|
21
|
+
attachTo = document.body,
|
|
22
|
+
...rest
|
|
23
|
+
} = options
|
|
24
|
+
|
|
25
|
+
return mount(FormField, {
|
|
26
|
+
...defaultMountOptions,
|
|
27
|
+
attachTo,
|
|
28
|
+
props: {
|
|
29
|
+
label: 'Test Field',
|
|
30
|
+
for: 'test-input',
|
|
31
|
+
...props,
|
|
32
|
+
},
|
|
33
|
+
slots: {
|
|
34
|
+
default: slot,
|
|
35
|
+
},
|
|
36
|
+
global: {
|
|
37
|
+
stubs: {
|
|
38
|
+
RouterLink: routerLinkStub,
|
|
39
|
+
...global.stubs,
|
|
40
|
+
},
|
|
41
|
+
...global,
|
|
42
|
+
},
|
|
43
|
+
...rest,
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function mountFormFieldWithError(initialError = '') {
|
|
48
|
+
const error = ref(initialError)
|
|
49
|
+
|
|
50
|
+
const wrapper = mount(
|
|
51
|
+
defineComponent({
|
|
52
|
+
components: { FormField },
|
|
53
|
+
setup() {
|
|
54
|
+
return { error }
|
|
55
|
+
},
|
|
56
|
+
template: `
|
|
57
|
+
<FormField label="Email" for="email" v-model:error="error">
|
|
58
|
+
<input id="email" />
|
|
59
|
+
</FormField>
|
|
60
|
+
`,
|
|
61
|
+
}),
|
|
62
|
+
{
|
|
63
|
+
attachTo: document.body,
|
|
64
|
+
global: {
|
|
65
|
+
stubs: {
|
|
66
|
+
RouterLink: routerLinkStub,
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return { wrapper, error }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function flushControlState() {
|
|
76
|
+
const { nextTick } = await import('vue')
|
|
77
|
+
await nextTick()
|
|
78
|
+
await nextTick()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
82
|
+
export const MIN_COMMENTS_LENGTH = 50
|
|
83
|
+
|
|
84
|
+
export function mountValidationForm() {
|
|
85
|
+
const formData = ref({
|
|
86
|
+
email: '',
|
|
87
|
+
comments: '',
|
|
88
|
+
accessibility: [],
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const emailError = computed(() => {
|
|
92
|
+
const email = formData.value.email.trim()
|
|
93
|
+
if (!email) {
|
|
94
|
+
return 'Email is required.'
|
|
95
|
+
}
|
|
96
|
+
if (!EMAIL_PATTERN.test(email)) {
|
|
97
|
+
return 'Enter a valid email address.'
|
|
98
|
+
}
|
|
99
|
+
return ''
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const commentsError = computed(() => {
|
|
103
|
+
const comments = formData.value.comments.trim()
|
|
104
|
+
if (comments.length > 0 && comments.length < MIN_COMMENTS_LENGTH) {
|
|
105
|
+
return `Comments must be at least ${MIN_COMMENTS_LENGTH} characters.`
|
|
106
|
+
}
|
|
107
|
+
return ''
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
function validateAccessibilityRequirements() {
|
|
111
|
+
const selected = formData.value.accessibility
|
|
112
|
+
if (selected.includes('high-contrast') && selected.includes('low-contrast')) {
|
|
113
|
+
return 'High contrast and low contrast UI cannot both be selected.'
|
|
114
|
+
}
|
|
115
|
+
return ''
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const submitted = ref(false)
|
|
119
|
+
|
|
120
|
+
const wrapper = mount(
|
|
121
|
+
defineComponent({
|
|
122
|
+
components: { FormField, FormLayout },
|
|
123
|
+
setup() {
|
|
124
|
+
function handleSubmit(event) {
|
|
125
|
+
if (!event.target.reportValidity()) {
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
if (validateAccessibilityRequirements()) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
submitted.value = true
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
formData,
|
|
136
|
+
emailError,
|
|
137
|
+
commentsError,
|
|
138
|
+
validateAccessibilityRequirements,
|
|
139
|
+
submitted,
|
|
140
|
+
handleSubmit,
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
template: `
|
|
144
|
+
<FormLayout @submit.prevent="handleSubmit">
|
|
145
|
+
<FormField
|
|
146
|
+
label="Email"
|
|
147
|
+
for="email"
|
|
148
|
+
:error="emailError"
|
|
149
|
+
>
|
|
150
|
+
<input
|
|
151
|
+
id="email"
|
|
152
|
+
v-model="formData.email"
|
|
153
|
+
type="email"
|
|
154
|
+
required
|
|
155
|
+
/>
|
|
156
|
+
</FormField>
|
|
157
|
+
|
|
158
|
+
<FormField
|
|
159
|
+
label="Comments"
|
|
160
|
+
for="comments"
|
|
161
|
+
:error="commentsError"
|
|
162
|
+
>
|
|
163
|
+
<textarea id="comments" v-model="formData.comments" />
|
|
164
|
+
</FormField>
|
|
165
|
+
|
|
166
|
+
<FormField
|
|
167
|
+
label="Accessibility requirements"
|
|
168
|
+
component-has-label
|
|
169
|
+
:validate="validateAccessibilityRequirements"
|
|
170
|
+
:validate-deps="[formData.accessibility]"
|
|
171
|
+
>
|
|
172
|
+
<div role="group" aria-label="Accessibility requirements">
|
|
173
|
+
<label>
|
|
174
|
+
<input
|
|
175
|
+
type="checkbox"
|
|
176
|
+
value="high-contrast"
|
|
177
|
+
v-model="formData.accessibility"
|
|
178
|
+
/>
|
|
179
|
+
High contrast
|
|
180
|
+
</label>
|
|
181
|
+
<label>
|
|
182
|
+
<input
|
|
183
|
+
type="checkbox"
|
|
184
|
+
value="low-contrast"
|
|
185
|
+
v-model="formData.accessibility"
|
|
186
|
+
/>
|
|
187
|
+
Low contrast
|
|
188
|
+
</label>
|
|
189
|
+
</div>
|
|
190
|
+
</FormField>
|
|
191
|
+
|
|
192
|
+
<template #actions>
|
|
193
|
+
<button type="submit">Submit</button>
|
|
194
|
+
</template>
|
|
195
|
+
</FormLayout>
|
|
196
|
+
`,
|
|
197
|
+
}),
|
|
198
|
+
{
|
|
199
|
+
global: {
|
|
200
|
+
stubs: {
|
|
201
|
+
RouterLink: routerLinkStub,
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
attachTo: document.body,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return { wrapper, formData, submitted }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function mountLoginValidationForm() {
|
|
212
|
+
const usernameError = ref('')
|
|
213
|
+
const passwordError = ref('')
|
|
214
|
+
const formError = ref('')
|
|
215
|
+
|
|
216
|
+
const wrapper = mount(
|
|
217
|
+
defineComponent({
|
|
218
|
+
components: { FormField, NotificationBlock },
|
|
219
|
+
setup() {
|
|
220
|
+
const credentials = ref({ username: '', password: '' })
|
|
221
|
+
|
|
222
|
+
function applyServerErrors(errors) {
|
|
223
|
+
usernameError.value = errors.username || ''
|
|
224
|
+
passwordError.value = errors.password || ''
|
|
225
|
+
formError.value = errors.form || ''
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function handleSubmit() {
|
|
229
|
+
usernameError.value = ''
|
|
230
|
+
passwordError.value = ''
|
|
231
|
+
formError.value = ''
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
credentials,
|
|
236
|
+
usernameError,
|
|
237
|
+
passwordError,
|
|
238
|
+
formError,
|
|
239
|
+
applyServerErrors,
|
|
240
|
+
handleSubmit,
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
template: `
|
|
244
|
+
<form @submit.prevent="handleSubmit">
|
|
245
|
+
<FormField label="Username" for="username" label-skip v-model:error="usernameError">
|
|
246
|
+
<input id="username" v-model="credentials.username" required aria-label="Username" />
|
|
247
|
+
</FormField>
|
|
248
|
+
<FormField label="Password" for="password" label-skip v-model:error="passwordError">
|
|
249
|
+
<input id="password" v-model="credentials.password" type="password" required aria-label="Password" />
|
|
250
|
+
</FormField>
|
|
251
|
+
<button type="submit">Login</button>
|
|
252
|
+
<NotificationBlock
|
|
253
|
+
v-if="formError"
|
|
254
|
+
class="login-form-error"
|
|
255
|
+
type="bad"
|
|
256
|
+
label="Error"
|
|
257
|
+
:message="formError"
|
|
258
|
+
role="alert"
|
|
259
|
+
/>
|
|
260
|
+
</form>
|
|
261
|
+
`,
|
|
262
|
+
}),
|
|
263
|
+
{
|
|
264
|
+
global: {
|
|
265
|
+
stubs: {
|
|
266
|
+
RouterLink: routerLinkStub,
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
attachTo: document.body,
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
wrapper,
|
|
275
|
+
usernameError,
|
|
276
|
+
passwordError,
|
|
277
|
+
formError,
|
|
278
|
+
}
|
|
279
|
+
}
|