@globalbrain/sefirot 4.55.0 → 4.57.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/LensCreateExtension.ts +43 -0
- package/lib/blocks/lens/components/LensInputSelect.vue +92 -0
- package/lib/blocks/lens/components/LensSheet.vue +47 -5
- package/lib/blocks/lens/components/LensSheetField.vue +15 -2
- package/lib/blocks/lens/components/LensTableAvatarCell.vue +3 -6
- package/lib/blocks/lens/components/LensTableEditableCell.vue +3 -26
- package/lib/blocks/lens/fields/Field.ts +9 -0
- package/lib/components/SDropdownSectionFilter.vue +3 -1
- package/lib/components/SInputDropdownItem.vue +3 -3
- package/lib/components/SInputSelectSearch.vue +753 -0
- package/lib/support/Dom.ts +109 -0
- package/package.json +1 -1
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A create-form extension contributed by a sheet slot (`#before` / `#after`).
|
|
3
|
+
*
|
|
4
|
+
* The catalog's create form builds its payload from the entity's own Lens
|
|
5
|
+
* fields. A page may need to create related data that isn't a Lens field (e.g. a
|
|
6
|
+
* collaborator's social links, written server-side alongside the record). Such a
|
|
7
|
+
* slot registers an extension via the `registerCreateExtension` slot prop: on
|
|
8
|
+
* submit the sheet runs every registered extension to validate the slot's own
|
|
9
|
+
* inputs and merge its extra keys into the create payload.
|
|
10
|
+
*/
|
|
11
|
+
export interface LensCreateContribution {
|
|
12
|
+
/**
|
|
13
|
+
* Whether the slot's own inputs passed validation. When any registered
|
|
14
|
+
* extension reports `false`, the create is aborted (the sheet stays open so
|
|
15
|
+
* each slot can show its own errors).
|
|
16
|
+
*/
|
|
17
|
+
valid: boolean
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Extra key/value pairs merged into the create payload (e.g.
|
|
21
|
+
* `{ social_links: [...] }`). Ignored when `valid` is `false`.
|
|
22
|
+
*/
|
|
23
|
+
values: Record<string, any>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A function the sheet calls on create-submit. It validates the slot's inputs
|
|
28
|
+
* and returns its contribution; it may be async (validation usually is). It
|
|
29
|
+
* should report `valid: false` rather than throw — a throw propagates to the
|
|
30
|
+
* sheet's submit handler as an unexpected error.
|
|
31
|
+
*/
|
|
32
|
+
export type LensCreateExtension = () =>
|
|
33
|
+
| LensCreateContribution
|
|
34
|
+
| Promise<LensCreateContribution>
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Register a {@link LensCreateExtension} with the sheet's create form. Returns a
|
|
38
|
+
* disposer that unregisters it — call it on unmount (the create slot content
|
|
39
|
+
* unmounts with the sheet, so a component-lifecycle cleanup suffices).
|
|
40
|
+
*/
|
|
41
|
+
export type RegisterLensCreateExtension = (
|
|
42
|
+
extension: LensCreateExtension
|
|
43
|
+
) => () => void
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import SInputSelectSearch, { type Option } from '../../../components/SInputSelectSearch.vue'
|
|
3
|
+
import { useMutation } from '../../../composables/Api'
|
|
4
|
+
import { useLang } from '../../../composables/Lang'
|
|
5
|
+
import { type LensQuerySettings, type LensQuerySort } from '../LensQuery'
|
|
6
|
+
import { isAuthError } from '../validation/ServerErrors'
|
|
7
|
+
|
|
8
|
+
export type { Color, Size } from '../../../components/SInputBase.vue'
|
|
9
|
+
export type { Option }
|
|
10
|
+
|
|
11
|
+
// A lens-backed search-select. Combines the search (a lens query), the fetch (no
|
|
12
|
+
// repo needed), and the row → option mapping (no ops needed) into one component:
|
|
13
|
+
// the consumer configures the entity and how a row becomes an option, and the
|
|
14
|
+
// component searches the server as the user types via SInputSelectSearch.
|
|
15
|
+
//
|
|
16
|
+
// Selection UI props (multiple, nullable, disabled, placeholder, size, label,
|
|
17
|
+
// validation, position, closeOnSelect, debounce, …) fall through to
|
|
18
|
+
// SInputSelectSearch as attributes.
|
|
19
|
+
export interface Props {
|
|
20
|
+
// The lens search endpoint (e.g. `/api/admin/lens/search`).
|
|
21
|
+
endpoint: string
|
|
22
|
+
|
|
23
|
+
// The entity to search.
|
|
24
|
+
entity: string
|
|
25
|
+
|
|
26
|
+
// Columns to fetch for each row, projected into an option by `toOption`.
|
|
27
|
+
select: string[]
|
|
28
|
+
|
|
29
|
+
// Columns matched against the typed query — OR-ed together with `contains`.
|
|
30
|
+
// An empty query fetches the initial (unfiltered) page. Named to match
|
|
31
|
+
// LensCatalog's `queryKeys`.
|
|
32
|
+
queryKeys: string[]
|
|
33
|
+
|
|
34
|
+
// Sort applied to the results (e.g. `[['name', 'asc']]`). Re-read on each fetch,
|
|
35
|
+
// so a locale-dependent sort stays current.
|
|
36
|
+
sort?: LensQuerySort[]
|
|
37
|
+
|
|
38
|
+
// Page size of each search. The server enforces its own perPage ceiling.
|
|
39
|
+
perPage?: number
|
|
40
|
+
|
|
41
|
+
// Per-request settings (e.g. language). Usually unneeded — the server negotiates
|
|
42
|
+
// language from the request headers.
|
|
43
|
+
settings?: LensQuerySettings
|
|
44
|
+
|
|
45
|
+
// Map a raw result row to an option. The selected option(s) are the model, so
|
|
46
|
+
// include everything the consumer needs back (e.g. a related record), plus the
|
|
47
|
+
// `value` / `label` (and `image` for an avatar option) the select renders.
|
|
48
|
+
toOption: (row: Record<string, any>) => Option
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const props = defineProps<Props>()
|
|
52
|
+
|
|
53
|
+
// Pass the selection through to SInputSelectSearch. Typed loosely so a consumer
|
|
54
|
+
// can bind a ref of its own richer option type (see SInputSelectSearch).
|
|
55
|
+
const model = defineModel<any>({ required: true })
|
|
56
|
+
|
|
57
|
+
// Don't let lens-config attributes that happen to share a name leak onto the
|
|
58
|
+
// child; only the explicitly-forwarded selection props (via `$attrs`) should.
|
|
59
|
+
defineOptions({ inheritAttrs: false })
|
|
60
|
+
|
|
61
|
+
// Default the request language to the active app language (like LensCatalog), so
|
|
62
|
+
// server-localized fields come back in the right locale even when HttpConfig /
|
|
63
|
+
// request headers differ; explicit `settings` (incl. `lang`) overrides it.
|
|
64
|
+
const lang = useLang()
|
|
65
|
+
|
|
66
|
+
const { execute } = useMutation((http, query: string) =>
|
|
67
|
+
http.post<{ data: Record<string, any>[] }>(props.endpoint, {
|
|
68
|
+
entity: props.entity,
|
|
69
|
+
select: props.select,
|
|
70
|
+
filters: query
|
|
71
|
+
? [['$or', props.queryKeys.map((key) => [key, 'contains', query])]]
|
|
72
|
+
: [],
|
|
73
|
+
sort: props.sort ?? [],
|
|
74
|
+
page: 1,
|
|
75
|
+
perPage: props.perPage ?? 25,
|
|
76
|
+
settings: { lang, ...props.settings }
|
|
77
|
+
})
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
// Read every config field through `props` at call time so a reactive change
|
|
81
|
+
// (e.g. a locale-dependent sort or label) takes effect on the next search.
|
|
82
|
+
async function fetch(query: string): Promise<Option[]> {
|
|
83
|
+
const res = await execute(query)
|
|
84
|
+
return res.data.map(props.toOption)
|
|
85
|
+
}
|
|
86
|
+
</script>
|
|
87
|
+
|
|
88
|
+
<template>
|
|
89
|
+
<SInputSelectSearch v-model="model" :fetch :rethrow="isAuthError" v-bind="$attrs">
|
|
90
|
+
<template v-if="$slots.info" #info><slot name="info" /></template>
|
|
91
|
+
</SInputSelectSearch>
|
|
92
|
+
</template>
|
|
@@ -11,6 +11,7 @@ import { usePower } from '../../../composables/Power'
|
|
|
11
11
|
import { useValidation } from '../../../composables/Validation'
|
|
12
12
|
import { useSnackbars } from '../../../stores/Snackbars'
|
|
13
13
|
import { type FieldData } from '../FieldData'
|
|
14
|
+
import { type LensCreateExtension } from '../LensCreateExtension'
|
|
14
15
|
import { useFieldFactory } from '../composables/FieldFactory'
|
|
15
16
|
import { useLensEdit } from '../composables/LensEdit'
|
|
16
17
|
import { extractServerErrors, extractServerMessage } from '../validation/ServerErrors'
|
|
@@ -177,6 +178,19 @@ watch(
|
|
|
177
178
|
{ immediate: true }
|
|
178
179
|
)
|
|
179
180
|
|
|
181
|
+
// Create-form extensions registered by sheet slots (`#before` / `#after`) — a
|
|
182
|
+
// page's custom inputs that aren't Lens fields (e.g. social links written
|
|
183
|
+
// server-side alongside the record). On submit each is run to validate its own
|
|
184
|
+
// inputs and contribute extra keys to the create payload. Registered on mount and
|
|
185
|
+
// disposed on unmount; the create slot content unmounts with the sheet, so a
|
|
186
|
+
// component-lifecycle cleanup keeps this empty between creates.
|
|
187
|
+
const createExtensions = new Set<LensCreateExtension>()
|
|
188
|
+
|
|
189
|
+
function registerCreateExtension(extension: LensCreateExtension): () => void {
|
|
190
|
+
createExtensions.add(extension)
|
|
191
|
+
return () => { createExtensions.delete(extension) }
|
|
192
|
+
}
|
|
193
|
+
|
|
180
194
|
async function onCreate() {
|
|
181
195
|
// Guard re-entry: set `saving` before the first await so a fast double-click on
|
|
182
196
|
// Create can't start a second submission (and create a duplicate record) while
|
|
@@ -197,13 +211,32 @@ async function onCreate() {
|
|
|
197
211
|
serverErrors.value = {}
|
|
198
212
|
|
|
199
213
|
try {
|
|
200
|
-
|
|
214
|
+
// Validate the built-in fields and every registered slot extension, running
|
|
215
|
+
// them all even when something already failed, so each section surfaces its
|
|
216
|
+
// own errors on a single submit rather than one at a time. A valid extension
|
|
217
|
+
// contributes extra keys to the payload; any failure aborts the create.
|
|
218
|
+
const fieldsValid = await validate()
|
|
219
|
+
const contributions: Record<string, any>[] = []
|
|
220
|
+
let extensionsValid = true
|
|
221
|
+
for (const extend of createExtensions) {
|
|
222
|
+
const { valid, values } = await extend()
|
|
223
|
+
if (valid) {
|
|
224
|
+
contributions.push(values)
|
|
225
|
+
} else {
|
|
226
|
+
extensionsValid = false
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!fieldsValid || !extensionsValid) {
|
|
201
230
|
return
|
|
202
231
|
}
|
|
203
232
|
const values: Record<string, any> = {}
|
|
204
233
|
for (const { key, field } of createInputViews.value) {
|
|
205
234
|
values[key] = field.inputToPayload(createModel[key])
|
|
206
235
|
}
|
|
236
|
+
// Merge each extension's contribution (e.g. `social_links`) into the payload.
|
|
237
|
+
for (const extra of contributions) {
|
|
238
|
+
Object.assign(values, extra)
|
|
239
|
+
}
|
|
207
240
|
// A picked avatar `File` rides `values`; `edit.create` sends the payload
|
|
208
241
|
// multipart when one is present, so the avatar persists with the new record.
|
|
209
242
|
await edit!.create(values)
|
|
@@ -266,9 +299,11 @@ function requestClose() {
|
|
|
266
299
|
// that hosts the catalog, so it cannot `inject` the edit context (which is
|
|
267
300
|
// provided inside `LensCatalog`, a child of that page). Expose the pieces a
|
|
268
301
|
// slot needs as slot props instead: the resolved record id, a record-bound
|
|
269
|
-
// partial `save`,
|
|
270
|
-
//
|
|
271
|
-
//
|
|
302
|
+
// partial `save`, the entity key, the sheet `mode` (so a slot can render
|
|
303
|
+
// different content for create vs view), and `registerCreateExtension` (so a
|
|
304
|
+
// create-mode slot can contribute extra keys to the create payload). This keeps
|
|
305
|
+
// the generic sheet free of one-off concerns (avatar upload, social links,
|
|
306
|
+
// linked records) while still letting a page implement them.
|
|
272
307
|
|
|
273
308
|
const resolvedId = computed(() => (props.record && edit ? edit.resolveId(props.record) : null))
|
|
274
309
|
|
|
@@ -293,6 +328,9 @@ const slotProps = computed(() => ({
|
|
|
293
328
|
record: props.record ?? null,
|
|
294
329
|
id: resolvedId.value,
|
|
295
330
|
entity: props.entity,
|
|
331
|
+
// The sheet's current mode, so a slot can branch its content (e.g. show
|
|
332
|
+
// editable inputs only while creating, and a read/edit view otherwise).
|
|
333
|
+
mode: props.mode,
|
|
296
334
|
// Surfaced so custom editors can disable their save controls until the full
|
|
297
335
|
// record has loaded; `save` also hard-refuses while loading/error as a guard.
|
|
298
336
|
loading: props.loading ?? false,
|
|
@@ -301,7 +339,11 @@ const slotProps = computed(() => ({
|
|
|
301
339
|
// editor can disable its own controls for a rejected row; `save` enforces it
|
|
302
340
|
// regardless, but otherwise the refusal is only visible as a silent no-op.
|
|
303
341
|
canEdit: !!props.record && !!edit?.canEdit(props.record),
|
|
304
|
-
save: saveRecord
|
|
342
|
+
save: saveRecord,
|
|
343
|
+
// Let a create-mode slot register inputs that contribute extra keys to the
|
|
344
|
+
// create payload (see LensCreateExtension). Stable identity; a no-op in view
|
|
345
|
+
// mode (a slot only registers while the create form is shown).
|
|
346
|
+
registerCreateExtension
|
|
305
347
|
}))
|
|
306
348
|
</script>
|
|
307
349
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import IconPencilSimple from '~icons/ph/pencil-simple'
|
|
3
|
-
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import { computed, nextTick, ref, watch } from 'vue'
|
|
4
4
|
import SButton from '../../../components/SButton.vue'
|
|
5
5
|
import SDataListItem from '../../../components/SDataListItem.vue'
|
|
6
6
|
import { useTrans } from '../../../composables/Lang'
|
|
7
7
|
import { useValidation } from '../../../composables/Validation'
|
|
8
|
+
import { dispatchEditorKeydown, focusFirstEditable } from '../../../support/Dom'
|
|
8
9
|
import { type FieldData } from '../FieldData'
|
|
9
10
|
import { useLensEdit } from '../composables/LensEdit'
|
|
10
11
|
import { type Field } from '../fields/Field'
|
|
@@ -90,11 +91,17 @@ const { validation, validate, reset } = useValidation(
|
|
|
90
91
|
() => ({ input: props.field.generateValidationRules() })
|
|
91
92
|
)
|
|
92
93
|
|
|
94
|
+
const formEl = ref<HTMLElement | null>(null)
|
|
95
|
+
|
|
93
96
|
function start() {
|
|
94
97
|
const raw = props.record[props.fieldKey]
|
|
95
98
|
model.value = props.field.payloadToInput(raw ?? props.field.inputEmptyValue())
|
|
96
99
|
reset()
|
|
97
100
|
editing.value = true
|
|
101
|
+
// Focus the input on open (matching the inline table editor): better UX, and
|
|
102
|
+
// it routes the editor's keydowns — notably Escape — through the form handler
|
|
103
|
+
// so Escape cancels the edit rather than closing the sheet.
|
|
104
|
+
nextTick(() => focusFirstEditable(formEl.value))
|
|
98
105
|
}
|
|
99
106
|
|
|
100
107
|
function cancel() {
|
|
@@ -135,6 +142,12 @@ async function apply() {
|
|
|
135
142
|
})
|
|
136
143
|
editing.value = false
|
|
137
144
|
}
|
|
145
|
+
|
|
146
|
+
function onEditorKeydown(event: KeyboardEvent) {
|
|
147
|
+
// `shield` keeps Escape from reaching the surrounding sheet, which otherwise
|
|
148
|
+
// closes on it (via SSheet's window-level handler).
|
|
149
|
+
dispatchEditorKeydown(event, { cancel, submit: apply, shield: true })
|
|
150
|
+
}
|
|
138
151
|
</script>
|
|
139
152
|
|
|
140
153
|
<template>
|
|
@@ -156,7 +169,7 @@ async function apply() {
|
|
|
156
169
|
</button>
|
|
157
170
|
</div>
|
|
158
171
|
|
|
159
|
-
<div v-else class="form">
|
|
172
|
+
<div v-else ref="formEl" class="form" @keydown="onEditorKeydown">
|
|
160
173
|
<component :is="inputComponent" v-model="model" :validation="validation.input" />
|
|
161
174
|
<div class="actions">
|
|
162
175
|
<SButton size="mini" :label="t.cancel" @click="cancel" />
|
|
@@ -10,6 +10,7 @@ import { useManualDropdownPosition } from '../../../composables/Dropdown'
|
|
|
10
10
|
import { useTrans } from '../../../composables/Lang'
|
|
11
11
|
import { useValidation } from '../../../composables/Validation'
|
|
12
12
|
import { useSnackbars } from '../../../stores/Snackbars'
|
|
13
|
+
import { dispatchEditorKeydown, focusFirstEditable } from '../../../support/Dom'
|
|
13
14
|
import { type FieldData } from '../FieldData'
|
|
14
15
|
import { useLensEdit } from '../composables/LensEdit'
|
|
15
16
|
import { useLensInlineEdit } from '../composables/LensInlineEdit'
|
|
@@ -198,8 +199,7 @@ function startNames() {
|
|
|
198
199
|
inline?.start(myKey.value)
|
|
199
200
|
nextTick(() => {
|
|
200
201
|
update()
|
|
201
|
-
|
|
202
|
-
el?.focus()
|
|
202
|
+
focusFirstEditable(editorEl.value)
|
|
203
203
|
})
|
|
204
204
|
}
|
|
205
205
|
|
|
@@ -246,10 +246,7 @@ async function applyNames() {
|
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
function onEditorKeydown(event: KeyboardEvent) {
|
|
249
|
-
|
|
250
|
-
event.preventDefault()
|
|
251
|
-
cancelNames()
|
|
252
|
-
}
|
|
249
|
+
dispatchEditorKeydown(event, { cancel: cancelNames, submit: applyNames })
|
|
253
250
|
}
|
|
254
251
|
</script>
|
|
255
252
|
|
|
@@ -10,6 +10,7 @@ import { useManualDropdownPosition } from '../../../composables/Dropdown'
|
|
|
10
10
|
import { useTrans } from '../../../composables/Lang'
|
|
11
11
|
import { useValidation } from '../../../composables/Validation'
|
|
12
12
|
import { day } from '../../../support/Day'
|
|
13
|
+
import { dispatchEditorKeydown, focusFirstEditable } from '../../../support/Dom'
|
|
13
14
|
import { type FieldData } from '../FieldData'
|
|
14
15
|
import { useLensEdit } from '../composables/LensEdit'
|
|
15
16
|
import { useLensInlineEdit } from '../composables/LensInlineEdit'
|
|
@@ -159,10 +160,7 @@ function start() {
|
|
|
159
160
|
inline?.start(myKey.value)
|
|
160
161
|
nextTick(() => {
|
|
161
162
|
update()
|
|
162
|
-
|
|
163
|
-
'input, textarea, [contenteditable], [tabindex]'
|
|
164
|
-
) as HTMLElement | null
|
|
165
|
-
el?.focus()
|
|
163
|
+
focusFirstEditable(editorEl.value)
|
|
166
164
|
})
|
|
167
165
|
}
|
|
168
166
|
|
|
@@ -218,28 +216,7 @@ async function apply() {
|
|
|
218
216
|
}
|
|
219
217
|
|
|
220
218
|
function onEditorKeydown(event: KeyboardEvent) {
|
|
221
|
-
|
|
222
|
-
event.preventDefault()
|
|
223
|
-
cancel()
|
|
224
|
-
return
|
|
225
|
-
}
|
|
226
|
-
// Enter saves only from a plain text-like input. A textarea inserts a
|
|
227
|
-
// newline, and controls that handle Enter themselves (e.g. a dropdown that
|
|
228
|
-
// opens its menu on Enter) must keep it — otherwise a keyboard user submits
|
|
229
|
-
// the old value instead of choosing an option.
|
|
230
|
-
if (event.key === 'Enter' && isTextLikeInput(event.target)) {
|
|
231
|
-
event.preventDefault()
|
|
232
|
-
apply()
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function isTextLikeInput(target: EventTarget | null): boolean {
|
|
237
|
-
const el = target as HTMLElement | null
|
|
238
|
-
if (!el || el.tagName !== 'INPUT') {
|
|
239
|
-
return false
|
|
240
|
-
}
|
|
241
|
-
const type = (el as HTMLInputElement).type
|
|
242
|
-
return ['text', 'search', 'url', 'email', 'tel', 'password', 'number'].includes(type)
|
|
219
|
+
dispatchEditorKeydown(event, { cancel, submit: apply })
|
|
243
220
|
}
|
|
244
221
|
</script>
|
|
245
222
|
|
|
@@ -273,6 +273,15 @@ export abstract class Field<T extends FieldData> {
|
|
|
273
273
|
|
|
274
274
|
/**
|
|
275
275
|
* Returns the form input component for the field.
|
|
276
|
+
*
|
|
277
|
+
* Several field types currently `throw new Error('Not implemented.')` here —
|
|
278
|
+
* they're shown read-only and have no inline/sheet editor yet. When making one
|
|
279
|
+
* editable, mind the inline editor's keyboard handling: if the input is a
|
|
280
|
+
* composite control with its own nested text input (e.g. a dropdown search
|
|
281
|
+
* filter), that nested input must keep Enter/Escape from bubbling to the
|
|
282
|
+
* editor (see `SDropdownSectionFilter` and `support/Dom`'s
|
|
283
|
+
* `dispatchEditorKeydown`), or typing a value and pressing Enter would
|
|
284
|
+
* submit/cancel the whole editor.
|
|
276
285
|
*/
|
|
277
286
|
abstract formInputComponent(): any
|
|
278
287
|
|
|
@@ -70,7 +70,9 @@ function onClick(option: DropdownSectionFilterOption, value: any) {
|
|
|
70
70
|
<template>
|
|
71
71
|
<div class="SDropdownSectionFilter">
|
|
72
72
|
<div v-if="search" class="search">
|
|
73
|
-
|
|
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>
|
|
74
76
|
</div>
|
|
75
77
|
|
|
76
78
|
<ul v-if="filteredOptions.length" class="list">
|
|
@@ -47,7 +47,7 @@ function emitRemove(value: any) {
|
|
|
47
47
|
<template v-for="(el, i) in item" :key="i">
|
|
48
48
|
<div v-if="el.type === undefined || el.type === 'text'" class="many-text">
|
|
49
49
|
<div class="many-text-value">{{ el.label }}</div>
|
|
50
|
-
<button v-if="removable" class="many-text-close" @click.stop="emitRemove(el.value)">
|
|
50
|
+
<button v-if="removable" type="button" class="many-text-close" @click.stop="emitRemove(el.value)">
|
|
51
51
|
<IconX class="many-text-close-icon" />
|
|
52
52
|
</button>
|
|
53
53
|
</div>
|
|
@@ -56,7 +56,7 @@ function emitRemove(value: any) {
|
|
|
56
56
|
<div class="many-avatar-image"><SAvatar size="fill" :avatar="el.image" /></div>
|
|
57
57
|
<div class="many-avatar-name">{{ el.label }}</div>
|
|
58
58
|
</div>
|
|
59
|
-
<button v-if="removable" class="many-avatar-close" @click.stop="emitRemove(el.value)">
|
|
59
|
+
<button v-if="removable" type="button" class="many-avatar-close" @click.stop="emitRemove(el.value)">
|
|
60
60
|
<IconX class="many-avatar-close-icon" />
|
|
61
61
|
</button>
|
|
62
62
|
</div>
|
|
@@ -70,7 +70,7 @@ function emitRemove(value: any) {
|
|
|
70
70
|
<div class="one-avatar-image"><SAvatar size="fill" :avatar="item.image" /></div>
|
|
71
71
|
<div class="one-avatar-name">{{ item.label }}</div>
|
|
72
72
|
</div>
|
|
73
|
-
<button v-if="removable" class="one-close" @click.stop="emitRemove(item.value)">
|
|
73
|
+
<button v-if="removable" type="button" class="one-close" @click.stop="emitRemove(item.value)">
|
|
74
74
|
<IconX class="one-close-icon" />
|
|
75
75
|
</button>
|
|
76
76
|
</div>
|
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import IconCaretDown from '~icons/ph/caret-down'
|
|
3
|
+
import IconCaretUp from '~icons/ph/caret-up'
|
|
4
|
+
import IconCheck from '~icons/ph/check'
|
|
5
|
+
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
|
6
|
+
import { useManualDropdownPosition } from '../composables/Dropdown'
|
|
7
|
+
import { useFlyout } from '../composables/Flyout'
|
|
8
|
+
import { useTrans } from '../composables/Lang'
|
|
9
|
+
import SDropdownSectionFilterItem from './SDropdownSectionFilterItem.vue'
|
|
10
|
+
import SInputBase, { type Props as BaseProps } from './SInputBase.vue'
|
|
11
|
+
import SInputDropdownItem from './SInputDropdownItem.vue'
|
|
12
|
+
import SSpinner from './SSpinner.vue'
|
|
13
|
+
|
|
14
|
+
export type { Color, Size } from './SInputBase.vue'
|
|
15
|
+
|
|
16
|
+
export type Option = OptionText | OptionAvatar
|
|
17
|
+
|
|
18
|
+
export interface OptionBase {
|
|
19
|
+
type?: 'text' | 'avatar'
|
|
20
|
+
value: any
|
|
21
|
+
disabled?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface OptionText extends OptionBase {
|
|
25
|
+
type?: 'text'
|
|
26
|
+
label: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OptionAvatar extends OptionBase {
|
|
30
|
+
type: 'avatar'
|
|
31
|
+
label: string
|
|
32
|
+
image?: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Props extends BaseProps {
|
|
36
|
+
placeholder?: string
|
|
37
|
+
// Fetch the options matching `query` from the server. Called when the dropdown
|
|
38
|
+
// opens (with the current query, usually empty for the initial list) and again,
|
|
39
|
+
// debounced, on each keystroke. Out-of-order responses are discarded, so it does
|
|
40
|
+
// not need to guard against races itself.
|
|
41
|
+
fetch: (query: string) => Promise<Option[]>
|
|
42
|
+
// Decide whether a `fetch` rejection should propagate (be re-thrown) instead of
|
|
43
|
+
// being swallowed into an empty list — e.g. to let auth/session failures reach
|
|
44
|
+
// the app's re-auth flow. By default every rejection is swallowed.
|
|
45
|
+
rethrow?: (error: unknown) => boolean
|
|
46
|
+
// Whether multiple options can be selected. The model is then an array of the
|
|
47
|
+
// selected options; otherwise it is a single option or `null`.
|
|
48
|
+
multiple?: boolean
|
|
49
|
+
// Whether the (single) value can be cleared, or the (multiple) selection emptied.
|
|
50
|
+
nullable?: boolean
|
|
51
|
+
disabled?: boolean
|
|
52
|
+
position?: 'top' | 'bottom'
|
|
53
|
+
// Whether to keep the dropdown open after a selection. Defaults to closing in
|
|
54
|
+
// single mode and staying open in multiple mode.
|
|
55
|
+
closeOnSelect?: boolean
|
|
56
|
+
// Debounce window (ms) between a keystroke and the refetch.
|
|
57
|
+
debounce?: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// `closeOnSelect` defaults to `undefined` (not Vue's usual absent-Boolean → false)
|
|
61
|
+
// so the `?? !multiple` fallback in onSelect can tell "omitted" from an explicit
|
|
62
|
+
// `false`, keeping the single-select-closes / multiple-stays-open default.
|
|
63
|
+
const props = withDefaults(defineProps<Props>(), { closeOnSelect: undefined })
|
|
64
|
+
|
|
65
|
+
// The selected option(s). A single `Option | null`, or an `Option[]` when
|
|
66
|
+
// `multiple`. Typed loosely (like SInputDropdown) so a consumer can bind a ref of
|
|
67
|
+
// its own richer option type without v-model variance friction.
|
|
68
|
+
const model = defineModel<any>({ required: true })
|
|
69
|
+
|
|
70
|
+
const { t } = useTrans({
|
|
71
|
+
en: {
|
|
72
|
+
ph: 'Search',
|
|
73
|
+
searching: 'Searching…',
|
|
74
|
+
not_found: 'No options found.'
|
|
75
|
+
},
|
|
76
|
+
ja: {
|
|
77
|
+
ph: '検索',
|
|
78
|
+
searching: '検索中…',
|
|
79
|
+
not_found: 'オプションが見つかりません。'
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const container = ref<HTMLDivElement>()
|
|
84
|
+
const input = ref<HTMLInputElement | null>(null)
|
|
85
|
+
const list = ref<HTMLUListElement | null>(null)
|
|
86
|
+
|
|
87
|
+
const { isOpen, open, close } = useFlyout(container)
|
|
88
|
+
const { inset, update: updatePosition } = useManualDropdownPosition(
|
|
89
|
+
container,
|
|
90
|
+
() => props.position
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
const query = ref('')
|
|
94
|
+
const options = ref<Option[]>([])
|
|
95
|
+
const loading = ref(false)
|
|
96
|
+
|
|
97
|
+
// Monotonic token so only the latest fetch may assign `options` / clear `loading`.
|
|
98
|
+
// With a debounced, user-driven search several requests can overlap; an older one
|
|
99
|
+
// resolving late must not clobber the newer result.
|
|
100
|
+
let fetchSeq = 0
|
|
101
|
+
|
|
102
|
+
const classes = computed(() => [
|
|
103
|
+
props.size ?? 'small',
|
|
104
|
+
{ disabled: props.disabled }
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
const selected = computed<Option | Option[] | null>(() => {
|
|
108
|
+
if (props.multiple) {
|
|
109
|
+
return Array.isArray(model.value) ? (model.value as Option[]) : []
|
|
110
|
+
}
|
|
111
|
+
return (model.value as Option | null) ?? null
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const hasSelected = computed(() =>
|
|
115
|
+
props.multiple ? (selected.value as Option[]).length > 0 : selected.value !== null
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
const removable = computed(() => {
|
|
119
|
+
if (props.multiple) {
|
|
120
|
+
return !!props.nullable || (selected.value as Option[]).length > 1
|
|
121
|
+
}
|
|
122
|
+
return !!props.nullable
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
async function runFetch(q: string): Promise<void> {
|
|
126
|
+
const seq = ++fetchSeq
|
|
127
|
+
loading.value = true
|
|
128
|
+
try {
|
|
129
|
+
const res = await props.fetch(q)
|
|
130
|
+
if (seq === fetchSeq) {
|
|
131
|
+
options.value = res
|
|
132
|
+
}
|
|
133
|
+
} catch (e) {
|
|
134
|
+
// A failed search yields no options (the empty state shows)…
|
|
135
|
+
if (seq === fetchSeq) {
|
|
136
|
+
options.value = []
|
|
137
|
+
}
|
|
138
|
+
// …unless the consumer wants this failure propagated (e.g. an auth/session
|
|
139
|
+
// error that must reach the app's re-auth flow rather than look like "no
|
|
140
|
+
// results"). Re-throw regardless of `seq`: the failure is real even if a
|
|
141
|
+
// newer search has since superseded this one.
|
|
142
|
+
if (props.rethrow?.(e)) {
|
|
143
|
+
throw e
|
|
144
|
+
}
|
|
145
|
+
} finally {
|
|
146
|
+
if (seq === fetchSeq) {
|
|
147
|
+
loading.value = false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Debounce keystroke-driven refetches with a cancelable timer. The pending timer
|
|
153
|
+
// is cancelled on close and before each immediate open-fetch, so a keystroke from
|
|
154
|
+
// a previous open session can't fire (as the latest fetch) after a reopen and
|
|
155
|
+
// clobber the fresh initial list.
|
|
156
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
157
|
+
|
|
158
|
+
function cancelPendingFetch(): void {
|
|
159
|
+
if (debounceTimer !== undefined) {
|
|
160
|
+
clearTimeout(debounceTimer)
|
|
161
|
+
debounceTimer = undefined
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Refetch as the user types, but only while open (the reset-on-close below also
|
|
166
|
+
// writes `query`, and that shouldn't fire a fetch against a closed dropdown).
|
|
167
|
+
watch(query, (q) => {
|
|
168
|
+
if (!isOpen.value) {
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
cancelPendingFetch()
|
|
172
|
+
// Supersede any in-flight fetch right away — not only once the timer fires. A
|
|
173
|
+
// previous query's request that resolves during the debounce window would
|
|
174
|
+
// otherwise still pass runFetch's `seq === fetchSeq` check and replace the
|
|
175
|
+
// options (and clear loading) for a query the input has already moved past.
|
|
176
|
+
// Bumping the token invalidates it; keep `loading` on so the pending search
|
|
177
|
+
// stays indicated until the new fetch resolves.
|
|
178
|
+
fetchSeq++
|
|
179
|
+
loading.value = true
|
|
180
|
+
debounceTimer = setTimeout(() => {
|
|
181
|
+
debounceTimer = undefined
|
|
182
|
+
runFetch(q)
|
|
183
|
+
}, props.debounce ?? 250)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
function onOpen() {
|
|
187
|
+
if (props.disabled) {
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
updatePosition()
|
|
191
|
+
open()
|
|
192
|
+
// Fetch the current (initial) list immediately — dropping any keystroke timer
|
|
193
|
+
// left armed from a previous open — and focus the search input.
|
|
194
|
+
cancelPendingFetch()
|
|
195
|
+
runFetch(query.value)
|
|
196
|
+
nextTick(() => input.value?.focus())
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
watch(isOpen, (value) => {
|
|
200
|
+
// On close: drop any pending refetch, invalidate any in-flight fetch (so a late
|
|
201
|
+
// response can't repopulate the list while closed), and clear the query /
|
|
202
|
+
// options / loading. Reopening then starts fresh instead of briefly rendering
|
|
203
|
+
// the previous query's results (which would be selectable). The selected chips
|
|
204
|
+
// are unaffected — they render from the model, not from `options`.
|
|
205
|
+
if (!value) {
|
|
206
|
+
cancelPendingFetch()
|
|
207
|
+
fetchSeq++
|
|
208
|
+
query.value = ''
|
|
209
|
+
options.value = []
|
|
210
|
+
loading.value = false
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
onUnmounted(cancelPendingFetch)
|
|
215
|
+
|
|
216
|
+
// Close the dropdown if the control becomes disabled while open (e.g. a form
|
|
217
|
+
// submission starts), so its options can't be selected until it re-enables.
|
|
218
|
+
// `onSelect` also re-checks `props.disabled` as a belt-and-braces guard.
|
|
219
|
+
watch(() => props.disabled, (disabled) => {
|
|
220
|
+
if (disabled) {
|
|
221
|
+
close()
|
|
222
|
+
}
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
function isActive(value: any): boolean {
|
|
226
|
+
if (props.multiple) {
|
|
227
|
+
return (selected.value as Option[]).some((o) => o.value === value)
|
|
228
|
+
}
|
|
229
|
+
return (selected.value as Option | null)?.value === value
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function onSelect(option: Option): void {
|
|
233
|
+
if (props.disabled || option.disabled) {
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
props.validation?.$touch()
|
|
238
|
+
|
|
239
|
+
if (props.multiple) {
|
|
240
|
+
const current = (model.value as Option[] | null) ?? []
|
|
241
|
+
const exists = current.some((o) => o.value === option.value)
|
|
242
|
+
const next = exists
|
|
243
|
+
? current.filter((o) => o.value !== option.value)
|
|
244
|
+
: [...current, option]
|
|
245
|
+
// Keep at least one unless clearing is allowed (mirrors SInputDropdown).
|
|
246
|
+
if (next.length !== 0 || props.nullable) {
|
|
247
|
+
model.value = next
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
const currentValue = (model.value as Option | null)?.value
|
|
251
|
+
if (currentValue !== option.value) {
|
|
252
|
+
model.value = option
|
|
253
|
+
} else if (props.nullable) {
|
|
254
|
+
model.value = null
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (props.closeOnSelect ?? !props.multiple) {
|
|
259
|
+
close()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Remove a selected chip from the box (the X on SInputDropdownItem).
|
|
264
|
+
function onRemove(value: any): void {
|
|
265
|
+
props.validation?.$touch()
|
|
266
|
+
|
|
267
|
+
if (props.multiple) {
|
|
268
|
+
const current = (model.value as Option[] | null) ?? []
|
|
269
|
+
model.value = current.filter((o) => o.value !== value)
|
|
270
|
+
} else {
|
|
271
|
+
model.value = null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Move focus from the search input into the first option (ArrowDown from the box).
|
|
276
|
+
function focusFirstOption(): void {
|
|
277
|
+
list.value?.querySelector<HTMLElement>('.button:not([disabled])')?.focus()
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The next/previous enabled option button, skipping disabled ones (consistent
|
|
281
|
+
// with focusFirstOption and the click guard).
|
|
282
|
+
function enabledSibling(el: HTMLElement, forward: boolean): HTMLElement | null {
|
|
283
|
+
let li: Element | null | undefined = el.parentElement
|
|
284
|
+
for (;;) {
|
|
285
|
+
li = forward ? li?.nextElementSibling : li?.previousElementSibling
|
|
286
|
+
if (!li) {
|
|
287
|
+
return null
|
|
288
|
+
}
|
|
289
|
+
const button = li.querySelector<HTMLElement>('.button:not([disabled])')
|
|
290
|
+
if (button) {
|
|
291
|
+
return button
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function focusPrev(event: any): void {
|
|
297
|
+
// ArrowUp from the first option returns focus to the search input.
|
|
298
|
+
const prev = enabledSibling(event.target as HTMLElement, false)
|
|
299
|
+
prev ? prev.focus() : input.value?.focus()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function focusNext(event: any): void {
|
|
303
|
+
enabledSibling(event.target as HTMLElement, true)?.focus()
|
|
304
|
+
}
|
|
305
|
+
</script>
|
|
306
|
+
|
|
307
|
+
<template>
|
|
308
|
+
<SInputBase
|
|
309
|
+
class="SInputSelectSearch"
|
|
310
|
+
:class="classes"
|
|
311
|
+
:size
|
|
312
|
+
:name
|
|
313
|
+
:label
|
|
314
|
+
:note
|
|
315
|
+
:info
|
|
316
|
+
:help
|
|
317
|
+
:check-icon
|
|
318
|
+
:check-text
|
|
319
|
+
:check-color
|
|
320
|
+
:validation
|
|
321
|
+
:warning
|
|
322
|
+
:hide-error
|
|
323
|
+
:hide-warning
|
|
324
|
+
>
|
|
325
|
+
<div ref="container" class="container">
|
|
326
|
+
<div
|
|
327
|
+
class="box"
|
|
328
|
+
role="button"
|
|
329
|
+
tabindex="0"
|
|
330
|
+
@click="onOpen"
|
|
331
|
+
@keydown.down.prevent
|
|
332
|
+
@keyup.enter="onOpen"
|
|
333
|
+
@keyup.down="onOpen"
|
|
334
|
+
>
|
|
335
|
+
<div class="box-content">
|
|
336
|
+
<SInputDropdownItem
|
|
337
|
+
v-if="hasSelected"
|
|
338
|
+
:item="selected!"
|
|
339
|
+
:size="size ?? 'small'"
|
|
340
|
+
:removable
|
|
341
|
+
:disabled="disabled ?? false"
|
|
342
|
+
@remove="onRemove"
|
|
343
|
+
/>
|
|
344
|
+
|
|
345
|
+
<div v-else class="box-placeholder">{{ placeholder ?? t.ph }}</div>
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
<div class="box-icon">
|
|
349
|
+
<IconCaretUp class="box-icon-svg up" />
|
|
350
|
+
<IconCaretDown class="box-icon-svg down" />
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
|
|
354
|
+
<div v-if="isOpen" class="dropdown" :style="inset">
|
|
355
|
+
<div class="dropdown-content">
|
|
356
|
+
<div class="search">
|
|
357
|
+
<!-- Keep Enter inside the search field: it drives the dropdown and
|
|
358
|
+
must neither bubble to an enclosing form (`.stop`) nor trigger the
|
|
359
|
+
browser's implicit form submission (`.prevent`). ArrowDown moves
|
|
360
|
+
focus into the option list. -->
|
|
361
|
+
<input
|
|
362
|
+
ref="input"
|
|
363
|
+
v-model="query"
|
|
364
|
+
class="search-input"
|
|
365
|
+
:placeholder="t.ph"
|
|
366
|
+
@keydown.enter.stop.prevent
|
|
367
|
+
@keydown.down.prevent
|
|
368
|
+
@keyup.down.prevent="focusFirstOption"
|
|
369
|
+
>
|
|
370
|
+
<!-- A refetch while options are already shown keeps the (stale) list
|
|
371
|
+
visible; this spinner signals that newer results are loading. -->
|
|
372
|
+
<SSpinner v-if="loading && options.length" class="search-spinner" />
|
|
373
|
+
</div>
|
|
374
|
+
|
|
375
|
+
<ul v-if="options.length" ref="list" class="list">
|
|
376
|
+
<li v-for="option in options" :key="option.value" class="item">
|
|
377
|
+
<button
|
|
378
|
+
class="button"
|
|
379
|
+
:class="{ active: isActive(option.value) }"
|
|
380
|
+
type="button"
|
|
381
|
+
:disabled="option.disabled"
|
|
382
|
+
tabindex="0"
|
|
383
|
+
@keyup.up.prevent="focusPrev"
|
|
384
|
+
@keyup.down.prevent="focusNext"
|
|
385
|
+
@click="onSelect(option)"
|
|
386
|
+
>
|
|
387
|
+
<span v-if="multiple" class="checkbox">
|
|
388
|
+
<span class="checkbox-box">
|
|
389
|
+
<IconCheck class="checkbox-icon" />
|
|
390
|
+
</span>
|
|
391
|
+
</span>
|
|
392
|
+
<span v-else class="radio">
|
|
393
|
+
<span class="radio-dot" />
|
|
394
|
+
</span>
|
|
395
|
+
<span class="option-item">
|
|
396
|
+
<SDropdownSectionFilterItem :option />
|
|
397
|
+
</span>
|
|
398
|
+
</button>
|
|
399
|
+
</li>
|
|
400
|
+
</ul>
|
|
401
|
+
|
|
402
|
+
<div v-else-if="loading" class="state">
|
|
403
|
+
<SSpinner class="state-spinner" />
|
|
404
|
+
<span class="state-text">{{ t.searching }}</span>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<p v-else class="empty">{{ t.not_found }}</p>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
<template v-if="$slots.info" #info><slot name="info" /></template>
|
|
412
|
+
</SInputBase>
|
|
413
|
+
</template>
|
|
414
|
+
|
|
415
|
+
<style scoped lang="postcss">
|
|
416
|
+
.container {
|
|
417
|
+
position: relative;
|
|
418
|
+
width: 100%;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
.box {
|
|
422
|
+
position: relative;
|
|
423
|
+
display: flex;
|
|
424
|
+
align-items: center;
|
|
425
|
+
border: 1px solid var(--input-border-color);
|
|
426
|
+
border-radius: 8px;
|
|
427
|
+
width: 100%;
|
|
428
|
+
color: var(--input-text);
|
|
429
|
+
background-color: var(--input-bg-color);
|
|
430
|
+
box-shadow: var(--input-box-shadow);
|
|
431
|
+
cursor: pointer;
|
|
432
|
+
transition: border-color 0.25s, background-color 0.25s;
|
|
433
|
+
|
|
434
|
+
&:hover {
|
|
435
|
+
border-color: var(--input-hover-border-color);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
.box-content {
|
|
440
|
+
display: flex;
|
|
441
|
+
align-items: center;
|
|
442
|
+
flex-grow: 1;
|
|
443
|
+
max-width: 100%;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
.box-placeholder {
|
|
447
|
+
line-height: 24px;
|
|
448
|
+
color: var(--input-placeholder-color);
|
|
449
|
+
overflow: hidden;
|
|
450
|
+
white-space: nowrap;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.box-icon {
|
|
454
|
+
position: absolute;
|
|
455
|
+
z-index: 10;
|
|
456
|
+
cursor: pointer;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
.box-icon-svg {
|
|
460
|
+
display: block;
|
|
461
|
+
width: 14px;
|
|
462
|
+
height: 14px;
|
|
463
|
+
color: var(--c-text-2);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.box-icon-svg.up {
|
|
467
|
+
margin-bottom: -4px;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
.dropdown {
|
|
471
|
+
position: fixed;
|
|
472
|
+
z-index: var(--z-index-dropdown);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.dropdown-content {
|
|
476
|
+
border: 1px solid var(--c-border-mute-1);
|
|
477
|
+
border-radius: 8px;
|
|
478
|
+
background-color: var(--c-bg-elv-3);
|
|
479
|
+
box-shadow: var(--shadow-depth-3);
|
|
480
|
+
overflow: hidden;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
.search {
|
|
484
|
+
position: sticky;
|
|
485
|
+
top: 0;
|
|
486
|
+
z-index: 10;
|
|
487
|
+
border-bottom: 1px solid var(--c-gutter);
|
|
488
|
+
padding: 8px;
|
|
489
|
+
background-color: var(--c-bg-elv-3);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.search-spinner {
|
|
493
|
+
position: absolute;
|
|
494
|
+
top: 50%;
|
|
495
|
+
right: 16px;
|
|
496
|
+
width: 16px;
|
|
497
|
+
height: 16px;
|
|
498
|
+
transform: translateY(-50%);
|
|
499
|
+
color: var(--c-text-2);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
.search-input {
|
|
503
|
+
border: 1px solid var(--c-divider);
|
|
504
|
+
border-radius: 6px;
|
|
505
|
+
padding: 0 8px;
|
|
506
|
+
width: 100%;
|
|
507
|
+
font-size: 14px;
|
|
508
|
+
font-family: var(--input-value-font-family);
|
|
509
|
+
line-height: 32px;
|
|
510
|
+
background-color: var(--input-bg-color);
|
|
511
|
+
transition: border-color 0.25s;
|
|
512
|
+
|
|
513
|
+
&::placeholder {
|
|
514
|
+
color: var(--input-placeholder-color);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
&:hover { border-color: var(--input-hover-border-color); }
|
|
518
|
+
&:focus { border-color: var(--input-focus-border-color); }
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.list {
|
|
522
|
+
max-height: 280px;
|
|
523
|
+
padding: 8px;
|
|
524
|
+
overflow-y: auto;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
.button {
|
|
528
|
+
display: flex;
|
|
529
|
+
border-radius: 6px;
|
|
530
|
+
padding: 0 8px;
|
|
531
|
+
width: 100%;
|
|
532
|
+
text-align: left;
|
|
533
|
+
transition: color 0.25s, background-color 0.25s;
|
|
534
|
+
|
|
535
|
+
&:hover:not(:disabled) {
|
|
536
|
+
background-color: var(--c-bg-mute-1);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
&:disabled {
|
|
540
|
+
cursor: not-allowed;
|
|
541
|
+
opacity: 0.5;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.checkbox {
|
|
546
|
+
display: block;
|
|
547
|
+
padding-top: 8px;
|
|
548
|
+
padding-bottom: 8px;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
.checkbox-box {
|
|
552
|
+
display: flex;
|
|
553
|
+
justify-content: center;
|
|
554
|
+
align-items: center;
|
|
555
|
+
border: 1px solid var(--c-border-mute-1);
|
|
556
|
+
border-radius: 4px;
|
|
557
|
+
width: 16px;
|
|
558
|
+
height: 16px;
|
|
559
|
+
background-color: var(--input-bg-color);
|
|
560
|
+
transition: border-color 0.1s, background-color 0.1s;
|
|
561
|
+
|
|
562
|
+
.button.active & {
|
|
563
|
+
border-color: var(--c-fg-info-1);
|
|
564
|
+
background-color: var(--c-fg-info-1);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.checkbox-icon {
|
|
569
|
+
display: block;
|
|
570
|
+
width: 10px;
|
|
571
|
+
height: 10px;
|
|
572
|
+
color: var(--c-white);
|
|
573
|
+
opacity: 0;
|
|
574
|
+
transition: opacity 0.25s;
|
|
575
|
+
|
|
576
|
+
.button.active & {
|
|
577
|
+
opacity: 1;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.radio {
|
|
582
|
+
position: relative;
|
|
583
|
+
flex-shrink: 0;
|
|
584
|
+
border: 1px solid var(--c-border-mute-1);
|
|
585
|
+
border-radius: 50%;
|
|
586
|
+
margin-top: 8px;
|
|
587
|
+
margin-bottom: 8px;
|
|
588
|
+
width: 16px;
|
|
589
|
+
height: 16px;
|
|
590
|
+
background-color: var(--input-bg-color);
|
|
591
|
+
transition: border-color 0.25s;
|
|
592
|
+
|
|
593
|
+
.button.active & {
|
|
594
|
+
border-color: var(--c-border-info-1);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
.radio-dot {
|
|
599
|
+
position: absolute;
|
|
600
|
+
top: 0;
|
|
601
|
+
right: 0;
|
|
602
|
+
bottom: 0;
|
|
603
|
+
left: 0;
|
|
604
|
+
display: flex;
|
|
605
|
+
justify-content: center;
|
|
606
|
+
align-items: center;
|
|
607
|
+
border-radius: 50%;
|
|
608
|
+
width: 100%;
|
|
609
|
+
background-color: var(--c-fg-info-1);
|
|
610
|
+
opacity: 0;
|
|
611
|
+
transform: scale(0);
|
|
612
|
+
transition: opacity 0.25s, transform 0.1s;
|
|
613
|
+
|
|
614
|
+
.button.active & {
|
|
615
|
+
opacity: 1;
|
|
616
|
+
transform: scale(0.6);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
.option-item {
|
|
621
|
+
padding-left: 12px;
|
|
622
|
+
width: 100%;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
.state {
|
|
626
|
+
display: flex;
|
|
627
|
+
align-items: center;
|
|
628
|
+
gap: 8px;
|
|
629
|
+
padding: 14px 16px;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
.state-spinner {
|
|
633
|
+
width: 16px;
|
|
634
|
+
height: 16px;
|
|
635
|
+
color: var(--c-text-2);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
.state-text {
|
|
639
|
+
font-size: 12px;
|
|
640
|
+
color: var(--c-text-2);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
.empty {
|
|
644
|
+
padding: 14px 16px;
|
|
645
|
+
font-size: 12px;
|
|
646
|
+
color: var(--c-text-2);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
.SInputSelectSearch.sm,
|
|
650
|
+
.SInputSelectSearch.mini {
|
|
651
|
+
.box {
|
|
652
|
+
min-height: 32px;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.box-content {
|
|
656
|
+
padding: 0 30px 0 0;
|
|
657
|
+
line-height: 24px;
|
|
658
|
+
font-size: var(--input-font-size, var(--input-mini-font-size));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
.box-placeholder {
|
|
662
|
+
padding-left: 10px;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
.box-icon {
|
|
666
|
+
top: 3px;
|
|
667
|
+
right: 8px;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.SInputSelectSearch.md {
|
|
672
|
+
.box {
|
|
673
|
+
min-height: 36px;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
.box-content {
|
|
677
|
+
padding: 0 30px 0 0;
|
|
678
|
+
line-height: 24px;
|
|
679
|
+
font-size: var(--input-font-size, 14px);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
.box-placeholder {
|
|
683
|
+
padding-left: 10px;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
.box-icon {
|
|
687
|
+
top: 5px;
|
|
688
|
+
right: 8px;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
.SInputSelectSearch.small {
|
|
693
|
+
.box {
|
|
694
|
+
min-height: 40px;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
.box-content {
|
|
698
|
+
padding: 0 30px 0 0;
|
|
699
|
+
line-height: 24px;
|
|
700
|
+
font-size: var(--input-font-size, var(--input-small-font-size));
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
.box-placeholder {
|
|
704
|
+
padding-left: 12px;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
.box-icon {
|
|
708
|
+
top: 7px;
|
|
709
|
+
right: 8px;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
.SInputSelectSearch.medium {
|
|
714
|
+
.box {
|
|
715
|
+
min-height: 48px;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
.box-content {
|
|
719
|
+
padding: 0 40px 0 0;
|
|
720
|
+
line-height: 24px;
|
|
721
|
+
font-size: var(--input-font-size, var(--input-medium-font-size));
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
.box-placeholder {
|
|
725
|
+
padding-left: 16px;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
.box-icon {
|
|
729
|
+
top: 11px;
|
|
730
|
+
right: 12px;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
.SInputSelectSearch.disabled {
|
|
735
|
+
.box {
|
|
736
|
+
border-color: var(--input-disabled-border-color);
|
|
737
|
+
background-color: var(--input-disabled-bg-color);
|
|
738
|
+
cursor: not-allowed;
|
|
739
|
+
|
|
740
|
+
&:hover { border-color: var(--input-disabled-border-color); }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
.box-icon {
|
|
744
|
+
cursor: not-allowed;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
.SInputSelectSearch.has-error {
|
|
749
|
+
.box {
|
|
750
|
+
border-color: var(--input-error-border-color);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
</style>
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the event target is a plain text-like `<input>` — the controls where
|
|
3
|
+
* pressing Enter should submit an inline editor. Excludes textareas (Enter
|
|
4
|
+
* inserts a newline) and richer controls that handle Enter themselves (e.g. a
|
|
5
|
+
* dropdown that opens its menu on Enter), so a keyboard user isn't forced to
|
|
6
|
+
* submit instead of operating the control.
|
|
7
|
+
*/
|
|
8
|
+
export function isTextLikeInput(target: EventTarget | null): boolean {
|
|
9
|
+
const el = target as HTMLElement | null
|
|
10
|
+
|
|
11
|
+
if (!el || el.tagName !== 'INPUT') {
|
|
12
|
+
return false
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const type = (el as HTMLInputElement).type
|
|
16
|
+
|
|
17
|
+
return ['text', 'search', 'url', 'email', 'tel', 'password', 'number'].includes(type)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether an Enter keydown should submit an inline editor. Ctrl/Cmd+Enter
|
|
22
|
+
* submits from any control — it's the near-universal "submit" gesture (GitHub,
|
|
23
|
+
* Gmail, Slack, …) and never clashes with a textarea's newline or a control that
|
|
24
|
+
* handles bare Enter itself (e.g. a dropdown opening its menu). Bare Enter
|
|
25
|
+
* submits only from a plain text-like input (see {@link isTextLikeInput}).
|
|
26
|
+
*
|
|
27
|
+
* Shift+Enter is left for newlines, and Alt/Option+Enter has no consistent
|
|
28
|
+
* submit meaning (Excel uses it for an in-cell newline), so neither submits.
|
|
29
|
+
* The Enter that commits an IME composition (e.g. CJK input) never submits.
|
|
30
|
+
*/
|
|
31
|
+
export function isEditorSubmitKeydown(event: KeyboardEvent): boolean {
|
|
32
|
+
if (event.key !== 'Enter') {
|
|
33
|
+
return false
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Ignore the Enter that commits an IME composition (e.g. CJK input): it
|
|
37
|
+
// confirms the candidate rather than submitting.
|
|
38
|
+
if (event.isComposing) {
|
|
39
|
+
return false
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return event.ctrlKey || event.metaKey || isTextLikeInput(event.target)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether a keydown should cancel an inline editor: Escape, except the Escape
|
|
47
|
+
* that cancels an in-progress IME composition (which reverts the candidate and
|
|
48
|
+
* should leave the editor open).
|
|
49
|
+
*/
|
|
50
|
+
export function isEditorCancelKeydown(event: KeyboardEvent): boolean {
|
|
51
|
+
return event.key === 'Escape' && !event.isComposing
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Dispatch a keydown for an inline editor (opened from a cell or sheet field):
|
|
56
|
+
* cancel on Escape, submit on Enter — using the IME- and modifier-aware rules
|
|
57
|
+
* in {@link isEditorCancelKeydown} / {@link isEditorSubmitKeydown}.
|
|
58
|
+
*
|
|
59
|
+
* `shield` is for an editor nested in a surface that itself closes on Escape
|
|
60
|
+
* (e.g. a sheet): Escape is always kept from propagating to that surface — even
|
|
61
|
+
* the Escape that only cancels an IME composition (where the edit stays open) —
|
|
62
|
+
* so the surface can't close underneath the editor.
|
|
63
|
+
*
|
|
64
|
+
* NOTE for future inline/sheet-editable fields: several field types currently
|
|
65
|
+
* throw on `formInputComponent()` (not yet editable). When making one editable,
|
|
66
|
+
* if its input is a composite control with its own nested text input (e.g. a
|
|
67
|
+
* 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.
|
|
70
|
+
*/
|
|
71
|
+
export function dispatchEditorKeydown(
|
|
72
|
+
event: KeyboardEvent,
|
|
73
|
+
handlers: { cancel: () => void; submit: () => void; shield?: boolean }
|
|
74
|
+
): void {
|
|
75
|
+
if (event.key === 'Escape') {
|
|
76
|
+
if (handlers.shield) {
|
|
77
|
+
event.stopPropagation()
|
|
78
|
+
}
|
|
79
|
+
if (isEditorCancelKeydown(event)) {
|
|
80
|
+
event.preventDefault()
|
|
81
|
+
handlers.cancel()
|
|
82
|
+
}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isEditorSubmitKeydown(event)) {
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
handlers.submit()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Focus the first focusable editor control (an input, textarea, contenteditable,
|
|
94
|
+
* or any `[tabindex]`) inside the container — used to focus an inline editor when
|
|
95
|
+
* it opens.
|
|
96
|
+
*
|
|
97
|
+
* Candidates inside `skip` (default `.actions`, the editor's action row) are
|
|
98
|
+
* ignored, so focus never lands on a Cancel/Save button — those are `[tabindex]`
|
|
99
|
+
* elements too — when the field input has no focusable control of its own (e.g.
|
|
100
|
+
* a radio/checkbox group). In that case nothing is focused. Pass `skip: ''` to
|
|
101
|
+
* disable.
|
|
102
|
+
*/
|
|
103
|
+
export function focusFirstEditable(container: HTMLElement | null, skip = '.actions'): void {
|
|
104
|
+
const candidates = Array.from(
|
|
105
|
+
container?.querySelectorAll<HTMLElement>('input, textarea, [contenteditable], [tabindex]') ?? []
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
candidates.find((el) => !skip || !el.closest(skip))?.focus()
|
|
109
|
+
}
|