@veltra/desktop 1.3.7 → 1.3.9

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.
@@ -1 +1 @@
1
- {"version":3,"file":"tree-select.js","names":["$attrs","$slots"],"sources":["../../../src/components/tree-select/tree-select.vue"],"sourcesContent":["<template>\n <u-dropdown\n v-if=\"!readonly\"\n v-bind=\"$attrs\"\n :class=\"[cls.b, bem.is('disabled', disabled)]\"\n trigger=\"click\"\n :content-class=\"[cls.e('panel'), cls.em('panel', size), contentClass]\"\n :content-style=\"contentStyle\"\n ref=\"dropdownRef\"\n v-model:visible=\"dropdownVisible\"\n :min-width=\"minWidth\"\n :width=\"width\"\n :disabled=\"disabled\"\n @update:visible=\"handleDropdownVisible\"\n @mouseenter=\"hovered = true\"\n @mouseleave=\"hovered = false\"\n >\n <template #trigger>\n <u-input\n ref=\"inputRef\"\n :size=\"size\"\n :disabled=\"disabled\"\n :placeholder=\"inputPlaceholder\"\n :clearable=\"false\"\n :model-value=\"inputValue\"\n @update:model-value=\"handleQueryInput\"\n :native-readonly=\"!filterable || !querying\"\n @click.capture=\"handleTriggerClickCapture\"\n >\n <template #prefix v-if=\"$slots.prefix\">\n <slot name=\"prefix\" />\n </template>\n <template #suffix>\n <transition name=\"zoom-in\" mode=\"out-in\">\n <u-icon\n v-if=\"showClear\"\n :class=\"cls.e('clear')\"\n title=\"清除\"\n key=\"clear\"\n @click.stop=\"handleClear\"\n >\n <Close />\n </u-icon>\n <u-icon v-else :class=\"cls.e('arrow')\" key=\"arrow\"><ArrowDown /></u-icon>\n </transition>\n </template>\n </u-input>\n </template>\n <template #content>\n <!-- 菜单列表 -->\n\n <u-tree\n v-bind=\"treeProps\"\n :selected=\"model\"\n @update:selected=\"handleSelect\"\n ref=\"treeRef\"\n selectable\n :class=\"cls.e('content-tree')\"\n :slots=\"slots\"\n scroll-to-view\n />\n </template>\n </u-dropdown>\n\n <template v-else>\n {{ text || label || FORM_EMPTY_CONTENT }}\n </template>\n</template>\n\n<script lang=\"ts\" setup>\nimport { dfs, o } from '@cat-kit/core'\nimport { useFormFallbackProps } from '@veltra/compositions'\nimport { ArrowDown, Close } from '@veltra/icons/normal'\nimport { bem, fieldKey, FORM_EMPTY_CONTENT } from '@veltra/utils'\nimport { injectFormContext } from '@veltra/utils'\nimport { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'\n\nimport type { TreeSelectProps, TreeSelectEmits, TreeExposed, InputExposed } from '../../types'\nimport { UDropdown } from '../dropdown'\nimport { UIcon } from '../icon'\nimport { UInput } from '../input'\nimport { UTree } from '../tree'\nimport type { TreeSlotsScope } from '../tree/di'\n\ndefineOptions({ name: 'UTreeSelect', inheritAttrs: false })\n\nconst props = withDefaults(defineProps<TreeSelectProps>(), {\n labelKey: 'label',\n valueKey: 'value',\n placeholder: '请选择',\n expandAll: false,\n clearable: true,\n disabled: undefined,\n readonly: undefined,\n filterable: false,\n minWidth: '280px'\n})\n\nconst emit = defineEmits<TreeSelectEmits>()\n\nconst slots = defineSlots<{\n /** 默认插槽 */\n default?: (props: TreeSlotsScope) => any\n /** 前缀插槽 */\n prefix?: () => any\n}>()\n\nconst treeProps = computed(() => {\n return o(props as Record<string, any>).omit([\n 'tips',\n 'field',\n 'placeholder',\n 'disabled',\n 'label',\n 'readonly',\n 'contentClass',\n 'contentStyle',\n 'minWidth',\n 'width'\n ])\n})\n\nconst cls = bem('tree-select')\n\nconst labelKey = computed(() => fieldKey(props.labelKey, 'label'))\nconst valueKey = computed(() => fieldKey(props.valueKey, 'value'))\n\n/**过滤 */\nconst qs = shallowRef('')\nwatch(qs, (qs) => {\n treeRef.value?.filter(qs)\n})\n\nconst model = defineModel<string | number>()\n\nconst label = shallowRef<string>()\n\nconst dropdownVisible = shallowRef(false)\n\n/**\n * 是否处于查询态:决定触发输入框显示查询串还是选中标签。\n * 与面板可见状态解耦 —— 选择后立即退出查询态,\n * 不等面板关闭动画结束(否则显示值会延迟一个动画时长才恢复)。\n */\nconst querying = shallowRef(false)\n\nconst inputRef = useTemplateRef<InputExposed>('inputRef')\n\n/** 触发输入框的值:查询态下承载查询串,否则展示选中标签 */\nconst inputValue = computed(() => {\n if (props.filterable && querying.value) return qs.value\n return props.text ?? (model.value ? label.value : undefined)\n})\n\n/** 查询态下已选标签降级为占位提示 */\nconst inputPlaceholder = computed(() => {\n const display = props.text ?? (model.value ? label.value : undefined)\n if (props.filterable && querying.value && display) {\n return display\n }\n return props.placeholder\n})\n\nfunction handleQueryInput(value: string) {\n if (!props.filterable) return\n qs.value = value\n}\n\nconst hovered = shallowRef(false)\n\n/** 悬停且存在选中值时展示清除按钮(替代下拉箭头) */\nconst showClear = computed(() => {\n if (!props.clearable || disabled.value || !hovered.value) return false\n return !!(props.text ?? (model.value ? label.value : undefined))\n})\n\n/**\n * 过滤模式下拦截输入区域的点击:\n * 阻止 dropdown 的 trigger 开合切换,保持面板展开以不中断输入。\n * 非输入区域(箭头、留白)放行,维持原开合行为。\n */\nfunction handleTriggerClickCapture(e: MouseEvent) {\n if (!props.filterable || disabled.value) return\n if (!(e.target instanceof HTMLInputElement)) return\n\n e.stopPropagation()\n if (!dropdownVisible.value) dropdownRef.value?.open()\n}\n\nwatch(dropdownVisible, (visible) => {\n if (visible && props.filterable) {\n // 面板展开后进入查询态并聚焦输入框,可以立即输入查询\n querying.value = true\n nextTick(() => inputRef.value?.el?.focus())\n } else if (!visible) {\n querying.value = false\n }\n})\n\nconst { formProps } = injectFormContext()\n\nconst { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, props])\n\nconst treeRef = shallowRef<TreeExposed>()\n\nconst dropdownRef = shallowRef<InstanceType<typeof UDropdown>>()\n\n/**清空 */\nconst handleClear = () => {\n model.value = ''\n label.value = undefined\n emit('clear')\n emit('change', undefined)\n emit('update:text', '')\n}\n\nlet changedByEvent = false\nwatch(\n [() => props.data, model],\n ([data, model]) => {\n if (changedByEvent) return\n if (!data?.length || model === undefined) {\n label.value = undefined\n model = undefined\n return\n }\n\n let founded = false\n const childrenKey = props.childrenKey ?? 'children'\n data.some((item) => {\n dfs(\n item,\n (v) => {\n if (v[valueKey.value] === model) {\n label.value = v[labelKey.value]\n founded = true\n return true\n }\n },\n childrenKey\n )\n\n return founded\n })\n },\n { immediate: true }\n)\n\nconst handleSelect = (selected?: string | number, selectedData?: Record<string, any>) => {\n changedByEvent = true\n nextTick(() => {\n changedByEvent = false\n })\n\n model.value = selected ?? ''\n\n if (selectedData) {\n label.value = o(selectedData).get(labelKey.value)\n } else {\n label.value = ''\n }\n\n emit('change', selectedData)\n emit('update:text', label.value)\n // 立即退出查询态,输入框同步恢复显示选中标签\n querying.value = false\n qs.value = ''\n dropdownRef.value?.close()\n}\n\nconst handleDropdownVisible = (visible: boolean) => {\n if (!visible) {\n qs.value = ''\n }\n}\n</script>\n"],"mappings":"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"}
1
+ {"version":3,"file":"tree-select.js","names":["$attrs","$slots"],"sources":["../../../src/components/tree-select/tree-select.vue"],"sourcesContent":["<template>\n <u-dropdown\n v-if=\"!readonly\"\n v-bind=\"$attrs\"\n :class=\"[cls.b, bem.is('disabled', disabled)]\"\n trigger=\"click\"\n :content-class=\"[cls.e('panel'), cls.em('panel', size), contentClass]\"\n :content-style=\"contentStyle\"\n ref=\"dropdownRef\"\n v-model:visible=\"dropdownVisible\"\n :min-width=\"minWidth\"\n :width=\"width\"\n :disabled=\"disabled\"\n @update:visible=\"handleDropdownVisible\"\n @mouseenter=\"hovered = true\"\n @mouseleave=\"hovered = false\"\n >\n <template #trigger>\n <u-input\n ref=\"inputRef\"\n :size=\"size\"\n :disabled=\"disabled\"\n :placeholder=\"inputPlaceholder\"\n :clearable=\"false\"\n :model-value=\"inputValue\"\n @update:model-value=\"handleQueryInput\"\n :native-readonly=\"!filterable || !querying\"\n @click.capture=\"handleTriggerClickCapture\"\n >\n <template #prefix v-if=\"$slots.prefix\">\n <slot name=\"prefix\" />\n </template>\n <template #suffix>\n <transition name=\"zoom-in\" mode=\"out-in\">\n <u-icon\n v-if=\"showClear\"\n :class=\"cls.e('clear')\"\n title=\"清除\"\n key=\"clear\"\n @click.stop=\"handleClear\"\n >\n <Close />\n </u-icon>\n <u-icon v-else :class=\"cls.e('arrow')\" key=\"arrow\"><ArrowDown /></u-icon>\n </transition>\n </template>\n </u-input>\n </template>\n <template #content>\n <!-- 菜单列表 -->\n\n <u-tree\n v-bind=\"treeProps\"\n :selected=\"model\"\n @update:selected=\"handleSelect\"\n ref=\"treeRef\"\n selectable\n :class=\"cls.e('content-tree')\"\n :slots=\"slots\"\n scroll-to-view\n />\n </template>\n </u-dropdown>\n\n <template v-else>\n {{ label || FORM_EMPTY_CONTENT }}\n </template>\n</template>\n\n<script lang=\"ts\" setup>\nimport { dfs, o } from '@cat-kit/core'\nimport { useFormFallbackProps, useUserAction } from '@veltra/compositions'\nimport { ArrowDown, Close } from '@veltra/icons/normal'\nimport { bem, fieldKey, FORM_EMPTY_CONTENT } from '@veltra/utils'\nimport { injectFormContext } from '@veltra/utils'\nimport { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'\n\nimport type { TreeSelectProps, TreeSelectEmits, TreeExposed, InputExposed } from '../../types'\nimport { UDropdown } from '../dropdown'\nimport { UIcon } from '../icon'\nimport { UInput } from '../input'\nimport { UTree } from '../tree'\nimport type { TreeSlotsScope } from '../tree/di'\n\ndefineOptions({ name: 'UTreeSelect', inheritAttrs: false })\n\nconst props = withDefaults(defineProps<TreeSelectProps>(), {\n labelKey: 'label',\n valueKey: 'value',\n placeholder: '请选择',\n expandAll: false,\n clearable: true,\n disabled: undefined,\n readonly: undefined,\n filterable: false,\n minWidth: '280px'\n})\n\nconst emit = defineEmits<TreeSelectEmits>()\n\nconst slots = defineSlots<{\n /** 默认插槽 */\n default?: (props: TreeSlotsScope) => any\n /** 前缀插槽 */\n prefix?: () => any\n}>()\n\nconst treeProps = computed(() => {\n return o(props as Record<string, any>).omit([\n 'tips',\n 'field',\n 'placeholder',\n 'disabled',\n 'label',\n 'readonly',\n 'contentClass',\n 'contentStyle',\n 'minWidth',\n 'width'\n ])\n})\n\nconst cls = bem('tree-select')\n\nconst labelKey = computed(() => fieldKey(props.labelKey, 'label'))\nconst valueKey = computed(() => fieldKey(props.valueKey, 'value'))\n\n/**过滤 */\nconst qs = shallowRef('')\nwatch(qs, (qs) => {\n treeRef.value?.filter(qs)\n})\n\nconst model = defineModel<string | number>()\n\n/** 内部展示文案,仅由 data 推导;通过 update:text 单向通知父级 */\nconst label = shallowRef<string>()\n\nconst dropdownVisible = shallowRef(false)\n\n/**\n * 是否处于查询态:决定触发输入框显示查询串还是选中标签。\n * 与面板可见状态解耦 —— 选择后立即退出查询态,\n * 不等面板关闭动画结束(否则显示值会延迟一个动画时长才恢复)。\n */\nconst querying = shallowRef(false)\n\nconst inputRef = useTemplateRef<InputExposed>('inputRef')\n\n/** 更新内部文案;值变化时单向 emit update:text */\nfunction setLabel(next?: string) {\n if (label.value === next) return\n label.value = next\n emit('update:text', next)\n}\n\n/** 触发输入框的值:查询态下承载查询串,否则展示选中标签 */\nconst inputValue = computed(() => {\n if (props.filterable && querying.value) return qs.value\n return model.value || model.value === 0 ? label.value : undefined\n})\n\n/** 查询态下已选标签降级为占位提示 */\nconst inputPlaceholder = computed(() => {\n const display = model.value || model.value === 0 ? label.value : undefined\n if (props.filterable && querying.value && display) {\n return display\n }\n return props.placeholder\n})\n\nfunction handleQueryInput(value: string) {\n if (!props.filterable) return\n qs.value = value\n}\n\nconst hovered = shallowRef(false)\n\n/** 悬停且存在选中值时展示清除按钮(替代下拉箭头) */\nconst showClear = computed(() => {\n if (!props.clearable || disabled.value || !hovered.value) return false\n return !!(model.value || model.value === 0 ? label.value : undefined)\n})\n\n/**\n * 过滤模式下拦截输入区域的点击:\n * 阻止 dropdown 的 trigger 开合切换,保持面板展开以不中断输入。\n * 非输入区域(箭头、留白)放行,维持原开合行为。\n */\nfunction handleTriggerClickCapture(e: MouseEvent) {\n if (!props.filterable || disabled.value) return\n if (!(e.target instanceof HTMLInputElement)) return\n\n e.stopPropagation()\n if (!dropdownVisible.value) dropdownRef.value?.open()\n}\n\nwatch(dropdownVisible, (visible) => {\n if (visible && props.filterable) {\n // 面板展开后进入查询态并聚焦输入框,可以立即输入查询\n querying.value = true\n nextTick(() => inputRef.value?.el?.focus())\n } else if (!visible) {\n querying.value = false\n }\n})\n\nconst { formProps } = injectFormContext()\n\nconst { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, props])\n\nconst treeRef = shallowRef<TreeExposed>()\n\nconst dropdownRef = shallowRef<InstanceType<typeof UDropdown>>()\n\nconst { userAction, isUserActive } = useUserAction()\n\n/** 清空 */\nconst handleClear = userAction(() => {\n model.value = ''\n setLabel(undefined)\n emit('clear')\n emit('change', undefined)\n})\n\n/** 外部回显:按 data 查找 label(O(n));用户动作期内跳过 */\nwatch(\n [() => props.data, model],\n ([data, modelVal]) => {\n if (isUserActive()) return\n if (!data?.length || modelVal === undefined || modelVal === null || modelVal === '') {\n setLabel(undefined)\n return\n }\n\n let nextLabel: string | undefined\n const childrenKey = props.childrenKey ?? 'children'\n data.some((item) => {\n let founded = false\n dfs(\n item,\n (v) => {\n if (v[valueKey.value] === modelVal) {\n nextLabel = v[labelKey.value]\n founded = true\n return true\n }\n },\n childrenKey\n )\n\n return founded\n })\n setLabel(nextLabel)\n },\n { immediate: true }\n)\n\n/** 用户选择:O(1) 写入值与文案,跳过 data 回显查找 */\nconst handleSelect = userAction(\n (selected?: string | number, selectedData?: Record<string, any>) => {\n model.value = selected ?? ''\n\n if (selectedData) {\n setLabel(o(selectedData).get(labelKey.value))\n } else {\n setLabel(undefined)\n }\n\n emit('change', selectedData)\n // 立即退出查询态,输入框同步恢复显示选中标签\n querying.value = false\n qs.value = ''\n dropdownRef.value?.close()\n }\n)\nconst handleDropdownVisible = (visible: boolean) => {\n if (!visible) {\n qs.value = ''\n }\n}\n</script>\n"],"mappings":"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"}
@@ -28,13 +28,13 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {
28
28
  }>, {
29
29
  disabled: boolean;
30
30
  readonly: boolean;
31
- minWidth: string;
32
31
  expandAll: boolean;
33
32
  labelKey: string;
34
33
  valueKey: string;
35
34
  placeholder: string;
36
35
  clearable: boolean;
37
36
  filterable: boolean;
37
+ minWidth: string;
38
38
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
39
39
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
40
40
  declare const _default: typeof __VLS_export;
@@ -6,8 +6,8 @@ type __VLS_Slots = {} & {
6
6
  default?: (props: typeof __VLS_7) => any;
7
7
  };
8
8
  declare const __VLS_base: import("vue").DefineComponent<WatermarkProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<WatermarkProps> & Readonly<{}>, {
9
- route: number;
10
9
  fontSize: number;
10
+ route: number;
11
11
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
12
12
  declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
13
13
  declare const _default: typeof __VLS_export;
@@ -5,8 +5,6 @@ import { DeconstructValue, FormComponentProps } from "@veltra/utils";
5
5
  interface SelectProps extends FormComponentProps {
6
6
  /** 绑定值 */
7
7
  modelValue?: any;
8
- /** 文本内容 */
9
- text?: string;
10
8
  /**
11
9
  * 列表选项
12
10
  * @description 如果传入一个函数,那么filterable会被强制启用
@@ -54,8 +52,11 @@ interface SelectProps extends FormComponentProps {
54
52
  };
55
53
  }
56
54
  interface SelectEmits {
57
- /** 触发更新label事件 */
58
- (e: 'update:label', label?: string): void;
55
+ /**
56
+ * 选中项文案变化(单向通知,用于同步父级冗余字段)
57
+ * @description 展示始终由 options 推导,请用 `@update:text` 而非 `v-model:text`
58
+ */
59
+ (e: 'update:text', text?: string): void;
59
60
  (e: 'update:modelValue', modelValue?: any): void;
60
61
  (e: 'change', option?: Record<string, any>): void;
61
62
  }
@@ -25,8 +25,6 @@ interface TreeSelectProps extends FormComponentProps, Omit<TreeProps, 'selected'
25
25
  * @default 跟随触发元素的宽度
26
26
  */
27
27
  width?: string;
28
- /** 显示文本 */
29
- text?: string;
30
28
  /** 内容容器样式 */
31
29
  contentStyle?: CSSProperties | string;
32
30
  /** 内容容器类名 */
@@ -37,6 +35,10 @@ interface TreeSelectEmits {
37
35
  (e: 'clear'): void;
38
36
  (e: 'update:modelValue', value?: string | number): void;
39
37
  (e: 'change', selectedData?: Record<string, any>): void;
38
+ /**
39
+ * 选中项文案变化(单向通知,用于同步父级冗余字段)
40
+ * @description 展示始终由 data 推导,请用 `@update:text` 而非 `v-model:text`
41
+ */
40
42
  (e: 'update:text', text?: string): void;
41
43
  }
42
44
  /** 树形选择器组件暴露的属性和方法(组件内部使用) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veltra/desktop",
3
- "version": "1.3.7",
3
+ "version": "1.3.9",
4
4
  "files": [
5
5
  "dist",
6
6
  "src"
@@ -57,11 +57,11 @@
57
57
  "@cat-kit/core": ">=1.1.8",
58
58
  "@cat-kit/excel": ">=1.0.0",
59
59
  "@cat-kit/fe": "^1.1.8",
60
- "@veltra/compositions": "1.3.7",
61
- "@veltra/directives": "1.3.7",
62
- "@veltra/icons": "1.3.7",
63
- "@veltra/styles": "1.3.7",
64
- "@veltra/utils": "1.3.7",
60
+ "@veltra/compositions": "1.3.9",
61
+ "@veltra/directives": "1.3.9",
62
+ "@veltra/icons": "1.3.9",
63
+ "@veltra/styles": "1.3.9",
64
+ "@veltra/utils": "1.3.9",
65
65
  "vue": ">=3.5.40"
66
66
  },
67
67
  "devDependencies": {
@@ -1,22 +1,29 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { createApp, h, nextTick, ref } from 'vue'
2
+ import { createApp, h, nextTick, ref, type Ref } from 'vue'
3
3
 
4
4
  import USelect from '../select.vue'
5
5
 
6
6
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
7
7
 
8
- function mountSelect(props: Record<string, unknown>) {
8
+ function mountSelect(props: Record<string, unknown> = {}) {
9
9
  const host = document.createElement('div')
10
10
  document.body.appendChild(host)
11
11
 
12
12
  const model = ref(props.modelValue)
13
+ const options = ref(props.options) as Ref<unknown>
14
+ const texts: (string | undefined)[] = []
15
+
13
16
  const app = createApp({
14
17
  render() {
15
18
  return h(USelect, {
16
19
  ...props,
17
20
  modelValue: model.value,
21
+ options: options.value as any,
18
22
  'onUpdate:modelValue': (value: unknown) => {
19
23
  model.value = value
24
+ },
25
+ 'onUpdate:text': (text?: string) => {
26
+ texts.push(text)
20
27
  }
21
28
  })
22
29
  }
@@ -27,6 +34,8 @@ function mountSelect(props: Record<string, unknown>) {
27
34
  return {
28
35
  host,
29
36
  model,
37
+ options,
38
+ texts,
30
39
  unmount() {
31
40
  app.unmount()
32
41
  host.remove()
@@ -107,4 +116,70 @@ describe('USelect', () => {
107
116
  unmount()
108
117
  }
109
118
  })
119
+
120
+ it('emits update:text with option label when modelValue echoes against options', async () => {
121
+ const { host, texts, unmount } = mountSelect({
122
+ modelValue: 'beijing',
123
+ options: [
124
+ { label: '北京(最新)', value: 'beijing' },
125
+ { label: '上海(最新)', value: 'shanghai' }
126
+ ]
127
+ })
128
+
129
+ try {
130
+ await nextTick()
131
+ expect(texts.at(-1)).toBe('北京(最新)')
132
+ expect(host.querySelector('input')!.value).toBe('北京(最新)')
133
+ } finally {
134
+ unmount()
135
+ }
136
+ })
137
+
138
+ it('emits update:text on user select and clear', async () => {
139
+ const { host, model, texts, unmount } = mountSelect({
140
+ filterable: true,
141
+ options: [
142
+ { label: '北京', value: 'beijing' },
143
+ { label: '上海', value: 'shanghai' }
144
+ ]
145
+ })
146
+
147
+ try {
148
+ await openDropdown(host)
149
+ await clickOption(queryOptions().find((el) => el.textContent === '上海')!)
150
+ expect(model.value).toBe('shanghai')
151
+ expect(texts.at(-1)).toBe('上海')
152
+
153
+ // 清除:父级清空值时回显同步发出 update:text
154
+ model.value = undefined
155
+ await nextTick()
156
+ await nextTick()
157
+ expect(texts.at(-1)).toBeUndefined()
158
+ expect(host.querySelector('input')!.value).toBe('')
159
+ } finally {
160
+ unmount()
161
+ }
162
+ })
163
+
164
+ it('emits update:text after async options arrive for an existing modelValue', async () => {
165
+ const { host, options, texts, unmount } = mountSelect({ modelValue: 'shanghai', options: [] })
166
+
167
+ try {
168
+ await nextTick()
169
+ expect(texts).toHaveLength(0)
170
+ expect(host.querySelector('input')!.value).toBe('shanghai')
171
+
172
+ options.value = [
173
+ { label: '北京(最新)', value: 'beijing' },
174
+ { label: '上海(最新)', value: 'shanghai' }
175
+ ]
176
+ await nextTick()
177
+ await nextTick()
178
+
179
+ expect(texts.at(-1)).toBe('上海(最新)')
180
+ expect(host.querySelector('input')!.value).toBe('上海(最新)')
181
+ } finally {
182
+ unmount()
183
+ }
184
+ })
110
185
  })
@@ -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
+ })