@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.
@@ -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`), but rows fetched while
588
- // `editable` was still false or before an `indexField` change settled — won't
589
- // have it until the triggered refetch lands. Editing is gated on this (see the
590
- // `editable` getter) so a quick save in that window can't post `id: undefined`.
591
- // An empty result has nothing to edit, so it doesn't block.
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. An explicit
599
- // `indexField`, or `id` for editable catalogs (which always carry it). Without
600
- // this, an editable catalog that relies on the default would leave the table on
601
- // positional selection keys, so an optimistic create/delete that shifts rows
602
- // would leave `selected` pointing at the wrong records. Mirrors `edit.indexField`.
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`: when `editable` flips true (or `indexField`
605
- // changes) the on-screen rows don't carry the new key until the triggered refetch
606
- // lands. Switching STable onto it during that window keys every row's selection to
607
- // `undefined` wiping the current selection and making any row-select emit
608
- // `[undefined]` (so parent bulk actions act on no/wrong records). Stay on the
609
- // previous positional key until the loaded rows carry it, matching the
610
- // `edit.editable` gate so selection and editing flip together.
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
- const field = props.indexField ?? (props.editable ? 'id' : undefined)
613
- return field && rowsCarryIndexField.value ? field : undefined
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`. When the caller has no concrete select list, nothing
626
- // is added either leaving the request empty lets the server use its own
627
- // defaults (which include `id`, so the read-only sheet still opens). See
628
- // `indexField` prop docs above.
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 { dispatchEditorKeydown, focusFirstEditable } from '../../../support/Dom'
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
- const editing = ref(false)
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()` sets
82
- // `editing = false` synchronously right after, so this async watcher sees it
83
- // already closed; user typing only touches the local `model`, never the record.
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) { editing.value = false } }
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 ?? props.field.inputEmptyValue())
145
+ model.value = raw != null ? props.field.payloadToInput(raw) : props.field.inputEmptyValue()
99
146
  reset()
100
- editing.value = true
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(() => focusFirstEditable(formEl.value))
151
+ nextTick(() => {
152
+ focusFirstEditable(formEl.value)
153
+ syncActiveEditorTarget()
154
+ })
105
155
  }
106
156
 
107
157
  function cancel() {
108
- editing.value = false
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
- editing.value = false
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 = false
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 :is="displayComponent" v-if="displayComponent" :value="record[fieldKey]" />
157
- <SDataListItem v-else>
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 v-else ref="formEl" class="form" @keydown="onEditorKeydown">
173
- <component :is="inputComponent" v-model="model" :validation="validation.input" />
174
- <div class="actions">
175
- <SButton size="mini" :label="t.cancel" @click="cancel" />
176
- <SButton size="mini" mode="info" :label="t.apply" @click="apply" />
177
- </div>
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
- padding: 8px 0;
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: 10px;
199
- right: 0;
360
+ top: 50%;
361
+ right: 8px;
200
362
  display: flex;
201
363
  align-items: center;
202
364
  justify-content: center;
203
- width: 28px;
204
- height: 28px;
205
- border-radius: 6px;
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
- margin-top: 8px;
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. an editable catalog resolves permissions async
240
- // and flips from positional keys to `id`. STable reads this inside a computed,
241
- // so the getter establishes the dependency and selection re-keys correctly.
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
- nameModel[entry.key] = entry.field.payloadToInput(props.record[entry.key] ?? entry.field.inputEmptyValue())
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
- model.value = props.field.payloadToInput(props.value ?? props.field.inputEmptyValue())
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 table cell currently has its inline editor open, so that
5
- * opening one editor closes any other. The key is `${recordId}:${fieldKey}`.
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 cell currently being edited, or null. */
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 cell key (closing any other). */
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,