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
@@ -0,0 +1,186 @@
1
+ <template>
2
+ <section class="with-header-and-content danger-zone">
3
+ <details
4
+ class="danger-zone-details"
5
+ :open="open"
6
+ @toggle="onToggle"
7
+ >
8
+ <summary class="danger-zone-summary section-header flex-row">
9
+ <div class="fg1 danger-zone-heading">
10
+ <h2 :title="subtitle || undefined" class="danger-zone-title">
11
+ <HugeiconsIcon
12
+ v-if="icon"
13
+ :icon="icon"
14
+ class="danger-zone-title-icon"
15
+ width="1.1em"
16
+ height="1.1em"
17
+ :strokeWidth="2"
18
+ aria-hidden="true"
19
+ />
20
+ <span>{{ title }}</span>
21
+ </h2>
22
+ <span v-if="subtitle" class="danger-zone-subtitle subtle">{{ subtitle }}</span>
23
+ <span v-if="description" class="danger-zone-description subtle">{{ description }}</span>
24
+ </div>
25
+ <span class="danger-zone-chevron" aria-hidden="true">▸</span>
26
+ </summary>
27
+ <Section
28
+ :title="title"
29
+ :subtitle="subtitle"
30
+ :padding="padding"
31
+ :icon="icon"
32
+ :id="sectionId"
33
+ classes="danger-zone-section"
34
+ >
35
+ <template v-if="$slots.toolbar" #toolbar>
36
+ <slot name="toolbar" />
37
+ </template>
38
+ <p v-if="warning" class="danger-zone-warning">{{ warning }}</p>
39
+ <slot />
40
+ </Section>
41
+ </details>
42
+ </section>
43
+ </template>
44
+
45
+ <script setup>
46
+ import { HugeiconsIcon } from '@hugeicons/vue'
47
+ import { Alert02Icon } from '@hugeicons/core-free-icons'
48
+ import Section from './Section.vue'
49
+
50
+ defineProps({
51
+ title: {
52
+ type: String,
53
+ default: 'Danger zone',
54
+ },
55
+ subtitle: {
56
+ type: String,
57
+ default: '',
58
+ },
59
+ description: {
60
+ type: String,
61
+ default: '',
62
+ },
63
+ warning: {
64
+ type: String,
65
+ default: 'These actions are permanent and cannot be undone.',
66
+ },
67
+ padding: {
68
+ type: Boolean,
69
+ default: true,
70
+ },
71
+ icon: {
72
+ type: [Object, Array, Function],
73
+ default: () => Alert02Icon,
74
+ },
75
+ sectionId: {
76
+ type: String,
77
+ default: '',
78
+ },
79
+ })
80
+
81
+ const open = defineModel('open', {
82
+ type: Boolean,
83
+ default: false,
84
+ })
85
+
86
+ function onToggle(event) {
87
+ open.value = event.target.open
88
+ }
89
+ </script>
90
+
91
+ <style scoped>
92
+ .danger-zone-details {
93
+ display: block;
94
+ }
95
+
96
+ .danger-zone-summary {
97
+ cursor: pointer;
98
+ list-style: none;
99
+ user-select: none;
100
+ transition: background-color 0.2s ease;
101
+ }
102
+
103
+ .danger-zone-heading {
104
+ display: flex;
105
+ flex-wrap: wrap;
106
+ align-items: center;
107
+ gap: 0.35em 0.75em;
108
+ min-width: 0;
109
+ }
110
+
111
+ .danger-zone-subtitle,
112
+ .danger-zone-description {
113
+ line-height: 1.4;
114
+ }
115
+
116
+ .danger-zone-chevron {
117
+ flex-shrink: 0;
118
+ margin-left: 0.75em;
119
+ transition: transform 0.15s ease;
120
+ }
121
+
122
+ .danger-zone-details[open] > .danger-zone-summary .danger-zone-chevron {
123
+ transform: rotate(90deg);
124
+ }
125
+
126
+ .danger-zone-details[open] > .danger-zone-summary {
127
+ background: var(--danger-zone-summary-hover-bg);
128
+ border-bottom-color: var(--danger-zone-border);
129
+ }
130
+
131
+ .danger-zone-details:not([open]) > .danger-zone-summary {
132
+ border-radius: 0.4em;
133
+ border-bottom: none;
134
+ }
135
+
136
+ .danger-zone-summary::-webkit-details-marker {
137
+ display: none;
138
+ }
139
+
140
+ .danger-zone-summary:hover {
141
+ background: var(--danger-zone-summary-hover-bg);
142
+ }
143
+
144
+ .danger-zone-summary:focus {
145
+ outline: none;
146
+ }
147
+
148
+ .danger-zone-summary:focus-visible {
149
+ outline: 2px solid var(--danger-zone-focus-outline);
150
+ outline-offset: -2px;
151
+ }
152
+
153
+ .danger-zone-title {
154
+ display: inline-flex;
155
+ align-items: center;
156
+ gap: 0.45em;
157
+ margin: 0;
158
+ padding-top: 0.5em;
159
+ padding-bottom: 0.5em;
160
+ }
161
+
162
+ .danger-zone-title-icon {
163
+ flex: 0 0 auto;
164
+ }
165
+
166
+ .danger-zone :deep(.danger-zone-section.with-header-and-content) {
167
+ margin: 0;
168
+ border: none;
169
+ border-radius: 0;
170
+ box-shadow: none;
171
+ background: transparent;
172
+ }
173
+
174
+ .danger-zone :deep(.danger-zone-section .section-header) {
175
+ display: none;
176
+ }
177
+
178
+ .danger-zone :deep(.danger-zone-section .section-content) {
179
+ border-top: none;
180
+ }
181
+
182
+ .danger-zone-warning {
183
+ margin: 0 0 1rem;
184
+ color: var(--danger-zone-warning-fg);
185
+ }
186
+ </style>
@@ -0,0 +1,350 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mountFormField, flushControlState } from '../test/formTestUtils.js'
3
+
4
+ describe('FormField', () => {
5
+ describe('labels and structure', () => {
6
+ it('renders a label associated with the control via for/id', () => {
7
+ const wrapper = mountFormField({
8
+ props: {
9
+ label: 'Email',
10
+ for: 'email',
11
+ },
12
+ slot: '<input id="email" type="email" />',
13
+ })
14
+
15
+ const label = wrapper.find('label[for="email"]')
16
+ expect(label.exists()).toBe(true)
17
+ expect(label.text()).toContain('Email')
18
+ expect(wrapper.find('#email').exists()).toBe(true)
19
+ })
20
+
21
+ it('renders a fake label when component-has-label is set', () => {
22
+ const wrapper = mountFormField({
23
+ props: {
24
+ label: 'Role',
25
+ componentHasLabel: true,
26
+ },
27
+ slot: '<div role="radiogroup">Options</div>',
28
+ })
29
+
30
+ expect(wrapper.find('label').exists()).toBe(false)
31
+ expect(wrapper.find('.fake-label').text()).toContain('Role')
32
+ })
33
+
34
+ it('skips the label when label-skip is set', () => {
35
+ const wrapper = mountFormField({
36
+ props: {
37
+ label: 'Username',
38
+ labelSkip: true,
39
+ for: 'username',
40
+ },
41
+ slot: '<input id="username" aria-label="Username" />',
42
+ })
43
+
44
+ expect(wrapper.find('label').exists()).toBe(false)
45
+ expect(wrapper.find('.fake-label').exists()).toBe(false)
46
+ expect(wrapper.find('.form-field-label-skip').exists()).toBe(true)
47
+ })
48
+
49
+ it('renders description below the control by default', () => {
50
+ const wrapper = mountFormField({
51
+ props: {
52
+ description: 'Helpful hint text.',
53
+ },
54
+ })
55
+
56
+ const description = wrapper.find('.form-field-description')
57
+ expect(description.exists()).toBe(true)
58
+ expect(description.text()).toBe('Helpful hint text.')
59
+ })
60
+
61
+ it('renders description above the control when description-above is set', () => {
62
+ const wrapper = mountFormField({
63
+ props: {
64
+ description: 'Shown first.',
65
+ descriptionAbove: true,
66
+ },
67
+ })
68
+
69
+ const metaBlocks = wrapper.findAll('.form-field-meta')
70
+ expect(metaBlocks).toHaveLength(1)
71
+ expect(metaBlocks[0].find('.form-field-description').text()).toBe('Shown first.')
72
+ })
73
+
74
+ it('renders a docs link when docs-url is provided', () => {
75
+ const wrapper = mountFormField({
76
+ props: {
77
+ docsUrl: 'https://example.com/docs',
78
+ docsUrlTitle: 'Read the docs',
79
+ },
80
+ })
81
+
82
+ const link = wrapper.find('.form-field-docs-link')
83
+ expect(link.attributes('href')).toBe('https://example.com/docs')
84
+ expect(link.attributes('target')).toBe('_blank')
85
+ expect(link.attributes('rel')).toBe('noopener noreferrer')
86
+ expect(link.text()).toBe('Read the docs')
87
+ })
88
+ })
89
+
90
+ describe('required indicator', () => {
91
+ it('shows a required asterisk when label-required is set', () => {
92
+ const wrapper = mountFormField({
93
+ props: {
94
+ labelRequired: true,
95
+ },
96
+ })
97
+
98
+ const marker = wrapper.find('.form-field-required')
99
+ expect(marker.exists()).toBe(true)
100
+ expect(marker.attributes('aria-hidden')).toBe('true')
101
+ expect(marker.text()).toBe('*')
102
+ })
103
+
104
+ it('shows a required asterisk when the slotted control is required', async () => {
105
+ const wrapper = mountFormField({
106
+ slot: '<input id="test-input" required />',
107
+ })
108
+
109
+ await flushControlState()
110
+
111
+ expect(wrapper.find('.form-field-required').exists()).toBe(true)
112
+ })
113
+ })
114
+
115
+ describe('error prop validation', () => {
116
+ it('shows a NotificationBlock when error is set', async () => {
117
+ const wrapper = mountFormField({
118
+ props: {
119
+ error: 'This field is required.',
120
+ },
121
+ })
122
+
123
+ await flushControlState()
124
+
125
+ const errorBlock = wrapper.find('.form-field-error')
126
+ expect(errorBlock.exists()).toBe(true)
127
+ expect(errorBlock.attributes('role')).toBe('alert')
128
+ expect(errorBlock.text()).toContain('This field is required.')
129
+ })
130
+
131
+ it('does not show an error block when error is empty', async () => {
132
+ const wrapper = mountFormField({
133
+ props: {
134
+ error: '',
135
+ },
136
+ })
137
+
138
+ await flushControlState()
139
+
140
+ expect(wrapper.find('.form-field-error').exists()).toBe(false)
141
+ })
142
+
143
+ it('marks the native control invalid when error is set', async () => {
144
+ const wrapper = mountFormField({
145
+ props: {
146
+ error: 'Invalid value.',
147
+ },
148
+ })
149
+
150
+ await flushControlState()
151
+
152
+ const input = wrapper.find('#test-input').element
153
+ expect(input.getAttribute('aria-invalid')).toBe('true')
154
+ expect(input.classList.contains('field-invalid')).toBe(true)
155
+ expect(input.validationMessage).toBe('Invalid value.')
156
+ expect(input.validity.customError).toBe(true)
157
+ })
158
+
159
+ it('links the control to the error via aria-describedby', async () => {
160
+ const wrapper = mountFormField({
161
+ props: {
162
+ error: 'Invalid value.',
163
+ },
164
+ })
165
+
166
+ await flushControlState()
167
+
168
+ const input = wrapper.find('#test-input').element
169
+ const errorId = wrapper.find('.form-field-error').attributes('id')
170
+ expect(input.getAttribute('aria-describedby')).toContain(errorId)
171
+ })
172
+
173
+ it('clears native invalid state when error is removed', async () => {
174
+ const wrapper = mountFormField({
175
+ props: {
176
+ error: 'Invalid value.',
177
+ },
178
+ })
179
+
180
+ await flushControlState()
181
+ await wrapper.setProps({ error: '' })
182
+ await flushControlState()
183
+
184
+ const input = wrapper.find('#test-input').element
185
+ expect(input.hasAttribute('aria-invalid')).toBe(false)
186
+ expect(input.classList.contains('field-invalid')).toBe(false)
187
+ expect(input.validationMessage).toBe('')
188
+ })
189
+
190
+ it('emits update:error when the user edits a field with an external error', async () => {
191
+ const wrapper = mountFormField({
192
+ props: {
193
+ error: 'Server rejected this value.',
194
+ },
195
+ })
196
+
197
+ await flushControlState()
198
+ await wrapper.find('#test-input').setValue('corrected')
199
+ await flushControlState()
200
+
201
+ expect(wrapper.emitted('update:error')).toContainEqual([''])
202
+ })
203
+
204
+ it('does not emit update:error on input when validate() supplies the error', async () => {
205
+ const wrapper = mountFormField({
206
+ props: {
207
+ validate: () => 'Always invalid.',
208
+ validateDeps: [],
209
+ },
210
+ })
211
+
212
+ await flushControlState()
213
+ await wrapper.find('#test-input').setValue('anything')
214
+ await flushControlState()
215
+
216
+ expect(wrapper.emitted('update:error')).toBeUndefined()
217
+ })
218
+ })
219
+
220
+ describe('validate function', () => {
221
+ it('shows an error from the validate callback', async () => {
222
+ const wrapper = mountFormField({
223
+ props: {
224
+ validate: () => 'Pick at least one option.',
225
+ validateDeps: [],
226
+ },
227
+ })
228
+
229
+ await flushControlState()
230
+
231
+ expect(wrapper.find('.form-field-error').text()).toContain('Pick at least one option.')
232
+ })
233
+
234
+ it('re-runs validate when validateDeps change', async () => {
235
+ const state = { value: 'a' }
236
+ const wrapper = mountFormField({
237
+ props: {
238
+ validateDeps: [state.value],
239
+ validate: () => (state.value === 'a' ? 'First value is invalid.' : ''),
240
+ },
241
+ })
242
+
243
+ await flushControlState()
244
+ expect(wrapper.find('.form-field-error').exists()).toBe(true)
245
+
246
+ state.value = 'b'
247
+ await wrapper.setProps({ validateDeps: ['b'] })
248
+ await flushControlState()
249
+ expect(wrapper.find('.form-field-error').exists()).toBe(false)
250
+ })
251
+
252
+ it('prefers the error prop over validate()', async () => {
253
+ const wrapper = mountFormField({
254
+ props: {
255
+ error: 'External error.',
256
+ validate: () => 'Validation error.',
257
+ validateDeps: [],
258
+ },
259
+ })
260
+
261
+ await flushControlState()
262
+
263
+ expect(wrapper.find('.form-field-error').text()).toContain('External error.')
264
+ expect(wrapper.find('.form-field-error').text()).not.toContain('Validation error.')
265
+ })
266
+
267
+ it('ignores non-string validate results', async () => {
268
+ const wrapper = mountFormField({
269
+ props: {
270
+ validate: () => false,
271
+ validateDeps: [],
272
+ },
273
+ })
274
+
275
+ await flushControlState()
276
+
277
+ expect(wrapper.find('.form-field-error').exists()).toBe(false)
278
+ })
279
+ })
280
+
281
+ describe('component-has-label controls', () => {
282
+ it('does not apply native validity to composite controls', async () => {
283
+ const wrapper = mountFormField({
284
+ props: {
285
+ componentHasLabel: true,
286
+ error: 'Group error.',
287
+ },
288
+ slot: '<input id="test-input" />',
289
+ })
290
+
291
+ await flushControlState()
292
+
293
+ const input = wrapper.find('#test-input').element
294
+ expect(input.validationMessage).toBe('')
295
+ expect(input.hasAttribute('aria-invalid')).toBe(false)
296
+ })
297
+ })
298
+
299
+ describe('lifecycle', () => {
300
+ it('clears native validity on unmount', async () => {
301
+ const wrapper = mountFormField({
302
+ props: {
303
+ error: 'Invalid value.',
304
+ },
305
+ })
306
+
307
+ await flushControlState()
308
+
309
+ const input = wrapper.find('#test-input').element
310
+ expect(input.validationMessage).toBe('Invalid value.')
311
+
312
+ wrapper.unmount()
313
+
314
+ expect(input.validationMessage).toBe('')
315
+ expect(input.hasAttribute('aria-invalid')).toBe(false)
316
+ })
317
+ })
318
+
319
+ describe('disabled state', () => {
320
+ it('applies disabled styling class to the label', () => {
321
+ const wrapper = mountFormField({
322
+ props: {
323
+ disabled: true,
324
+ },
325
+ })
326
+
327
+ expect(wrapper.find('label.disabled').exists()).toBe(true)
328
+ })
329
+ })
330
+ })
331
+
332
+ describe('FormField htmlFor alias', () => {
333
+ it('accepts htmlFor as an alias for for', async () => {
334
+ const wrapper = mountFormField({
335
+ props: {
336
+ label: 'Name',
337
+ htmlFor: 'name-field',
338
+ for: '',
339
+ },
340
+ slot: '<input id="name-field" />',
341
+ })
342
+
343
+ expect(wrapper.find('label[for="name-field"]').exists()).toBe(true)
344
+
345
+ await wrapper.setProps({ error: 'Required.' })
346
+ await flushControlState()
347
+
348
+ expect(wrapper.find('#name-field').element.getAttribute('aria-invalid')).toBe('true')
349
+ })
350
+ })