@globalbrain/sefirot 4.60.0 → 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.
@@ -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
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>
@@ -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. */
@@ -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,
@@ -222,13 +222,20 @@ export abstract class Field<T extends FieldData> {
222
222
  * Helper function to define the data list item component.
223
223
  */
224
224
  protected defineDataListItemComponent(fn: (value: any) => any, options: any = null): any {
225
- return defineComponent((props) => {
226
- return () => h(SDataListItem, options, {
225
+ return defineComponent((props, { emit }) => {
226
+ return () => h(SDataListItem, {
227
+ ...(options ?? {}),
228
+ 'valueAction': props.valueAction,
229
+ 'onClick:value': (event: MouseEvent) => {
230
+ emit('click:value', event)
231
+ }
232
+ }, {
227
233
  label: () => this.label(),
228
234
  value: () => fn(props.value)
229
235
  })
230
236
  }, {
231
- props: ['value']
237
+ props: ['value', 'valueAction'],
238
+ emits: ['click:value']
232
239
  })
233
240
  }
234
241
 
@@ -305,7 +312,7 @@ export abstract class Field<T extends FieldData> {
305
312
  */
306
313
  protected defineFormInputComponent(fn: (props: any, emit: any) => any): any {
307
314
  return defineComponent(fn, {
308
- props: ['modelValue', 'validation'],
315
+ props: ['modelValue', 'validation', 'size'],
309
316
  emits: ['update:modelValue']
310
317
  })
311
318
  }
@@ -52,7 +52,7 @@ export class FileUploadField extends Field<FileUploadFieldData> {
52
52
  override formInputComponent(): any {
53
53
  return this.defineFormInputComponent((props, { emit }) => {
54
54
  return () => h(SInputFileUpload, {
55
- 'size': 'mini',
55
+ 'size': props.size ?? 'mini',
56
56
  'label': this.formInputLabel(),
57
57
  'placeholder': this.placeholder() || undefined,
58
58
  'help': this.help() || undefined,
@@ -38,7 +38,7 @@ export class LinkField extends Field<LinkFieldData> {
38
38
  override formInputComponent(): any {
39
39
  return this.defineFormInputComponent((props, { emit }) => {
40
40
  return () => h(SInputText, {
41
- 'size': 'md',
41
+ 'size': props.size ?? 'md',
42
42
  'label': this.formInputLabel(),
43
43
  'placeholder': this.placeholder() || undefined,
44
44
  'help': this.help() || undefined,
@@ -162,7 +162,7 @@ export class SelectField extends Field<SelectFieldData> {
162
162
  defineDropdownInputComponent(): any {
163
163
  return this.defineFormInputComponent((props, { emit }) => {
164
164
  return () => h(SInputDropdown, {
165
- 'size': 'md',
165
+ 'size': props.size ?? 'md',
166
166
  'label': this.formInputLabel(),
167
167
  'placeholder': this.placeholder() || undefined,
168
168
  'help': this.help() || undefined,
@@ -184,7 +184,7 @@ export class SelectField extends Field<SelectFieldData> {
184
184
  const Comp = this.data.multiple ? SInputCheckboxes : SInputRadios
185
185
 
186
186
  return () => h(Comp as any, {
187
- 'size': 'md',
187
+ 'size': props.size ?? 'md',
188
188
  'label': this.formInputLabel(),
189
189
  'help': this.help() || undefined,
190
190
  'options': this.data.options.map((o) => ({
@@ -34,7 +34,7 @@ export class TextField extends Field<TextFieldData> {
34
34
  override formInputComponent(): any {
35
35
  return this.defineFormInputComponent((props, { emit }) => {
36
36
  return () => h(SInputText, {
37
- 'size': 'md',
37
+ 'size': props.size ?? 'md',
38
38
  'label': this.formInputLabel(),
39
39
  'placeholder': this.placeholder() || undefined,
40
40
  'help': this.help() || undefined,
@@ -36,7 +36,7 @@ export class TextareaField extends Field<TextareaFieldData> {
36
36
  override formInputComponent(): any {
37
37
  return this.defineFormInputComponent((props, { emit }) => {
38
38
  return () => h(SInputTextarea, {
39
- 'size': 'md',
39
+ 'size': props.size ?? 'md',
40
40
  'label': this.formInputLabel(),
41
41
  'placeholder': this.placeholder() || undefined,
42
42
  'help': this.help() || undefined,
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { type Component, type MaybeRef, computed, unref, useSlots } from 'vue'
3
3
  import { type Position } from '../composables/Tooltip'
4
+ import { useHasSlotContent } from '../composables/Utils'
4
5
  import { type ColorMode } from '../support/Color'
5
6
  import SFragment from './SFragment.vue'
6
7
  import SLink from './SLink.vue'
@@ -60,11 +61,15 @@ const emit = defineEmits<{
60
61
 
61
62
  const _leadIcon = computed(() => props.leadIcon ?? props.icon)
62
63
 
64
+ // Rendered content, not slot presence: a slot that renders empty falls back
65
+ // to the `label` prop (and an icon-only button keeps its icon-only padding).
66
+ const hasDefaultSlot = useHasSlotContent()
67
+
63
68
  const classes = computed(() => [
64
69
  props.size ?? 'medium',
65
70
  props.type ?? 'fill',
66
71
  props.mode ?? 'default',
67
- { 'has-label': !!props.label },
72
+ { 'has-label': !!props.label || hasDefaultSlot.value },
68
73
  { 'has-lead-icon': !!_leadIcon.value },
69
74
  { 'has-trail-icon': !!props.trailIcon },
70
75
  { loading: props.loading },
@@ -118,7 +123,8 @@ function onClick(): void {
118
123
  <span v-if="_leadIcon" class="icon" :class="iconMode">
119
124
  <component :is="_leadIcon" class="icon-svg" />
120
125
  </span>
121
- <span v-if="label" class="label" :class="labelMode" v-html="label" />
126
+ <span v-if="hasDefaultSlot" class="label" :class="labelMode"><slot /></span>
127
+ <span v-else-if="label" class="label" :class="labelMode" v-html="label" />
122
128
  <span v-if="trailIcon" class="icon" :class="iconMode">
123
129
  <component :is="trailIcon" class="icon-svg" />
124
130
  </span>
@@ -9,11 +9,16 @@ const props = withDefaults(defineProps<{
9
9
  preWrap?: boolean
10
10
  lineClamp?: string | number
11
11
  tnum?: boolean
12
+ valueAction?: boolean
12
13
  }>(), {
13
14
  dir: 'row',
14
15
  maxWidth: '100%'
15
16
  })
16
17
 
18
+ const emit = defineEmits<{
19
+ 'click:value': [event: MouseEvent]
20
+ }>()
21
+
17
22
  const { labelWidth } = useDataListState()
18
23
 
19
24
  const slots = useSlots()
@@ -48,6 +53,45 @@ function hasSlotContent(name = 'default'): boolean {
48
53
  return true
49
54
  })
50
55
  }
56
+
57
+ function onValueClick(event: MouseEvent): void {
58
+ if (!props.valueAction || isInteractiveClick(event)) {
59
+ return
60
+ }
61
+
62
+ emit('click:value', event)
63
+ }
64
+
65
+ // Whether the click landed on interactive content (a link, button, input, …)
66
+ // inside the cell, which handles the click itself. The `closest()` match is
67
+ // bounded to the cell (`currentTarget`): an interactive *ancestor* of the
68
+ // whole list — a tabindexed scroll container, say — must not swallow every
69
+ // cell click.
70
+ function isInteractiveClick(event: MouseEvent): boolean {
71
+ const cell = event.currentTarget
72
+ const target = event.target
73
+
74
+ if (!(cell instanceof HTMLElement) || !(target instanceof HTMLElement)) {
75
+ return false
76
+ }
77
+
78
+ const interactive = target.closest([
79
+ 'a',
80
+ 'button',
81
+ 'input',
82
+ 'textarea',
83
+ 'select',
84
+ '[contenteditable="true"]',
85
+ '[role="button"]',
86
+ '[role="link"]',
87
+ '[role="checkbox"]',
88
+ '[role="radio"]',
89
+ '[role="switch"]',
90
+ '[tabindex]:not([tabindex="-1"])'
91
+ ].join(','))
92
+
93
+ return !!interactive && cell.contains(interactive)
94
+ }
51
95
  </script>
52
96
 
53
97
  <template>
@@ -56,10 +100,10 @@ function hasSlotContent(name = 'default'): boolean {
56
100
  <div class="label" :style="labelStyles">
57
101
  <slot name="label" />
58
102
  </div>
59
- <div v-if="!hasValue" class="empty">
103
+ <div v-if="!hasValue" class="empty" :class="{ action: valueAction }" @click="onValueClick">
60
104
 
61
105
  </div>
62
- <div v-else-if="hasValue" class="value" :style="valueStyles">
106
+ <div v-else-if="hasValue" class="value" :class="{ action: valueAction }" :style="valueStyles" @click="onValueClick">
63
107
  <slot name="value" />
64
108
  </div>
65
109
  </div>
@@ -102,6 +146,11 @@ function hasSlotContent(name = 'default'): boolean {
102
146
  color: var(--c-text-1);
103
147
  }
104
148
 
149
+ .empty.action,
150
+ .value.action {
151
+ cursor: pointer;
152
+ }
153
+
105
154
  .SDataListItem.row .content {
106
155
  flex-direction: row;
107
156
  }
@@ -7,6 +7,7 @@ import {
7
7
  type DropdownSectionFilterSelectedValue
8
8
  } from '../composables/Dropdown'
9
9
  import { useTrans } from '../composables/Lang'
10
+ import { stopNonSubmitEnterKeydown } from '../support/Dom'
10
11
  import SDropdownSectionFilterItem from './SDropdownSectionFilterItem.vue'
11
12
 
12
13
  const props = defineProps<{
@@ -28,6 +29,7 @@ const { t } = useTrans({
28
29
  })
29
30
 
30
31
  const input = ref<HTMLElement | null>(null)
32
+ const list = ref<HTMLUListElement | null>(null)
31
33
  const query = ref('')
32
34
 
33
35
  const enabledOptions = computed(() => {
@@ -53,8 +55,16 @@ function isActive(value: any) {
53
55
  return Array.isArray(selected) ? selected.includes(value) : selected === value
54
56
  }
55
57
 
58
+ // Move focus from the search input into the first option (ArrowDown from the
59
+ // search field).
60
+ function focusFirstOption() {
61
+ list.value?.querySelector<HTMLElement>('.button')?.focus()
62
+ }
63
+
56
64
  function focusPrev(event: any) {
57
- event.target.parentNode.previousElementSibling?.firstElementChild?.focus()
65
+ // ArrowUp from the first option returns focus to the search input (if any).
66
+ const prev = event.target.parentNode.previousElementSibling?.firstElementChild
67
+ prev ? prev.focus() : input.value?.focus()
58
68
  }
59
69
 
60
70
  function focusNext(event: any) {
@@ -70,12 +80,18 @@ function onClick(option: DropdownSectionFilterOption, value: any) {
70
80
  <template>
71
81
  <div class="SDropdownSectionFilter">
72
82
  <div v-if="search" class="search">
73
- <!-- Keep Enter inside the filter: it drives the dropdown, and must not
74
- bubble to an enclosing editor/form as a submit. -->
75
- <input ref="input" v-model="query" class="input" :placeholder="t.i_ph" @keydown.enter.stop>
83
+ <input
84
+ ref="input"
85
+ v-model="query"
86
+ class="input"
87
+ :placeholder="t.i_ph"
88
+ @keydown.enter="stopNonSubmitEnterKeydown"
89
+ @keydown.down.prevent
90
+ @keyup.down.prevent="focusFirstOption"
91
+ >
76
92
  </div>
77
93
 
78
- <ul v-if="filteredOptions.length" class="list">
94
+ <ul v-if="filteredOptions.length" ref="list" class="list">
79
95
  <li v-for="option in filteredOptions" :key="option.label" class="item">
80
96
  <button
81
97
  class="button"
@@ -154,6 +170,13 @@ function onClick(option: DropdownSectionFilterOption, value: any) {
154
170
  &:hover {
155
171
  background-color: var(--c-bg-mute-1);
156
172
  }
173
+
174
+ /* The global reset strips `button:focus` outlines, so keyboard focus
175
+ (arrowing through the options) needs its own visible ring. */
176
+ &:focus-visible {
177
+ outline: 2px solid var(--input-focus-border-color);
178
+ outline-offset: -2px;
179
+ }
157
180
  }
158
181
 
159
182
  .checkbox {
@@ -6,6 +6,7 @@ import { type Ref, computed, nextTick, onUnmounted, ref, watch } from 'vue'
6
6
  import { useManualDropdownPosition } from '../composables/Dropdown'
7
7
  import { useFlyout } from '../composables/Flyout'
8
8
  import { useTrans } from '../composables/Lang'
9
+ import { stopNonSubmitEnterKeydown } from '../support/Dom'
9
10
  import { type Option } from '../support/InputDropdown'
10
11
  import SDropdownSectionFilterItem from './SDropdownSectionFilterItem.vue'
11
12
  import SInputBase, { type Props as BaseProps } from './SInputBase.vue'
@@ -76,6 +77,7 @@ const { t } = useTrans({
76
77
  })
77
78
 
78
79
  const container = ref<HTMLDivElement>()
80
+ const box = ref<HTMLDivElement | null>(null)
79
81
  const input = ref<HTMLInputElement | null>(null)
80
82
  const list = ref<HTMLUListElement | null>(null)
81
83
 
@@ -209,6 +211,44 @@ function onOpen() {
209
211
  nextTick(() => input.value?.focus())
210
212
  }
211
213
 
214
+ // Clicking (or Enter on — see onEnterToggle) the box toggles the flyout —
215
+ // closing returns focus to the box so keyboard interaction continues from the
216
+ // control instead of falling to `document.body`. ArrowDown stays open-only.
217
+ function onToggle() {
218
+ if (isOpen.value) {
219
+ close()
220
+ box.value?.focus()
221
+ return
222
+ }
223
+ onOpen()
224
+ }
225
+
226
+ // Bare Enter toggles on keydown, not keyup: a Cmd/Ctrl+Enter submit (handled
227
+ // on keydown by an enclosing editor) sheds its modifier before the Enter keyup
228
+ // when the modifier is released first, and that keyup must not reopen the
229
+ // flyout — acting on keydown sidesteps release order entirely. Key repeat is
230
+ // ignored so holding Enter doesn't flap the flyout.
231
+ function onEnterToggle(event: KeyboardEvent) {
232
+ if (!event.repeat) {
233
+ onToggle()
234
+ }
235
+ }
236
+
237
+ // Escape while the flyout is open belongs to the flyout: close it and return
238
+ // focus to the box, keeping the keydown from an enclosing surface (an inline
239
+ // editor would cancel, a sheet would close). A closed dropdown leaves Escape
240
+ // to those surfaces. The Escape that cancels an IME composition never
241
+ // operates the flyout.
242
+ function onEscapeKeydown(event: KeyboardEvent) {
243
+ if (!isOpen.value || event.isComposing) {
244
+ return
245
+ }
246
+ event.stopPropagation()
247
+ event.preventDefault()
248
+ close()
249
+ box.value?.focus()
250
+ }
251
+
212
252
  watch(isOpen, (value) => {
213
253
  // On close: drop any pending refetch, invalidate any in-flight fetch (so a late
214
254
  // response can't repopulate the list while closed), and clear the query /
@@ -264,8 +304,12 @@ function onSelect(item: T): void {
264
304
  commit(null)
265
305
  }
266
306
 
307
+ // Return focus to the box on a selection that closes the flyout, so keyboard
308
+ // interaction (reopening, an editor's submit shortcut) continues from the
309
+ // control rather than falling to `document.body`.
267
310
  if (props.closeOnSelect ?? !props.multiple) {
268
311
  close()
312
+ box.value?.focus()
269
313
  }
270
314
  }
271
315
 
@@ -330,14 +374,15 @@ function focusNext(event: any): void {
330
374
  :hide-error
331
375
  :hide-warning
332
376
  >
333
- <div ref="container" class="container">
377
+ <div ref="container" class="container" @keydown.esc="onEscapeKeydown">
334
378
  <div
379
+ ref="box"
335
380
  class="box"
336
381
  role="button"
337
382
  tabindex="0"
338
- @click="onOpen"
383
+ @click="onToggle"
339
384
  @keydown.down.prevent
340
- @keyup.enter="onOpen"
385
+ @keydown.enter.exact="onEnterToggle"
341
386
  @keyup.down="onOpen"
342
387
  >
343
388
  <div class="box-content">
@@ -362,16 +407,12 @@ function focusNext(event: any): void {
362
407
  <div v-if="isOpen" class="dropdown" :style="inset">
363
408
  <div class="dropdown-content">
364
409
  <div class="search">
365
- <!-- Keep Enter inside the search field: it drives the dropdown and
366
- must neither bubble to an enclosing form (`.stop`) nor trigger the
367
- browser's implicit form submission (`.prevent`). ArrowDown moves
368
- focus into the option list. -->
369
410
  <input
370
411
  ref="input"
371
412
  v-model="query"
372
413
  class="search-input"
373
414
  :placeholder="t.ph"
374
- @keydown.enter.stop.prevent
415
+ @keydown.enter="stopNonSubmitEnterKeydown"
375
416
  @keydown.down.prevent
376
417
  @keyup.down.prevent="focusFirstOption"
377
418
  >
@@ -442,6 +483,13 @@ function focusNext(event: any): void {
442
483
  &:hover {
443
484
  border-color: var(--input-hover-border-color);
444
485
  }
486
+
487
+ /* Keyboard focus shows via the border (the input-family idiom), not the
488
+ UA's default ring. */
489
+ &:focus-visible {
490
+ outline: none;
491
+ border-color: var(--input-focus-border-color);
492
+ }
445
493
  }
446
494
 
447
495
  .box-content {
@@ -544,6 +592,13 @@ function focusNext(event: any): void {
544
592
  background-color: var(--c-bg-mute-1);
545
593
  }
546
594
 
595
+ /* The global reset strips `button:focus` outlines, so keyboard focus
596
+ (arrowing through the options) needs its own visible ring. */
597
+ &:focus-visible {
598
+ outline: 2px solid var(--input-focus-border-color);
599
+ outline-offset: -2px;
600
+ }
601
+
547
602
  &:disabled {
548
603
  cursor: not-allowed;
549
604
  opacity: 0.5;
@@ -38,6 +38,7 @@ const { t } = useTrans({
38
38
  })
39
39
 
40
40
  const container = ref<HTMLDivElement>()
41
+ const box = ref<HTMLDivElement>()
41
42
 
42
43
  const { isOpen, open, close } = useFlyout(container)
43
44
  const { inset, update: updatePosition } = useManualDropdownPosition(
@@ -85,6 +86,44 @@ async function onOpen() {
85
86
  }
86
87
  }
87
88
 
89
+ // Clicking (or Enter on — see onEnterToggle) the box toggles the flyout —
90
+ // closing returns focus to the box so keyboard interaction continues from the
91
+ // control instead of falling to `document.body`. ArrowDown stays open-only.
92
+ function onToggle() {
93
+ if (isOpen.value) {
94
+ close()
95
+ box.value?.focus()
96
+ return
97
+ }
98
+ onOpen()
99
+ }
100
+
101
+ // Bare Enter toggles on keydown, not keyup: a Cmd/Ctrl+Enter submit (handled
102
+ // on keydown by an enclosing editor) sheds its modifier before the Enter keyup
103
+ // when the modifier is released first, and that keyup must not reopen the
104
+ // flyout — acting on keydown sidesteps release order entirely. Key repeat is
105
+ // ignored so holding Enter doesn't flap the flyout.
106
+ function onEnterToggle(event: KeyboardEvent) {
107
+ if (!event.repeat) {
108
+ onToggle()
109
+ }
110
+ }
111
+
112
+ // Escape while the flyout is open belongs to the flyout: close it and return
113
+ // focus to the box, keeping the keydown from an enclosing surface (an inline
114
+ // editor would cancel, a sheet would close). A closed dropdown leaves Escape
115
+ // to those surfaces. The Escape that cancels an IME composition never
116
+ // operates the flyout.
117
+ function onEscapeKeydown(event: KeyboardEvent) {
118
+ if (!isOpen.value || event.isComposing) {
119
+ return
120
+ }
121
+ event.stopPropagation()
122
+ event.preventDefault()
123
+ close()
124
+ box.value?.focus()
125
+ }
126
+
88
127
  function onSelect(value: T) {
89
128
  props.validation?.$touch()
90
129
 
@@ -99,7 +138,13 @@ function onSelect(value: T) {
99
138
  model.value = null
100
139
  }
101
140
 
102
- props.closeOnClick && close()
141
+ // Return focus to the box on a selection that closes the flyout, so
142
+ // keyboard interaction (reopening, an editor's submit shortcut) continues
143
+ // from the control rather than falling to `document.body`.
144
+ if (props.closeOnClick) {
145
+ close()
146
+ box.value?.focus()
147
+ }
103
148
  }
104
149
  </script>
105
150
 
@@ -121,14 +166,15 @@ function onSelect(value: T) {
121
166
  :hide-error
122
167
  :hide-warning
123
168
  >
124
- <div ref="container" class="container">
169
+ <div ref="container" class="container" @keydown.esc="onEscapeKeydown">
125
170
  <div
171
+ ref="box"
126
172
  class="box"
127
173
  role="button"
128
174
  tabindex="0"
129
- @click="onOpen"
175
+ @click="onToggle"
130
176
  @keydown.down.prevent
131
- @keyup.enter="onOpen"
177
+ @keydown.enter.exact="onEnterToggle"
132
178
  @keyup.down="onOpen"
133
179
  >
134
180
  <div class="box-content">
@@ -182,6 +228,13 @@ function onSelect(value: T) {
182
228
  &:hover {
183
229
  border-color: var(--input-hover-border-color);
184
230
  }
231
+
232
+ /* Keyboard focus shows via the border (the input-family idiom), not the
233
+ UA's default ring. */
234
+ &:focus-visible {
235
+ outline: none;
236
+ border-color: var(--input-focus-border-color);
237
+ }
185
238
  }
186
239
 
187
240
  .box-content {
@@ -1,8 +1,13 @@
1
1
  import {
2
+ Comment,
2
3
  type ComputedRef,
4
+ Fragment,
3
5
  type MaybeRefOrGetter,
6
+ Text,
7
+ type VNode,
4
8
  computed,
5
9
  getCurrentInstance,
10
+ isVNode,
6
11
  onMounted,
7
12
  toValue,
8
13
  useSlots
@@ -52,17 +57,52 @@ export function computedArrayWhen<T = any, C = any>(
52
57
  }, [])
53
58
  }
54
59
 
55
- /**
56
- * Checks whether the slot has a non empty value.
57
- */
60
+ function hasSlotContent(value: unknown): boolean {
61
+ if (value == null || typeof value === 'boolean') {
62
+ return false
63
+ }
64
+
65
+ if (typeof value === 'string') {
66
+ return value.trim().length > 0
67
+ }
68
+
69
+ if (typeof value === 'number' || typeof value === 'bigint') {
70
+ return true
71
+ }
72
+
73
+ if (Array.isArray(value)) {
74
+ return value.some(hasSlotContent)
75
+ }
76
+
77
+ if (!isVNode(value)) {
78
+ return false
79
+ }
80
+
81
+ const vnode = value as VNode
82
+
83
+ if (vnode.type === Comment) {
84
+ return false
85
+ }
86
+
87
+ if (vnode.type === Text || vnode.type === Fragment) {
88
+ return hasSlotContent(vnode.children)
89
+ }
90
+
91
+ // An element with only text children is judged by that text —
92
+ // `<div>{{ maybeEmpty }}</div>` must not defeat SDesc's empty fallback.
93
+ // Anything else is content in itself: a component, or an element whose
94
+ // `children` is `null` (an icon, an `<img>`, …), an array, or a slots object.
95
+ if (typeof vnode.children === 'string') {
96
+ return hasSlotContent(vnode.children)
97
+ }
98
+
99
+ return true
100
+ }
101
+
58
102
  export function useHasSlotContent(name = 'default'): ComputedRef<boolean> {
59
103
  const slots = useSlots()
60
104
 
61
- return computed(() => {
62
- return !!slots[name]?.().some((s) => {
63
- return Array.isArray(s.children) ? true : !!(s.children as string).trim()
64
- })
65
- })
105
+ return computed(() => hasSlotContent(slots[name]?.()))
66
106
  }
67
107
 
68
108
  /**
@@ -17,6 +17,75 @@ export function isTextLikeInput(target: EventTarget | null): boolean {
17
17
  return ['text', 'search', 'url', 'email', 'tel', 'password', 'number'].includes(type)
18
18
  }
19
19
 
20
+ export type EditorSubmitShortcut = 'enter' | 'command-enter' | 'control-enter'
21
+
22
+ /**
23
+ * The user-facing shortcut to show for the currently focused editor control.
24
+ * Plain Enter is truthful only for simple text-like inputs; other controls need
25
+ * the platform's primary modifier to avoid clashing with their own Enter key
26
+ * behavior. A text input nested inside a dropdown (its search filter, see
27
+ * `SDropdownSectionFilter` / `SInputAsyncDropdown`) keeps Enter to itself
28
+ * too, so only the modifier gesture reaches the editor from there.
29
+ */
30
+ export function editorSubmitShortcutForTarget(
31
+ target: EventTarget | null,
32
+ platform = currentPlatform()
33
+ ): EditorSubmitShortcut {
34
+ if (
35
+ isTextLikeInput(target)
36
+ && !(target as HTMLElement).closest('.SDropdown, .SInputAsyncDropdown')
37
+ ) {
38
+ return 'enter'
39
+ }
40
+
41
+ return hasCommandShortcutModifier(platform) ? 'command-enter' : 'control-enter'
42
+ }
43
+
44
+ const commandShortcutPlatforms = new Set([
45
+ 'ios',
46
+ 'ipados',
47
+ 'macos',
48
+
49
+ // `navigator.platform` fallback values.
50
+ 'ipad',
51
+ 'iphone',
52
+ 'macintel'
53
+ ])
54
+
55
+ function hasCommandShortcutModifier(platform: string): boolean {
56
+ return commandShortcutPlatforms.has(platform.toLowerCase())
57
+ }
58
+
59
+ function currentPlatform(): string {
60
+ if (typeof navigator === 'undefined') {
61
+ return ''
62
+ }
63
+
64
+ const nav = navigator as Navigator & { userAgentData?: { platform?: string } }
65
+
66
+ return nav.userAgentData?.platform ?? nav.platform ?? ''
67
+ }
68
+
69
+ /**
70
+ * Keydown handler for a text input nested inside a dropdown-like control (a
71
+ * search filter): Enter operates the control, not an enclosing inline editor,
72
+ * so it is stopped — with its default prevented — unless it carries the
73
+ * universal submit modifier (Cmd/Ctrl, see {@link isEditorSubmitKeydown}).
74
+ * Shift/Alt+Enter are contained too: the editor's submit predicate would read
75
+ * them off a text-like input as a plain submitting Enter. The Enter that
76
+ * commits an IME composition belongs to the IME — swallowing its default would
77
+ * drop the composed text — so it passes untouched (the editor ignores it).
78
+ */
79
+ export function stopNonSubmitEnterKeydown(event: KeyboardEvent): void {
80
+ if (event.isComposing) {
81
+ return
82
+ }
83
+ if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey) {
84
+ event.stopPropagation()
85
+ event.preventDefault()
86
+ }
87
+ }
88
+
20
89
  /**
21
90
  * Whether an Enter keydown should submit an inline editor. Ctrl/Cmd+Enter
22
91
  * submits from any control — it's the near-universal "submit" gesture (GitHub,
@@ -65,8 +134,9 @@ export function isEditorCancelKeydown(event: KeyboardEvent): boolean {
65
134
  * throw on `formInputComponent()` (not yet editable). When making one editable,
66
135
  * if its input is a composite control with its own nested text input (e.g. a
67
136
  * dropdown's search filter), that nested input must keep Enter/Escape from
68
- * bubbling to this handler — see `SDropdownSectionFilter` otherwise typing a
69
- * value and pressing Enter would submit/cancel the whole editor.
137
+ * bubbling to this handler — {@link stopNonSubmitEnterKeydown} is the Enter
138
+ * half; see `SDropdownSectionFilter` otherwise typing a value and pressing
139
+ * Enter would submit/cancel the whole editor.
70
140
  */
71
141
  export function dispatchEditorKeydown(
72
142
  event: KeyboardEvent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@globalbrain/sefirot",
3
- "version": "4.60.0",
3
+ "version": "4.61.0",
4
4
  "description": "Vue Components for Global Brain Design System.",
5
5
  "keywords": [
6
6
  "components",
@@ -56,6 +56,7 @@
56
56
  "release": "npm whoami >/dev/null 2>&1 || npm login && release-it"
57
57
  },
58
58
  "dependencies": {
59
+ "@iconify-json/lucide": "^1.2.116",
59
60
  "@iconify-json/ph": "^1.2.2",
60
61
  "@iconify-json/ri": "^1.2.10",
61
62
  "@popperjs/core": "^2.11.8",