picocrank 1.25.2 → 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 +75 -0
- package/package.json +17 -4
- package/router.js +74 -0
- package/styles/dark-theme.css +20 -11
- package/styles/picocrank-extensions.css +123 -30
- package/supplemental-themes/catppuccin-latte-frappe/theme.css +10 -24
- package/supplemental-themes/dracula-alucard/theme.css +10 -24
- package/supplemental-themes/gruvbox-dark-light/theme.css +10 -24
- package/supplemental-themes/waffles/theme.css +2 -16
- package/vite.config.js +5 -0
- package/vue/components/Breadcrumbs.vue +192 -45
- package/vue/components/Calendar.vue +162 -145
- package/vue/components/DangerZone.vue +186 -0
- 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.vue +22 -6
- package/vue/components/Login.vue +152 -41
- package/vue/components/NavigationGrid.vue +26 -180
- package/vue/components/NotificationBlock.vue +120 -0
- package/vue/components/NotificationPopup.vue +44 -41
- package/vue/components/Pagination.vue +5 -4
- package/vue/components/ReadOnlyTextArea.vue +1 -1
- package/vue/components/SidebarNavLink.vue +3 -3
- package/vue/components/Table.vue +107 -79
- package/vue/components/TableColumnOptionsPopover.vue +9 -0
- package/vue/components/Tabs.spec.js +101 -0
- package/vue/components/Tabs.vue +455 -81
- package/vue/components/TopBar.vue +2 -2
- package/vue/components/form-validation.spec.js +126 -0
- package/vue/composables/tableColumnPriorities.js +33 -0
- package/vue/composables/tableFilters.js +1 -1
- package/vue/data/navSectionHubs.js +196 -0
- package/vue/data/routeBreadcrumbs.js +90 -0
- package/vue/examples/Admin.vue +12 -12
- package/vue/examples/App.vue +27 -36
- package/vue/examples/CalendarExample.vue +32 -22
- package/vue/examples/ExampleHeaderThemeSwitcher.vue +1 -1
- package/vue/examples/FormExample.vue +76 -48
- package/vue/examples/Login.vue +5 -3
- package/vue/examples/LoginExample.vue +99 -15
- package/vue/examples/NavSectionHub.vue +36 -0
- package/vue/examples/PageStructure.vue +15 -12
- package/vue/examples/StatusExample.vue +248 -129
- package/vue/examples/TableExample.vue +72 -71
- package/vue/examples/TabsExample.vue +236 -257
- package/vue/examples/ThemePreview.vue +21 -23
- package/vue/examples/ViewItem.vue +32 -5
- package/vue/examples/Welcome.vue +3 -40
- package/vue/test/formTestUtils.js +279 -0
- package/vue/test/setup.js +5 -0
package/vue/examples/Welcome.vue
CHANGED
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
<Section
|
|
41
41
|
id="start-here"
|
|
42
42
|
title="Start here"
|
|
43
|
-
subtitle="
|
|
43
|
+
subtitle="Browse example categories — use the sidebar for direct links, or the top bar when hubs are enabled"
|
|
44
44
|
>
|
|
45
45
|
<Navigation ref="welcomeNavigation">
|
|
46
46
|
<NavigationGrid />
|
|
@@ -86,6 +86,7 @@ import Section from '../components/Section.vue'
|
|
|
86
86
|
import Navigation from '../components/Navigation.vue'
|
|
87
87
|
import NavigationGrid from '../components/NavigationGrid.vue'
|
|
88
88
|
import { useNotificationPopups } from '../composables/useNotificationPopups.js'
|
|
89
|
+
import { registerWelcomeHubGrid } from '../data/navSectionHubs.js'
|
|
89
90
|
import logoUrl from '/logo.png'
|
|
90
91
|
|
|
91
92
|
const { show: showPopup } = useNotificationPopups()
|
|
@@ -102,45 +103,7 @@ function showWelcomeToast() {
|
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
onMounted(() => {
|
|
105
|
-
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const nav = welcomeNavigation.value
|
|
110
|
-
|
|
111
|
-
nav.addRouterLink('PageStructure', 'Page structure', {
|
|
112
|
-
description: 'Sidebar, header, QuickSearch, and breadcrumbs',
|
|
113
|
-
})
|
|
114
|
-
nav.addRouterLink('ThemePreview', 'Theme Switcher', {
|
|
115
|
-
description: 'Drop-in themes on Femtocrank',
|
|
116
|
-
})
|
|
117
|
-
nav.addRouterLink('TableExample', 'Table', {
|
|
118
|
-
description: 'Lists with sorting, pagination, and filters',
|
|
119
|
-
})
|
|
120
|
-
nav.addRouterLink('TableRemoteExample', 'Remote table', {
|
|
121
|
-
description: 'Server-style fetchRows pagination and filters',
|
|
122
|
-
})
|
|
123
|
-
nav.addRouterLink('CalendarExample', 'Calendar', {
|
|
124
|
-
description: 'Month view with events',
|
|
125
|
-
})
|
|
126
|
-
nav.addRouterLink('ButtonsExample', 'Buttons', {
|
|
127
|
-
description: 'Button variants and toolbars',
|
|
128
|
-
})
|
|
129
|
-
nav.addRouterLink('FormExample', 'Forms', {
|
|
130
|
-
description: 'Labels, inputs, and fieldsets',
|
|
131
|
-
})
|
|
132
|
-
nav.addRouterLink('LoginExample', 'Login', {
|
|
133
|
-
description: 'Auth form with OAuth tabs',
|
|
134
|
-
})
|
|
135
|
-
nav.addRouterLink('StatusExample', 'Status', {
|
|
136
|
-
description: 'Notifications and inline status',
|
|
137
|
-
})
|
|
138
|
-
nav.addRouterLink('NavigationGridExample', 'Navigation grid', {
|
|
139
|
-
description: 'Icon grid for settings hubs',
|
|
140
|
-
})
|
|
141
|
-
nav.addRouterLink('TabsExample', 'Tabs', {
|
|
142
|
-
description: 'Tabbed content regions',
|
|
143
|
-
})
|
|
106
|
+
registerWelcomeHubGrid(welcomeNavigation.value)
|
|
144
107
|
})
|
|
145
108
|
</script>
|
|
146
109
|
|
|
@@ -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
|
+
}
|