picocrank 1.33.0 → 1.35.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 (36) hide show
  1. package/docs/filter-query-v1.md +5 -1
  2. package/docs/table.md +21 -2
  3. package/package.json +1 -1
  4. package/router.js +33 -0
  5. package/styles/picocrank-extensions.css +23 -2
  6. package/vue/components/Accordion.spec.js +48 -0
  7. package/vue/components/Accordion.vue +46 -0
  8. package/vue/components/AccordionItem.vue +103 -0
  9. package/vue/components/FieldValue.spec.js +88 -0
  10. package/vue/components/FieldValue.vue +222 -0
  11. package/vue/components/FormField.spec.js +1 -0
  12. package/vue/components/FormField.vue +38 -10
  13. package/vue/components/Header.vue +24 -2
  14. package/vue/components/NotificationBlock.vue +25 -0
  15. package/vue/components/ReorderList.spec.js +318 -0
  16. package/vue/components/ReorderList.vue +614 -0
  17. package/vue/components/Table.spec.js +193 -0
  18. package/vue/components/Table.vue +821 -115
  19. package/vue/components/TableColumnFilterPopover.vue +63 -19
  20. package/vue/components/TableColumnOptionsPopover.vue +248 -273
  21. package/vue/components/TopBar.vue +6 -2
  22. package/vue/components/accordionContext.js +1 -0
  23. package/vue/composables/tableColumnPresets.js +33 -0
  24. package/vue/composables/tableGroupBy.js +173 -0
  25. package/vue/composables/tableGroupBy.spec.js +69 -0
  26. package/vue/composables/tableViewSettings.js +34 -0
  27. package/vue/composables/tableViewSettings.spec.js +21 -0
  28. package/vue/composables/useHeaderTopBarFit.js +91 -0
  29. package/vue/composables/useResponsiveNav.js +5 -3
  30. package/vue/data/navSectionHubs.js +16 -1
  31. package/vue/examples/AccordionExample.vue +33 -0
  32. package/vue/examples/FieldValueExample.vue +143 -0
  33. package/vue/examples/FormExample.vue +11 -6
  34. package/vue/examples/ReorderListExample.vue +236 -0
  35. package/vue/examples/TableExample.vue +5 -1
  36. package/vue/examples/TableRemoteExample.vue +28 -4
@@ -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 (localStorage or `layoutPresets` callbacks) store **`filters`** (UI storage shape) inside `state`, not `filterQuery`. When a preset is applied, the table rebuilds `filterQuery` automatically. Server-side layout persistence can store either:
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 CHANGED
@@ -36,6 +36,16 @@ their rows. `Table` reverses its result for descending order and keeps null
36
36
  values last. `sortRows(rows, sortBy, sortDir, comparator)` accepts the same
37
37
  optional comparator.
38
38
 
39
+ ## Column resize
40
+
41
+ Hover a data-column header to reveal a drag handle on its right edge. Drag to
42
+ set a pixel width; the first drag freezes sibling column widths so only the
43
+ active column changes. Double-click the handle to clear that column’s width and
44
+ return it to browser auto sizing. Set `resizable: false` on a header to hide its
45
+ handle. Initial widths still come from optional `header.width`. While any
46
+ column has a resized width, overflowing header and cell content is clipped with
47
+ ellipsis.
48
+
39
49
  ## Cells and display hooks
40
50
 
41
51
  Named `#cell-{key}` slots and the generic `#cell` slot receive:
@@ -79,8 +89,17 @@ the native click event.
79
89
  />
80
90
  ```
81
91
 
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:
92
+ Clicking a data cell emits `cell-click` with the enriched cell context, focuses
93
+ the cell when the click target is not interactive, and adds the visibly outlined
94
+ `table-active-cell` class. While an active cell is focused, arrow keys move
95
+ selection within the displayed grid (with focus following).
96
+
97
+ Sortable headers expose `aria-sort` (`none` / `ascending` / `descending`).
98
+ Filterable headers use `aria-haspopup="dialog"` and `aria-expanded`. The table
99
+ sets `aria-busy` while loading and `aria-colcount` for the visible column count
100
+ (including select and column-options columns when present).
101
+
102
+ The component ref exposes:
84
103
 
85
104
  - `clearFilters()`
86
105
  - `focusActiveCell(options?)` (returns whether a displayed active cell was focused)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.33.0",
3
+ "version": "1.35.0",
4
4
  "type": "module",
5
5
  "description": "A set of zero dependency Vue components built on the Femtocrank global CSS theme.",
6
6
  "main": "vite.config.js",
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
- section .section-header [role="toolbar"] > :not(button):not(.button) {
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: 0 0 0 3px color-mix(in srgb, var(--karma-bad) 35%, transparent);
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>