@globalbrain/sefirot 4.59.1 → 4.61.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/lib/blocks/lens/FieldData.ts +10 -0
- package/lib/blocks/lens/components/LensCatalog.vue +60 -23
- package/lib/blocks/lens/components/LensSheet.vue +6 -0
- package/lib/blocks/lens/components/LensSheetField.vue +261 -31
- package/lib/blocks/lens/components/LensTable.vue +4 -3
- package/lib/blocks/lens/components/LensTableAvatarCell.vue +2 -1
- package/lib/blocks/lens/components/LensTableEditableCell.vue +2 -1
- package/lib/blocks/lens/composables/LensInlineEdit.ts +6 -4
- package/lib/blocks/lens/fields/AvatarField.ts +0 -4
- package/lib/blocks/lens/fields/DateField.ts +1 -1
- package/lib/blocks/lens/fields/Field.ts +24 -6
- package/lib/blocks/lens/fields/FileUploadField.ts +1 -5
- package/lib/blocks/lens/fields/LinkField.ts +1 -1
- package/lib/blocks/lens/fields/SelectField.ts +2 -6
- package/lib/blocks/lens/fields/TextField.ts +1 -1
- package/lib/blocks/lens/fields/TextareaField.ts +1 -1
- package/lib/components/SButton.vue +8 -2
- package/lib/components/SDataListItem.vue +51 -2
- package/lib/components/SDropdownSectionFilter.vue +28 -5
- package/lib/components/SInputAsyncDropdown.vue +63 -8
- package/lib/components/SInputDropdown.vue +57 -4
- package/lib/composables/Utils.ts +48 -8
- package/lib/support/Dom.ts +72 -2
- package/package.json +9 -8
|
@@ -61,6 +61,16 @@ export interface FieldDataBase {
|
|
|
61
61
|
showOnDetail?: boolean
|
|
62
62
|
showOnCreate?: boolean
|
|
63
63
|
showOnUpdate?: boolean
|
|
64
|
+
/**
|
|
65
|
+
* The server-declared blank for the field's input, as a wire-format
|
|
66
|
+
* payload value — the server owns this entirely: types whose blank isn't
|
|
67
|
+
* null (a multiple select's `[]`) declare it here, and an entity may set
|
|
68
|
+
* its own (e.g. `'draft'` for a status select). Feeds `inputEmptyValue`:
|
|
69
|
+
* the create form seeds from it, and an editor opening on a null stored
|
|
70
|
+
* value falls back to it. Null or absent (backends predating the key)
|
|
71
|
+
* means a null blank.
|
|
72
|
+
*/
|
|
73
|
+
emptyValue?: any
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
export interface AvatarFieldData extends FieldDataBase {
|
|
@@ -74,6 +74,11 @@ export interface Props {
|
|
|
74
74
|
// normally. The value remains accessible via `record[indexField]` in
|
|
75
75
|
// `cell-clicked` events and through the `selected` model regardless.
|
|
76
76
|
//
|
|
77
|
+
// When omitted, the identifier defaults to `id`, and the `selected`
|
|
78
|
+
// model is keyed by it on any catalog — editable or not — whenever the
|
|
79
|
+
// loaded rows carry it (server-default selects typically do). Only rows
|
|
80
|
+
// without the identifier fall back to positional selection keys.
|
|
81
|
+
//
|
|
77
82
|
// If the caller's `select` is empty or `null` (i.e. "use server
|
|
78
83
|
// defaults"), the index field is **not** auto-appended — that would
|
|
79
84
|
// narrow the result to just that single column on backends that treat
|
|
@@ -584,33 +589,59 @@ const tableSelect = computed(() => {
|
|
|
584
589
|
const idField = computed(() => props.indexField ?? 'id')
|
|
585
590
|
|
|
586
591
|
// Whether the currently loaded rows actually carry the effective row identifier.
|
|
587
|
-
// Editing / opening a row needs it (`resolveId`),
|
|
588
|
-
//
|
|
589
|
-
//
|
|
590
|
-
//
|
|
591
|
-
//
|
|
592
|
+
// Editing / opening a row needs it (`resolveId`), and the table's selection key
|
|
593
|
+
// only switches onto it when present (`tableIndexField` below). Rows fetched
|
|
594
|
+
// while `editable` was still false — or before an `indexField` change settled —
|
|
595
|
+
// won't have it until the triggered refetch lands. Editing is gated on this (see
|
|
596
|
+
// the `editable` getter) so a quick save in that window can't post
|
|
597
|
+
// `id: undefined`. An empty result has nothing to edit or select, so it
|
|
598
|
+
// doesn't block.
|
|
592
599
|
const rowsCarryIndexField = computed(() => {
|
|
593
600
|
const rows = result.value?.data
|
|
594
601
|
if (!rows || rows.length === 0) { return true }
|
|
595
602
|
return idField.value in rows[0]
|
|
596
603
|
})
|
|
597
604
|
|
|
598
|
-
// The row-identifier the table uses as its selection key
|
|
599
|
-
// `
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
605
|
+
// The row-identifier the table uses as its selection key: the effective
|
|
606
|
+
// `idField`, on any catalog whose loaded rows carry it. Without this, a catalog
|
|
607
|
+
// relying on the default `id` would leave the table on positional selection
|
|
608
|
+
// keys, so anything that shifts rows — an optimistic create/delete, a page
|
|
609
|
+
// change, a re-sort, a refresh-banner apply — would leave `selected` pointing
|
|
610
|
+
// at the wrong records. Editable or not: a read-only catalog with
|
|
611
|
+
// `v-model:selected` (a picker dialog, say) needs identity keys just as much,
|
|
612
|
+
// and a server-default select typically already carries `id`. Mirrors
|
|
613
|
+
// `edit.indexField`.
|
|
603
614
|
//
|
|
604
|
-
// Gated on `rowsCarryIndexField`:
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
615
|
+
// Gated on `rowsCarryIndexField`: while the loaded rows don't carry the key —
|
|
616
|
+
// an explicit `select` that omits it, or an `indexField` change (on an editable
|
|
617
|
+
// catalog the triggered refetch below brings the new key in; a read-only
|
|
618
|
+
// catalog stays positional until a fetch happens to include it) — switching
|
|
619
|
+
// STable onto it would key every row's selection to `undefined`, wiping the
|
|
620
|
+
// current selection and making any row-select emit `[undefined]` (so parent
|
|
621
|
+
// bulk actions act on no/wrong records). Stay on the positional key until the
|
|
622
|
+
// loaded rows carry it.
|
|
611
623
|
const tableIndexField = computed(() => {
|
|
612
|
-
|
|
613
|
-
|
|
624
|
+
return rowsCarryIndexField.value ? idField.value : undefined
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
// `selected` keys live in `tableIndexField`'s domain — record identity vs row
|
|
628
|
+
// position. If that domain changes while rows are on screen (a view change
|
|
629
|
+
// dropping the identifier column, one re-adding it, a runtime `indexField`
|
|
630
|
+
// swap), the held keys mean nothing under the new domain — but STable's
|
|
631
|
+
// selection pruning keeps any that happen to *collide* with it (a numeric id
|
|
632
|
+
// surviving as a row position, or vice versa), silently re-pointing the
|
|
633
|
+
// selection at wrong rows for the parent's bulk actions. Clear it instead.
|
|
634
|
+
//
|
|
635
|
+
// A flip settling on a fetch that follows an empty table (`hadRows` false —
|
|
636
|
+
// first load, or a filter state with no matches) doesn't count: nothing was
|
|
637
|
+
// selectable under the previous domain, so anything already in `selected` is a
|
|
638
|
+
// parent-provided seed targeting the domain being settled, and must survive it.
|
|
639
|
+
let hadRows = false
|
|
640
|
+
watch([tableIndexField, () => result.value?.data], ([field], [prevField]) => {
|
|
641
|
+
if (field !== prevField && hadRows && selected.value?.length) {
|
|
642
|
+
selected.value = []
|
|
643
|
+
}
|
|
644
|
+
hadRows = (result.value?.data?.length ?? 0) > 0
|
|
614
645
|
})
|
|
615
646
|
|
|
616
647
|
// The `indexField` is appended to the request `select` so the server
|
|
@@ -622,10 +653,16 @@ const tableIndexField = computed(() => {
|
|
|
622
653
|
//
|
|
623
654
|
// Editable catalogs must carry a row identifier so updates / deletes can
|
|
624
655
|
// address each row; when no `indexField` is configured the identifier
|
|
625
|
-
// defaults to `id`.
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
656
|
+
// defaults to `id`. Read-only catalogs get no *default* identifier appended
|
|
657
|
+
// (an explicit `indexField` is always included, per its prop doc): the
|
|
658
|
+
// selection key (`tableIndexField` above) is opportunistic — it keys by the
|
|
659
|
+
// identifier when the rows already carry it (server defaults typically
|
|
660
|
+
// include `id`) and stays positional when an explicit `select` omits it,
|
|
661
|
+
// rather than growing every read-only request by an extra column. When the
|
|
662
|
+
// caller has no concrete select list, nothing is added either — leaving the
|
|
663
|
+
// request empty lets the server use its own defaults (which typically
|
|
664
|
+
// include `id`, so the read-only sheet still opens). See `indexField` prop
|
|
665
|
+
// docs above.
|
|
629
666
|
function withIndexField(fields: string[]): string[] {
|
|
630
667
|
if (fields.length === 0) { return [] }
|
|
631
668
|
const field = props.indexField ?? (props.editable ? 'id' : null)
|
|
@@ -14,6 +14,7 @@ import { type FieldData } from '../FieldData'
|
|
|
14
14
|
import { type LensCreateExtension } from '../LensCreateExtension'
|
|
15
15
|
import { useFieldFactory } from '../composables/FieldFactory'
|
|
16
16
|
import { useLensEdit } from '../composables/LensEdit'
|
|
17
|
+
import { provideLensInlineEdit } from '../composables/LensInlineEdit'
|
|
17
18
|
import { extractServerErrors, extractServerMessage } from '../validation/ServerErrors'
|
|
18
19
|
import LensSheetAvatarField from './LensSheetAvatarField.vue'
|
|
19
20
|
import LensSheetField from './LensSheetField.vue'
|
|
@@ -66,6 +67,11 @@ const edit = useLensEdit()
|
|
|
66
67
|
const factory = useFieldFactory()
|
|
67
68
|
const snackbars = useSnackbars()
|
|
68
69
|
|
|
70
|
+
// One field editor open at a time across the sheet: fields derive their
|
|
71
|
+
// `editing` state from this shared context (mirroring the table's inline
|
|
72
|
+
// cells), so opening an editor closes any other.
|
|
73
|
+
provideLensInlineEdit()
|
|
74
|
+
|
|
69
75
|
// Provide the data-list label width directly (instead of wrapping rows in
|
|
70
76
|
// SDataList) so each LensSheetField wrapper doesn't make its SDataListItem a
|
|
71
77
|
// `:first-child`, which would otherwise double SDataList's dashed dividers.
|
|
@@ -1,13 +1,22 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import IconChevronUp from '~icons/lucide/chevron-up'
|
|
3
|
+
import IconCommand from '~icons/lucide/command'
|
|
4
|
+
import IconCornerDownLeft from '~icons/lucide/corner-down-left'
|
|
2
5
|
import IconPencilSimple from '~icons/ph/pencil-simple'
|
|
3
|
-
import { computed, nextTick, ref, watch } from 'vue'
|
|
6
|
+
import { type Component, computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
|
4
7
|
import SButton from '../../../components/SButton.vue'
|
|
5
8
|
import SDataListItem from '../../../components/SDataListItem.vue'
|
|
6
9
|
import { useTrans } from '../../../composables/Lang'
|
|
7
10
|
import { useValidation } from '../../../composables/Validation'
|
|
8
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
type EditorSubmitShortcut,
|
|
13
|
+
dispatchEditorKeydown,
|
|
14
|
+
editorSubmitShortcutForTarget,
|
|
15
|
+
focusFirstEditable
|
|
16
|
+
} from '../../../support/Dom'
|
|
9
17
|
import { type FieldData } from '../FieldData'
|
|
10
18
|
import { useLensEdit } from '../composables/LensEdit'
|
|
19
|
+
import { useLensInlineEdit } from '../composables/LensInlineEdit'
|
|
11
20
|
import { type Field } from '../fields/Field'
|
|
12
21
|
|
|
13
22
|
const props = defineProps<{
|
|
@@ -71,19 +80,40 @@ const canEdit = computed(() =>
|
|
|
71
80
|
&& props.field.supportsOptimisticUpdate()
|
|
72
81
|
)
|
|
73
82
|
|
|
74
|
-
|
|
83
|
+
// The editor's visible label. The input's own label — the one that carries the
|
|
84
|
+
// required marker — is visually hidden (the row label stands in for it), so
|
|
85
|
+
// re-attach the marker here.
|
|
86
|
+
const editorLabel = computed(() =>
|
|
87
|
+
(props.field as any).data?.required ? `${props.field.label()} *` : props.field.label()
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
// One sheet editor open at a time: `editing` derives from the sheet-scoped
|
|
91
|
+
// inline-edit context (provided by LensSheet), so opening another field's
|
|
92
|
+
// editor closes this one — mirroring the table's inline cells.
|
|
93
|
+
const inline = useLensInlineEdit()
|
|
94
|
+
const editing = computed(() => inline?.activeKey.value === props.fieldKey)
|
|
75
95
|
const model = ref<any>(null)
|
|
96
|
+
const activeEditorTarget = ref<EventTarget | null>(null)
|
|
97
|
+
|
|
98
|
+
// The sheet can close (or flip to create mode) while an editor is open; the
|
|
99
|
+
// field unmounts but the shared key would survive and reopen this field's
|
|
100
|
+
// editor on the next mount. Release it if it's still ours.
|
|
101
|
+
onUnmounted(() => {
|
|
102
|
+
if (editing.value) {
|
|
103
|
+
inline?.stop()
|
|
104
|
+
}
|
|
105
|
+
})
|
|
76
106
|
|
|
77
107
|
// If the backing record is replaced/rebound while this editor is open (the
|
|
78
108
|
// refresh banner, a parent `refresh()`, or a `/show` merge filling detail keys),
|
|
79
109
|
// the `model` captured in `start()` is stale; applying it would overwrite the
|
|
80
110
|
// freshly bound value. Close the editor so the user re-opens against the current
|
|
81
|
-
// value. Our own optimistic save also mutates this value, but `apply()`
|
|
82
|
-
//
|
|
83
|
-
//
|
|
111
|
+
// value. Our own optimistic save also mutates this value, but `apply()` stops
|
|
112
|
+
// the edit synchronously right after, so this async watcher sees it already
|
|
113
|
+
// closed; user typing only touches the local `model`, never the record.
|
|
84
114
|
watch(
|
|
85
115
|
() => props.record[props.fieldKey],
|
|
86
|
-
() => { if (editing.value) {
|
|
116
|
+
() => { if (editing.value) { inline?.stop() } }
|
|
87
117
|
)
|
|
88
118
|
|
|
89
119
|
const { validation, validate, reset } = useValidation(
|
|
@@ -93,19 +123,39 @@ const { validation, validate, reset } = useValidation(
|
|
|
93
123
|
|
|
94
124
|
const formEl = ref<HTMLElement | null>(null)
|
|
95
125
|
|
|
126
|
+
// No hint while nothing inside the form is focused (e.g. a radio-group editor,
|
|
127
|
+
// whose options aren't keyboard-focusable): keydowns don't route through the
|
|
128
|
+
// form then, so no shortcut would actually work.
|
|
129
|
+
const submitShortcut = computed(() =>
|
|
130
|
+
activeEditorTarget.value ? editorSubmitShortcutForTarget(activeEditorTarget.value) : null
|
|
131
|
+
)
|
|
132
|
+
const submitShortcutModifierIcon = computed<Component | null>(() => {
|
|
133
|
+
return submitShortcut.value === 'command-enter'
|
|
134
|
+
? IconCommand
|
|
135
|
+
: submitShortcut.value === 'control-enter'
|
|
136
|
+
? IconChevronUp
|
|
137
|
+
: null
|
|
138
|
+
})
|
|
139
|
+
const submitShortcutLabel = computed(() =>
|
|
140
|
+
submitShortcut.value ? shortcutLabel(submitShortcut.value) : null
|
|
141
|
+
)
|
|
142
|
+
|
|
96
143
|
function start() {
|
|
97
144
|
const raw = props.record[props.fieldKey]
|
|
98
|
-
model.value = props.field.payloadToInput(raw
|
|
145
|
+
model.value = raw != null ? props.field.payloadToInput(raw) : props.field.inputEmptyValue()
|
|
99
146
|
reset()
|
|
100
|
-
|
|
147
|
+
inline?.start(props.fieldKey)
|
|
101
148
|
// Focus the input on open (matching the inline table editor): better UX, and
|
|
102
149
|
// it routes the editor's keydowns — notably Escape — through the form handler
|
|
103
150
|
// so Escape cancels the edit rather than closing the sheet.
|
|
104
|
-
nextTick(() =>
|
|
151
|
+
nextTick(() => {
|
|
152
|
+
focusFirstEditable(formEl.value)
|
|
153
|
+
syncActiveEditorTarget()
|
|
154
|
+
})
|
|
105
155
|
}
|
|
106
156
|
|
|
107
157
|
function cancel() {
|
|
108
|
-
|
|
158
|
+
inline?.stop()
|
|
109
159
|
}
|
|
110
160
|
|
|
111
161
|
async function apply() {
|
|
@@ -132,7 +182,11 @@ async function apply() {
|
|
|
132
182
|
// is open (e.g. a refresh marks it locked). Re-check before persisting so an
|
|
133
183
|
// already-open editor can't save a row the policy now rejects.
|
|
134
184
|
if (!canEdit.value) {
|
|
135
|
-
|
|
185
|
+
// Only release the shared key if it's still ours: another field's editor
|
|
186
|
+
// may have opened during the awaited validation above.
|
|
187
|
+
if (editing.value) {
|
|
188
|
+
inline?.stop()
|
|
189
|
+
}
|
|
136
190
|
return
|
|
137
191
|
}
|
|
138
192
|
|
|
@@ -140,7 +194,9 @@ async function apply() {
|
|
|
140
194
|
edit!.save(props.record, {
|
|
141
195
|
[props.fieldKey]: props.field.inputToPayload(model.value)
|
|
142
196
|
})
|
|
143
|
-
editing.value
|
|
197
|
+
if (editing.value) {
|
|
198
|
+
inline?.stop()
|
|
199
|
+
}
|
|
144
200
|
}
|
|
145
201
|
|
|
146
202
|
function onEditorKeydown(event: KeyboardEvent) {
|
|
@@ -148,13 +204,53 @@ function onEditorKeydown(event: KeyboardEvent) {
|
|
|
148
204
|
// closes on it (via SSheet's window-level handler).
|
|
149
205
|
dispatchEditorKeydown(event, { cancel, submit: apply, shield: true })
|
|
150
206
|
}
|
|
207
|
+
|
|
208
|
+
function onEditorFocusin(event: FocusEvent) {
|
|
209
|
+
if (event.target instanceof HTMLElement && event.target.closest('.actions')) {
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
activeEditorTarget.value = event.target
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function onEditorFocusout(event: FocusEvent) {
|
|
217
|
+
// Focus moving within the form (including onto the action buttons) keeps the
|
|
218
|
+
// hint stable; focus leaving the editor entirely clears it, so the hint can't
|
|
219
|
+
// keep advertising a control that's no longer focused.
|
|
220
|
+
if (!(event.relatedTarget instanceof HTMLElement && formEl.value?.contains(event.relatedTarget))) {
|
|
221
|
+
activeEditorTarget.value = null
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function syncActiveEditorTarget() {
|
|
226
|
+
const activeElement = document.activeElement
|
|
227
|
+
activeEditorTarget.value = activeElement instanceof HTMLElement
|
|
228
|
+
&& formEl.value?.contains(activeElement)
|
|
229
|
+
&& !activeElement.closest('.actions')
|
|
230
|
+
? activeElement
|
|
231
|
+
: null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function shortcutLabel(shortcut: EditorSubmitShortcut): string {
|
|
235
|
+
return shortcut === 'enter'
|
|
236
|
+
? 'Enter'
|
|
237
|
+
: shortcut === 'command-enter'
|
|
238
|
+
? 'Command+Enter'
|
|
239
|
+
: 'Control+Enter'
|
|
240
|
+
}
|
|
151
241
|
</script>
|
|
152
242
|
|
|
153
243
|
<template>
|
|
154
|
-
<div class="LensSheetField" :class="{ editing }">
|
|
244
|
+
<div class="LensSheetField" :class="{ editing, 'is-editable': canEdit }">
|
|
155
245
|
<div v-if="!editing" class="display">
|
|
156
|
-
<component
|
|
157
|
-
|
|
246
|
+
<component
|
|
247
|
+
:is="displayComponent"
|
|
248
|
+
v-if="displayComponent"
|
|
249
|
+
:value="record[fieldKey]"
|
|
250
|
+
:value-action="canEdit"
|
|
251
|
+
@click:value="start"
|
|
252
|
+
/>
|
|
253
|
+
<SDataListItem v-else :value-action="canEdit" @click:value="start">
|
|
158
254
|
<template #label>{{ field.label() }}</template>
|
|
159
255
|
<template v-if="displayValue !== null" #value>{{ displayValue }}</template>
|
|
160
256
|
</SDataListItem>
|
|
@@ -163,18 +259,54 @@ function onEditorKeydown(event: KeyboardEvent) {
|
|
|
163
259
|
class="edit"
|
|
164
260
|
type="button"
|
|
165
261
|
:aria-label="`${t.edit} ${field.label()}`"
|
|
166
|
-
@click="start"
|
|
262
|
+
@click.stop="start"
|
|
167
263
|
>
|
|
168
264
|
<IconPencilSimple class="edit-icon" />
|
|
169
265
|
</button>
|
|
170
266
|
</div>
|
|
171
267
|
|
|
172
|
-
<div
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
268
|
+
<div
|
|
269
|
+
v-else
|
|
270
|
+
ref="formEl"
|
|
271
|
+
class="form"
|
|
272
|
+
@keydown="onEditorKeydown"
|
|
273
|
+
@focusin="onEditorFocusin"
|
|
274
|
+
@focusout="onEditorFocusout"
|
|
275
|
+
>
|
|
276
|
+
<SDataListItem>
|
|
277
|
+
<template #label>{{ editorLabel }}</template>
|
|
278
|
+
<template #value>
|
|
279
|
+
<div class="editor">
|
|
280
|
+
<div class="editor-input">
|
|
281
|
+
<component
|
|
282
|
+
:is="inputComponent"
|
|
283
|
+
v-model="model"
|
|
284
|
+
size="mini"
|
|
285
|
+
:validation="validation.input"
|
|
286
|
+
/>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="actions">
|
|
289
|
+
<SButton size="mini" :label="t.cancel" @click="cancel" />
|
|
290
|
+
<SButton size="mini" mode="info" @click="apply">
|
|
291
|
+
<span class="apply-content">
|
|
292
|
+
<span>{{ t.apply }}</span>
|
|
293
|
+
<template v-if="submitShortcut">
|
|
294
|
+
<span class="visually-hidden">({{ submitShortcutLabel }})</span>
|
|
295
|
+
<span class="shortcut" :title="submitShortcutLabel ?? undefined" aria-hidden="true">
|
|
296
|
+
<component
|
|
297
|
+
:is="submitShortcutModifierIcon"
|
|
298
|
+
v-if="submitShortcutModifierIcon"
|
|
299
|
+
class="shortcut-icon"
|
|
300
|
+
/>
|
|
301
|
+
<IconCornerDownLeft class="shortcut-icon" />
|
|
302
|
+
</span>
|
|
303
|
+
</template>
|
|
304
|
+
</span>
|
|
305
|
+
</SButton>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</template>
|
|
309
|
+
</SDataListItem>
|
|
178
310
|
</div>
|
|
179
311
|
</div>
|
|
180
312
|
</template>
|
|
@@ -186,29 +318,63 @@ function onEditorKeydown(event: KeyboardEvent) {
|
|
|
186
318
|
}
|
|
187
319
|
|
|
188
320
|
.form {
|
|
189
|
-
|
|
321
|
+
position: relative;
|
|
190
322
|
}
|
|
191
323
|
|
|
192
324
|
.display {
|
|
193
325
|
position: relative;
|
|
326
|
+
isolation: isolate;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* Scope the hover chrome to the data-list row's own cell: display renderers
|
|
330
|
+
can nest their own `.value` (e.g. `SDescPill`), which must not grow a second
|
|
331
|
+
hover border. */
|
|
332
|
+
.display :deep(.SDataListItem > .content > .value),
|
|
333
|
+
.display :deep(.SDataListItem > .content > .empty) {
|
|
334
|
+
position: relative;
|
|
335
|
+
z-index: 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.LensSheetField.is-editable .display :deep(.SDataListItem > .content > .value)::before,
|
|
339
|
+
.LensSheetField.is-editable .display :deep(.SDataListItem > .content > .empty)::before {
|
|
340
|
+
content: "";
|
|
341
|
+
position: absolute;
|
|
342
|
+
inset: -6px 0 -6px -16px;
|
|
343
|
+
z-index: -1;
|
|
344
|
+
pointer-events: none;
|
|
345
|
+
border: 1px solid var(--c-border);
|
|
346
|
+
border-radius: 8px;
|
|
347
|
+
background-color: var(--c-bg-1);
|
|
348
|
+
box-shadow: var(--shadow-depth-2);
|
|
349
|
+
opacity: 0;
|
|
350
|
+
transition: opacity 0.1s;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.LensSheetField.is-editable:hover .display :deep(.SDataListItem > .content > .value)::before,
|
|
354
|
+
.LensSheetField.is-editable:hover .display :deep(.SDataListItem > .content > .empty)::before {
|
|
355
|
+
opacity: 1;
|
|
194
356
|
}
|
|
195
357
|
|
|
196
358
|
.edit {
|
|
197
359
|
position: absolute;
|
|
198
|
-
top:
|
|
199
|
-
right:
|
|
360
|
+
top: 50%;
|
|
361
|
+
right: 8px;
|
|
200
362
|
display: flex;
|
|
201
363
|
align-items: center;
|
|
202
364
|
justify-content: center;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
365
|
+
z-index: 1;
|
|
366
|
+
width: 32px;
|
|
367
|
+
height: 32px;
|
|
368
|
+
transform: translateY(-50%);
|
|
369
|
+
border-radius: 8px;
|
|
206
370
|
color: var(--c-text-2);
|
|
371
|
+
background-color: var(--c-bg-1);
|
|
372
|
+
box-shadow: var(--shadow-depth-1);
|
|
207
373
|
opacity: 0;
|
|
208
374
|
transition: opacity 0.1s, background-color 0.1s, color 0.1s;
|
|
209
375
|
}
|
|
210
376
|
|
|
211
|
-
.LensSheetField:hover .edit {
|
|
377
|
+
.LensSheetField.is-editable:hover .edit {
|
|
212
378
|
opacity: 1;
|
|
213
379
|
}
|
|
214
380
|
|
|
@@ -222,10 +388,74 @@ function onEditorKeydown(event: KeyboardEvent) {
|
|
|
222
388
|
height: 16px;
|
|
223
389
|
}
|
|
224
390
|
|
|
391
|
+
.form :deep(.value) {
|
|
392
|
+
position: relative;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.editor {
|
|
396
|
+
position: relative;
|
|
397
|
+
z-index: 2;
|
|
398
|
+
margin: -8px 0 0 -16px;
|
|
399
|
+
width: calc(100% + 16px);
|
|
400
|
+
border: 1px solid var(--c-border);
|
|
401
|
+
border-radius: 8px;
|
|
402
|
+
background-color: var(--c-bg-1);
|
|
403
|
+
box-shadow: var(--shadow-depth-2);
|
|
404
|
+
overflow: visible;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.editor-input {
|
|
408
|
+
position: relative;
|
|
409
|
+
z-index: 2;
|
|
410
|
+
padding: 8px;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* Visually hidden but kept in the accessibility tree (`display: none` would
|
|
414
|
+
drop it): the input's own label keeps naming the input for screen readers
|
|
415
|
+
while the row label stands in for it visually, and the shortcut text keeps
|
|
416
|
+
reaching screen readers alongside the icon-only hint. */
|
|
417
|
+
.editor-input :deep(.SInputBase > .label),
|
|
418
|
+
.visually-hidden {
|
|
419
|
+
position: absolute;
|
|
420
|
+
margin: -1px;
|
|
421
|
+
padding: 0;
|
|
422
|
+
width: 1px;
|
|
423
|
+
height: 1px;
|
|
424
|
+
min-height: 0;
|
|
425
|
+
border: 0;
|
|
426
|
+
clip-path: inset(50%);
|
|
427
|
+
white-space: nowrap;
|
|
428
|
+
overflow: hidden;
|
|
429
|
+
}
|
|
430
|
+
|
|
225
431
|
.actions {
|
|
432
|
+
position: relative;
|
|
433
|
+
z-index: 1;
|
|
226
434
|
display: flex;
|
|
227
435
|
justify-content: flex-end;
|
|
228
436
|
gap: 8px;
|
|
229
|
-
|
|
437
|
+
padding: 8px;
|
|
438
|
+
border-top: 1px solid var(--c-divider);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.apply-content {
|
|
442
|
+
display: inline-flex;
|
|
443
|
+
align-items: center;
|
|
444
|
+
gap: 6px;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.shortcut {
|
|
448
|
+
display: inline-flex;
|
|
449
|
+
align-items: center;
|
|
450
|
+
gap: 1px;
|
|
451
|
+
padding: 2px 3px;
|
|
452
|
+
border-radius: 5px;
|
|
453
|
+
background-color: color-mix(in oklab, currentColor 16%, transparent);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
.shortcut-icon {
|
|
457
|
+
width: 12px;
|
|
458
|
+
height: 12px;
|
|
459
|
+
opacity: 0.9;
|
|
230
460
|
}
|
|
231
461
|
</style>
|
|
@@ -236,9 +236,10 @@ const table = useTable({
|
|
|
236
236
|
orders,
|
|
237
237
|
columns,
|
|
238
238
|
// A getter (not a snapshot) so the selection key stays reactive: `indexField`
|
|
239
|
-
// can change after mount — e.g.
|
|
240
|
-
//
|
|
241
|
-
//
|
|
239
|
+
// can change after mount — e.g. rows landing without the identifier flip the
|
|
240
|
+
// catalog from identity keys back to positional, and an `indexField` change
|
|
241
|
+
// settling re-keys it. STable reads this inside a computed, so the getter
|
|
242
|
+
// establishes the dependency and selection re-keys correctly.
|
|
242
243
|
get indexField() { return props.indexField },
|
|
243
244
|
borderless: true
|
|
244
245
|
})
|
|
@@ -193,7 +193,8 @@ function startNames() {
|
|
|
193
193
|
return
|
|
194
194
|
}
|
|
195
195
|
for (const entry of nameEntries.value) {
|
|
196
|
-
|
|
196
|
+
const raw = props.record[entry.key]
|
|
197
|
+
nameModel[entry.key] = raw != null ? entry.field.payloadToInput(raw) : entry.field.inputEmptyValue()
|
|
197
198
|
}
|
|
198
199
|
reset()
|
|
199
200
|
inline?.start(myKey.value)
|
|
@@ -156,7 +156,8 @@ function start() {
|
|
|
156
156
|
}
|
|
157
157
|
|
|
158
158
|
inputComponent.value = props.field.formInputComponent()
|
|
159
|
-
|
|
159
|
+
const raw = props.value
|
|
160
|
+
model.value = raw != null ? props.field.payloadToInput(raw) : props.field.inputEmptyValue()
|
|
160
161
|
reset()
|
|
161
162
|
inline?.start(myKey.value)
|
|
162
163
|
nextTick(() => {
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { type InjectionKey, type Ref, inject, provide, ref } from 'vue'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Tracks which
|
|
5
|
-
* opening one editor closes any other. The
|
|
4
|
+
* Tracks which inline editor is currently open within the providing scope, so
|
|
5
|
+
* that opening one editor closes any other. The table provides an instance for
|
|
6
|
+
* its cells (keyed `${recordId}:${fieldKey}`) and the record sheet provides
|
|
7
|
+
* its own for its fields (keyed by field key) — the scopes are independent.
|
|
6
8
|
*/
|
|
7
9
|
export interface LensInlineEditContext {
|
|
8
|
-
/** The key of the
|
|
10
|
+
/** The key of the editor currently open, or null. */
|
|
9
11
|
activeKey: Ref<string | null>
|
|
10
12
|
|
|
11
|
-
/** Open the editor for the given
|
|
13
|
+
/** Open the editor for the given key (closing any other). */
|
|
12
14
|
start: (key: string) => void
|
|
13
15
|
|
|
14
16
|
/** Close the active editor. */
|
|
@@ -56,10 +56,6 @@ export class AvatarField extends Field<AvatarFieldData> {
|
|
|
56
56
|
return false
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
override inputEmptyValue(): any {
|
|
60
|
-
return null
|
|
61
|
-
}
|
|
62
|
-
|
|
63
59
|
override formInputComponent(): any {
|
|
64
60
|
return this.defineFormInputComponent((props, { emit }) => {
|
|
65
61
|
return () => h(LensInputAvatar, {
|
|
@@ -59,7 +59,7 @@ export class DateField extends Field<DateFieldData> {
|
|
|
59
59
|
override formInputComponent(): any {
|
|
60
60
|
return this.defineFormInputComponent((props, { emit }) => {
|
|
61
61
|
return () => h(SInputDate, {
|
|
62
|
-
'size': 'md',
|
|
62
|
+
'size': props.size ?? 'md',
|
|
63
63
|
'label': this.formInputLabel(),
|
|
64
64
|
'placeholder': this.placeholder() || undefined,
|
|
65
65
|
'help': this.help() || undefined,
|