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
@@ -1,39 +1,51 @@
1
1
  <template>
2
- <label v-if="!fake" :for="controlId" :class="{ disabled }">{{ label }}</label>
3
- <span v-else class="fake-label" :class="{ disabled }">{{ label }}</span>
4
- <div v-if="hasFieldMeta" class="form-field-control">
5
- <template v-if="descriptionAbove">
6
- <div v-if="description || docsUrl" class="form-field-meta">
7
- <p v-if="description" class="subtle form-field-description">{{ description }}</p>
8
- <a
9
- v-if="docsUrl"
10
- class="form-field-docs-link"
11
- :href="docsUrl"
12
- target="_blank"
13
- rel="noopener noreferrer"
14
- >{{ docsLinkTitle }}</a>
15
- </div>
16
- <slot />
17
- </template>
18
- <template v-else>
19
- <slot />
20
- <div v-if="description || docsUrl" class="form-field-meta">
21
- <p v-if="description" class="subtle form-field-description">{{ description }}</p>
22
- <a
23
- v-if="docsUrl"
24
- class="form-field-docs-link"
25
- :href="docsUrl"
26
- target="_blank"
27
- rel="noopener noreferrer"
28
- >{{ docsLinkTitle }}</a>
29
- </div>
30
- </template>
2
+ <label v-if="showLabel && !componentHasLabel" :for="controlId" :class="{ disabled }">
3
+ <span class="form-field-label-text">{{ label }}</span><span v-if="showRequiredLabel" class="form-field-required" aria-hidden="true">*</span>
4
+ </label>
5
+ <span v-else-if="showLabel" class="fake-label" :class="{ disabled }">
6
+ <span class="form-field-label-text">{{ label }}</span><span v-if="showRequiredLabel" class="form-field-required" aria-hidden="true">*</span>
7
+ </span>
8
+ <div
9
+ ref="fieldControlRef"
10
+ class="form-field-control"
11
+ :class="{ 'form-field-label-skip': labelSkip }"
12
+ >
13
+ <div v-if="descriptionAbove && hasFieldMeta" class="form-field-meta">
14
+ <p v-if="description" class="subtle form-field-description">{{ description }}</p>
15
+ <a
16
+ v-if="docsUrl"
17
+ class="form-field-docs-link"
18
+ :href="docsUrl"
19
+ target="_blank"
20
+ rel="noopener noreferrer"
21
+ >{{ docsLinkTitle }}</a>
22
+ </div>
23
+ <slot />
24
+ <NotificationBlock
25
+ v-if="fieldError"
26
+ :id="errorId"
27
+ class="form-field-error"
28
+ type="bad"
29
+ label="Error"
30
+ :message="fieldError"
31
+ role="alert"
32
+ />
33
+ <div v-if="!descriptionAbove && hasFieldMeta" class="form-field-meta">
34
+ <p v-if="description" class="subtle form-field-description">{{ description }}</p>
35
+ <a
36
+ v-if="docsUrl"
37
+ class="form-field-docs-link"
38
+ :href="docsUrl"
39
+ target="_blank"
40
+ rel="noopener noreferrer"
41
+ >{{ docsLinkTitle }}</a>
42
+ </div>
31
43
  </div>
32
- <slot v-else />
33
44
  </template>
34
45
 
35
46
  <script setup>
36
- import { computed } from 'vue'
47
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, watch } from 'vue'
48
+ import NotificationBlock from './NotificationBlock.vue'
37
49
 
38
50
  const props = defineProps({
39
51
  label: {
@@ -64,7 +76,15 @@ const props = defineProps({
64
76
  type: String,
65
77
  default: '',
66
78
  },
67
- fake: {
79
+ componentHasLabel: {
80
+ type: Boolean,
81
+ default: false,
82
+ },
83
+ labelSkip: {
84
+ type: Boolean,
85
+ default: false,
86
+ },
87
+ labelRequired: {
68
88
  type: Boolean,
69
89
  default: false,
70
90
  },
@@ -72,11 +92,186 @@ const props = defineProps({
72
92
  type: Boolean,
73
93
  default: false,
74
94
  },
95
+ error: {
96
+ type: String,
97
+ default: '',
98
+ },
99
+ validate: {
100
+ type: Function,
101
+ default: null,
102
+ },
103
+ validateDeps: {
104
+ type: Array,
105
+ default: () => [],
106
+ },
75
107
  })
76
108
 
109
+ const emit = defineEmits(['update:error'])
110
+
77
111
  const controlId = computed(() => props.htmlFor || props.for)
112
+ const showLabel = computed(() => !props.labelSkip)
78
113
  const hasFieldMeta = computed(() => Boolean(props.description || props.docsUrl))
79
114
  const docsLinkTitle = computed(() => props.docsUrlTitle || props.docsUrl)
115
+ const errorId = useId()
116
+ const FIELD_INVALID_CLASS = 'field-invalid'
117
+ const fieldControlRef = ref(null)
118
+ const controlRequired = ref(false)
119
+ let boundControl = null
120
+
121
+ function onControlInput() {
122
+ if (props.error.trim()) {
123
+ emit('update:error', '')
124
+ }
125
+ }
126
+
127
+ function bindControlListeners() {
128
+ unbindControlListeners()
129
+ const element = getControlElement()
130
+ if (!element) {
131
+ return
132
+ }
133
+ element.addEventListener('input', onControlInput)
134
+ element.addEventListener('change', onControlInput)
135
+ boundControl = element
136
+ }
137
+
138
+ function unbindControlListeners() {
139
+ if (!boundControl) {
140
+ return
141
+ }
142
+ boundControl.removeEventListener('input', onControlInput)
143
+ boundControl.removeEventListener('change', onControlInput)
144
+ boundControl = null
145
+ }
146
+
147
+ const showRequiredLabel = computed(() => props.labelRequired || controlRequired.value)
148
+
149
+ const fieldError = computed(() => {
150
+ // Re-run when parent passes reactive deps (e.g. v-model values).
151
+ void props.validateDeps
152
+
153
+ if (props.error.trim()) {
154
+ return props.error.trim()
155
+ }
156
+ if (typeof props.validate === 'function') {
157
+ const result = props.validate()
158
+ return typeof result === 'string' ? result.trim() : ''
159
+ }
160
+ return ''
161
+ })
162
+
163
+ function getControlElement() {
164
+ if (!controlId.value) {
165
+ return null
166
+ }
167
+ return document.getElementById(controlId.value)
168
+ }
169
+
170
+ function syncRequiredState() {
171
+ const linkedControl = getControlElement()
172
+ if (linkedControl) {
173
+ controlRequired.value = linkedControl.required === true
174
+ return
175
+ }
176
+
177
+ const container = fieldControlRef.value
178
+ controlRequired.value = Boolean(
179
+ container?.querySelector('input[required], select[required], textarea[required]'),
180
+ )
181
+ }
182
+
183
+ function syncControlState() {
184
+ syncNativeValidity()
185
+ syncRequiredState()
186
+ }
187
+
188
+ function syncNativeValidity() {
189
+ const element = getControlElement()
190
+ const canUseNativeValidity = Boolean(
191
+ controlId.value
192
+ && !props.componentHasLabel
193
+ && element
194
+ && typeof element.setCustomValidity === 'function',
195
+ )
196
+
197
+ if (!canUseNativeValidity) {
198
+ clearNativeFieldState(element)
199
+ return
200
+ }
201
+
202
+ element.setCustomValidity(fieldError.value || '')
203
+ if (fieldError.value) {
204
+ element.setAttribute('aria-invalid', 'true')
205
+ element.classList.add(FIELD_INVALID_CLASS)
206
+ setErrorDescribedBy(element)
207
+ } else {
208
+ element.removeAttribute('aria-invalid')
209
+ element.classList.remove(FIELD_INVALID_CLASS)
210
+ removeErrorDescribedBy(element)
211
+ }
212
+ }
213
+
214
+ function clearNativeFieldState(element) {
215
+ if (!element) {
216
+ return
217
+ }
218
+ if (typeof element.setCustomValidity === 'function') {
219
+ element.setCustomValidity('')
220
+ }
221
+ element.removeAttribute('aria-invalid')
222
+ element.classList.remove(FIELD_INVALID_CLASS)
223
+ removeErrorDescribedBy(element)
224
+ }
225
+
226
+ function setErrorDescribedBy(element) {
227
+ const existing = element.getAttribute('aria-describedby')
228
+ const ids = existing ? existing.split(/\s+/).filter(Boolean) : []
229
+ if (!ids.includes(errorId)) {
230
+ ids.push(errorId)
231
+ }
232
+ element.setAttribute('aria-describedby', ids.join(' '))
233
+ }
234
+
235
+ function removeErrorDescribedBy(element) {
236
+ const existing = element.getAttribute('aria-describedby')
237
+ if (!existing) {
238
+ return
239
+ }
240
+ const ids = existing.split(/\s+/).filter((id) => id !== errorId)
241
+ if (ids.length > 0) {
242
+ element.setAttribute('aria-describedby', ids.join(' '))
243
+ } else {
244
+ element.removeAttribute('aria-describedby')
245
+ }
246
+ }
247
+
248
+ watch(
249
+ () => [fieldError.value, controlId.value, props.componentHasLabel],
250
+ () => {
251
+ nextTick(syncControlState)
252
+ },
253
+ { flush: 'post' },
254
+ )
255
+
256
+ watch(
257
+ controlId,
258
+ () => {
259
+ nextTick(bindControlListeners)
260
+ },
261
+ )
262
+
263
+ onMounted(() => {
264
+ nextTick(() => {
265
+ syncControlState()
266
+ bindControlListeners()
267
+ })
268
+ })
269
+
270
+ onBeforeUnmount(() => {
271
+ const element = boundControl || getControlElement()
272
+ unbindControlListeners()
273
+ clearNativeFieldState(element)
274
+ })
80
275
  </script>
81
276
 
82
277
  <style scoped>
@@ -107,4 +302,13 @@ label.disabled,
107
302
  font-size: 0.9em;
108
303
  align-self: flex-start;
109
304
  }
305
+
306
+ .form-field-control :deep(.form-field-error) {
307
+ font-size: 0.9em;
308
+ }
309
+
310
+ .form-field-required {
311
+ color: var(--form-field-required-fg);
312
+ margin-left: 0.15em;
313
+ }
110
314
  </style>
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import FormLayout from './FormLayout.vue'
4
+
5
+ describe('FormLayout', () => {
6
+ it('renders a form element with default slot content', () => {
7
+ const wrapper = mount(FormLayout, {
8
+ attachTo: document.body,
9
+ slots: {
10
+ default: '<input id="name" />',
11
+ },
12
+ })
13
+
14
+ expect(wrapper.element.tagName).toBe('FORM')
15
+ expect(wrapper.find('#name').exists()).toBe(true)
16
+ })
17
+
18
+ it('does not render an actions fieldset when the actions slot is empty', () => {
19
+ const wrapper = mount(FormLayout, {
20
+ slots: {
21
+ default: '<input id="name" />',
22
+ },
23
+ })
24
+
25
+ expect(wrapper.find('fieldset').exists()).toBe(false)
26
+ })
27
+
28
+ it('renders an actions fieldset when the actions slot is provided', () => {
29
+ const wrapper = mount(FormLayout, {
30
+ slots: {
31
+ default: '<input id="name" />',
32
+ actions: '<button type="submit">Save</button>',
33
+ },
34
+ })
35
+
36
+ const fieldset = wrapper.find('fieldset')
37
+ expect(fieldset.exists()).toBe(true)
38
+ expect(fieldset.find('button[type="submit"]').exists()).toBe(true)
39
+ })
40
+
41
+ it('forwards submit events from the native form', async () => {
42
+ const onSubmit = vi.fn((event) => event.preventDefault())
43
+ const wrapper = mount(FormLayout, {
44
+ attrs: {
45
+ onSubmit,
46
+ },
47
+ slots: {
48
+ default: '<button type="submit">Save</button>',
49
+ },
50
+ })
51
+
52
+ await wrapper.find('form').trigger('submit')
53
+
54
+ expect(onSubmit).toHaveBeenCalledTimes(1)
55
+ })
56
+ })
@@ -24,9 +24,9 @@
24
24
  </button>
25
25
  </div>
26
26
 
27
- <TopBar v-if="showTopBar" :navigation="navigation" />
27
+ <Breadcrumbs v-if="showBreadcrumbsBar" />
28
28
 
29
- <Breadcrumbs v-if="breadcrumbs" />
29
+ <TopBar v-if="showTopBar" :navigation="topBarNavigation" />
30
30
 
31
31
  <div class = "fg1"></div>
32
32
 
@@ -138,15 +138,23 @@
138
138
  type: Object,
139
139
  default: null,
140
140
  },
141
+ topBarNavigation: {
142
+ type: Object,
143
+ default: null,
144
+ },
141
145
  });
142
146
 
143
147
  const fallbackSidebar = ref(null);
144
148
 
145
- const { showTopBar, showSidebarChrome, needsFallbackSidebar } = useResponsiveNav(
149
+ const { showTopBar, showSidebarChrome, needsFallbackSidebar, isMobile } = useResponsiveNav(
146
150
  () => props.sidebarEnabled,
147
151
  () => props.topBarEnabled,
148
152
  );
149
153
 
154
+ const showBreadcrumbsBar = computed(
155
+ () => props.breadcrumbs && !isMobile.value,
156
+ );
157
+
150
158
  function toggleNavSidebar() {
151
159
  if (needsFallbackSidebar.value) {
152
160
  fallbackSidebar.value?.toggle();
@@ -165,8 +173,15 @@
165
173
  </script>
166
174
 
167
175
  <style scoped>
176
+ #sidebar-button {
177
+ flex-shrink: 0;
178
+ width: 14em;
179
+ min-width: 14em;
180
+ }
181
+
168
182
  #sidebar-button.disabled-branding {
169
183
  width: auto;
184
+ min-width: 0;
170
185
  }
171
186
 
172
187
  .logo-home-link {
@@ -194,6 +209,7 @@
194
209
  .header-actions {
195
210
  align-self: stretch;
196
211
  gap: 0.25rem;
212
+ flex-shrink: 0;
197
213
  }
198
214
 
199
215
  .theme-toggle {
@@ -205,7 +221,7 @@
205
221
  .header-actions :deep(button) {
206
222
  border: 0;
207
223
  border-radius: 0;
208
- color: var(--header-fg-color, var(--header-text-color, #fff));
224
+ color: var(--header-fg-color);
209
225
  background-color: transparent;
210
226
  align-self: stretch;
211
227
  display: inline-flex;
@@ -227,7 +243,7 @@
227
243
  #sidebar-toggler-button {
228
244
  border: 0;
229
245
  border-radius: 0;
230
- color: var(--header-fg-color, var(--header-text-color, #fff));
246
+ color: var(--header-fg-color);
231
247
  background-color: transparent;
232
248
  align-self: stretch;
233
249
  display: inline-flex;
@@ -260,7 +276,7 @@ header .search-trigger,
260
276
  header .search-trigger.neutral {
261
277
  border: 0;
262
278
  border-radius: 0;
263
- color: var(--header-fg-color, var(--header-text-color, #fff));
279
+ color: var(--header-fg-color);
264
280
  background-color: transparent;
265
281
  align-self: stretch;
266
282
  height: 100%;