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.
Files changed (35) hide show
  1. package/docs/filter-query-v1.md +5 -1
  2. package/package.json +1 -1
  3. package/router.js +33 -0
  4. package/styles/picocrank-extensions.css +23 -2
  5. package/vue/components/Accordion.spec.js +48 -0
  6. package/vue/components/Accordion.vue +46 -0
  7. package/vue/components/AccordionItem.vue +103 -0
  8. package/vue/components/FieldValue.spec.js +88 -0
  9. package/vue/components/FieldValue.vue +222 -0
  10. package/vue/components/FormField.spec.js +1 -0
  11. package/vue/components/FormField.vue +38 -10
  12. package/vue/components/Header.vue +24 -2
  13. package/vue/components/NotificationBlock.vue +25 -0
  14. package/vue/components/ReorderList.spec.js +318 -0
  15. package/vue/components/ReorderList.vue +614 -0
  16. package/vue/components/Table.spec.js +27 -0
  17. package/vue/components/Table.vue +458 -112
  18. package/vue/components/TableColumnFilterPopover.vue +63 -19
  19. package/vue/components/TableColumnOptionsPopover.vue +248 -273
  20. package/vue/components/TopBar.vue +6 -2
  21. package/vue/components/accordionContext.js +1 -0
  22. package/vue/composables/tableColumnPresets.js +33 -0
  23. package/vue/composables/tableGroupBy.js +173 -0
  24. package/vue/composables/tableGroupBy.spec.js +69 -0
  25. package/vue/composables/tableViewSettings.js +34 -0
  26. package/vue/composables/tableViewSettings.spec.js +21 -0
  27. package/vue/composables/useHeaderTopBarFit.js +91 -0
  28. package/vue/composables/useResponsiveNav.js +5 -3
  29. package/vue/data/navSectionHubs.js +16 -1
  30. package/vue/examples/AccordionExample.vue +33 -0
  31. package/vue/examples/FieldValueExample.vue +143 -0
  32. package/vue/examples/FormExample.vue +11 -6
  33. package/vue/examples/ReorderListExample.vue +236 -0
  34. package/vue/examples/TableExample.vue +5 -1
  35. 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "picocrank",
3
- "version": "1.33.0",
3
+ "version": "1.34.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>
@@ -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
  })