picocrank 1.32.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/docs/table.md +99 -0
- 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 +490 -1
- package/vue/components/Table.vue +1038 -111
- 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/tableFilters.js +6 -1
- 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/docs/table.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Table
|
|
2
|
+
|
|
3
|
+
`Table` supports local data and remote `fetchRows` data. Existing usages remain
|
|
4
|
+
uncontrolled; pass any query-state prop to control that field independently.
|
|
5
|
+
|
|
6
|
+
## Query state
|
|
7
|
+
|
|
8
|
+
```vue
|
|
9
|
+
<Table
|
|
10
|
+
v-model:sort-by="sortBy"
|
|
11
|
+
v-model:sort-dir="sortDir"
|
|
12
|
+
v-model:page="page"
|
|
13
|
+
v-model:page-size="pageSize"
|
|
14
|
+
/>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
`sortBy`, `sortDir`, `page`, and `pageSize` default to internal state when
|
|
18
|
+
omitted. Controlled values may be changed after mount. User actions emit the
|
|
19
|
+
corresponding `update:*` event. Sorting resets the page by default; set
|
|
20
|
+
`:reset-page-on-sort="false"` to preserve the current page. Page-size, data, and
|
|
21
|
+
filter changes continue to reset to page 1.
|
|
22
|
+
|
|
23
|
+
A sortable header can provide a value comparator:
|
|
24
|
+
|
|
25
|
+
```js
|
|
26
|
+
{
|
|
27
|
+
key: 'version',
|
|
28
|
+
sortable: true,
|
|
29
|
+
comparator: (left, right, leftRow, rightRow) =>
|
|
30
|
+
left.localeCompare(right, undefined, { numeric: true }),
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The comparator defines ascending order and receives non-null cell values plus
|
|
35
|
+
their rows. `Table` reverses its result for descending order and keeps null
|
|
36
|
+
values last. `sortRows(rows, sortBy, sortDir, comparator)` accepts the same
|
|
37
|
+
optional comparator.
|
|
38
|
+
|
|
39
|
+
## Cells and display hooks
|
|
40
|
+
|
|
41
|
+
Named `#cell-{key}` slots and the generic `#cell` slot receive:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
{ row, value, header, rowIndex, sourceIndex }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`rowIndex` is the index on the displayed page. For local data, `sourceIndex` is
|
|
48
|
+
the index in `data`; for remote data it is the index in the current response.
|
|
49
|
+
|
|
50
|
+
The `rowClass` and `rowStyle` callbacks receive
|
|
51
|
+
`{ row, rowIndex, sourceIndex }`. `cellClass`, `cellStyle`, and `cellAttrs`
|
|
52
|
+
receive that context plus `{ value, header }`. Return Vue-compatible class or
|
|
53
|
+
style values. `cellAttrs` is intended for attributes such as `title`, `aria-*`,
|
|
54
|
+
and `data-*`; use the dedicated class/style callbacks for those fields.
|
|
55
|
+
Header `class` continues to apply to both its header and body cells.
|
|
56
|
+
|
|
57
|
+
## Selection and active cells
|
|
58
|
+
|
|
59
|
+
`selectionClickMode` controls selectable-row clicks:
|
|
60
|
+
|
|
61
|
+
- `row` (default): row clicks toggle selection; Ctrl/Cmd also toggles and Shift
|
|
62
|
+
selects a range on the displayed page.
|
|
63
|
+
- `checkbox`: only checkboxes change selection; row clicks emit `row-click`.
|
|
64
|
+
- `modifier`: Ctrl/Cmd toggles, Shift selects a displayed-page range, and an
|
|
65
|
+
unmodified click emits `row-click`.
|
|
66
|
+
- `extended`: unmodified clicks replace the selection, Ctrl/Cmd toggles, and
|
|
67
|
+
Shift adds a range on the displayed page. This matches desktop-style
|
|
68
|
+
selection while leaving the default behavior unchanged.
|
|
69
|
+
|
|
70
|
+
The additive `row-click` payload is `{ row, index, event }`, where `event` is
|
|
71
|
+
the native click event.
|
|
72
|
+
|
|
73
|
+
`activeCell` is an optional controlled `{ rowKey, columnKey }` value:
|
|
74
|
+
|
|
75
|
+
```vue
|
|
76
|
+
<Table
|
|
77
|
+
v-model:active-cell="activeCell"
|
|
78
|
+
@cell-click="onCellClick"
|
|
79
|
+
/>
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Clicking a data cell emits `cell-click` with the enriched cell context and adds
|
|
83
|
+
the visibly outlined `table-active-cell` class. The component ref exposes:
|
|
84
|
+
|
|
85
|
+
- `clearFilters()`
|
|
86
|
+
- `focusActiveCell(options?)` (returns whether a displayed active cell was focused)
|
|
87
|
+
- `clearActiveCell()`
|
|
88
|
+
- `activateCellAt(rowIndex, columnIndex, options?)`
|
|
89
|
+
- `moveActiveCell(rowDelta, columnDelta, options?)`
|
|
90
|
+
- `getActiveCellContext()`
|
|
91
|
+
- the existing `loadDeveloperDefaults()` and `layoutLabel`
|
|
92
|
+
|
|
93
|
+
Cell indices use `displayRows` and `visibleHeaders` and are clamped to the
|
|
94
|
+
displayed grid. Pass `{ wrapColumns: true }` to `moveActiveCell` to move from
|
|
95
|
+
the last column of one row to the first column of the next (or in reverse),
|
|
96
|
+
which is suitable for Tab/Shift+Tab. Pass `{ focus: true, focusOptions? }` to
|
|
97
|
+
activation or movement methods to focus the destination after Vue renders.
|
|
98
|
+
`getActiveCellContext()` returns the cell slot context plus `rowKey`,
|
|
99
|
+
`columnKey`, and `columnIndex`, or `null` when the active cell is not displayed.
|
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
|
+
})
|