@veltra/desktop 1.3.5 → 1.3.6
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/dist/components/action/action-group.vue.d.ts +1 -1
- package/dist/components/action/action.vue.d.ts +1 -1
- package/dist/components/cascade/cascade.js +8 -2
- package/dist/components/cascade/cascade.js.map +1 -1
- package/dist/components/cascade/style2.css +4 -0
- package/dist/components/date-range-picker/date-range-picker.js +5 -2
- package/dist/components/date-range-picker/date-range-picker.js.map +1 -1
- package/dist/components/date-range-picker/style2.css +4 -0
- package/dist/components/empty/empty.vue.d.ts +1 -1
- package/dist/components/form-item/form-item.js.map +1 -1
- package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js +2 -2
- package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/form-item/style2.css +2 -1
- package/dist/components/grid-input/style2.css +3 -0
- package/dist/components/multi-select/multi-select.js +67 -40
- package/dist/components/multi-select/multi-select.js.map +1 -1
- package/dist/components/multi-select/multi-select.vue.d.ts +4 -4
- package/dist/components/multi-select/style2.css +23 -10
- package/dist/components/multi-tree-select/multi-tree-select.js +52 -35
- package/dist/components/multi-tree-select/multi-tree-select.js.map +1 -1
- package/dist/components/multi-tree-select/multi-tree-select.vue.d.ts +1 -1
- package/dist/components/multi-tree-select/style2.css +26 -16
- package/dist/components/progress-nodes/progress-nodes.vue.d.ts +1 -1
- package/dist/components/select/select.js +159 -95
- package/dist/components/select/select.js.map +1 -1
- package/dist/components/select/style2.css +4 -17
- package/dist/components/select/use-options.js +67 -26
- package/dist/components/select/use-options.js.map +1 -1
- package/dist/components/steps/steps.vue.d.ts +2 -2
- package/dist/components/tabs/tabs-horizontal.vue.d.ts +2 -2
- package/dist/components/tabs/tabs-vertical.vue.d.ts +2 -2
- package/dist/components/tabs/tabs.vue.d.ts +2 -2
- package/dist/components/textarea/style2.css +1 -0
- package/dist/components/textarea/textarea.vue.d.ts +1 -1
- package/dist/components/tree/tree.vue.d.ts +1 -1
- package/dist/components/tree-select/style2.css +6 -15
- package/dist/components/tree-select/tree-select.js +95 -41
- package/dist/components/tree-select/tree-select.js.map +1 -1
- package/dist/components/tree-select/tree-select.vue.d.ts +1 -1
- package/package.json +6 -6
- package/src/components/cascade/cascade.vue +15 -2
- package/src/components/cascade/style.scss +5 -0
- package/src/components/date-range-picker/date-range-picker.vue +9 -1
- package/src/components/date-range-picker/style.scss +5 -0
- package/src/components/form-item/form-item.vue +2 -2
- package/src/components/form-item/style.scss +2 -1
- package/src/components/grid-input/style.scss +1 -0
- package/src/components/multi-select/multi-select.vue +50 -19
- package/src/components/multi-select/style.scss +27 -4
- package/src/components/multi-tree-select/multi-tree-select.vue +34 -14
- package/src/components/multi-tree-select/style.scss +28 -9
- package/src/components/select/__test__/select.test.ts +110 -0
- package/src/components/select/select.vue +130 -46
- package/src/components/select/style.scss +5 -14
- package/src/components/select/use-options.ts +83 -39
- package/src/components/textarea/style.scss +1 -0
- package/src/components/tree-select/style.scss +4 -8
- package/src/components/tree-select/tree-select.vue +90 -17
|
@@ -13,44 +13,48 @@
|
|
|
13
13
|
:width="width"
|
|
14
14
|
@keydown="handleKeydown"
|
|
15
15
|
@update:visible="handleDropdownVisible"
|
|
16
|
+
@mouseenter="hovered = true"
|
|
17
|
+
@mouseleave="hovered = false"
|
|
16
18
|
>
|
|
17
19
|
<!-- 触发 -->
|
|
18
20
|
<template #trigger>
|
|
19
21
|
<u-input
|
|
22
|
+
ref="inputRef"
|
|
20
23
|
:size="size"
|
|
21
24
|
:disabled="disabled"
|
|
22
|
-
:placeholder="
|
|
23
|
-
:clearable="
|
|
24
|
-
:model-value="
|
|
25
|
-
@
|
|
25
|
+
:placeholder="inputPlaceholder"
|
|
26
|
+
:clearable="false"
|
|
27
|
+
:model-value="inputValue"
|
|
28
|
+
@update:model-value="handleQueryInput"
|
|
26
29
|
@keydown="handleKeydown"
|
|
27
|
-
native-readonly
|
|
30
|
+
:native-readonly="!filterable || !querying"
|
|
31
|
+
@click.capture="handleTriggerClickCapture"
|
|
28
32
|
>
|
|
29
33
|
<template #prefix v-if="$slots.prefix">
|
|
30
34
|
<slot name="prefix" />
|
|
31
35
|
</template>
|
|
32
36
|
|
|
33
37
|
<template #suffix>
|
|
34
|
-
<
|
|
35
|
-
<
|
|
36
|
-
|
|
38
|
+
<transition name="zoom-in" mode="out-in">
|
|
39
|
+
<u-icon
|
|
40
|
+
v-if="showClear"
|
|
41
|
+
:class="cls.e('clear')"
|
|
42
|
+
title="清除"
|
|
43
|
+
key="clear"
|
|
44
|
+
@click.stop="handleClear"
|
|
45
|
+
>
|
|
46
|
+
<Close />
|
|
47
|
+
</u-icon>
|
|
48
|
+
<u-icon v-else :class="cls.e('arrow')" key="arrow">
|
|
49
|
+
<ArrowDown />
|
|
50
|
+
</u-icon>
|
|
51
|
+
</transition>
|
|
37
52
|
</template>
|
|
38
53
|
</u-input>
|
|
39
54
|
</template>
|
|
40
55
|
|
|
41
56
|
<!-- 下拉内容 -->
|
|
42
57
|
<template #content>
|
|
43
|
-
<!-- 过滤器 -->
|
|
44
|
-
<div v-if="filterable" :class="cls.e('content-filter')">
|
|
45
|
-
<u-input placeholder="输入关键字进行搜索" tabindex="0" v-focus v-model="queryString">
|
|
46
|
-
<template #suffix>
|
|
47
|
-
<u-icon>
|
|
48
|
-
<Search />
|
|
49
|
-
</u-icon>
|
|
50
|
-
</template>
|
|
51
|
-
</u-input>
|
|
52
|
-
</div>
|
|
53
|
-
|
|
54
58
|
<!--
|
|
55
59
|
单选列表。虚拟化启用时,内容容器 height 由 useVirtualizer 命令式写入;
|
|
56
60
|
此处 content-style 仅承担 grid 相关样式,避免 height 变化触发模板重渲染。
|
|
@@ -117,17 +121,17 @@
|
|
|
117
121
|
<script lang="ts" setup>
|
|
118
122
|
import { o } from '@cat-kit/core'
|
|
119
123
|
import { useFormFallbackProps, useVirtualizer } from '@veltra/compositions'
|
|
120
|
-
import {
|
|
121
|
-
import { ArrowDown, Search } from '@veltra/icons/normal'
|
|
124
|
+
import { ArrowDown, Close } from '@veltra/icons/normal'
|
|
122
125
|
import { bem, fieldKey, FORM_EMPTY_CONTENT, scrollIntoContainerView, withUnit } from '@veltra/utils'
|
|
123
126
|
import { injectFormContext } from '@veltra/utils'
|
|
124
|
-
import { computed, nextTick, shallowRef, watch } from 'vue'
|
|
127
|
+
import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
|
|
125
128
|
|
|
126
129
|
import type {
|
|
127
130
|
SelectEmits,
|
|
128
131
|
SelectProps,
|
|
129
132
|
_SelectExposed,
|
|
130
133
|
DropdownExposed,
|
|
134
|
+
InputExposed,
|
|
131
135
|
ScrollExposed
|
|
132
136
|
} from '../../types'
|
|
133
137
|
import { UDropdown } from '../dropdown'
|
|
@@ -184,6 +188,7 @@ const displayedValue = computed(() => {
|
|
|
184
188
|
|
|
185
189
|
const dropdownRef = shallowRef<DropdownExposed>()
|
|
186
190
|
const scrollRef = shallowRef<ScrollExposed>()
|
|
191
|
+
const inputRef = useTemplateRef<InputExposed>('inputRef')
|
|
187
192
|
|
|
188
193
|
const filterable = computed(() => {
|
|
189
194
|
return props.filterable || typeof props.options === 'function'
|
|
@@ -193,37 +198,101 @@ const { queryString, options, temOptionsToCreatedOptions, clearCreatedOptions }
|
|
|
193
198
|
props
|
|
194
199
|
})
|
|
195
200
|
|
|
201
|
+
const dropdownVisible = shallowRef(false)
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 是否处于查询态:决定触发输入框显示查询串还是选中标签。
|
|
205
|
+
* 与面板可见状态解耦 —— 选择后立即退出查询态,
|
|
206
|
+
* 不等面板关闭动画结束(否则显示值会延迟一个动画时长才恢复)。
|
|
207
|
+
*/
|
|
208
|
+
const querying = shallowRef(false)
|
|
209
|
+
|
|
210
|
+
/** 触发输入框的值:查询态下承载查询串,否则展示选中标签 */
|
|
211
|
+
const inputValue = computed(() => {
|
|
212
|
+
if (filterable.value && querying.value) return queryString.value
|
|
213
|
+
return displayedValue.value
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
/** 查询态下已选标签降级为占位提示 */
|
|
217
|
+
const inputPlaceholder = computed(() => {
|
|
218
|
+
if (filterable.value && querying.value && displayedValue.value) {
|
|
219
|
+
return displayedValue.value
|
|
220
|
+
}
|
|
221
|
+
return props.placeholder
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
function handleQueryInput(value: string) {
|
|
225
|
+
if (!filterable.value) return
|
|
226
|
+
queryString.value = value
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const hovered = shallowRef(false)
|
|
230
|
+
|
|
231
|
+
/** 悬停且存在选中值时展示清除按钮(替代下拉箭头) */
|
|
232
|
+
const showClear = computed(() => {
|
|
233
|
+
return props.clearable && !disabled.value && hovered.value && !!displayedValue.value
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 过滤模式下拦截输入区域的点击:
|
|
238
|
+
* 阻止 dropdown 的 trigger 开合切换,保持面板展开以不中断输入。
|
|
239
|
+
* 非输入区域(箭头、留白)放行,维持原开合行为。
|
|
240
|
+
*/
|
|
241
|
+
function handleTriggerClickCapture(e: MouseEvent) {
|
|
242
|
+
if (!filterable.value || disabled.value) return
|
|
243
|
+
if (!(e.target instanceof HTMLInputElement)) return
|
|
244
|
+
|
|
245
|
+
e.stopPropagation()
|
|
246
|
+
if (!dropdownVisible.value) dropdownRef.value?.open()
|
|
247
|
+
}
|
|
248
|
+
|
|
196
249
|
// TODO: 优化
|
|
197
250
|
let userSelecting = false
|
|
198
251
|
function lock() {
|
|
199
252
|
userSelecting = true
|
|
200
253
|
}
|
|
201
254
|
|
|
202
|
-
|
|
255
|
+
/**
|
|
256
|
+
* @param preferredValue 选择时传入刚提交的值,避免 nextTick 时 props.modelValue 尚未回传而用旧值覆盖高亮/标签
|
|
257
|
+
*/
|
|
258
|
+
function unlock(preferredValue?: any) {
|
|
203
259
|
nextTick(() => {
|
|
204
260
|
userSelecting = false
|
|
261
|
+
// 选择/清除会同步重建选项列表(临时项转正、查询串清空),
|
|
262
|
+
// 重建触发的回显在锁定期间被跳过,解锁后按最新列表重同步一次,
|
|
263
|
+
// 避免高亮停留在选择前的旧索引上
|
|
264
|
+
const value = preferredValue !== undefined ? preferredValue : props.modelValue
|
|
265
|
+
syncSelected(value, options.value)
|
|
205
266
|
})
|
|
206
267
|
}
|
|
207
268
|
|
|
269
|
+
/** 按 modelValue 与选项列表同步高亮索引、选中项与显示标签 */
|
|
270
|
+
function syncSelected(modelValue: any, options: Record<string, any>[] | undefined) {
|
|
271
|
+
// 查询输入期间高亮第一项(创建模式下通常是临时项,便于回车创建)
|
|
272
|
+
if (queryString.value) {
|
|
273
|
+
currentIndex.value = options?.length ? 0 : -1
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!options?.length) return
|
|
278
|
+
|
|
279
|
+
if (modelValue !== undefined && modelValue !== null && modelValue !== '') {
|
|
280
|
+
currentIndex.value = options.findIndex((option) => o(option).get(valueKey.value) === modelValue)
|
|
281
|
+
selected.value = currentIndex.value >= 0 ? options[currentIndex.value] : undefined
|
|
282
|
+
label.value = selected.value ? o(selected.value).get(labelKey.value) : undefined
|
|
283
|
+
} else {
|
|
284
|
+
currentIndex.value = -1
|
|
285
|
+
selected.value = undefined
|
|
286
|
+
label.value = undefined
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
208
290
|
// 回显
|
|
209
291
|
watch(
|
|
210
292
|
[() => props.modelValue, options],
|
|
211
293
|
([modelValue, options]) => {
|
|
212
294
|
if (userSelecting) return
|
|
213
|
-
|
|
214
|
-
if (!options?.length) return
|
|
215
|
-
|
|
216
|
-
if (modelValue !== undefined && modelValue !== null && modelValue !== '') {
|
|
217
|
-
currentIndex.value = options.findIndex(
|
|
218
|
-
(option) => o(option).get(valueKey.value) === modelValue
|
|
219
|
-
)
|
|
220
|
-
selected.value = currentIndex.value >= 0 ? options[currentIndex.value] : undefined
|
|
221
|
-
label.value = selected.value ? o(selected.value).get(labelKey.value) : undefined
|
|
222
|
-
} else {
|
|
223
|
-
currentIndex.value = -1
|
|
224
|
-
selected.value = undefined
|
|
225
|
-
label.value = undefined
|
|
226
|
-
}
|
|
295
|
+
syncSelected(modelValue, options)
|
|
227
296
|
},
|
|
228
297
|
{ immediate: true }
|
|
229
298
|
)
|
|
@@ -275,10 +344,16 @@ watch([scrollRef, virtualEnabled], ([scroll, virtualEnabled]) => {
|
|
|
275
344
|
}
|
|
276
345
|
})
|
|
277
346
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
347
|
+
watch(dropdownVisible, (visible) => {
|
|
348
|
+
if (visible) {
|
|
349
|
+
// 面板展开后进入查询态并聚焦输入框,可以立即输入查询
|
|
350
|
+
if (filterable.value) {
|
|
351
|
+
queryString.value = ''
|
|
352
|
+
querying.value = true
|
|
353
|
+
nextTick(() => inputRef.value?.el?.focus())
|
|
354
|
+
}
|
|
355
|
+
} else {
|
|
356
|
+
querying.value = false
|
|
282
357
|
queryString.value = ''
|
|
283
358
|
}
|
|
284
359
|
})
|
|
@@ -290,20 +365,29 @@ const handleDropdownVisible = (visible: boolean) => {
|
|
|
290
365
|
}
|
|
291
366
|
|
|
292
367
|
/** 单选 */
|
|
293
|
-
const handleSelect = (option: Record<string, any>,
|
|
368
|
+
const handleSelect = (option: Record<string, any>, _index: number) => {
|
|
294
369
|
lock()
|
|
370
|
+
const value = option?.[valueKey.value]
|
|
295
371
|
selected.value = option
|
|
296
372
|
|
|
297
|
-
emit('update:modelValue',
|
|
373
|
+
emit('update:modelValue', value)
|
|
298
374
|
label.value = option?.[labelKey.value]
|
|
299
375
|
emit('change', option)
|
|
300
376
|
if (option.__isTemp) {
|
|
377
|
+
// 转正后列表同步重建为完整源(临时项置顶索引失效),必须按 value 重定位
|
|
301
378
|
temOptionsToCreatedOptions()
|
|
302
379
|
}
|
|
303
|
-
|
|
380
|
+
// 立即退出查询态,输入框同步恢复显示选中标签
|
|
381
|
+
querying.value = false
|
|
382
|
+
queryString.value = ''
|
|
383
|
+
// 按当前列表定位高亮,避免创建项转正后仍停留在点击时的临时索引(通常为 0)
|
|
384
|
+
currentIndex.value = options.value.findIndex((item) => o(item).get(valueKey.value) === value)
|
|
385
|
+
if (currentIndex.value >= 0) {
|
|
386
|
+
selected.value = options.value[currentIndex.value]
|
|
387
|
+
}
|
|
304
388
|
dropdownRef.value?.close()
|
|
305
389
|
|
|
306
|
-
unlock()
|
|
390
|
+
unlock(value)
|
|
307
391
|
}
|
|
308
392
|
|
|
309
393
|
/** 清除选项 */
|
|
@@ -315,7 +399,7 @@ const handleClear = () => {
|
|
|
315
399
|
clearCreatedOptions()
|
|
316
400
|
emit('update:modelValue', undefined)
|
|
317
401
|
emit('change', undefined)
|
|
318
|
-
unlock()
|
|
402
|
+
unlock(null)
|
|
319
403
|
}
|
|
320
404
|
|
|
321
405
|
function getCurrentEl() {
|
|
@@ -5,17 +5,8 @@ $root-name: select;
|
|
|
5
5
|
@include m.b($root-name) {
|
|
6
6
|
width: 100%;
|
|
7
7
|
|
|
8
|
-
@include m.e(content-filter) {
|
|
9
|
-
border-bottom: 1px solid fn.use-var(border, color);
|
|
10
|
-
background-color: fn.use-var(bg-color, middle);
|
|
11
|
-
|
|
12
|
-
@include m.bem(input) {
|
|
13
|
-
box-shadow: none;
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
8
|
@include m.e(options) {
|
|
18
|
-
max-height:
|
|
9
|
+
max-height: 240px;
|
|
19
10
|
}
|
|
20
11
|
|
|
21
12
|
@include m.is(disabled) {
|
|
@@ -24,6 +15,10 @@ $root-name: select;
|
|
|
24
15
|
}
|
|
25
16
|
}
|
|
26
17
|
|
|
18
|
+
@include m.e(clear) {
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
}
|
|
21
|
+
|
|
27
22
|
@include m.e(options-wrap) {
|
|
28
23
|
list-style: none;
|
|
29
24
|
margin: 0;
|
|
@@ -100,10 +95,6 @@ $root-name: select;
|
|
|
100
95
|
|
|
101
96
|
@include m.e(panel) {
|
|
102
97
|
@include m.size using ($size) {
|
|
103
|
-
@include m.bem($root-name, content-filter) {
|
|
104
|
-
padding: calc(fn.use-var(gap, $size) / 2) fn.use-var(gap, $size);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
98
|
@include m.bem($root-name, options) {
|
|
108
99
|
padding: fn.use-var(gap, $size);
|
|
109
100
|
}
|
|
@@ -23,8 +23,8 @@ interface UseOptionsReturned {
|
|
|
23
23
|
|
|
24
24
|
// 允许创建的实现
|
|
25
25
|
// 1. 在过滤时将选项加入待选列表中
|
|
26
|
-
// 2.
|
|
27
|
-
// 3.
|
|
26
|
+
// 2. 如果选择了该临时选项,则取消该选项的临时标记并并入已创建列表
|
|
27
|
+
// 3. 多次创建累计保留,已创建项参与后续过滤匹配
|
|
28
28
|
|
|
29
29
|
export function useOptions(o: Options): UseOptionsReturned {
|
|
30
30
|
const { props } = o
|
|
@@ -39,14 +39,38 @@ export function useOptions(o: Options): UseOptionsReturned {
|
|
|
39
39
|
// 创建的选项
|
|
40
40
|
const createdOptions = shallowRef<({ __isTemp: false } & Record<string, any>)[]>([])
|
|
41
41
|
|
|
42
|
+
function getSourceOptions(propsOptions: SelectProps['options']) {
|
|
43
|
+
if (typeof propsOptions === 'function') return remoteOptions.value
|
|
44
|
+
return [...createdOptions.value, ...(propsOptions ?? [])]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 无查询时同步展示完整列表(含已创建项),避免选择后等高亮索引错位 */
|
|
48
|
+
function resetToSourceOptions() {
|
|
49
|
+
const sourceOptions = getSourceOptions(props.options)
|
|
50
|
+
if (typeof props.options === 'function') {
|
|
51
|
+
setTempOption('', remoteOptions.value)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
filteredOptions.value = sourceOptions
|
|
55
|
+
tempOptions.value = []
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
function temOptionsToCreatedOptions() {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
59
|
+
if (!tempOptions.value.length) return
|
|
60
|
+
|
|
61
|
+
createdOptions.value = [
|
|
62
|
+
...createdOptions.value,
|
|
63
|
+
...tempOptions.value.map((item) => {
|
|
64
|
+
return { ...item, __isTemp: false as const }
|
|
65
|
+
})
|
|
66
|
+
]
|
|
67
|
+
// 立即清掉临时项并回到完整列表,不等待过滤防抖
|
|
68
|
+
resetToSourceOptions()
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
function clearCreatedOptions() {
|
|
49
72
|
createdOptions.value = []
|
|
73
|
+
resetToSourceOptions()
|
|
50
74
|
}
|
|
51
75
|
|
|
52
76
|
const setTempOption = (qs: string, options?: Record<string, any>[]) => {
|
|
@@ -62,59 +86,79 @@ export function useOptions(o: Options): UseOptionsReturned {
|
|
|
62
86
|
})
|
|
63
87
|
: false
|
|
64
88
|
|
|
65
|
-
|
|
66
|
-
tempOptions.value = [{ [labelKey]: qs, [valueKey]: qs, __isTemp: true }]
|
|
67
|
-
}
|
|
89
|
+
tempOptions.value = exactMatch ? [] : [{ [labelKey]: qs, [valueKey]: qs, __isTemp: true }]
|
|
68
90
|
}
|
|
69
91
|
|
|
70
92
|
const allOptions = computed(() => {
|
|
71
93
|
const { options } = props
|
|
72
94
|
if (typeof options === 'function') return remoteOptions.value
|
|
73
|
-
return options ?? []
|
|
95
|
+
return [...createdOptions.value, ...(options ?? [])]
|
|
74
96
|
})
|
|
75
97
|
|
|
76
|
-
/**
|
|
98
|
+
/**
|
|
99
|
+
* 展示的选项
|
|
100
|
+
* @description 已创建选项并入过滤数据源参与匹配;临时选项(待创建)固定置顶
|
|
101
|
+
*/
|
|
77
102
|
const options = computed(() => {
|
|
78
103
|
const { options } = props
|
|
79
104
|
|
|
80
105
|
if (typeof options === 'function') return remoteOptions.value
|
|
81
106
|
|
|
82
|
-
|
|
107
|
+
return [...tempOptions.value, ...filteredOptions.value]
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const applyLocalFilter = (qs: string, propsOptions: Record<string, any>[] | undefined) => {
|
|
111
|
+
const labelKey = fieldKey(props.labelKey, 'label')
|
|
112
|
+
const sourceOptions = [...createdOptions.value, ...(propsOptions ?? [])]
|
|
83
113
|
|
|
84
|
-
if (!
|
|
85
|
-
|
|
114
|
+
if (!qs) {
|
|
115
|
+
filteredOptions.value = sourceOptions
|
|
116
|
+
setTempOption(qs, filteredOptions.value)
|
|
117
|
+
return
|
|
86
118
|
}
|
|
87
119
|
|
|
88
|
-
|
|
89
|
-
|
|
120
|
+
const _filteredOptions = sourceOptions.filter((item) => {
|
|
121
|
+
return chainObj(item).get(labelKey)?.includes(qs) ?? false
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
setTempOption(qs, _filteredOptions)
|
|
125
|
+
filteredOptions.value = _filteredOptions
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 仅对「有查询词」的本地过滤做防抖;清空查询同步生效,避免选中后列表延迟重建
|
|
129
|
+
const debouncedLocalFilter = debounce(
|
|
130
|
+
(qs: string, propsOptions: Record<string, any>[] | undefined) => {
|
|
131
|
+
// 防抖回调可能晚于清空查询到达,以当前 queryString 为准
|
|
132
|
+
if (!queryString.value) {
|
|
133
|
+
applyLocalFilter('', propsOptions)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
applyLocalFilter(qs, propsOptions)
|
|
137
|
+
},
|
|
138
|
+
200
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const debouncedRemoteFilter = debounce(async (qs: string, propsOptions: Function) => {
|
|
142
|
+
const options = await propsOptions(queryString.value)
|
|
143
|
+
remoteOptions.value = options
|
|
144
|
+
setTempOption(queryString.value, options)
|
|
145
|
+
}, 200)
|
|
90
146
|
|
|
91
|
-
// TODO: 如有性能问题可以考虑优化
|
|
92
147
|
watch(
|
|
93
|
-
[queryString, () => props.options],
|
|
94
|
-
|
|
148
|
+
[queryString, () => props.options, createdOptions],
|
|
149
|
+
([qs, propsOptions]) => {
|
|
95
150
|
if (typeof propsOptions === 'function') {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
setTempOption(qs, options)
|
|
99
|
-
} else {
|
|
100
|
-
// 当选项不是函数时,可以创建选项
|
|
101
|
-
const labelKey = fieldKey(props.labelKey, 'label')
|
|
102
|
-
|
|
103
|
-
if (!qs) {
|
|
104
|
-
filteredOptions.value = propsOptions ?? []
|
|
105
|
-
setTempOption(qs, filteredOptions.value)
|
|
106
|
-
return
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const _filteredOptions =
|
|
110
|
-
propsOptions?.filter((item) => {
|
|
111
|
-
return chainObj(item).get(labelKey)?.includes(qs) ?? false
|
|
112
|
-
}) ?? []
|
|
113
|
-
|
|
114
|
-
setTempOption(qs, _filteredOptions)
|
|
115
|
-
filteredOptions.value = _filteredOptions
|
|
151
|
+
debouncedRemoteFilter(qs, propsOptions)
|
|
152
|
+
return
|
|
116
153
|
}
|
|
117
|
-
|
|
154
|
+
|
|
155
|
+
if (!qs) {
|
|
156
|
+
applyLocalFilter('', propsOptions)
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
debouncedLocalFilter(qs, propsOptions)
|
|
161
|
+
},
|
|
118
162
|
{ immediate: true }
|
|
119
163
|
)
|
|
120
164
|
|
|
@@ -30,8 +30,8 @@ $root-name: tree-select;
|
|
|
30
30
|
background: none;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
@include m.e(
|
|
34
|
-
|
|
33
|
+
@include m.e(clear) {
|
|
34
|
+
cursor: pointer;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
@include m.e(content-header) {
|
|
@@ -42,17 +42,13 @@ $root-name: tree-select;
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
@include m.e(content-tree) {
|
|
45
|
-
max-height:
|
|
45
|
+
max-height: 240px;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
@include m.e(panel) {
|
|
49
49
|
@include m.size using ($size) {
|
|
50
|
-
@include m.bem($root-name, content-filter) {
|
|
51
|
-
padding: calc(fn.use-var(gap, $size) / 2) fn.use-var(gap, $size);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
50
|
@include m.bem($root-name, content-tree) {
|
|
55
|
-
padding:
|
|
51
|
+
padding: fn.use-var(gap, $size);
|
|
56
52
|
}
|
|
57
53
|
|
|
58
54
|
@include m.bem($root-name, empty) {
|
|
@@ -7,38 +7,46 @@
|
|
|
7
7
|
:content-class="[cls.e('panel'), cls.em('panel', size), contentClass]"
|
|
8
8
|
:content-style="contentStyle"
|
|
9
9
|
ref="dropdownRef"
|
|
10
|
+
v-model:visible="dropdownVisible"
|
|
10
11
|
:min-width="minWidth"
|
|
11
12
|
:width="width"
|
|
12
13
|
:disabled="disabled"
|
|
13
14
|
@update:visible="handleDropdownVisible"
|
|
15
|
+
@mouseenter="hovered = true"
|
|
16
|
+
@mouseleave="hovered = false"
|
|
14
17
|
>
|
|
15
18
|
<template #trigger>
|
|
16
19
|
<u-input
|
|
20
|
+
ref="inputRef"
|
|
17
21
|
:size="size"
|
|
18
22
|
:disabled="disabled"
|
|
19
|
-
:placeholder="
|
|
20
|
-
:clearable="
|
|
21
|
-
:model-value="
|
|
22
|
-
@
|
|
23
|
-
native-readonly
|
|
23
|
+
:placeholder="inputPlaceholder"
|
|
24
|
+
:clearable="false"
|
|
25
|
+
:model-value="inputValue"
|
|
26
|
+
@update:model-value="handleQueryInput"
|
|
27
|
+
:native-readonly="!filterable || !querying"
|
|
28
|
+
@click.capture="handleTriggerClickCapture"
|
|
24
29
|
>
|
|
25
30
|
<template #prefix v-if="$slots.prefix">
|
|
26
31
|
<slot name="prefix" />
|
|
27
32
|
</template>
|
|
28
33
|
<template #suffix>
|
|
29
|
-
<
|
|
34
|
+
<transition name="zoom-in" mode="out-in">
|
|
35
|
+
<u-icon
|
|
36
|
+
v-if="showClear"
|
|
37
|
+
:class="cls.e('clear')"
|
|
38
|
+
title="清除"
|
|
39
|
+
key="clear"
|
|
40
|
+
@click.stop="handleClear"
|
|
41
|
+
>
|
|
42
|
+
<Close />
|
|
43
|
+
</u-icon>
|
|
44
|
+
<u-icon v-else :class="cls.e('arrow')" key="arrow"><ArrowDown /></u-icon>
|
|
45
|
+
</transition>
|
|
30
46
|
</template>
|
|
31
47
|
</u-input>
|
|
32
48
|
</template>
|
|
33
49
|
<template #content>
|
|
34
|
-
<!-- 过滤器 -->
|
|
35
|
-
<div v-if="filterable" :class="[cls.e('content-filter'), cls.m(size)]">
|
|
36
|
-
<u-input placeholder="输入关键字进行过滤" v-model="qs">
|
|
37
|
-
<template #suffix>
|
|
38
|
-
<u-icon><Search /></u-icon>
|
|
39
|
-
</template>
|
|
40
|
-
</u-input>
|
|
41
|
-
</div>
|
|
42
50
|
<!-- 菜单列表 -->
|
|
43
51
|
|
|
44
52
|
<u-tree
|
|
@@ -62,12 +70,12 @@
|
|
|
62
70
|
<script lang="ts" setup>
|
|
63
71
|
import { dfs, o } from '@cat-kit/core'
|
|
64
72
|
import { useFormFallbackProps } from '@veltra/compositions'
|
|
65
|
-
import { ArrowDown,
|
|
73
|
+
import { ArrowDown, Close } from '@veltra/icons/normal'
|
|
66
74
|
import { bem, fieldKey, FORM_EMPTY_CONTENT } from '@veltra/utils'
|
|
67
75
|
import { injectFormContext } from '@veltra/utils'
|
|
68
|
-
import { computed, nextTick, shallowRef, watch } from 'vue'
|
|
76
|
+
import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
|
|
69
77
|
|
|
70
|
-
import type { TreeSelectProps, TreeSelectEmits, TreeExposed } from '../../types'
|
|
78
|
+
import type { TreeSelectProps, TreeSelectEmits, TreeExposed, InputExposed } from '../../types'
|
|
71
79
|
import { UDropdown } from '../dropdown'
|
|
72
80
|
import { UIcon } from '../icon'
|
|
73
81
|
import { UInput } from '../input'
|
|
@@ -127,6 +135,68 @@ const model = defineModel<string | number>()
|
|
|
127
135
|
|
|
128
136
|
const label = shallowRef<string>()
|
|
129
137
|
|
|
138
|
+
const dropdownVisible = shallowRef(false)
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 是否处于查询态:决定触发输入框显示查询串还是选中标签。
|
|
142
|
+
* 与面板可见状态解耦 —— 选择后立即退出查询态,
|
|
143
|
+
* 不等面板关闭动画结束(否则显示值会延迟一个动画时长才恢复)。
|
|
144
|
+
*/
|
|
145
|
+
const querying = shallowRef(false)
|
|
146
|
+
|
|
147
|
+
const inputRef = useTemplateRef<InputExposed>('inputRef')
|
|
148
|
+
|
|
149
|
+
/** 触发输入框的值:查询态下承载查询串,否则展示选中标签 */
|
|
150
|
+
const inputValue = computed(() => {
|
|
151
|
+
if (props.filterable && querying.value) return qs.value
|
|
152
|
+
return props.text ?? (model.value ? label.value : undefined)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
/** 查询态下已选标签降级为占位提示 */
|
|
156
|
+
const inputPlaceholder = computed(() => {
|
|
157
|
+
const display = props.text ?? (model.value ? label.value : undefined)
|
|
158
|
+
if (props.filterable && querying.value && display) {
|
|
159
|
+
return display
|
|
160
|
+
}
|
|
161
|
+
return props.placeholder
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
function handleQueryInput(value: string) {
|
|
165
|
+
if (!props.filterable) return
|
|
166
|
+
qs.value = value
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const hovered = shallowRef(false)
|
|
170
|
+
|
|
171
|
+
/** 悬停且存在选中值时展示清除按钮(替代下拉箭头) */
|
|
172
|
+
const showClear = computed(() => {
|
|
173
|
+
if (!props.clearable || disabled.value || !hovered.value) return false
|
|
174
|
+
return !!(props.text ?? (model.value ? label.value : undefined))
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 过滤模式下拦截输入区域的点击:
|
|
179
|
+
* 阻止 dropdown 的 trigger 开合切换,保持面板展开以不中断输入。
|
|
180
|
+
* 非输入区域(箭头、留白)放行,维持原开合行为。
|
|
181
|
+
*/
|
|
182
|
+
function handleTriggerClickCapture(e: MouseEvent) {
|
|
183
|
+
if (!props.filterable || disabled.value) return
|
|
184
|
+
if (!(e.target instanceof HTMLInputElement)) return
|
|
185
|
+
|
|
186
|
+
e.stopPropagation()
|
|
187
|
+
if (!dropdownVisible.value) dropdownRef.value?.open()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
watch(dropdownVisible, (visible) => {
|
|
191
|
+
if (visible && props.filterable) {
|
|
192
|
+
// 面板展开后进入查询态并聚焦输入框,可以立即输入查询
|
|
193
|
+
querying.value = true
|
|
194
|
+
nextTick(() => inputRef.value?.el?.focus())
|
|
195
|
+
} else if (!visible) {
|
|
196
|
+
querying.value = false
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
|
|
130
200
|
const { formProps } = injectFormContext()
|
|
131
201
|
|
|
132
202
|
const { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, props])
|
|
@@ -192,6 +262,9 @@ const handleSelect = (selected?: string | number, selectedData?: Record<string,
|
|
|
192
262
|
|
|
193
263
|
emit('change', selectedData)
|
|
194
264
|
emit('update:text', label.value)
|
|
265
|
+
// 立即退出查询态,输入框同步恢复显示选中标签
|
|
266
|
+
querying.value = false
|
|
267
|
+
qs.value = ''
|
|
195
268
|
dropdownRef.value?.close()
|
|
196
269
|
}
|
|
197
270
|
|