picocrank 1.33.0 → 1.34.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/docs/filter-query-v1.md +5 -1
- package/package.json +1 -1
- package/router.js +33 -0
- package/styles/picocrank-extensions.css +23 -2
- package/vue/components/Accordion.spec.js +48 -0
- package/vue/components/Accordion.vue +46 -0
- package/vue/components/AccordionItem.vue +103 -0
- package/vue/components/FieldValue.spec.js +88 -0
- package/vue/components/FieldValue.vue +222 -0
- package/vue/components/FormField.spec.js +1 -0
- package/vue/components/FormField.vue +38 -10
- package/vue/components/Header.vue +24 -2
- package/vue/components/NotificationBlock.vue +25 -0
- package/vue/components/ReorderList.spec.js +318 -0
- package/vue/components/ReorderList.vue +614 -0
- package/vue/components/Table.spec.js +27 -0
- package/vue/components/Table.vue +458 -112
- package/vue/components/TableColumnFilterPopover.vue +63 -19
- package/vue/components/TableColumnOptionsPopover.vue +248 -273
- package/vue/components/TopBar.vue +6 -2
- package/vue/components/accordionContext.js +1 -0
- package/vue/composables/tableColumnPresets.js +33 -0
- package/vue/composables/tableGroupBy.js +173 -0
- package/vue/composables/tableGroupBy.spec.js +69 -0
- package/vue/composables/tableViewSettings.js +34 -0
- package/vue/composables/tableViewSettings.spec.js +21 -0
- package/vue/composables/useHeaderTopBarFit.js +91 -0
- package/vue/composables/useResponsiveNav.js +5 -3
- package/vue/data/navSectionHubs.js +16 -1
- package/vue/examples/AccordionExample.vue +33 -0
- package/vue/examples/FieldValueExample.vue +143 -0
- package/vue/examples/FormExample.vue +11 -6
- package/vue/examples/ReorderListExample.vue +236 -0
- package/vue/examples/TableExample.vue +5 -1
- package/vue/examples/TableRemoteExample.vue +28 -4
package/docs/filter-query-v1.md
CHANGED
|
@@ -160,7 +160,11 @@ To mirror client semantics in Node without reimplementing operators, reuse `appl
|
|
|
160
160
|
|
|
161
161
|
## Layout presets vs filterQuery
|
|
162
162
|
|
|
163
|
-
Saved layouts
|
|
163
|
+
Saved layouts store **`filters`** (UI storage shape) inside `state`, not `filterQuery`. When a preset is applied, the table rebuilds `filterQuery` automatically.
|
|
164
|
+
|
|
165
|
+
Table **`viewControlMode`**: `localStorageControlled` (default; needs `tableId`) or `callbackControlled` (needs `viewSettings` with `list` / `save` / `load` / etc.). Legacy `layoutPresets` is an alias for `viewSettings`.
|
|
166
|
+
|
|
167
|
+
Callback-backed persistence can store either:
|
|
164
168
|
|
|
165
169
|
- **`state.filters`** — round-trips the table UI exactly, or
|
|
166
170
|
- **`filterQuery`** — if you control load/save; convert with `filterQueryToFilters` before `setFilters` if needed.
|
package/package.json
CHANGED
package/router.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ViewIcon } from '@hugeicons/core-free-icons';
|
|
|
6
6
|
import { SecurityValidationIcon } from '@hugeicons/core-free-icons';
|
|
7
7
|
import { CalendarIcon } from '@hugeicons/core-free-icons';
|
|
8
8
|
import { EditIcon } from '@hugeicons/core-free-icons';
|
|
9
|
+
import { Edit03Icon } from '@hugeicons/core-free-icons';
|
|
9
10
|
import { Menu01Icon } from '@hugeicons/core-free-icons';
|
|
10
11
|
import { Navigation01Icon } from '@hugeicons/core-free-icons';
|
|
11
12
|
import { ClipboardCopyIcon } from '@hugeicons/core-free-icons';
|
|
@@ -16,6 +17,8 @@ import { PaintBoardIcon } from '@hugeicons/core-free-icons';
|
|
|
16
17
|
import { UserIcon } from '@hugeicons/core-free-icons';
|
|
17
18
|
import { InformationCircleIcon } from '@hugeicons/core-free-icons';
|
|
18
19
|
import { Loading03Icon } from '@hugeicons/core-free-icons';
|
|
20
|
+
import { DragDropVerticalIcon } from '@hugeicons/core-free-icons';
|
|
21
|
+
import { ArrowDown01Icon } from '@hugeicons/core-free-icons';
|
|
19
22
|
|
|
20
23
|
import {
|
|
21
24
|
hubChildBreadcrumbs,
|
|
@@ -117,6 +120,16 @@ const routes = [
|
|
|
117
120
|
breadcrumbs: hubChildBreadcrumbs('NavDataDisplayHub', 'CalendarExample', 'Calendar'),
|
|
118
121
|
}
|
|
119
122
|
},
|
|
123
|
+
{
|
|
124
|
+
name: 'ReorderListExample',
|
|
125
|
+
path: '/reorder-list-example',
|
|
126
|
+
component: () => import('./vue/examples/ReorderListExample.vue'),
|
|
127
|
+
meta: {
|
|
128
|
+
title: 'Reorder list',
|
|
129
|
+
icon: DragDropVerticalIcon,
|
|
130
|
+
breadcrumbs: hubChildBreadcrumbs('NavDataDisplayHub', 'ReorderListExample', 'Reorder list'),
|
|
131
|
+
}
|
|
132
|
+
},
|
|
120
133
|
{
|
|
121
134
|
name: 'Login',
|
|
122
135
|
path: '/login',
|
|
@@ -147,6 +160,16 @@ const routes = [
|
|
|
147
160
|
breadcrumbs: hubChildBreadcrumbs('NavFormsInputHub', 'FormExample', 'Forms'),
|
|
148
161
|
}
|
|
149
162
|
},
|
|
163
|
+
{
|
|
164
|
+
name: 'FieldValueExample',
|
|
165
|
+
path: '/field-value-example',
|
|
166
|
+
component: () => import('./vue/examples/FieldValueExample.vue'),
|
|
167
|
+
meta: {
|
|
168
|
+
title: 'Field value',
|
|
169
|
+
icon: Edit03Icon,
|
|
170
|
+
breadcrumbs: hubChildBreadcrumbs('NavFormsInputHub', 'FieldValueExample', 'Field value'),
|
|
171
|
+
}
|
|
172
|
+
},
|
|
150
173
|
{
|
|
151
174
|
name: 'ReadOnlyTextAreaExample',
|
|
152
175
|
path: '/readonly-textarea-example',
|
|
@@ -187,6 +210,16 @@ const routes = [
|
|
|
187
210
|
breadcrumbs: hubChildBreadcrumbs('NavFeedbackStatusHub', 'LoadingAreaExample', 'Loading area'),
|
|
188
211
|
}
|
|
189
212
|
},
|
|
213
|
+
{
|
|
214
|
+
name: 'AccordionExample',
|
|
215
|
+
path: '/accordion-example',
|
|
216
|
+
component: () => import('./vue/examples/AccordionExample.vue'),
|
|
217
|
+
meta: {
|
|
218
|
+
title: 'Accordion',
|
|
219
|
+
icon: ArrowDown01Icon,
|
|
220
|
+
breadcrumbs: hubChildBreadcrumbs('NavLayoutsHub', 'AccordionExample', 'Accordion'),
|
|
221
|
+
}
|
|
222
|
+
},
|
|
190
223
|
{
|
|
191
224
|
name: 'TabsExample',
|
|
192
225
|
path: '/tabs-example',
|
|
@@ -93,6 +93,8 @@
|
|
|
93
93
|
|
|
94
94
|
/* Matches Femtocrank header min-height (logo 2em + .5em padding). */
|
|
95
95
|
--picocrank-fixed-header-offset: 3em;
|
|
96
|
+
|
|
97
|
+
--field-invalid-outline: 0 0 0 3px color-mix(in srgb, var(--karma-bad) 35%, transparent);
|
|
96
98
|
}
|
|
97
99
|
|
|
98
100
|
/* No fixed header → no sidebar/main offset. */
|
|
@@ -133,11 +135,18 @@ section .section-header [role="toolbar"] .button {
|
|
|
133
135
|
flex-shrink: 0;
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
/* Grow search/filter controls only — toolbar labels stay content-sized. */
|
|
139
|
+
section .section-header [role="toolbar"] > :not(button):not(.button):not(label):not(span) {
|
|
137
140
|
flex: 1 1 auto;
|
|
138
141
|
min-width: 0;
|
|
139
142
|
}
|
|
140
143
|
|
|
144
|
+
section .section-header [role="toolbar"] > label {
|
|
145
|
+
flex: 0 0 auto;
|
|
146
|
+
width: auto;
|
|
147
|
+
min-width: auto;
|
|
148
|
+
}
|
|
149
|
+
|
|
141
150
|
section.narrow {
|
|
142
151
|
max-width: 480px;
|
|
143
152
|
margin-left: auto;
|
|
@@ -157,6 +166,10 @@ form > .form-field-label-skip {
|
|
|
157
166
|
grid-column: 1 / -1;
|
|
158
167
|
}
|
|
159
168
|
|
|
169
|
+
label > input[type="checkbox"] {
|
|
170
|
+
margin-right: 0.35em;
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
/*
|
|
161
174
|
* FormField applies .field-invalid when an error is set programmatically.
|
|
162
175
|
* Mirrors femtocrank :user-invalid (which only applies after user interaction).
|
|
@@ -167,7 +180,11 @@ select.field-invalid {
|
|
|
167
180
|
border-color: var(--karma-bad-border);
|
|
168
181
|
background-color: var(--karma-bad-wash);
|
|
169
182
|
color: var(--text-color);
|
|
170
|
-
box-shadow:
|
|
183
|
+
box-shadow: var(--field-invalid-outline);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.notification.attached-below {
|
|
187
|
+
margin: 0;
|
|
171
188
|
}
|
|
172
189
|
|
|
173
190
|
input.field-invalid:focus-visible,
|
|
@@ -184,6 +201,10 @@ select.field-invalid:focus-visible {
|
|
|
184
201
|
* (unlayered) so they are not overridden by stuck-mode defaults.
|
|
185
202
|
* Alternatively wrap with .layout-fixed-header when header and #layout are direct children.
|
|
186
203
|
*/
|
|
204
|
+
header {
|
|
205
|
+
flex-wrap: nowrap;
|
|
206
|
+
}
|
|
207
|
+
|
|
187
208
|
header.fixed {
|
|
188
209
|
top: 0;
|
|
189
210
|
left: 0;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { nextTick } from 'vue'
|
|
4
|
+
import Accordion from './Accordion.vue'
|
|
5
|
+
import AccordionItem from './AccordionItem.vue'
|
|
6
|
+
|
|
7
|
+
const globalStubs = {
|
|
8
|
+
HugeiconsIcon: true,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('Accordion', () => {
|
|
12
|
+
it('opens the default subsection and toggles the rest', async () => {
|
|
13
|
+
const wrapper = mount(Accordion, {
|
|
14
|
+
props: {
|
|
15
|
+
defaultOpenIds: ['details'],
|
|
16
|
+
},
|
|
17
|
+
slots: {
|
|
18
|
+
default: `
|
|
19
|
+
<AccordionItem item-id="details" title="Details"><p class="details">Open</p></AccordionItem>
|
|
20
|
+
<AccordionItem item-id="notes" title="Notes"><p class="notes">Closed</p></AccordionItem>
|
|
21
|
+
`,
|
|
22
|
+
},
|
|
23
|
+
global: {
|
|
24
|
+
stubs: globalStubs,
|
|
25
|
+
components: { AccordionItem },
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
await nextTick()
|
|
30
|
+
|
|
31
|
+
const details = wrapper.get('#accordion-item-details')
|
|
32
|
+
const notes = wrapper.get('#accordion-item-notes')
|
|
33
|
+
expect(details.attributes('open')).toBeDefined()
|
|
34
|
+
expect(notes.attributes('open')).toBeUndefined()
|
|
35
|
+
|
|
36
|
+
notes.element.open = true
|
|
37
|
+
await notes.trigger('toggle')
|
|
38
|
+
await nextTick()
|
|
39
|
+
|
|
40
|
+
expect(wrapper.get('#accordion-item-notes').attributes('open')).toBeDefined()
|
|
41
|
+
|
|
42
|
+
details.element.open = false
|
|
43
|
+
await details.trigger('toggle')
|
|
44
|
+
await nextTick()
|
|
45
|
+
|
|
46
|
+
expect(wrapper.get('#accordion-item-details').attributes('open')).toBeUndefined()
|
|
47
|
+
})
|
|
48
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="accordion">
|
|
3
|
+
<slot />
|
|
4
|
+
</div>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup>
|
|
8
|
+
import { provide, ref, watch } from 'vue'
|
|
9
|
+
import { accordionContextKey } from './accordionContext.js'
|
|
10
|
+
|
|
11
|
+
const props = defineProps({
|
|
12
|
+
/** Panel ids that start expanded. */
|
|
13
|
+
defaultOpenIds: {
|
|
14
|
+
type: Array,
|
|
15
|
+
default: () => [],
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const openIds = ref(new Set())
|
|
20
|
+
|
|
21
|
+
function syncDefaultOpen() {
|
|
22
|
+
openIds.value = new Set(props.defaultOpenIds.filter(Boolean))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
watch(() => props.defaultOpenIds, syncDefaultOpen, { immediate: true, deep: true })
|
|
26
|
+
|
|
27
|
+
function isOpen(id) {
|
|
28
|
+
return openIds.value.has(id)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setOpen(id, isOpenNow) {
|
|
32
|
+
const currently = openIds.value.has(id)
|
|
33
|
+
if (currently === isOpenNow) {
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
const next = new Set(openIds.value)
|
|
37
|
+
if (isOpenNow) {
|
|
38
|
+
next.add(id)
|
|
39
|
+
} else {
|
|
40
|
+
next.delete(id)
|
|
41
|
+
}
|
|
42
|
+
openIds.value = next
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
provide(accordionContextKey, { isOpen, setOpen })
|
|
46
|
+
</script>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<details
|
|
3
|
+
:id="`accordion-item-${itemId}`"
|
|
4
|
+
class="accordion-item"
|
|
5
|
+
:open="open"
|
|
6
|
+
@toggle="onToggle"
|
|
7
|
+
>
|
|
8
|
+
<summary
|
|
9
|
+
class="section-subheader accordion-trigger flex-row"
|
|
10
|
+
:id="`accordion-trigger-${itemId}`"
|
|
11
|
+
>
|
|
12
|
+
<HugeiconsIcon
|
|
13
|
+
class="accordion-chevron"
|
|
14
|
+
:icon="ArrowDown01Icon"
|
|
15
|
+
width="1em"
|
|
16
|
+
height="1em"
|
|
17
|
+
aria-hidden="true"
|
|
18
|
+
/>
|
|
19
|
+
<h3>{{ title }}</h3>
|
|
20
|
+
</summary>
|
|
21
|
+
<div
|
|
22
|
+
:id="`accordion-panel-${itemId}`"
|
|
23
|
+
class="section-content padding"
|
|
24
|
+
>
|
|
25
|
+
<slot />
|
|
26
|
+
</div>
|
|
27
|
+
</details>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<script setup>
|
|
31
|
+
import { computed, inject } from 'vue'
|
|
32
|
+
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
33
|
+
import { ArrowDown01Icon } from '@hugeicons/core-free-icons'
|
|
34
|
+
import { accordionContextKey } from './accordionContext.js'
|
|
35
|
+
|
|
36
|
+
const props = defineProps({
|
|
37
|
+
itemId: {
|
|
38
|
+
type: String,
|
|
39
|
+
required: true,
|
|
40
|
+
},
|
|
41
|
+
title: {
|
|
42
|
+
type: String,
|
|
43
|
+
required: true,
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const accordion = inject(accordionContextKey, null)
|
|
48
|
+
|
|
49
|
+
const open = computed(() => accordion?.isOpen(props.itemId) ?? false)
|
|
50
|
+
|
|
51
|
+
function onToggle(event) {
|
|
52
|
+
accordion?.setOpen(props.itemId, event.currentTarget.open)
|
|
53
|
+
}
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<style scoped>
|
|
57
|
+
.accordion-trigger {
|
|
58
|
+
width: 100%;
|
|
59
|
+
margin: 0;
|
|
60
|
+
padding-right: 1em;
|
|
61
|
+
border: none;
|
|
62
|
+
background: none;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
font: inherit;
|
|
65
|
+
color: inherit;
|
|
66
|
+
text-align: left;
|
|
67
|
+
list-style: none;
|
|
68
|
+
gap: 0.35rem;
|
|
69
|
+
box-sizing: border-box;
|
|
70
|
+
transition: background-color 0.15s ease, color 0.15s ease;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.accordion-trigger:hover {
|
|
74
|
+
background-color: var(--hover-background-color);
|
|
75
|
+
color: var(--hover-text-color, inherit);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.accordion-trigger:focus-visible {
|
|
79
|
+
outline: 2px solid var(--focus-outline-color, var(--text-color));
|
|
80
|
+
outline-offset: -2px;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.accordion-trigger::-webkit-details-marker {
|
|
84
|
+
display: none;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.accordion-trigger::marker {
|
|
88
|
+
content: '';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.accordion-trigger h3 {
|
|
92
|
+
margin: 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.accordion-chevron {
|
|
96
|
+
flex-shrink: 0;
|
|
97
|
+
transition: transform 0.15s ease;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.accordion-item[open] .accordion-chevron {
|
|
101
|
+
transform: rotate(180deg);
|
|
102
|
+
}
|
|
103
|
+
</style>
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mount } from '@vue/test-utils'
|
|
3
|
+
import { nextTick } from 'vue'
|
|
4
|
+
import FieldValue from './FieldValue.vue'
|
|
5
|
+
|
|
6
|
+
const globalStubs = {
|
|
7
|
+
HugeiconsIcon: true,
|
|
8
|
+
RouterLink: {
|
|
9
|
+
props: ['to'],
|
|
10
|
+
template: '<a class="outline-link" :href="typeof to === \'string\' ? to : \'#\'"><slot /></a>',
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('FieldValue', () => {
|
|
15
|
+
it('shows a read-only icon when no edit template or outline target is provided', () => {
|
|
16
|
+
const wrapper = mount(FieldValue, {
|
|
17
|
+
props: { modelValue: 'Yesterday' },
|
|
18
|
+
global: { stubs: globalStubs },
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
expect(wrapper.text()).toContain('Yesterday')
|
|
22
|
+
expect(wrapper.find('[aria-label="Read only"]').exists()).toBe(true)
|
|
23
|
+
expect(wrapper.find('.field-value-outline').exists()).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('shows an inline-edit icon and the edit template after click', async () => {
|
|
27
|
+
const wrapper = mount(FieldValue, {
|
|
28
|
+
props: { modelValue: 'Ada' },
|
|
29
|
+
slots: {
|
|
30
|
+
edit: '<input class="edit-box" :value="value" />',
|
|
31
|
+
},
|
|
32
|
+
global: { stubs: globalStubs },
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
expect(wrapper.find('[aria-label="Read only"]').exists()).toBe(false)
|
|
36
|
+
expect(wrapper.find('.field-value-display.editable').exists()).toBe(true)
|
|
37
|
+
|
|
38
|
+
await wrapper.find('.field-value-display').trigger('click')
|
|
39
|
+
await nextTick()
|
|
40
|
+
|
|
41
|
+
expect(wrapper.find('.edit-box').exists()).toBe(true)
|
|
42
|
+
expect(wrapper.find('.edit-box').element.value).toBe('Ada')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('commits the edited value from the template', async () => {
|
|
46
|
+
const wrapper = mount(FieldValue, {
|
|
47
|
+
props: { modelValue: 'Ada' },
|
|
48
|
+
slots: {
|
|
49
|
+
edit: `
|
|
50
|
+
<input
|
|
51
|
+
class="edit-box"
|
|
52
|
+
:value="value"
|
|
53
|
+
@input="update($event.target.value)"
|
|
54
|
+
@keyup.enter="commit"
|
|
55
|
+
/>
|
|
56
|
+
`,
|
|
57
|
+
},
|
|
58
|
+
global: { stubs: globalStubs },
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
await wrapper.find('.field-value-display').trigger('click')
|
|
62
|
+
await nextTick()
|
|
63
|
+
const input = wrapper.find('.edit-box')
|
|
64
|
+
await input.setValue('Grace')
|
|
65
|
+
await input.trigger('keyup.enter')
|
|
66
|
+
|
|
67
|
+
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['Grace'])
|
|
68
|
+
expect(wrapper.emitted('commit')?.[0]).toEqual(['Grace'])
|
|
69
|
+
expect(wrapper.text()).toContain('Ada')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('shows an outline-edit link when a route is provided and no edit template', () => {
|
|
73
|
+
const wrapper = mount(FieldValue, {
|
|
74
|
+
props: {
|
|
75
|
+
modelValue: 'Website',
|
|
76
|
+
to: '/customers/1/edit',
|
|
77
|
+
editLabel: 'Edit customer',
|
|
78
|
+
},
|
|
79
|
+
global: { stubs: globalStubs },
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const link = wrapper.find('.field-value-outline')
|
|
83
|
+
expect(link.exists()).toBe(true)
|
|
84
|
+
expect(link.attributes('aria-label')).toBe('Edit customer')
|
|
85
|
+
expect(link.attributes('href')).toBe('/customers/1/edit')
|
|
86
|
+
expect(wrapper.find('[aria-label="Read only"]').exists()).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="field-value"
|
|
4
|
+
:class="{
|
|
5
|
+
'is-inline': hasInlineEdit,
|
|
6
|
+
'is-outline': hasOutlineEdit,
|
|
7
|
+
'is-readonly': readOnly,
|
|
8
|
+
}"
|
|
9
|
+
>
|
|
10
|
+
<div
|
|
11
|
+
v-if="editing"
|
|
12
|
+
ref="editRoot"
|
|
13
|
+
class="field-value-edit"
|
|
14
|
+
@click.stop
|
|
15
|
+
>
|
|
16
|
+
<slot
|
|
17
|
+
name="edit"
|
|
18
|
+
:value="draft"
|
|
19
|
+
:update="updateDraft"
|
|
20
|
+
:commit="commit"
|
|
21
|
+
:cancel="cancel"
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
<div
|
|
25
|
+
v-else
|
|
26
|
+
class="field-value-display"
|
|
27
|
+
:class="{ editable: hasInlineEdit }"
|
|
28
|
+
@click="startEdit"
|
|
29
|
+
>
|
|
30
|
+
<span class="field-value-content">
|
|
31
|
+
<slot>{{ displayText }}</slot>
|
|
32
|
+
</span>
|
|
33
|
+
<HugeiconsIcon
|
|
34
|
+
v-if="hasInlineEdit"
|
|
35
|
+
:icon="Edit03Icon"
|
|
36
|
+
class="field-value-icon"
|
|
37
|
+
width="0.9em"
|
|
38
|
+
height="0.9em"
|
|
39
|
+
aria-hidden="true"
|
|
40
|
+
/>
|
|
41
|
+
<router-link
|
|
42
|
+
v-else-if="to"
|
|
43
|
+
:to="to"
|
|
44
|
+
class="field-value-icon field-value-outline"
|
|
45
|
+
:title="editLabel"
|
|
46
|
+
:aria-label="editLabel"
|
|
47
|
+
@click.stop
|
|
48
|
+
>
|
|
49
|
+
<HugeiconsIcon
|
|
50
|
+
:icon="FileEditIcon"
|
|
51
|
+
width="0.9em"
|
|
52
|
+
height="0.9em"
|
|
53
|
+
aria-hidden="true"
|
|
54
|
+
/>
|
|
55
|
+
</router-link>
|
|
56
|
+
<a
|
|
57
|
+
v-else-if="href"
|
|
58
|
+
:href="href"
|
|
59
|
+
class="field-value-icon field-value-outline"
|
|
60
|
+
:title="editLabel"
|
|
61
|
+
:aria-label="editLabel"
|
|
62
|
+
@click.stop
|
|
63
|
+
>
|
|
64
|
+
<HugeiconsIcon
|
|
65
|
+
:icon="FileEditIcon"
|
|
66
|
+
width="0.9em"
|
|
67
|
+
height="0.9em"
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
/>
|
|
70
|
+
</a>
|
|
71
|
+
<span
|
|
72
|
+
v-else
|
|
73
|
+
class="field-value-icon field-value-readonly"
|
|
74
|
+
title="Read only"
|
|
75
|
+
aria-label="Read only"
|
|
76
|
+
>
|
|
77
|
+
<HugeiconsIcon
|
|
78
|
+
:icon="SquareLock01Icon"
|
|
79
|
+
width="0.9em"
|
|
80
|
+
height="0.9em"
|
|
81
|
+
aria-hidden="true"
|
|
82
|
+
/>
|
|
83
|
+
</span>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<script setup>
|
|
89
|
+
import { computed, nextTick, ref, useSlots } from 'vue'
|
|
90
|
+
import { HugeiconsIcon } from '@hugeicons/vue'
|
|
91
|
+
import { Edit03Icon, FileEditIcon, SquareLock01Icon } from '@hugeicons/core-free-icons'
|
|
92
|
+
|
|
93
|
+
const props = defineProps({
|
|
94
|
+
modelValue: {
|
|
95
|
+
type: [String, Number, Boolean],
|
|
96
|
+
default: '',
|
|
97
|
+
},
|
|
98
|
+
/**
|
|
99
|
+
* Route for outline edit. Shown when no edit template is provided.
|
|
100
|
+
*/
|
|
101
|
+
to: {
|
|
102
|
+
type: [String, Object],
|
|
103
|
+
default: null,
|
|
104
|
+
},
|
|
105
|
+
/**
|
|
106
|
+
* URL for outline edit when a router location is not used.
|
|
107
|
+
*/
|
|
108
|
+
href: {
|
|
109
|
+
type: String,
|
|
110
|
+
default: '',
|
|
111
|
+
},
|
|
112
|
+
editLabel: {
|
|
113
|
+
type: String,
|
|
114
|
+
default: 'Edit',
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
const emit = defineEmits(['update:modelValue', 'commit', 'cancel'])
|
|
119
|
+
|
|
120
|
+
const slots = useSlots()
|
|
121
|
+
const editing = ref(false)
|
|
122
|
+
const draft = ref('')
|
|
123
|
+
const editRoot = ref(null)
|
|
124
|
+
|
|
125
|
+
const hasInlineEdit = computed(() => typeof slots.edit === 'function')
|
|
126
|
+
const hasOutlineEdit = computed(() => !hasInlineEdit.value && Boolean(props.to || props.href))
|
|
127
|
+
const readOnly = computed(() => !hasInlineEdit.value && !hasOutlineEdit.value)
|
|
128
|
+
const displayText = computed(() => (props.modelValue == null ? '' : String(props.modelValue)))
|
|
129
|
+
|
|
130
|
+
function startEdit() {
|
|
131
|
+
if (!hasInlineEdit.value || editing.value) {
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
draft.value = displayText.value
|
|
135
|
+
editing.value = true
|
|
136
|
+
nextTick(() => {
|
|
137
|
+
const control = editRoot.value?.querySelector('input, select, textarea')
|
|
138
|
+
if (!control) {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
control.focus()
|
|
142
|
+
if (control instanceof HTMLInputElement && control.type !== 'checkbox') {
|
|
143
|
+
control.select()
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function updateDraft(value) {
|
|
149
|
+
draft.value = value == null ? '' : String(value)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function commit() {
|
|
153
|
+
if (!editing.value) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
emit('update:modelValue', draft.value)
|
|
157
|
+
emit('commit', draft.value)
|
|
158
|
+
editing.value = false
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function cancel() {
|
|
162
|
+
if (!editing.value) {
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
editing.value = false
|
|
166
|
+
draft.value = displayText.value
|
|
167
|
+
emit('cancel')
|
|
168
|
+
}
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
<style scoped>
|
|
172
|
+
.field-value-display,
|
|
173
|
+
.field-value-edit {
|
|
174
|
+
display: inline-flex;
|
|
175
|
+
align-items: center;
|
|
176
|
+
gap: 0.35rem;
|
|
177
|
+
min-height: 1.5em;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.field-value-display.editable {
|
|
181
|
+
cursor: pointer;
|
|
182
|
+
padding: 0.25rem;
|
|
183
|
+
border-radius: 3px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.field-value-display.editable:hover {
|
|
187
|
+
background-color: var(--hover-background-color, #f8f9fa);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.field-value-content {
|
|
191
|
+
min-width: 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.field-value-icon {
|
|
195
|
+
flex-shrink: 0;
|
|
196
|
+
color: var(--muted-text-color, #8a8a8a);
|
|
197
|
+
opacity: 0.55;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.field-value-outline,
|
|
201
|
+
.field-value-readonly {
|
|
202
|
+
display: inline-flex;
|
|
203
|
+
line-height: 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.field-value-outline {
|
|
207
|
+
text-decoration: none;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.field-value-display.editable:hover .field-value-icon,
|
|
211
|
+
.field-value-outline:hover {
|
|
212
|
+
opacity: 0.9;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
.field-value-edit :deep(input),
|
|
216
|
+
.field-value-edit :deep(select),
|
|
217
|
+
.field-value-edit :deep(textarea) {
|
|
218
|
+
width: 100%;
|
|
219
|
+
max-width: 28rem;
|
|
220
|
+
box-sizing: border-box;
|
|
221
|
+
}
|
|
222
|
+
</style>
|
|
@@ -124,6 +124,7 @@ describe('FormField', () => {
|
|
|
124
124
|
|
|
125
125
|
const errorBlock = wrapper.find('.form-field-error')
|
|
126
126
|
expect(errorBlock.exists()).toBe(true)
|
|
127
|
+
expect(errorBlock.classes()).toContain('attached-below')
|
|
127
128
|
expect(errorBlock.attributes('role')).toBe('alert')
|
|
128
129
|
expect(errorBlock.text()).toContain('This field is required.')
|
|
129
130
|
})
|