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
|
@@ -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
|
+
}
|