@veltra/desktop 1.7.19 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/batch-edit/batch-edit-form.js +40 -26
- package/dist/components/batch-edit/batch-edit-form.js.map +1 -1
- package/dist/components/batch-edit/use-handlers.js +3 -3
- package/dist/components/batch-edit/use-handlers.js.map +1 -1
- package/dist/components/batch-edit/use-shortcut-key.js +4 -2
- package/dist/components/batch-edit/use-shortcut-key.js.map +1 -1
- package/dist/components/card/style2.css +0 -2
- package/dist/components/dropdown/dropdown.js +5 -3
- package/dist/components/dropdown/dropdown.js.map +1 -1
- package/dist/components/expression-editor/components/path-preview.js +3 -6
- package/dist/components/expression-editor/components/path-preview.js.map +1 -1
- package/dist/components/expression-editor/components/variable-picker.js +49 -12
- package/dist/components/expression-editor/components/variable-picker.js.map +1 -1
- package/dist/components/expression-editor/core/editor.js +20 -0
- package/dist/components/expression-editor/core/editor.js.map +1 -1
- package/dist/components/expression-editor/expression-editor.js +53 -18
- package/dist/components/expression-editor/expression-editor.js.map +1 -1
- package/dist/components/expression-editor/style2.css +135 -73
- package/dist/components/form/form.js.map +1 -1
- package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js +0 -1
- package/dist/components/form/form.vue_vue_type_script_setup_true_lang.js.map +1 -1
- package/dist/components/form/style2.css +8 -3
- package/dist/components/index.d.ts +26 -26
- package/dist/components/index.js +52 -52
- package/dist/components/multi-select/multi-select.js +16 -12
- package/dist/components/multi-select/multi-select.js.map +1 -1
- package/dist/components/multi-select/style2.css +7 -0
- package/dist/components/multi-tree-select/multi-tree-select.js +82 -56
- package/dist/components/multi-tree-select/multi-tree-select.js.map +1 -1
- package/dist/components/multi-tree-select/style2.css +7 -0
- package/dist/components/select/select.js +7 -3
- package/dist/components/select/select.js.map +1 -1
- package/dist/components/select/style2.css +7 -0
- package/dist/components/select/use-options.js +14 -3
- package/dist/components/select/use-options.js.map +1 -1
- package/dist/components/tree-select/style2.css +7 -0
- package/dist/components/tree-select/tree-select.js +49 -17
- package/dist/components/tree-select/tree-select.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +2 -2
- package/dist/types/dropdown.d.ts +10 -0
- package/dist/types/multi-tree-select.d.ts +7 -1
- package/dist/types/tree-select.d.ts +7 -1
- package/package.json +1 -1
- package/src/components/batch-edit/__test__/batch-edit.test.ts +112 -1
- package/src/components/batch-edit/batch-edit-form.vue +21 -2
- package/src/components/batch-edit/use-handlers.ts +6 -5
- package/src/components/batch-edit/use-shortcut-key.ts +5 -2
- package/src/components/card/style.scss +0 -2
- package/src/components/dropdown/dropdown.vue +9 -4
- package/src/components/expression-editor/components/path-preview.vue +1 -2
- package/src/components/expression-editor/components/variable-picker.vue +54 -13
- package/src/components/expression-editor/core/editor.ts +28 -0
- package/src/components/expression-editor/expression-editor.vue +47 -17
- package/src/components/expression-editor/style.scss +167 -82
- package/src/components/form/form.vue +0 -1
- package/src/components/form/style.scss +11 -4
- package/src/components/multi-select/multi-select.vue +11 -2
- package/src/components/multi-select/style.scss +6 -0
- package/src/components/multi-tree-select/multi-tree-select.vue +58 -9
- package/src/components/multi-tree-select/style.scss +6 -0
- package/src/components/select/__test__/select.test.ts +78 -0
- package/src/components/select/select.vue +13 -3
- package/src/components/select/style.scss +6 -0
- package/src/components/select/use-options.ts +25 -4
- package/src/components/tree-select/__test__/tree-select.test.ts +116 -0
- package/src/components/tree-select/style.scss +6 -0
- package/src/components/tree-select/tree-select.vue +63 -13
- package/src/types/dropdown.ts +10 -0
- package/src/types/multi-tree-select.ts +12 -1
- package/src/types/tree-select.ts +12 -1
|
@@ -3,15 +3,22 @@
|
|
|
3
3
|
@use 'pkg:@veltra/styles/vars';
|
|
4
4
|
|
|
5
5
|
@include m.b(form) {
|
|
6
|
+
// gap 走 CSS 而非 u-grid 内联样式,只读态才能覆盖行距
|
|
7
|
+
gap: 0 12px;
|
|
8
|
+
|
|
9
|
+
// 只读态:label 退为次级文本,值收进浅色圆角条;行距拉开让各项独立成块,避免粘连
|
|
6
10
|
@include m.is(readonly) {
|
|
7
|
-
gap:
|
|
11
|
+
gap: 10px 12px;
|
|
8
12
|
|
|
9
13
|
@include m.bem(form-item, label) {
|
|
10
|
-
|
|
14
|
+
color: fn.use-var(text-color, second);
|
|
11
15
|
}
|
|
16
|
+
|
|
12
17
|
@include m.bem(form-item, content) {
|
|
13
|
-
|
|
14
|
-
|
|
18
|
+
padding: 4px 12px;
|
|
19
|
+
color: fn.use-var(text-color, main);
|
|
20
|
+
background-color: fn.use-var(bg-color, middle);
|
|
21
|
+
border-radius: fn.use-var(radius, default);
|
|
15
22
|
}
|
|
16
23
|
}
|
|
17
24
|
|
|
@@ -80,11 +80,14 @@
|
|
|
80
80
|
虚拟化启用时内容容器 height 由 useVirtualizer 命令式写入;
|
|
81
81
|
模板不再绑定 totalSize,避免尺寸变化引起重渲染。
|
|
82
82
|
-->
|
|
83
|
+
<div v-if="loading" :class="cls.e('loading')">
|
|
84
|
+
<ULoading type="dual-ring" />
|
|
85
|
+
</div>
|
|
83
86
|
<u-scroll
|
|
84
87
|
tag="ul"
|
|
85
88
|
:class="[cls.e('options')]"
|
|
86
89
|
ref="scrollRef"
|
|
87
|
-
v-if="options.length"
|
|
90
|
+
v-else-if="options.length"
|
|
88
91
|
:content-class="[cls.e('options-wrap'), bem.is('virtual', virtualEnabled)]"
|
|
89
92
|
>
|
|
90
93
|
<template v-if="virtualEnabled">
|
|
@@ -166,6 +169,7 @@ import { UCheckbox } from '../checkbox'
|
|
|
166
169
|
import { UDropdown } from '../dropdown'
|
|
167
170
|
import { UEmpty } from '../empty'
|
|
168
171
|
import { UIcon } from '../icon'
|
|
172
|
+
import { ULoading } from '../loading'
|
|
169
173
|
import { UScroll } from '../scroll'
|
|
170
174
|
import { useOptions } from '../select/use-options'
|
|
171
175
|
import { UTag } from '../tag'
|
|
@@ -204,7 +208,12 @@ const scrollRef = shallowRef<ScrollExposed>()
|
|
|
204
208
|
|
|
205
209
|
const hovered = shallowRef(false)
|
|
206
210
|
|
|
207
|
-
const {
|
|
211
|
+
const {
|
|
212
|
+
options: rawOptions,
|
|
213
|
+
queryString,
|
|
214
|
+
loading,
|
|
215
|
+
allOptions: rawAllOptions
|
|
216
|
+
} = useOptions({ props })
|
|
208
217
|
|
|
209
218
|
const createdOptions = shallowRef<Record<string, any>[]>([])
|
|
210
219
|
|
|
@@ -170,4 +170,10 @@ $root-name: multi-select;
|
|
|
170
170
|
text-align: center;
|
|
171
171
|
padding: fn.use-var(gap, default) 0;
|
|
172
172
|
}
|
|
173
|
+
|
|
174
|
+
@include m.e(loading) {
|
|
175
|
+
// 与选项内容区同高,加载期间保持面板形态,避免塌缩成贴着输入框的小条
|
|
176
|
+
@include m.flex($justify: center, $align: center);
|
|
177
|
+
min-height: 240px;
|
|
178
|
+
}
|
|
173
179
|
}
|
|
@@ -71,15 +71,19 @@
|
|
|
71
71
|
{{ allExpanded ? '收起全部' : '展开全部' }}
|
|
72
72
|
</u-button>
|
|
73
73
|
</div>
|
|
74
|
+
<div v-if="remoteLoading" :class="cls.e('loading')">
|
|
75
|
+
<ULoading type="dual-ring" />
|
|
76
|
+
</div>
|
|
74
77
|
<!-- 菜单列表 -->
|
|
75
78
|
<u-tree
|
|
79
|
+
v-else
|
|
76
80
|
v-bind="treeProps"
|
|
77
81
|
v-model:checked="model"
|
|
78
82
|
@update:checked="handleCheck"
|
|
79
83
|
ref="treeRef"
|
|
80
84
|
:class="cls.e('content-tree')"
|
|
81
85
|
checkable
|
|
82
|
-
:data="
|
|
86
|
+
:data="treeData"
|
|
83
87
|
:slots="slots"
|
|
84
88
|
scroll-to-view
|
|
85
89
|
></u-tree>
|
|
@@ -100,7 +104,7 @@
|
|
|
100
104
|
</template>
|
|
101
105
|
|
|
102
106
|
<script lang="ts" setup>
|
|
103
|
-
import { dfs, o } from '@cat-kit/core'
|
|
107
|
+
import { debounce, dfs, o } from '@cat-kit/core'
|
|
104
108
|
import { useFormFallbackProps } from '@veltra/compositions'
|
|
105
109
|
import { ArrowDown, Close } from '@veltra/icons/normal'
|
|
106
110
|
import { bem, fieldKey, FORM_EMPTY_CONTENT } from '@veltra/utils'
|
|
@@ -113,6 +117,7 @@ import { UButton } from '../button'
|
|
|
113
117
|
import { UCheckbox } from '../checkbox'
|
|
114
118
|
import { UDropdown } from '../dropdown'
|
|
115
119
|
import { UIcon } from '../icon'
|
|
120
|
+
import { ULoading } from '../loading'
|
|
116
121
|
import { UTag } from '../tag'
|
|
117
122
|
import { UTree } from '../tree'
|
|
118
123
|
import type { TreeSlotsScope } from '../tree/di'
|
|
@@ -148,6 +153,7 @@ const treeProps = computed(() => {
|
|
|
148
153
|
'label',
|
|
149
154
|
'readonly',
|
|
150
155
|
'modelValue',
|
|
156
|
+
'data',
|
|
151
157
|
'contentClass',
|
|
152
158
|
'contentStyle',
|
|
153
159
|
'minWidth',
|
|
@@ -159,11 +165,56 @@ const emit = defineEmits<MultiTreeSelectEmits>()
|
|
|
159
165
|
|
|
160
166
|
const slots = defineSlots<{ default?: (props: TreeSlotsScope) => any }>()
|
|
161
167
|
|
|
162
|
-
|
|
168
|
+
/** 是否可搜索:显式声明或数据源为远程函数 */
|
|
169
|
+
const filterable = computed(() => props.filterable || typeof props.data === 'function')
|
|
170
|
+
|
|
171
|
+
/** 过滤 */
|
|
163
172
|
const qs = shallowRef('')
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
173
|
+
|
|
174
|
+
const treeRef = shallowRef<TreeExposed>()
|
|
175
|
+
|
|
176
|
+
/** 远程搜索返回的树数据 */
|
|
177
|
+
const remoteData = shallowRef<Record<string, any>[]>()
|
|
178
|
+
|
|
179
|
+
/** 面板渲染的树数据:data 为函数时来自远程搜索,否则为静态数据 */
|
|
180
|
+
const treeData = computed(() => (typeof props.data === 'function' ? remoteData.value : props.data))
|
|
181
|
+
|
|
182
|
+
const remoteLoading = shallowRef(false)
|
|
183
|
+
/** 远程请求序号;响应返回时序号不一致说明已有更新的请求,结果作废 */
|
|
184
|
+
let remoteSeq = 0
|
|
185
|
+
|
|
186
|
+
const debouncedRemoteSearch = debounce(
|
|
187
|
+
async (
|
|
188
|
+
query: string,
|
|
189
|
+
dataFn: (qs: string) => Promise<Record<string, any>[]> | Record<string, any>[]
|
|
190
|
+
) => {
|
|
191
|
+
const seq = ++remoteSeq
|
|
192
|
+
remoteLoading.value = true
|
|
193
|
+
try {
|
|
194
|
+
const data = await dataFn(query)
|
|
195
|
+
// 响应到达时查询词可能已变化或已有更新的请求,过期结果直接丢弃
|
|
196
|
+
if (seq !== remoteSeq || query !== qs.value) return
|
|
197
|
+
remoteData.value = data ?? []
|
|
198
|
+
// 搜索结果展示完整命中路径,与本地过滤自动展开祖先的行为对齐
|
|
199
|
+
if (query) nextTick(() => treeRef.value?.expandAll())
|
|
200
|
+
} finally {
|
|
201
|
+
if (seq === remoteSeq) remoteLoading.value = false
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
200
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
watch(
|
|
208
|
+
[qs, () => props.data],
|
|
209
|
+
([query, data]) => {
|
|
210
|
+
if (typeof data === 'function') {
|
|
211
|
+
debouncedRemoteSearch(query, data)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
treeRef.value?.filter(query)
|
|
215
|
+
},
|
|
216
|
+
{ immediate: true }
|
|
217
|
+
)
|
|
167
218
|
|
|
168
219
|
const model = defineModel<(string | number)[]>({ default: () => [] })
|
|
169
220
|
|
|
@@ -179,8 +230,6 @@ const showClear = computed(() => {
|
|
|
179
230
|
return props.clearable && model.value?.length && hovered.value && !disabled.value
|
|
180
231
|
})
|
|
181
232
|
|
|
182
|
-
const treeRef = shallowRef<TreeExposed>()
|
|
183
|
-
|
|
184
233
|
const dropdownRef = shallowRef<DropdownExposed>()
|
|
185
234
|
|
|
186
235
|
let changedByEvent = false
|
|
@@ -237,7 +286,7 @@ const handleClear = () => {
|
|
|
237
286
|
const keyDicts = shallowRef(new Map<string | number, Record<string, any>>())
|
|
238
287
|
|
|
239
288
|
watch(
|
|
240
|
-
|
|
289
|
+
treeData,
|
|
241
290
|
(data) => {
|
|
242
291
|
if (!data?.length) {
|
|
243
292
|
keyDicts.value = new Map()
|
|
@@ -126,4 +126,10 @@ $root-name: multi-tree-select;
|
|
|
126
126
|
@include m.e(content-tree) {
|
|
127
127
|
max-height: 240px;
|
|
128
128
|
}
|
|
129
|
+
|
|
130
|
+
@include m.e(loading) {
|
|
131
|
+
// 与树内容区同高,加载期间保持面板形态,避免塌缩成贴着输入框的小条
|
|
132
|
+
@include m.flex($justify: center, $align: center);
|
|
133
|
+
min-height: 240px;
|
|
134
|
+
}
|
|
129
135
|
}
|
|
@@ -218,4 +218,82 @@ describe('USelect', () => {
|
|
|
218
218
|
unmount()
|
|
219
219
|
}
|
|
220
220
|
})
|
|
221
|
+
|
|
222
|
+
it('loads options from a remote function with loading state', async () => {
|
|
223
|
+
let resolveRemote!: (options: Record<string, any>[]) => void
|
|
224
|
+
const calls: string[] = []
|
|
225
|
+
const { host, unmount } = mountSelect({
|
|
226
|
+
options: (qs: string) => {
|
|
227
|
+
calls.push(qs)
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
resolveRemote = resolve
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
// 挂载即以空串调用一次
|
|
236
|
+
expect(calls).toEqual([''])
|
|
237
|
+
|
|
238
|
+
await openDropdown(host)
|
|
239
|
+
expect(document.body.querySelector('.u-select__loading')).toBeTruthy()
|
|
240
|
+
expect(queryOptions()).toHaveLength(0)
|
|
241
|
+
|
|
242
|
+
resolveRemote([{ label: '远程A', value: 'a' }])
|
|
243
|
+
await sleep(50)
|
|
244
|
+
expect(document.body.querySelector('.u-select__loading')).toBeFalsy()
|
|
245
|
+
expect(queryOptions().map((el) => el.textContent)).toEqual(['远程A'])
|
|
246
|
+
} finally {
|
|
247
|
+
unmount()
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('queries the remote function on input', async () => {
|
|
252
|
+
const calls: string[] = []
|
|
253
|
+
const { host, unmount } = mountSelect({
|
|
254
|
+
options: (qs: string) => {
|
|
255
|
+
calls.push(qs)
|
|
256
|
+
return Promise.resolve(
|
|
257
|
+
qs ? [{ label: '上海', value: 'shanghai' }] : [{ label: '北京', value: 'beijing' }]
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
await openDropdown(host)
|
|
264
|
+
await typeQuery(host, '上海')
|
|
265
|
+
expect(calls).toEqual(['', '上海'])
|
|
266
|
+
expect(queryOptions().map((el) => el.textContent)).toEqual(['上海'])
|
|
267
|
+
} finally {
|
|
268
|
+
unmount()
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('discards stale remote responses when a newer query resolves first', async () => {
|
|
273
|
+
const pending: ((options: Record<string, any>[]) => void)[] = []
|
|
274
|
+
const { host, unmount } = mountSelect({
|
|
275
|
+
options: (qs: string) =>
|
|
276
|
+
new Promise((resolve) => {
|
|
277
|
+
pending.push(resolve)
|
|
278
|
+
})
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
await openDropdown(host)
|
|
283
|
+
await typeQuery(host, '北')
|
|
284
|
+
expect(pending).toHaveLength(2)
|
|
285
|
+
|
|
286
|
+
// 新查询先返回
|
|
287
|
+
pending[1]!([{ label: '北京', value: 'beijing' }])
|
|
288
|
+
await sleep(20)
|
|
289
|
+
expect(queryOptions().map((el) => el.textContent)).toEqual(['北京'])
|
|
290
|
+
|
|
291
|
+
// 慢的旧响应(初始空串查询)后返回,不能覆盖新结果
|
|
292
|
+
pending[0]!([{ label: '旧数据', value: 'old' }])
|
|
293
|
+
await sleep(20)
|
|
294
|
+
expect(queryOptions().map((el) => el.textContent)).toEqual(['北京'])
|
|
295
|
+
} finally {
|
|
296
|
+
unmount()
|
|
297
|
+
}
|
|
298
|
+
})
|
|
221
299
|
})
|
|
@@ -58,8 +58,11 @@
|
|
|
58
58
|
单选列表。虚拟化启用时,内容容器 height 由 useVirtualizer 命令式写入;
|
|
59
59
|
此处 content-style 仅承担 grid 相关样式,避免 height 变化触发模板重渲染。
|
|
60
60
|
-->
|
|
61
|
+
<div v-if="loading" :class="cls.e('loading')">
|
|
62
|
+
<ULoading type="dual-ring" />
|
|
63
|
+
</div>
|
|
61
64
|
<u-scroll
|
|
62
|
-
v-if="options.length"
|
|
65
|
+
v-else-if="options.length"
|
|
63
66
|
tag="ul"
|
|
64
67
|
:class="cls.e('options')"
|
|
65
68
|
ref="scrollRef"
|
|
@@ -137,6 +140,7 @@ import { UDropdown } from '../dropdown'
|
|
|
137
140
|
import { UEmpty } from '../empty'
|
|
138
141
|
import { UIcon } from '../icon'
|
|
139
142
|
import { UInput } from '../input'
|
|
143
|
+
import { ULoading } from '../loading'
|
|
140
144
|
import { UScroll } from '../scroll'
|
|
141
145
|
import { useKeyboard } from './use-keyboard'
|
|
142
146
|
import { useOptions } from './use-options'
|
|
@@ -194,8 +198,14 @@ const filterable = computed(() => {
|
|
|
194
198
|
return props.filterable || typeof props.options === 'function'
|
|
195
199
|
})
|
|
196
200
|
|
|
197
|
-
const {
|
|
198
|
-
|
|
201
|
+
const {
|
|
202
|
+
queryString,
|
|
203
|
+
loading,
|
|
204
|
+
options,
|
|
205
|
+
allOptions,
|
|
206
|
+
temOptionsToCreatedOptions,
|
|
207
|
+
clearCreatedOptions
|
|
208
|
+
} = useOptions({ props })
|
|
199
209
|
|
|
200
210
|
const dropdownVisible = shallowRef(false)
|
|
201
211
|
|
|
@@ -116,4 +116,10 @@ $root-name: select;
|
|
|
116
116
|
text-align: center;
|
|
117
117
|
padding: fn.use-var(gap, small) 0;
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
@include m.e(loading) {
|
|
121
|
+
// 与选项内容区同高,加载期间保持面板形态,避免塌缩成贴着输入框的小条
|
|
122
|
+
@include m.flex($justify: center, $align: center);
|
|
123
|
+
min-height: 240px;
|
|
124
|
+
}
|
|
119
125
|
}
|
|
@@ -11,6 +11,8 @@ interface Options {
|
|
|
11
11
|
interface UseOptionsReturned {
|
|
12
12
|
/** 查询字符串 */
|
|
13
13
|
queryString: ShallowRef<string>
|
|
14
|
+
/** 远程搜索加载中 */
|
|
15
|
+
loading: ShallowRef<boolean>
|
|
14
16
|
/** 选项 */
|
|
15
17
|
options: ComputedRef<Record<string, any>[]>
|
|
16
18
|
/** 所有选项 */
|
|
@@ -32,6 +34,10 @@ export function useOptions(o: Options): UseOptionsReturned {
|
|
|
32
34
|
/** 筛选 */
|
|
33
35
|
const queryString = shallowRef('')
|
|
34
36
|
|
|
37
|
+
const loading = shallowRef(false)
|
|
38
|
+
/** 远程请求序号;响应返回时序号不一致说明已有更新的请求,结果作废 */
|
|
39
|
+
let remoteSeq = 0
|
|
40
|
+
|
|
35
41
|
const remoteOptions = shallowRef<Record<string, any>[]>([])
|
|
36
42
|
const filteredOptions = shallowRef<Record<string, any>[]>([])
|
|
37
43
|
// 临时选项
|
|
@@ -139,9 +145,17 @@ export function useOptions(o: Options): UseOptionsReturned {
|
|
|
139
145
|
)
|
|
140
146
|
|
|
141
147
|
const debouncedRemoteFilter = debounce(async (qs: string, propsOptions: Function) => {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
const seq = ++remoteSeq
|
|
149
|
+
loading.value = true
|
|
150
|
+
try {
|
|
151
|
+
const options = await propsOptions(qs)
|
|
152
|
+
// 响应到达时查询词可能已变化或已有更新的请求,过期结果直接丢弃
|
|
153
|
+
if (seq !== remoteSeq || qs !== queryString.value) return
|
|
154
|
+
remoteOptions.value = options ?? []
|
|
155
|
+
setTempOption(qs, remoteOptions.value)
|
|
156
|
+
} finally {
|
|
157
|
+
if (seq === remoteSeq) loading.value = false
|
|
158
|
+
}
|
|
145
159
|
}, 200)
|
|
146
160
|
|
|
147
161
|
watch(
|
|
@@ -162,5 +176,12 @@ export function useOptions(o: Options): UseOptionsReturned {
|
|
|
162
176
|
{ immediate: true }
|
|
163
177
|
)
|
|
164
178
|
|
|
165
|
-
return {
|
|
179
|
+
return {
|
|
180
|
+
queryString,
|
|
181
|
+
loading,
|
|
182
|
+
options,
|
|
183
|
+
allOptions,
|
|
184
|
+
temOptionsToCreatedOptions,
|
|
185
|
+
clearCreatedOptions
|
|
186
|
+
}
|
|
166
187
|
}
|
|
@@ -65,6 +65,38 @@ async function openDropdown(host: HTMLElement) {
|
|
|
65
65
|
if (queryNodeContents().length > 0) return
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/** 打开面板并等待进入加载态(远程数据未返回前树不渲染) */
|
|
70
|
+
async function openPanelWithLoading(host: HTMLElement) {
|
|
71
|
+
const input = host.querySelector('input')!
|
|
72
|
+
input.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
|
|
73
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
74
|
+
await nextTick()
|
|
75
|
+
await sleep(25)
|
|
76
|
+
if (document.body.querySelector('.u-tree-select__loading') || queryNodeContents().length > 0) {
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function typeQuery(host: HTMLElement, text: string) {
|
|
83
|
+
const input = host.querySelector('input')!
|
|
84
|
+
input.focus()
|
|
85
|
+
// 清空查询串(查询态下 model 绑定查询串)
|
|
86
|
+
input.value = ''
|
|
87
|
+
input.dispatchEvent(
|
|
88
|
+
new InputEvent('input', { bubbles: true, data: '', inputType: 'deleteContentBackward' })
|
|
89
|
+
)
|
|
90
|
+
await nextTick()
|
|
91
|
+
|
|
92
|
+
input.value = text
|
|
93
|
+
input.dispatchEvent(
|
|
94
|
+
new InputEvent('input', { bubbles: true, data: text, inputType: 'insertText' })
|
|
95
|
+
)
|
|
96
|
+
await nextTick()
|
|
97
|
+
// 远程搜索防抖 200ms
|
|
98
|
+
await sleep(250)
|
|
99
|
+
}
|
|
68
100
|
function queryNodeContents() {
|
|
69
101
|
return [...document.body.querySelectorAll<HTMLElement>('.u-tree__node-content')]
|
|
70
102
|
}
|
|
@@ -130,4 +162,88 @@ describe('UTreeSelect', () => {
|
|
|
130
162
|
unmount()
|
|
131
163
|
}
|
|
132
164
|
})
|
|
165
|
+
|
|
166
|
+
it('loads default tree remotely on mount with loading state', async () => {
|
|
167
|
+
let resolveRemote!: (data: Record<string, any>[]) => void
|
|
168
|
+
const calls: string[] = []
|
|
169
|
+
const { host, unmount } = mountTreeSelect({
|
|
170
|
+
data: (qs: string) => {
|
|
171
|
+
calls.push(qs)
|
|
172
|
+
return new Promise((resolve) => {
|
|
173
|
+
resolveRemote = resolve
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
// 挂载即以空串调用一次
|
|
180
|
+
expect(calls).toEqual([''])
|
|
181
|
+
|
|
182
|
+
await openPanelWithLoading(host)
|
|
183
|
+
expect(document.body.querySelector('.u-tree-select__loading')).toBeTruthy()
|
|
184
|
+
expect(queryNodeContents()).toHaveLength(0)
|
|
185
|
+
|
|
186
|
+
resolveRemote(treeData)
|
|
187
|
+
await sleep(50)
|
|
188
|
+
expect(document.body.querySelector('.u-tree-select__loading')).toBeFalsy()
|
|
189
|
+
expect(queryNodeContents().length).toBeGreaterThan(0)
|
|
190
|
+
} finally {
|
|
191
|
+
unmount()
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('queries the remote function on input and expands the result', async () => {
|
|
196
|
+
const calls: string[] = []
|
|
197
|
+
const { host, unmount } = mountTreeSelect({
|
|
198
|
+
data: (qs: string) => {
|
|
199
|
+
calls.push(qs)
|
|
200
|
+
return Promise.resolve(qs ? [treeData[0]!] : treeData)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
// 初始空串加载完成
|
|
206
|
+
await sleep(50)
|
|
207
|
+
expect(calls).toEqual([''])
|
|
208
|
+
|
|
209
|
+
await openDropdown(host)
|
|
210
|
+
await typeQuery(host, '朝阳区')
|
|
211
|
+
expect(calls).toEqual(['', '朝阳区'])
|
|
212
|
+
|
|
213
|
+
const contents = queryNodeContents()
|
|
214
|
+
expect(contents.some((el) => el.textContent?.includes('朝阳区(最新)'))).toBe(true)
|
|
215
|
+
} finally {
|
|
216
|
+
unmount()
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('discards stale remote responses when a newer query resolves first', async () => {
|
|
221
|
+
const pending: ((data: Record<string, any>[]) => void)[] = []
|
|
222
|
+
const { host, unmount } = mountTreeSelect({
|
|
223
|
+
data: (qs: string) =>
|
|
224
|
+
new Promise((resolve) => {
|
|
225
|
+
pending.push(resolve)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await openPanelWithLoading(host)
|
|
231
|
+
expect(document.body.querySelector('.u-tree-select__loading')).toBeTruthy()
|
|
232
|
+
|
|
233
|
+
await typeQuery(host, '上海')
|
|
234
|
+
expect(pending).toHaveLength(2)
|
|
235
|
+
|
|
236
|
+
// 新查询先返回
|
|
237
|
+
pending[1]!([treeData[1]!])
|
|
238
|
+
await sleep(20)
|
|
239
|
+
expect(queryNodeContents().some((el) => el.textContent?.includes('上海'))).toBe(true)
|
|
240
|
+
|
|
241
|
+
// 慢的旧响应(初始空串查询)后返回,不能覆盖新结果
|
|
242
|
+
pending[0]!(treeData)
|
|
243
|
+
await sleep(20)
|
|
244
|
+
expect(queryNodeContents().some((el) => el.textContent?.includes('北京'))).toBe(false)
|
|
245
|
+
} finally {
|
|
246
|
+
unmount()
|
|
247
|
+
}
|
|
248
|
+
})
|
|
133
249
|
})
|
|
@@ -45,6 +45,12 @@ $root-name: tree-select;
|
|
|
45
45
|
max-height: 240px;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
@include m.e(loading) {
|
|
49
|
+
// 与树内容区同高,加载期间保持面板形态,避免塌缩成贴着输入框的小条
|
|
50
|
+
@include m.flex($justify: center, $align: center);
|
|
51
|
+
min-height: 240px;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
@include m.e(panel) {
|
|
49
55
|
@include m.size using ($size) {
|
|
50
56
|
@include m.bem($root-name, content-tree) {
|