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
|
@@ -1,39 +1,51 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<label v-if="!
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
27
|
+
<Breadcrumbs v-if="showBreadcrumbsBar" />
|
|
28
28
|
|
|
29
|
-
<
|
|
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
|
|
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
|
|
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
|
|
279
|
+
color: var(--header-fg-color);
|
|
264
280
|
background-color: transparent;
|
|
265
281
|
align-self: stretch;
|
|
266
282
|
height: 100%;
|