@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.
Files changed (58) hide show
  1. package/dist/components/action/action-group.vue.d.ts +1 -1
  2. package/dist/components/action/action.vue.d.ts +1 -1
  3. package/dist/components/cascade/cascade.js +8 -2
  4. package/dist/components/cascade/cascade.js.map +1 -1
  5. package/dist/components/cascade/style2.css +4 -0
  6. package/dist/components/date-range-picker/date-range-picker.js +5 -2
  7. package/dist/components/date-range-picker/date-range-picker.js.map +1 -1
  8. package/dist/components/date-range-picker/style2.css +4 -0
  9. package/dist/components/empty/empty.vue.d.ts +1 -1
  10. package/dist/components/form-item/form-item.js.map +1 -1
  11. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js +2 -2
  12. package/dist/components/form-item/form-item.vue_vue_type_script_setup_true_lang.js.map +1 -1
  13. package/dist/components/form-item/style2.css +2 -1
  14. package/dist/components/grid-input/style2.css +3 -0
  15. package/dist/components/multi-select/multi-select.js +67 -40
  16. package/dist/components/multi-select/multi-select.js.map +1 -1
  17. package/dist/components/multi-select/multi-select.vue.d.ts +4 -4
  18. package/dist/components/multi-select/style2.css +23 -10
  19. package/dist/components/multi-tree-select/multi-tree-select.js +52 -35
  20. package/dist/components/multi-tree-select/multi-tree-select.js.map +1 -1
  21. package/dist/components/multi-tree-select/multi-tree-select.vue.d.ts +1 -1
  22. package/dist/components/multi-tree-select/style2.css +26 -16
  23. package/dist/components/progress-nodes/progress-nodes.vue.d.ts +1 -1
  24. package/dist/components/select/select.js +159 -95
  25. package/dist/components/select/select.js.map +1 -1
  26. package/dist/components/select/style2.css +4 -17
  27. package/dist/components/select/use-options.js +67 -26
  28. package/dist/components/select/use-options.js.map +1 -1
  29. package/dist/components/steps/steps.vue.d.ts +2 -2
  30. package/dist/components/tabs/tabs-horizontal.vue.d.ts +2 -2
  31. package/dist/components/tabs/tabs-vertical.vue.d.ts +2 -2
  32. package/dist/components/tabs/tabs.vue.d.ts +2 -2
  33. package/dist/components/textarea/style2.css +1 -0
  34. package/dist/components/textarea/textarea.vue.d.ts +1 -1
  35. package/dist/components/tree/tree.vue.d.ts +1 -1
  36. package/dist/components/tree-select/style2.css +6 -15
  37. package/dist/components/tree-select/tree-select.js +95 -41
  38. package/dist/components/tree-select/tree-select.js.map +1 -1
  39. package/dist/components/tree-select/tree-select.vue.d.ts +1 -1
  40. package/package.json +6 -6
  41. package/src/components/cascade/cascade.vue +15 -2
  42. package/src/components/cascade/style.scss +5 -0
  43. package/src/components/date-range-picker/date-range-picker.vue +9 -1
  44. package/src/components/date-range-picker/style.scss +5 -0
  45. package/src/components/form-item/form-item.vue +2 -2
  46. package/src/components/form-item/style.scss +2 -1
  47. package/src/components/grid-input/style.scss +1 -0
  48. package/src/components/multi-select/multi-select.vue +50 -19
  49. package/src/components/multi-select/style.scss +27 -4
  50. package/src/components/multi-tree-select/multi-tree-select.vue +34 -14
  51. package/src/components/multi-tree-select/style.scss +28 -9
  52. package/src/components/select/__test__/select.test.ts +110 -0
  53. package/src/components/select/select.vue +130 -46
  54. package/src/components/select/style.scss +5 -14
  55. package/src/components/select/use-options.ts +83 -39
  56. package/src/components/textarea/style.scss +1 -0
  57. package/src/components/tree-select/style.scss +4 -8
  58. 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="placeholder"
23
- :clearable="clearable"
24
- :model-value="displayedValue"
25
- @clear="handleClear"
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
- <u-icon :class="cls.e('arrow')">
35
- <ArrowDown />
36
- </u-icon>
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 { vFocus } from '@veltra/directives'
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
- function unlock() {
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
- const dropdownVisible = shallowRef(false)
279
-
280
- watch(dropdownVisible, (v) => {
281
- if (!v) {
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>, index: number) => {
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', option?.[valueKey.value])
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
- currentIndex.value = index
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: 180px;
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
- createdOptions.value = tempOptions.value.map((item) => {
44
- return { ...item, __isTemp: false }
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
- if (!exactMatch) {
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
- const prependOptions = tempOptions.value?.length ? tempOptions.value : createdOptions.value
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 (!options) {
85
- return prependOptions
114
+ if (!qs) {
115
+ filteredOptions.value = sourceOptions
116
+ setTempOption(qs, filteredOptions.value)
117
+ return
86
118
  }
87
119
 
88
- return [...prependOptions, ...filteredOptions.value]
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
- debounce(async ([qs, propsOptions]) => {
148
+ [queryString, () => props.options, createdOptions],
149
+ ([qs, propsOptions]) => {
95
150
  if (typeof propsOptions === 'function') {
96
- const options = await propsOptions(qs)
97
- remoteOptions.value = options
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
- }, 200),
154
+
155
+ if (!qs) {
156
+ applyLocalFilter('', propsOptions)
157
+ return
158
+ }
159
+
160
+ debouncedLocalFilter(qs, propsOptions)
161
+ },
118
162
  { immediate: true }
119
163
  )
120
164
 
@@ -57,6 +57,7 @@ $root-name: textarea;
57
57
 
58
58
  @include m.is(focus) {
59
59
  border-color: fn.use-var(color, primary);
60
+ box-shadow: fn.use-var(focus-ring);
60
61
  }
61
62
 
62
63
  @include m.is(disabled) {
@@ -30,8 +30,8 @@ $root-name: tree-select;
30
30
  background: none;
31
31
  }
32
32
 
33
- @include m.e(filter) {
34
- margin-bottom: 4px;
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: 200px;
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: calc(fn.use-var(gap, $size) / 2) fn.use-var(gap, $size);
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="placeholder"
20
- :clearable="clearable"
21
- :model-value="text ?? (model ? label : undefined)"
22
- @clear="handleClear"
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
- <u-icon :class="cls.e('arrow')"><ArrowDown /></u-icon>
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, Search } from '@veltra/icons/normal'
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