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.
Files changed (51) hide show
  1. package/eslint.config.js +75 -0
  2. package/package.json +17 -4
  3. package/router.js +74 -0
  4. package/styles/dark-theme.css +20 -11
  5. package/styles/picocrank-extensions.css +123 -30
  6. package/supplemental-themes/catppuccin-latte-frappe/theme.css +10 -24
  7. package/supplemental-themes/dracula-alucard/theme.css +10 -24
  8. package/supplemental-themes/gruvbox-dark-light/theme.css +10 -24
  9. package/supplemental-themes/waffles/theme.css +2 -16
  10. package/vite.config.js +5 -0
  11. package/vue/components/Breadcrumbs.vue +192 -45
  12. package/vue/components/Calendar.vue +162 -145
  13. package/vue/components/DangerZone.vue +186 -0
  14. package/vue/components/FormField.spec.js +350 -0
  15. package/vue/components/FormField.vue +236 -32
  16. package/vue/components/FormLayout.spec.js +56 -0
  17. package/vue/components/Header.vue +22 -6
  18. package/vue/components/Login.vue +152 -41
  19. package/vue/components/NavigationGrid.vue +26 -180
  20. package/vue/components/NotificationBlock.vue +120 -0
  21. package/vue/components/NotificationPopup.vue +44 -41
  22. package/vue/components/Pagination.vue +5 -4
  23. package/vue/components/ReadOnlyTextArea.vue +1 -1
  24. package/vue/components/SidebarNavLink.vue +3 -3
  25. package/vue/components/Table.vue +107 -79
  26. package/vue/components/TableColumnOptionsPopover.vue +9 -0
  27. package/vue/components/Tabs.spec.js +101 -0
  28. package/vue/components/Tabs.vue +455 -81
  29. package/vue/components/TopBar.vue +2 -2
  30. package/vue/components/form-validation.spec.js +126 -0
  31. package/vue/composables/tableColumnPriorities.js +33 -0
  32. package/vue/composables/tableFilters.js +1 -1
  33. package/vue/data/navSectionHubs.js +196 -0
  34. package/vue/data/routeBreadcrumbs.js +90 -0
  35. package/vue/examples/Admin.vue +12 -12
  36. package/vue/examples/App.vue +27 -36
  37. package/vue/examples/CalendarExample.vue +32 -22
  38. package/vue/examples/ExampleHeaderThemeSwitcher.vue +1 -1
  39. package/vue/examples/FormExample.vue +76 -48
  40. package/vue/examples/Login.vue +5 -3
  41. package/vue/examples/LoginExample.vue +99 -15
  42. package/vue/examples/NavSectionHub.vue +36 -0
  43. package/vue/examples/PageStructure.vue +15 -12
  44. package/vue/examples/StatusExample.vue +248 -129
  45. package/vue/examples/TableExample.vue +72 -71
  46. package/vue/examples/TabsExample.vue +236 -257
  47. package/vue/examples/ThemePreview.vue +21 -23
  48. package/vue/examples/ViewItem.vue +32 -5
  49. package/vue/examples/Welcome.vue +3 -40
  50. package/vue/test/formTestUtils.js +279 -0
  51. package/vue/test/setup.js +5 -0
@@ -40,7 +40,7 @@
40
40
  <Section
41
41
  id="start-here"
42
42
  title="Start here"
43
- subtitle="Jump into the example areas"
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
- if (!welcomeNavigation.value) {
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
+ }
@@ -0,0 +1,5 @@
1
+ import { afterEach } from 'vitest'
2
+
3
+ afterEach(() => {
4
+ document.body.innerHTML = ''
5
+ })