@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.
- package/lib/blocks/lens/components/LensSheet.vue +6 -0
- package/lib/blocks/lens/components/LensSheetField.vue +260 -30
- package/lib/blocks/lens/composables/LensInlineEdit.ts +6 -4
- package/lib/blocks/lens/fields/DateField.ts +1 -1
- package/lib/blocks/lens/fields/Field.ts +11 -4
- package/lib/blocks/lens/fields/FileUploadField.ts +1 -1
- package/lib/blocks/lens/fields/LinkField.ts +1 -1
- package/lib/blocks/lens/fields/SelectField.ts +2 -2
- 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 +2 -1
|
@@ -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
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>
|
|
@@ -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. */
|
|
@@ -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,
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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="
|
|
383
|
+
@click="onToggle"
|
|
339
384
|
@keydown.down.prevent
|
|
340
|
-
@
|
|
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
|
|
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
|
-
|
|
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="
|
|
175
|
+
@click="onToggle"
|
|
130
176
|
@keydown.down.prevent
|
|
131
|
-
@
|
|
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 {
|
package/lib/composables/Utils.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/lib/support/Dom.ts
CHANGED
|
@@ -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 —
|
|
69
|
-
*
|
|
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.
|
|
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",
|