@veltra/desktop 1.3.7 → 1.3.8

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 (35) hide show
  1. package/dist/components/action/action-group.vue.d.ts +2 -2
  2. package/dist/components/action/action.vue.d.ts +1 -1
  3. package/dist/components/auto-complete/auto-complete.vue.d.ts +2 -2
  4. package/dist/components/cascade/cascade.vue.d.ts +5 -5
  5. package/dist/components/checkbox/checkbox-button.vue.d.ts +1 -1
  6. package/dist/components/checkbox-group/checkbox-group.vue.d.ts +2 -2
  7. package/dist/components/code-editor/code-editor.vue.d.ts +2 -2
  8. package/dist/components/date-panel/date-panel.vue.d.ts +1 -1
  9. package/dist/components/date-picker/date-picker.vue.d.ts +2 -2
  10. package/dist/components/date-range-picker/date-range-picker.vue.d.ts +2 -2
  11. package/dist/components/input/input.vue.d.ts +4 -4
  12. package/dist/components/multi-select/multi-select.vue.d.ts +3 -3
  13. package/dist/components/multi-tree-select/multi-tree-select.vue.d.ts +5 -5
  14. package/dist/components/number-input/number-input.vue.d.ts +4 -4
  15. package/dist/components/number-range-input/number-range-input.vue.d.ts +2 -2
  16. package/dist/components/password-input/password-input.vue.d.ts +1 -1
  17. package/dist/components/radio-group/radio-group.vue.d.ts +2 -2
  18. package/dist/components/rich-text-editor/rich-text-editor.vue.d.ts +1 -1
  19. package/dist/components/select/select.js +42 -51
  20. package/dist/components/select/select.js.map +1 -1
  21. package/dist/components/select/select.vue.d.ts +6 -14
  22. package/dist/components/steps/steps.vue.d.ts +3 -3
  23. package/dist/components/textarea/textarea.vue.d.ts +4 -4
  24. package/dist/components/tree-select/tree-select.js +40 -33
  25. package/dist/components/tree-select/tree-select.js.map +1 -1
  26. package/dist/components/tree-select/tree-select.vue.d.ts +5 -5
  27. package/dist/types/select.d.ts +5 -4
  28. package/dist/types/tree-select.d.ts +4 -2
  29. package/package.json +6 -6
  30. package/src/components/select/__test__/select.test.ts +77 -2
  31. package/src/components/select/select.vue +39 -47
  32. package/src/components/tree-select/__test__/tree-select.test.ts +133 -0
  33. package/src/components/tree-select/tree-select.vue +44 -38
  34. package/src/types/select.ts +5 -4
  35. package/src/types/tree-select.ts +4 -3
@@ -120,7 +120,7 @@
120
120
 
121
121
  <script lang="ts" setup>
122
122
  import { o } from '@cat-kit/core'
123
- import { useFormFallbackProps, useVirtualizer } from '@veltra/compositions'
123
+ import { useFormFallbackProps, useUserAction, useVirtualizer } from '@veltra/compositions'
124
124
  import { ArrowDown, Close } from '@veltra/icons/normal'
125
125
  import { bem, fieldKey, FORM_EMPTY_CONTENT, scrollIntoContainerView, withUnit } from '@veltra/utils'
126
126
  import { injectFormContext } from '@veltra/utils'
@@ -177,7 +177,8 @@ const { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, prop
177
177
  })
178
178
 
179
179
  const currentIndex = shallowRef(-1)
180
- const label = defineModel('text')
180
+ /** 内部展示文案,仅由选项推导;通过 update:text 单向通知父级 */
181
+ const label = shallowRef<string>()
181
182
  const selected = shallowRef<Record<string, any>>()
182
183
 
183
184
  const displayedValue = computed(() => {
@@ -194,9 +195,8 @@ const filterable = computed(() => {
194
195
  return props.filterable || typeof props.options === 'function'
195
196
  })
196
197
 
197
- const { queryString, options, temOptionsToCreatedOptions, clearCreatedOptions } = useOptions({
198
- props
199
- })
198
+ const { queryString, options, allOptions, temOptionsToCreatedOptions, clearCreatedOptions } =
199
+ useOptions({ props })
200
200
 
201
201
  const dropdownVisible = shallowRef(false)
202
202
 
@@ -246,57 +246,54 @@ function handleTriggerClickCapture(e: MouseEvent) {
246
246
  if (!dropdownVisible.value) dropdownRef.value?.open()
247
247
  }
248
248
 
249
- // TODO: 优化
250
- let userSelecting = false
251
- function lock() {
252
- userSelecting = true
253
- }
249
+ const { userAction, isUserActive } = useUserAction()
254
250
 
255
- /**
256
- * @param preferredValue 选择时传入刚提交的值,避免 nextTick 时 props.modelValue 尚未回传而用旧值覆盖高亮/标签
257
- */
258
- function unlock(preferredValue?: any) {
259
- nextTick(() => {
260
- userSelecting = false
261
- // 选择/清除会同步重建选项列表(临时项转正、查询串清空),
262
- // 重建触发的回显在锁定期间被跳过,解锁后按最新列表重同步一次,
263
- // 避免高亮停留在选择前的旧索引上
264
- const value = preferredValue !== undefined ? preferredValue : props.modelValue
265
- syncSelected(value, options.value)
266
- })
251
+ /** 更新内部文案;值变化时单向 emit update:text */
252
+ function setLabel(next?: string) {
253
+ if (label.value === next) return
254
+ label.value = next
255
+ emit('update:text', next)
267
256
  }
268
257
 
269
- /** 按 modelValue 与选项列表同步高亮索引、选中项与显示标签 */
270
- function syncSelected(modelValue: any, options: Record<string, any>[] | undefined) {
258
+ /** 按 modelValue 与完整选项列表同步高亮索引、选中项与显示标签(外部回显用,O(n)) */
259
+ function syncSelected(modelValue: any, sourceOptions: Record<string, any>[] | undefined) {
271
260
  // 查询输入期间高亮第一项(创建模式下通常是临时项,便于回车创建)
272
261
  if (queryString.value) {
273
- currentIndex.value = options?.length ? 0 : -1
262
+ currentIndex.value = options.value.length ? 0 : -1
274
263
  return
275
264
  }
276
265
 
277
- if (!options?.length) return
266
+ if (!sourceOptions?.length) return
278
267
 
279
268
  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
269
+ currentIndex.value = sourceOptions.findIndex(
270
+ (option) => o(option).get(valueKey.value) === modelValue
271
+ )
272
+ selected.value = currentIndex.value >= 0 ? sourceOptions[currentIndex.value] : undefined
273
+ setLabel(selected.value ? o(selected.value).get(labelKey.value) : undefined)
283
274
  } else {
284
275
  currentIndex.value = -1
285
276
  selected.value = undefined
286
- label.value = undefined
277
+ setLabel(undefined)
287
278
  }
288
279
  }
289
280
 
290
- // 回显
281
+ // 回显:以 allOptions 匹配,避免过滤列表缺项时清掉已选文案;用户动作期内跳过
291
282
  watch(
292
- [() => props.modelValue, options],
293
- ([modelValue, options]) => {
294
- if (userSelecting) return
295
- syncSelected(modelValue, options)
283
+ [() => props.modelValue, allOptions],
284
+ ([modelValue]) => {
285
+ if (isUserActive()) return
286
+ syncSelected(modelValue, allOptions.value)
296
287
  },
297
288
  { immediate: true }
298
289
  )
299
290
 
291
+ // 过滤时单独同步高亮(allOptions 不变,不会触发上方回显 watch)
292
+ watch([queryString, options], () => {
293
+ if (!queryString.value) return
294
+ currentIndex.value = options.value.length ? 0 : -1
295
+ })
296
+
300
297
  const baseVirtualEnabled = computed(() => options.value.length > 80)
301
298
  const virtualEnabled = computed(() => baseVirtualEnabled.value && !props.grid)
302
299
 
@@ -364,14 +361,13 @@ const handleDropdownVisible = (visible: boolean) => {
364
361
  }
365
362
  }
366
363
 
367
- /** 单选 */
368
- const handleSelect = (option: Record<string, any>, _index: number) => {
369
- lock()
364
+ /** 单选:用户动作内 O(1) 写入值与文案,跳过 modelValue 回显的 O(n) 查找 */
365
+ const handleSelect = userAction((option: Record<string, any>, _index: number) => {
370
366
  const value = option?.[valueKey.value]
371
367
  selected.value = option
372
368
 
373
369
  emit('update:modelValue', value)
374
- label.value = option?.[labelKey.value]
370
+ setLabel(option?.[labelKey.value])
375
371
  emit('change', option)
376
372
  if (option.__isTemp) {
377
373
  // 转正后列表同步重建为完整源(临时项置顶索引失效),必须按 value 重定位
@@ -386,21 +382,17 @@ const handleSelect = (option: Record<string, any>, _index: number) => {
386
382
  selected.value = options.value[currentIndex.value]
387
383
  }
388
384
  dropdownRef.value?.close()
389
-
390
- unlock(value)
391
- }
385
+ })
392
386
 
393
387
  /** 清除选项 */
394
- const handleClear = () => {
395
- lock()
388
+ const handleClear = userAction(() => {
396
389
  selected.value = undefined
397
390
  currentIndex.value = -1
398
- label.value = undefined
391
+ setLabel(undefined)
399
392
  clearCreatedOptions()
400
393
  emit('update:modelValue', undefined)
401
394
  emit('change', undefined)
402
- unlock(null)
403
- }
395
+ })
404
396
 
405
397
  function getCurrentEl() {
406
398
  return scrollRef.value?.contentRef?.querySelector('li.is-selected') as HTMLElement | undefined
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp, h, nextTick, ref, type Ref } from 'vue'
3
+
4
+ import UTreeSelect from '../tree-select.vue'
5
+
6
+ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
7
+
8
+ const treeData = [
9
+ {
10
+ label: '北京',
11
+ value: 'beijing',
12
+ children: [
13
+ { label: '朝阳区(最新)', value: 'chaoyang' },
14
+ { label: '海淀区(最新)', value: 'haidian' }
15
+ ]
16
+ },
17
+ { label: '上海(最新)', value: 'shanghai' }
18
+ ]
19
+
20
+ function mountTreeSelect(props: Record<string, unknown> = {}) {
21
+ const host = document.createElement('div')
22
+ document.body.appendChild(host)
23
+
24
+ const model = ref(props.modelValue) as Ref<string | number | undefined>
25
+ const data = ref(props.data ?? treeData)
26
+ const texts: (string | undefined)[] = []
27
+
28
+ const app = createApp({
29
+ render() {
30
+ return h(UTreeSelect, {
31
+ ...props,
32
+ modelValue: model.value,
33
+ data: data.value,
34
+ expandAll: true,
35
+ 'onUpdate:modelValue': (value?: string | number) => {
36
+ model.value = value
37
+ },
38
+ 'onUpdate:text': (text?: string) => {
39
+ texts.push(text)
40
+ }
41
+ })
42
+ }
43
+ })
44
+
45
+ app.mount(host)
46
+
47
+ return {
48
+ host,
49
+ model,
50
+ data,
51
+ texts,
52
+ unmount() {
53
+ app.unmount()
54
+ host.remove()
55
+ }
56
+ }
57
+ }
58
+
59
+ async function openDropdown(host: HTMLElement) {
60
+ const input = host.querySelector('input')!
61
+ input.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
62
+ for (let attempt = 0; attempt < 20; attempt++) {
63
+ await nextTick()
64
+ await sleep(25)
65
+ if (queryNodeContents().length > 0) return
66
+ }
67
+ }
68
+ function queryNodeContents() {
69
+ return [...document.body.querySelectorAll<HTMLElement>('.u-tree__node-content')]
70
+ }
71
+
72
+ async function clickNodeByLabel(label: string) {
73
+ const node = queryNodeContents().find((el) => el.textContent?.includes(label))
74
+ expect(node, `tree node "${label}" not found`).toBeTruthy()
75
+ node!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
76
+ await nextTick()
77
+ await sleep(50)
78
+ }
79
+
80
+ describe('UTreeSelect', () => {
81
+ it('emits update:text when modelValue echoes against data', async () => {
82
+ const { host, texts, unmount } = mountTreeSelect({ modelValue: 'chaoyang' })
83
+
84
+ try {
85
+ await nextTick()
86
+ expect(texts.at(-1)).toBe('朝阳区(最新)')
87
+ expect(host.querySelector('input')!.value).toBe('朝阳区(最新)')
88
+ } finally {
89
+ unmount()
90
+ }
91
+ })
92
+
93
+ it('emits update:text after data arrives for an existing modelValue', async () => {
94
+ const { host, data, texts, unmount } = mountTreeSelect({ modelValue: 'shanghai', data: [] })
95
+
96
+ try {
97
+ await nextTick()
98
+ expect(texts).toHaveLength(0)
99
+
100
+ data.value = treeData
101
+ await nextTick()
102
+ await nextTick()
103
+
104
+ expect(texts.at(-1)).toBe('上海(最新)')
105
+ expect(host.querySelector('input')!.value).toBe('上海(最新)')
106
+ } finally {
107
+ unmount()
108
+ }
109
+ })
110
+
111
+ it('emits update:text on user select and clear', async () => {
112
+ const { host, model, texts, unmount } = mountTreeSelect({
113
+ // filterable 下点击 input 会显式 open,避免非过滤态依赖冒泡开合的不稳定
114
+ filterable: true
115
+ })
116
+
117
+ try {
118
+ await openDropdown(host)
119
+ await clickNodeByLabel('上海(最新)')
120
+ expect(model.value).toBe('shanghai')
121
+ expect(texts.at(-1)).toBe('上海(最新)')
122
+
123
+ // 清除:父级清空值时回显同步发出 update:text
124
+ model.value = undefined
125
+ await nextTick()
126
+ await nextTick()
127
+ expect(texts.at(-1)).toBeUndefined()
128
+ expect(host.querySelector('input')!.value).toBe('')
129
+ } finally {
130
+ unmount()
131
+ }
132
+ })
133
+ })
@@ -63,13 +63,13 @@
63
63
  </u-dropdown>
64
64
 
65
65
  <template v-else>
66
- {{ text || label || FORM_EMPTY_CONTENT }}
66
+ {{ label || FORM_EMPTY_CONTENT }}
67
67
  </template>
68
68
  </template>
69
69
 
70
70
  <script lang="ts" setup>
71
71
  import { dfs, o } from '@cat-kit/core'
72
- import { useFormFallbackProps } from '@veltra/compositions'
72
+ import { useFormFallbackProps, useUserAction } from '@veltra/compositions'
73
73
  import { ArrowDown, Close } from '@veltra/icons/normal'
74
74
  import { bem, fieldKey, FORM_EMPTY_CONTENT } from '@veltra/utils'
75
75
  import { injectFormContext } from '@veltra/utils'
@@ -133,6 +133,7 @@ watch(qs, (qs) => {
133
133
 
134
134
  const model = defineModel<string | number>()
135
135
 
136
+ /** 内部展示文案,仅由 data 推导;通过 update:text 单向通知父级 */
136
137
  const label = shallowRef<string>()
137
138
 
138
139
  const dropdownVisible = shallowRef(false)
@@ -146,15 +147,22 @@ const querying = shallowRef(false)
146
147
 
147
148
  const inputRef = useTemplateRef<InputExposed>('inputRef')
148
149
 
150
+ /** 更新内部文案;值变化时单向 emit update:text */
151
+ function setLabel(next?: string) {
152
+ if (label.value === next) return
153
+ label.value = next
154
+ emit('update:text', next)
155
+ }
156
+
149
157
  /** 触发输入框的值:查询态下承载查询串,否则展示选中标签 */
150
158
  const inputValue = computed(() => {
151
159
  if (props.filterable && querying.value) return qs.value
152
- return props.text ?? (model.value ? label.value : undefined)
160
+ return model.value || model.value === 0 ? label.value : undefined
153
161
  })
154
162
 
155
163
  /** 查询态下已选标签降级为占位提示 */
156
164
  const inputPlaceholder = computed(() => {
157
- const display = props.text ?? (model.value ? label.value : undefined)
165
+ const display = model.value || model.value === 0 ? label.value : undefined
158
166
  if (props.filterable && querying.value && display) {
159
167
  return display
160
168
  }
@@ -171,7 +179,7 @@ const hovered = shallowRef(false)
171
179
  /** 悬停且存在选中值时展示清除按钮(替代下拉箭头) */
172
180
  const showClear = computed(() => {
173
181
  if (!props.clearable || disabled.value || !hovered.value) return false
174
- return !!(props.text ?? (model.value ? label.value : undefined))
182
+ return !!(model.value || model.value === 0 ? label.value : undefined)
175
183
  })
176
184
 
177
185
  /**
@@ -205,34 +213,35 @@ const treeRef = shallowRef<TreeExposed>()
205
213
 
206
214
  const dropdownRef = shallowRef<InstanceType<typeof UDropdown>>()
207
215
 
208
- /**清空 */
209
- const handleClear = () => {
216
+ const { userAction, isUserActive } = useUserAction()
217
+
218
+ /** 清空 */
219
+ const handleClear = userAction(() => {
210
220
  model.value = ''
211
- label.value = undefined
221
+ setLabel(undefined)
212
222
  emit('clear')
213
223
  emit('change', undefined)
214
- emit('update:text', '')
215
- }
224
+ })
216
225
 
217
- let changedByEvent = false
226
+ /** 外部回显:按 data 查找 label(O(n));用户动作期内跳过 */
218
227
  watch(
219
228
  [() => props.data, model],
220
- ([data, model]) => {
221
- if (changedByEvent) return
222
- if (!data?.length || model === undefined) {
223
- label.value = undefined
224
- model = undefined
229
+ ([data, modelVal]) => {
230
+ if (isUserActive()) return
231
+ if (!data?.length || modelVal === undefined || modelVal === null || modelVal === '') {
232
+ setLabel(undefined)
225
233
  return
226
234
  }
227
235
 
228
- let founded = false
236
+ let nextLabel: string | undefined
229
237
  const childrenKey = props.childrenKey ?? 'children'
230
238
  data.some((item) => {
239
+ let founded = false
231
240
  dfs(
232
241
  item,
233
242
  (v) => {
234
- if (v[valueKey.value] === model) {
235
- label.value = v[labelKey.value]
243
+ if (v[valueKey.value] === modelVal) {
244
+ nextLabel = v[labelKey.value]
236
245
  founded = true
237
246
  return true
238
247
  }
@@ -242,32 +251,29 @@ watch(
242
251
 
243
252
  return founded
244
253
  })
254
+ setLabel(nextLabel)
245
255
  },
246
256
  { immediate: true }
247
257
  )
248
258
 
249
- const handleSelect = (selected?: string | number, selectedData?: Record<string, any>) => {
250
- changedByEvent = true
251
- nextTick(() => {
252
- changedByEvent = false
253
- })
259
+ /** 用户选择:O(1) 写入值与文案,跳过 data 回显查找 */
260
+ const handleSelect = userAction(
261
+ (selected?: string | number, selectedData?: Record<string, any>) => {
262
+ model.value = selected ?? ''
254
263
 
255
- model.value = selected ?? ''
264
+ if (selectedData) {
265
+ setLabel(o(selectedData).get(labelKey.value))
266
+ } else {
267
+ setLabel(undefined)
268
+ }
256
269
 
257
- if (selectedData) {
258
- label.value = o(selectedData).get(labelKey.value)
259
- } else {
260
- label.value = ''
270
+ emit('change', selectedData)
271
+ // 立即退出查询态,输入框同步恢复显示选中标签
272
+ querying.value = false
273
+ qs.value = ''
274
+ dropdownRef.value?.close()
261
275
  }
262
-
263
- emit('change', selectedData)
264
- emit('update:text', label.value)
265
- // 立即退出查询态,输入框同步恢复显示选中标签
266
- querying.value = false
267
- qs.value = ''
268
- dropdownRef.value?.close()
269
- }
270
-
276
+ )
271
277
  const handleDropdownVisible = (visible: boolean) => {
272
278
  if (!visible) {
273
279
  qs.value = ''
@@ -5,8 +5,6 @@ import type { CSSProperties, ShallowRef } from 'vue'
5
5
  export interface SelectProps extends FormComponentProps {
6
6
  /** 绑定值 */
7
7
  modelValue?: any
8
- /** 文本内容 */
9
- text?: string
10
8
  /**
11
9
  * 列表选项
12
10
  * @description 如果传入一个函数,那么filterable会被强制启用
@@ -55,8 +53,11 @@ export interface SelectProps extends FormComponentProps {
55
53
  }
56
54
 
57
55
  export interface SelectEmits {
58
- /** 触发更新label事件 */
59
- (e: 'update:label', label?: string): void
56
+ /**
57
+ * 选中项文案变化(单向通知,用于同步父级冗余字段)
58
+ * @description 展示始终由 options 推导,请用 `@update:text` 而非 `v-model:text`
59
+ */
60
+ (e: 'update:text', text?: string): void
60
61
  (e: 'update:modelValue', modelValue?: any): void
61
62
  (e: 'change', option?: Record<string, any>): void
62
63
  }
@@ -29,9 +29,6 @@ export interface TreeSelectProps
29
29
  */
30
30
  width?: string
31
31
 
32
- /** 显示文本 */
33
- text?: string
34
-
35
32
  /** 内容容器样式 */
36
33
  contentStyle?: CSSProperties | string
37
34
 
@@ -44,6 +41,10 @@ export interface TreeSelectEmits {
44
41
  (e: 'clear'): void
45
42
  (e: 'update:modelValue', value?: string | number): void
46
43
  (e: 'change', selectedData?: Record<string, any>): void
44
+ /**
45
+ * 选中项文案变化(单向通知,用于同步父级冗余字段)
46
+ * @description 展示始终由 data 推导,请用 `@update:text` 而非 `v-model:text`
47
+ */
47
48
  (e: 'update:text', text?: string): void
48
49
  }
49
50