@globalbrain/sefirot 4.59.1 → 4.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/blocks/lens/FieldData.ts +10 -0
- package/lib/blocks/lens/components/LensCatalog.vue +60 -23
- package/lib/blocks/lens/components/LensSheet.vue +6 -0
- package/lib/blocks/lens/components/LensSheetField.vue +261 -31
- package/lib/blocks/lens/components/LensTable.vue +4 -3
- package/lib/blocks/lens/components/LensTableAvatarCell.vue +2 -1
- package/lib/blocks/lens/components/LensTableEditableCell.vue +2 -1
- package/lib/blocks/lens/composables/LensInlineEdit.ts +6 -4
- package/lib/blocks/lens/fields/AvatarField.ts +0 -4
- package/lib/blocks/lens/fields/DateField.ts +1 -1
- package/lib/blocks/lens/fields/Field.ts +24 -6
- package/lib/blocks/lens/fields/FileUploadField.ts +1 -5
- package/lib/blocks/lens/fields/LinkField.ts +1 -1
- package/lib/blocks/lens/fields/SelectField.ts +2 -6
- package/lib/blocks/lens/fields/TextField.ts +1 -1
- package/lib/blocks/lens/fields/TextareaField.ts +1 -1
- package/lib/components/SButton.vue +8 -2
- package/lib/components/SDataListItem.vue +51 -2
- package/lib/components/SDropdownSectionFilter.vue +28 -5
- package/lib/components/SInputAsyncDropdown.vue +63 -8
- package/lib/components/SInputDropdown.vue +57 -4
- package/lib/composables/Utils.ts +48 -8
- package/lib/support/Dom.ts +72 -2
- package/package.json +9 -8
|
@@ -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
|
|
|
@@ -255,10 +262,21 @@ export abstract class Field<T extends FieldData> {
|
|
|
255
262
|
}
|
|
256
263
|
|
|
257
264
|
/**
|
|
258
|
-
*
|
|
265
|
+
* The value an empty input starts from: the definition's `emptyValue`, a
|
|
266
|
+
* wire-format payload value run through `payloadToInput`. The server owns
|
|
267
|
+
* the blank entirely — field types with a non-null blank (a multiple
|
|
268
|
+
* select's `[]`, say) declare it in their definition, so there are no
|
|
269
|
+
* per-type overrides here. Only null/undefined fall back to a null blank,
|
|
270
|
+
* so `false` and `0` are real values.
|
|
271
|
+
*
|
|
272
|
+
* Feeds the create-form seeding and the editors' null fallback alike, so
|
|
273
|
+
* a non-blank `emptyValue` also pre-fills the editor of a record whose
|
|
274
|
+
* stored value is null.
|
|
259
275
|
*/
|
|
260
276
|
inputEmptyValue(): any {
|
|
261
|
-
return null
|
|
277
|
+
return this.data.emptyValue != null
|
|
278
|
+
? this.payloadToInput(this.data.emptyValue)
|
|
279
|
+
: null
|
|
262
280
|
}
|
|
263
281
|
|
|
264
282
|
/**
|
|
@@ -294,7 +312,7 @@ export abstract class Field<T extends FieldData> {
|
|
|
294
312
|
*/
|
|
295
313
|
protected defineFormInputComponent(fn: (props: any, emit: any) => any): any {
|
|
296
314
|
return defineComponent(fn, {
|
|
297
|
-
props: ['modelValue', 'validation'],
|
|
315
|
+
props: ['modelValue', 'validation', 'size'],
|
|
298
316
|
emits: ['update:modelValue']
|
|
299
317
|
})
|
|
300
318
|
}
|
|
@@ -49,14 +49,10 @@ export class FileUploadField extends Field<FileUploadFieldData> {
|
|
|
49
49
|
})
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
override inputEmptyValue(): any {
|
|
53
|
-
return []
|
|
54
|
-
}
|
|
55
|
-
|
|
56
52
|
override formInputComponent(): any {
|
|
57
53
|
return this.defineFormInputComponent((props, { emit }) => {
|
|
58
54
|
return () => h(SInputFileUpload, {
|
|
59
|
-
'size': 'mini',
|
|
55
|
+
'size': props.size ?? 'mini',
|
|
60
56
|
'label': this.formInputLabel(),
|
|
61
57
|
'placeholder': this.placeholder() || undefined,
|
|
62
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,
|
|
@@ -150,10 +150,6 @@ export class SelectField extends Field<SelectFieldData> {
|
|
|
150
150
|
return this.optionsForValues(value).map((o) => this.labelForOption(o)).join(', ')
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
override inputEmptyValue(): any {
|
|
154
|
-
return this.data.multiple ? [] : null
|
|
155
|
-
}
|
|
156
|
-
|
|
157
153
|
override formInputComponent(): any {
|
|
158
154
|
switch (this.data.inputAs) {
|
|
159
155
|
case 'dropdown':
|
|
@@ -166,7 +162,7 @@ export class SelectField extends Field<SelectFieldData> {
|
|
|
166
162
|
defineDropdownInputComponent(): any {
|
|
167
163
|
return this.defineFormInputComponent((props, { emit }) => {
|
|
168
164
|
return () => h(SInputDropdown, {
|
|
169
|
-
'size': 'md',
|
|
165
|
+
'size': props.size ?? 'md',
|
|
170
166
|
'label': this.formInputLabel(),
|
|
171
167
|
'placeholder': this.placeholder() || undefined,
|
|
172
168
|
'help': this.help() || undefined,
|
|
@@ -188,7 +184,7 @@ export class SelectField extends Field<SelectFieldData> {
|
|
|
188
184
|
const Comp = this.data.multiple ? SInputCheckboxes : SInputRadios
|
|
189
185
|
|
|
190
186
|
return () => h(Comp as any, {
|
|
191
|
-
'size': 'md',
|
|
187
|
+
'size': props.size ?? 'md',
|
|
192
188
|
'label': this.formInputLabel(),
|
|
193
189
|
'help': this.help() || undefined,
|
|
194
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
|
/**
|