@veltra/desktop 1.6.6 → 1.7.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.
Files changed (108) hide show
  1. package/dist/components/action/style2.css +1 -1
  2. package/dist/components/auto-complete/style2.css +1 -1
  3. package/dist/components/badge/style2.css +1 -1
  4. package/dist/components/batch-edit/style2.css +1 -1
  5. package/dist/components/breadcrumb/style2.css +1 -1
  6. package/dist/components/button/style2.css +1 -1
  7. package/dist/components/calendar/style2.css +1 -1
  8. package/dist/components/card/style2.css +1 -1
  9. package/dist/components/cascade/style2.css +1 -1
  10. package/dist/components/check-tag/style2.css +1 -1
  11. package/dist/components/checkbox/style2.css +1 -1
  12. package/dist/components/checkbox-group/style2.css +1 -1
  13. package/dist/components/code-editor/style2.css +1 -1
  14. package/dist/components/collapse/style2.css +1 -1
  15. package/dist/components/condition-editor/style2.css +1 -1
  16. package/dist/components/contextmenu/style2.css +1 -1
  17. package/dist/components/date-panel/style2.css +1 -1
  18. package/dist/components/date-picker/style2.css +1 -1
  19. package/dist/components/date-range-picker/style2.css +1 -1
  20. package/dist/components/dialog/style2.css +1 -1
  21. package/dist/components/drawer/style2.css +1 -1
  22. package/dist/components/dropdown/style2.css +1 -1
  23. package/dist/components/dual-nav/style2.css +1 -1
  24. package/dist/components/empty/style2.css +1 -1
  25. package/dist/components/expression-editor/style2.css +1 -1
  26. package/dist/components/file-picker/style2.css +1 -1
  27. package/dist/components/file-viewer/style2.css +1 -1
  28. package/dist/components/float-button/style2.css +1 -1
  29. package/dist/components/form/style2.css +1 -1
  30. package/dist/components/form-item/style2.css +1 -1
  31. package/dist/components/gantt-chart/style2.css +1 -1
  32. package/dist/components/grid/style2.css +1 -1
  33. package/dist/components/grid-input/style2.css +1 -1
  34. package/dist/components/group-input/style2.css +1 -1
  35. package/dist/components/group-nav/style2.css +1 -1
  36. package/dist/components/icon/style2.css +1 -1
  37. package/dist/components/index.d.ts +2 -1
  38. package/dist/components/index.js +3 -1
  39. package/dist/components/index.js.map +1 -1
  40. package/dist/components/input/style2.css +1 -1
  41. package/dist/components/kbd/style2.css +1 -1
  42. package/dist/components/layout/style2.css +1 -1
  43. package/dist/components/list/style2.css +1 -1
  44. package/dist/components/loading/style2.css +1 -1
  45. package/dist/components/message/style2.css +1 -1
  46. package/dist/components/message-confirm/style2.css +1 -1
  47. package/dist/components/multi-select/style2.css +1 -1
  48. package/dist/components/multi-tree-select/style2.css +1 -1
  49. package/dist/components/nav/style2.css +1 -1
  50. package/dist/components/node-render/style2.css +1 -1
  51. package/dist/components/notification/style2.css +1 -1
  52. package/dist/components/number/style2.css +1 -1
  53. package/dist/components/number-input/style2.css +1 -1
  54. package/dist/components/number-range-input/style2.css +1 -1
  55. package/dist/components/paginator/style2.css +1 -1
  56. package/dist/components/palette/style2.css +1 -1
  57. package/dist/components/password-input/style2.css +1 -1
  58. package/dist/components/pop-confirm/style2.css +1 -1
  59. package/dist/components/progress/style2.css +1 -1
  60. package/dist/components/progress-nodes/style2.css +1 -1
  61. package/dist/components/radio/style2.css +1 -1
  62. package/dist/components/radio-group/style2.css +4 -13
  63. package/dist/components/rich-text-editor/style2.css +1 -1
  64. package/dist/components/scroll/style2.css +1 -1
  65. package/dist/components/segment/segment.js +98 -0
  66. package/dist/components/segment/segment.js.map +1 -0
  67. package/dist/components/segment/style.d.ts +1 -0
  68. package/dist/components/segment/style.js +1 -0
  69. package/dist/components/segment/style2.css +113 -0
  70. package/dist/components/select/select.js +12 -6
  71. package/dist/components/select/select.js.map +1 -1
  72. package/dist/components/select/style2.css +1 -1
  73. package/dist/components/slider/style2.css +1 -1
  74. package/dist/components/steps/style2.css +1 -1
  75. package/dist/components/switch/style2.css +1 -1
  76. package/dist/components/table/style2.css +1 -1
  77. package/dist/components/table-editor/style2.css +1 -1
  78. package/dist/components/tabs/style2.css +1 -1
  79. package/dist/components/tag/style2.css +1 -1
  80. package/dist/components/text/style2.css +1 -1
  81. package/dist/components/textarea/style2.css +1 -1
  82. package/dist/components/theme/style2.css +1 -1
  83. package/dist/components/theme/theme.js +2 -1
  84. package/dist/components/theme/theme.js.map +1 -1
  85. package/dist/components/tip/style2.css +1 -1
  86. package/dist/components/tree/style2.css +1 -1
  87. package/dist/components/tree-select/style2.css +1 -1
  88. package/dist/components/watermark/style2.css +1 -1
  89. package/dist/index.d.ts +20 -18
  90. package/dist/index.js +19 -18
  91. package/dist/style.js +1 -0
  92. package/dist/types/index.d.ts +2 -1
  93. package/dist/types/segment.d.ts +41 -0
  94. package/package.json +6 -6
  95. package/src/components/index.ts +2 -0
  96. package/src/components/radio-group/__test__/radio-group.test.ts +113 -0
  97. package/src/components/radio-group/style.scss +4 -15
  98. package/src/components/segment/__test__/segment.test.ts +264 -0
  99. package/src/components/segment/index.ts +1 -0
  100. package/src/components/segment/segment.vue +77 -0
  101. package/src/components/segment/style.scss +92 -0
  102. package/src/components/segment/style.ts +1 -0
  103. package/src/components/select/__test__/select.test.ts +36 -0
  104. package/src/components/select/select.vue +18 -12
  105. package/src/components/theme/theme.vue +6 -1
  106. package/src/style.ts +1 -0
  107. package/src/types/index.ts +1 -0
  108. package/src/types/segment.ts +42 -0
@@ -0,0 +1,113 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp, h, nextTick, ref } from 'vue'
3
+
4
+ import URadioGroup from '../radio-group.vue'
5
+
6
+ const items = [
7
+ { label: 'A', value: 'a' },
8
+ { label: 'B', value: 'b' }
9
+ ]
10
+
11
+ function mountRadioGroup(props: Record<string, unknown> = {}) {
12
+ const host = document.createElement('div')
13
+ document.body.appendChild(host)
14
+
15
+ const model = ref(props.modelValue)
16
+ const changes: unknown[] = []
17
+
18
+ const app = createApp({
19
+ render() {
20
+ return h(URadioGroup, {
21
+ ...props,
22
+ items: items as any,
23
+ modelValue: model.value,
24
+ 'onUpdate:modelValue': (value: any) => {
25
+ model.value = value
26
+ },
27
+ onChange: (item: any) => {
28
+ changes.push(item)
29
+ }
30
+ })
31
+ }
32
+ })
33
+
34
+ app.mount(host)
35
+
36
+ return {
37
+ host,
38
+ model,
39
+ changes,
40
+ unmount() {
41
+ app.unmount()
42
+ host.remove()
43
+ }
44
+ }
45
+ }
46
+
47
+ function queryRoot(host: HTMLElement) {
48
+ return host.querySelector<HTMLElement>('.u-radio-group')!
49
+ }
50
+
51
+ function queryRadios(host: HTMLElement) {
52
+ return [...host.querySelectorAll<HTMLElement>('.u-radio')]
53
+ }
54
+
55
+ function pick(host: HTMLElement, index: number) {
56
+ const input = queryRadios(host)[index]!.querySelector<HTMLInputElement>('input')!
57
+ input.checked = true
58
+ input.dispatchEvent(new Event('change', { bubbles: true }))
59
+ }
60
+
61
+ describe('RadioGroup', () => {
62
+ it('基础渲染:圆点元素存在且选项正确渲染', async () => {
63
+ const { host, unmount } = mountRadioGroup({ modelValue: 'a' })
64
+ await nextTick()
65
+
66
+ try {
67
+ const root = queryRoot(host)
68
+ expect(root).toBeTruthy()
69
+ expect(root.querySelector('.u-radio__button')).toBeTruthy()
70
+ const radios = queryRadios(host)
71
+ expect(radios.length).toBe(2)
72
+ expect(radios[0]!.classList.contains('is-checked')).toBe(true)
73
+ expect(radios[1]!.classList.contains('is-checked')).toBe(false)
74
+ } finally {
75
+ unmount()
76
+ }
77
+ })
78
+
79
+ it('禁用项呈禁用态且原生不可选中', async () => {
80
+ const { host, unmount } = mountRadioGroup({
81
+ modelValue: 'a',
82
+ disabledItem: (item: any) => item.value === 'b'
83
+ })
84
+ await nextTick()
85
+
86
+ try {
87
+ const radios = queryRadios(host)
88
+ expect(radios[1]!.classList.contains('is-disabled')).toBe(true)
89
+
90
+ const inputs = radios.map((radio) => radio.querySelector<HTMLInputElement>('input')!)
91
+ expect(inputs[0]!.disabled).toBe(false)
92
+ expect(inputs[1]!.disabled).toBe(true)
93
+ } finally {
94
+ unmount()
95
+ }
96
+ })
97
+
98
+ it('点击选项更新 modelValue 并 emit change(参数为 item)', async () => {
99
+ const { host, model, changes, unmount } = mountRadioGroup({ modelValue: 'a' })
100
+ await nextTick()
101
+
102
+ try {
103
+ pick(host, 1)
104
+ await nextTick()
105
+
106
+ expect(model.value).toBe('b')
107
+ expect(changes).toEqual([items[1]])
108
+ expect(queryRadios(host)[1]!.classList.contains('is-checked')).toBe(true)
109
+ } finally {
110
+ unmount()
111
+ }
112
+ })
113
+ })
@@ -8,6 +8,10 @@
8
8
 
9
9
  @include m.bem(radio) {
10
10
  margin: 0;
11
+
12
+ & + & {
13
+ margin-left: 0;
14
+ }
11
15
  }
12
16
 
13
17
  @include m.size using ($size) {
@@ -21,19 +25,4 @@
21
25
  display: flex;
22
26
  }
23
27
  }
24
-
25
- @include m.e(button-item) {
26
- margin: 0px !important;
27
- border-right: 0 !important;
28
- border-radius: 0 !important;
29
-
30
- &:first-child {
31
- border-radius: fn.use-var(radius, default) 0 0 fn.use-var(radius, default) !important;
32
- }
33
-
34
- &:last-child {
35
- border-right: fn.use-var(border-muted) !important;
36
- border-radius: 0 fn.use-var(radius, default) fn.use-var(radius, default) 0 !important;
37
- }
38
- }
39
28
  }
@@ -0,0 +1,264 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createApp, h, nextTick, ref } from 'vue'
3
+
4
+ import USegment from '../segment.vue'
5
+
6
+ const defaultItems = [
7
+ { label: 'Option A', value: 'a' },
8
+ { label: 'Option B', value: 'b' },
9
+ { label: 'Option C', value: 'c' }
10
+ ]
11
+
12
+ function mountSegment(props: Record<string, unknown> = {}) {
13
+ const host = document.createElement('div')
14
+ document.body.appendChild(host)
15
+
16
+ const model = ref(props.modelValue)
17
+ const changes: unknown[] = []
18
+
19
+ const app = createApp({
20
+ render() {
21
+ return h(USegment, {
22
+ items: defaultItems,
23
+ ...props,
24
+ modelValue: model.value,
25
+ 'onUpdate:modelValue': (value: any) => {
26
+ model.value = value
27
+ },
28
+ onChange: (item: any) => {
29
+ changes.push(item)
30
+ }
31
+ })
32
+ }
33
+ })
34
+
35
+ app.mount(host)
36
+
37
+ return {
38
+ host,
39
+ model,
40
+ changes,
41
+ unmount() {
42
+ app.unmount()
43
+ host.remove()
44
+ }
45
+ }
46
+ }
47
+
48
+ function queryRoot(host: HTMLElement) {
49
+ return host.querySelector<HTMLElement>('.u-segment')!
50
+ }
51
+
52
+ function queryItems(host: HTMLElement) {
53
+ return [...host.querySelectorAll<HTMLElement>('.u-segment__item')]
54
+ }
55
+
56
+ describe('USegment', () => {
57
+ it('基础渲染与默认选中态', async () => {
58
+ const { host, unmount } = mountSegment({ modelValue: 'b' })
59
+ await nextTick()
60
+
61
+ try {
62
+ const root = queryRoot(host)
63
+ expect(root).toBeTruthy()
64
+ expect(root.getAttribute('role')).toBe('radiogroup')
65
+
66
+ const items = queryItems(host)
67
+ expect(items.length).toBe(3)
68
+ expect(items[0]!.textContent).toBe('Option A')
69
+ expect(items[1]!.textContent).toBe('Option B')
70
+ expect(items[2]!.textContent).toBe('Option C')
71
+
72
+ expect(items[0]!.classList.contains('is-active')).toBe(false)
73
+ expect(items[1]!.classList.contains('is-active')).toBe(true)
74
+ expect(items[2]!.classList.contains('is-active')).toBe(false)
75
+
76
+ expect(items[1]!.getAttribute('aria-checked')).toBe('true')
77
+ expect(items[0]!.getAttribute('aria-checked')).toBe('false')
78
+ } finally {
79
+ unmount()
80
+ }
81
+ })
82
+
83
+ it('点击选项触发 modelValue 更新与 change 事件', async () => {
84
+ const { host, model, changes, unmount } = mountSegment({ modelValue: 'a' })
85
+ await nextTick()
86
+
87
+ try {
88
+ const items = queryItems(host)
89
+ expect(items[0]!.classList.contains('is-active')).toBe(true)
90
+
91
+ items[1]!.click()
92
+ await nextTick()
93
+
94
+ expect(model.value).toBe('b')
95
+ expect(changes).toEqual([defaultItems[1]])
96
+ expect(items[1]!.classList.contains('is-active')).toBe(true)
97
+ expect(items[0]!.classList.contains('is-active')).toBe(false)
98
+
99
+ // 点击已选中项不重复触发 change
100
+ items[1]!.click()
101
+ await nextTick()
102
+ expect(changes.length).toBe(1)
103
+ } finally {
104
+ unmount()
105
+ }
106
+ })
107
+
108
+ it('支持键盘 Enter 和 Space 触发选择', async () => {
109
+ const { host, model, changes, unmount } = mountSegment({ modelValue: 'a' })
110
+ await nextTick()
111
+
112
+ try {
113
+ const items = queryItems(host)
114
+
115
+ items[2]!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
116
+ await nextTick()
117
+
118
+ expect(model.value).toBe('c')
119
+ expect(changes).toEqual([defaultItems[2]])
120
+
121
+ items[1]!.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
122
+ await nextTick()
123
+
124
+ expect(model.value).toBe('b')
125
+ expect(changes).toEqual([defaultItems[2], defaultItems[1]])
126
+ } finally {
127
+ unmount()
128
+ }
129
+ })
130
+
131
+ it('支持 custom labelKey 和 valueKey', async () => {
132
+ const customItems = [
133
+ { name: 'First', id: 1 },
134
+ { name: 'Second', id: 2 }
135
+ ]
136
+ const host = document.createElement('div')
137
+ document.body.appendChild(host)
138
+ const model = ref(1)
139
+ const changes: unknown[] = []
140
+
141
+ const app = createApp({
142
+ render() {
143
+ return h(USegment, {
144
+ items: customItems,
145
+ labelKey: 'name',
146
+ valueKey: 'id',
147
+ modelValue: model.value,
148
+ 'onUpdate:modelValue': (value: any) => {
149
+ model.value = value
150
+ },
151
+ onChange: (item: any) => {
152
+ changes.push(item)
153
+ }
154
+ })
155
+ }
156
+ })
157
+ app.mount(host)
158
+ await nextTick()
159
+
160
+ try {
161
+ const items = queryItems(host)
162
+ expect(items[0]!.textContent).toBe('First')
163
+ expect(items[1]!.textContent).toBe('Second')
164
+ expect(items[0]!.classList.contains('is-active')).toBe(true)
165
+
166
+ items[1]!.click()
167
+ await nextTick()
168
+
169
+ expect(model.value).toBe(2)
170
+ expect(changes).toEqual([customItems[1]])
171
+ } finally {
172
+ app.unmount()
173
+ host.remove()
174
+ }
175
+ })
176
+
177
+ it('整组 disabled 禁用控制', async () => {
178
+ const { host, model, changes, unmount } = mountSegment({ modelValue: 'a', disabled: true })
179
+ await nextTick()
180
+
181
+ try {
182
+ const root = queryRoot(host)
183
+ expect(root.classList.contains('is-disabled')).toBe(true)
184
+
185
+ const items = queryItems(host)
186
+ expect(items[0]!.classList.contains('is-disabled')).toBe(true)
187
+ expect(items[1]!.classList.contains('is-disabled')).toBe(true)
188
+
189
+ items[1]!.click()
190
+ await nextTick()
191
+
192
+ expect(model.value).toBe('a')
193
+ expect(changes.length).toBe(0)
194
+ } finally {
195
+ unmount()
196
+ }
197
+ })
198
+
199
+ it('disabledItem 单项禁用控制', async () => {
200
+ const { host, model, changes, unmount } = mountSegment({
201
+ modelValue: 'a',
202
+ disabledItem: (item: any) => item.value === 'b'
203
+ })
204
+ await nextTick()
205
+
206
+ try {
207
+ const items = queryItems(host)
208
+ expect(items[0]!.classList.contains('is-disabled')).toBe(false)
209
+ expect(items[1]!.classList.contains('is-disabled')).toBe(true)
210
+ expect(items[2]!.classList.contains('is-disabled')).toBe(false)
211
+
212
+ // 点击禁用项无反应
213
+ items[1]!.click()
214
+ await nextTick()
215
+ expect(model.value).toBe('a')
216
+ expect(changes.length).toBe(0)
217
+
218
+ // 点击非禁用项正常切换
219
+ items[2]!.click()
220
+ await nextTick()
221
+ expect(model.value).toBe('c')
222
+ expect(changes).toEqual([defaultItems[2]])
223
+ } finally {
224
+ unmount()
225
+ }
226
+ })
227
+
228
+ it('block 属性与 size 样式类名绑定', async () => {
229
+ const { host, unmount } = mountSegment({ modelValue: 'a', block: true, size: 'small' })
230
+ await nextTick()
231
+
232
+ try {
233
+ const root = queryRoot(host)
234
+ expect(root.classList.contains('is-block')).toBe(true)
235
+ expect(root.classList.contains('u-segment--small')).toBe(true)
236
+ } finally {
237
+ unmount()
238
+ }
239
+ })
240
+
241
+ it('readonly 模式回退展示文本', async () => {
242
+ const { host, unmount } = mountSegment({ modelValue: 'b', readonly: true })
243
+ await nextTick()
244
+
245
+ try {
246
+ expect(host.querySelector('.u-segment')).toBeNull()
247
+ expect(host.textContent?.trim()).toBe('Option B')
248
+ } finally {
249
+ unmount()
250
+ }
251
+ })
252
+
253
+ it('readonly 模式未匹配到值时展示 FORM_EMPTY_CONTENT(-)', async () => {
254
+ const { host, unmount } = mountSegment({ modelValue: 'non-existing', readonly: true })
255
+ await nextTick()
256
+
257
+ try {
258
+ expect(host.querySelector('.u-segment')).toBeNull()
259
+ expect(host.textContent?.trim()).toBe('-')
260
+ } finally {
261
+ unmount()
262
+ }
263
+ })
264
+ })
@@ -0,0 +1 @@
1
+ export { default as USegment } from './segment.vue'
@@ -0,0 +1,77 @@
1
+ <template>
2
+ <div
3
+ v-if="!readonly"
4
+ v-bind="$attrs"
5
+ :class="[cls.b, bem.is('block', block), bem.is('disabled', disabled), cls.m(size)]"
6
+ role="radiogroup"
7
+ >
8
+ <div
9
+ v-for="item of items"
10
+ :key="item[valueKey]"
11
+ :class="[
12
+ cls.e('item'),
13
+ bem.is('active', item[valueKey] === model),
14
+ bem.is('disabled', disabled || Boolean(disabledItem?.(item)))
15
+ ]"
16
+ role="radio"
17
+ :aria-checked="item[valueKey] === model"
18
+ :aria-disabled="disabled || Boolean(disabledItem?.(item))"
19
+ tabindex="0"
20
+ @click="handleSelect(item)"
21
+ @keydown.enter.prevent="handleSelect(item)"
22
+ @keydown.space.prevent="handleSelect(item)"
23
+ >
24
+ <slot name="item" :item="item" :active="item[valueKey] === model">
25
+ <span :class="cls.e('item-label')">{{ item[labelKey] }}</span>
26
+ </slot>
27
+ </div>
28
+ </div>
29
+
30
+ <template v-else>
31
+ {{ items.find((item) => item[valueKey] === model)?.[labelKey] || FORM_EMPTY_CONTENT }}
32
+ </template>
33
+ </template>
34
+
35
+ <script lang="ts" setup>
36
+ import { useFormFallbackProps } from '@veltra/compositions'
37
+ import { bem, fieldKey, FORM_EMPTY_CONTENT, injectFormContext } from '@veltra/utils'
38
+ import { computed } from 'vue'
39
+
40
+ import type { SegmentEmits, SegmentItem, SegmentProps } from '../../types'
41
+
42
+ defineOptions({ name: 'USegment', inheritAttrs: false })
43
+
44
+ const props = withDefaults(defineProps<SegmentProps>(), {
45
+ items: () => [],
46
+ labelKey: 'label',
47
+ valueKey: 'value',
48
+ disabled: undefined,
49
+ readonly: undefined,
50
+ block: false
51
+ })
52
+
53
+ const model = defineModel<any>()
54
+
55
+ const emit = defineEmits<SegmentEmits>()
56
+
57
+ const { formProps } = injectFormContext()
58
+
59
+ const { size, disabled, readonly } = useFormFallbackProps([formProps ?? {}, props], {
60
+ size: 'default',
61
+ disabled: false,
62
+ readonly: false
63
+ })
64
+
65
+ const cls = bem('segment')
66
+
67
+ const labelKey = computed(() => fieldKey(props.labelKey, 'label'))
68
+ const valueKey = computed(() => fieldKey(props.valueKey, 'value'))
69
+
70
+ const handleSelect = (item: SegmentItem) => {
71
+ if (disabled.value || props.disabledItem?.(item)) return
72
+ const val = item[valueKey.value]
73
+ if (model.value === val) return
74
+ model.value = val
75
+ emit('change', item)
76
+ }
77
+ </script>
@@ -0,0 +1,92 @@
1
+ @use 'pkg:@veltra/styles/mixins' as m;
2
+ @use 'pkg:@veltra/styles/functions' as fn;
3
+ @use 'pkg:@veltra/styles/vars';
4
+
5
+ @include m.b(segment) {
6
+ vertical-align: middle;
7
+ @include m.flex($display: inline-flex, $align: center);
8
+ gap: 2px;
9
+ padding: 2px;
10
+ background-color: fn.use-var(bg-color, bottom);
11
+ box-sizing: border-box;
12
+
13
+ @include m.size using ($size) {
14
+ border-radius: fn.use-var(radius, $size);
15
+
16
+ @include m.bem(segment, item) {
17
+ height: calc(fn.use-var(form-component-height, $size) - 4px);
18
+ padding: 0 calc(fn.use-var(gap, $size) * 1.5);
19
+ border-radius: calc(fn.use-var(radius, $size) - 2px);
20
+ font-size: fn.use-var(font-size-main, $size);
21
+ }
22
+ }
23
+
24
+ @include m.e(item) {
25
+ margin: 0;
26
+ display: inline-flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ background-color: transparent;
30
+ box-shadow: none;
31
+ font-weight: 500;
32
+ color: fn.use-var(text-color, second);
33
+ cursor: pointer;
34
+ user-select: none;
35
+ outline: none;
36
+ transition:
37
+ color fn.use-var(transition, fast) fn.use-var(transition, ease),
38
+ background-color fn.use-var(transition, fast) fn.use-var(transition, ease),
39
+ box-shadow fn.use-var(transition, fast) fn.use-var(transition, ease);
40
+
41
+ &:hover:not(.is-active):not(.is-disabled) {
42
+ color: fn.use-var(text-color, title);
43
+ background-color: fn.color-a(color, 5, primary);
44
+ }
45
+
46
+ @include m.is(active) {
47
+ color: fn.use-var(color, primary);
48
+ background-color: fn.use-var(bg-color, top);
49
+ box-shadow: fn.use-var(shadow, sm);
50
+ font-weight: 600;
51
+ }
52
+
53
+ @include m.is(disabled) {
54
+ opacity: 0.5;
55
+ cursor: not-allowed;
56
+ color: fn.use-var(text-color, disabled);
57
+ }
58
+
59
+ &:focus-visible:not(.is-disabled) {
60
+ box-shadow: fn.use-var(focus-ring);
61
+ }
62
+ }
63
+
64
+ @include m.e(item-label) {
65
+ @include m.ellipsis;
66
+ display: inline-flex;
67
+ align-items: center;
68
+ justify-content: center;
69
+ }
70
+
71
+ @include m.is(block) {
72
+ display: flex;
73
+ width: 100%;
74
+
75
+ @include m.bem(segment, item) {
76
+ flex: 1 1 0%;
77
+ width: 0;
78
+ min-width: 0;
79
+ display: inline-flex;
80
+ justify-content: center;
81
+ }
82
+ }
83
+
84
+ @include m.is(disabled) {
85
+ opacity: 0.7;
86
+ cursor: not-allowed;
87
+
88
+ @include m.bem(segment, item) {
89
+ cursor: not-allowed;
90
+ }
91
+ }
92
+ }
@@ -0,0 +1 @@
1
+ import './style.scss'
@@ -117,6 +117,42 @@ describe('USelect', () => {
117
117
  }
118
118
  })
119
119
 
120
+ it('keeps the filtered list while the panel is closing after a select', async () => {
121
+ const { host, model, unmount } = mountSelect({
122
+ filterable: true,
123
+ options: [
124
+ { label: '北京', value: 'beijing' },
125
+ { label: '上海', value: 'shanghai' },
126
+ { label: '广州', value: 'guangzhou' }
127
+ ]
128
+ })
129
+
130
+ try {
131
+ await openDropdown(host)
132
+ await typeQuery(host, '上海')
133
+ expect(queryOptions().map((el) => el.textContent)).toEqual(['上海'])
134
+
135
+ // 选择后关闭动画期间:面板仍在 DOM 中,列表必须保持过滤态,
136
+ // 不能瞬间恢复全量导致面板突然变长闪烁
137
+ queryOptions()[0]!.dispatchEvent(new MouseEvent('click', { bubbles: true }))
138
+ await nextTick()
139
+ expect(model.value).toBe('shanghai')
140
+ const closingOptions = queryOptions()
141
+ expect(closingOptions.length).toBeGreaterThan(0)
142
+ expect(closingOptions.map((el) => el.textContent)).toEqual(['上海'])
143
+
144
+ // 动画兜底结束后面板卸载
145
+ await sleep(150)
146
+ expect(queryOptions()).toHaveLength(0)
147
+
148
+ // 重新展开:列表已恢复为完整选项
149
+ await openDropdown(host)
150
+ expect(queryOptions()).toHaveLength(3)
151
+ } finally {
152
+ unmount()
153
+ }
154
+ })
155
+
120
156
  it('emits update:text with option label when modelValue echoes against options', async () => {
121
157
  const { host, texts, unmount } = mountSelect({
122
158
  modelValue: 'beijing',
@@ -12,7 +12,6 @@
12
12
  :min-width="minWidth"
13
13
  :width="width"
14
14
  @keydown="handleKeydown"
15
- @update:visible="handleDropdownVisible"
16
15
  @mouseenter="hovered = true"
17
16
  @mouseleave="hovered = false"
18
17
  >
@@ -200,6 +199,9 @@ const { queryString, options, allOptions, temOptionsToCreatedOptions, clearCreat
200
199
 
201
200
  const dropdownVisible = shallowRef(false)
202
201
 
202
+ /** 选中临时选项后待转正标记:面板关闭后再落定,避免关闭动画期间列表恢复全量导致面板变长 */
203
+ let pendingTempPromote = false
204
+
203
205
  /**
204
206
  * 是否处于查询态:决定触发输入框显示查询串还是选中标签。
205
207
  * 与面板可见状态解耦 —— 选择后立即退出查询态,
@@ -341,8 +343,17 @@ watch([scrollRef, virtualEnabled], ([scroll, virtualEnabled]) => {
341
343
  }
342
344
  })
343
345
 
346
+ /** 落定待转正的临时选项(面板 DOM 已卸载,列表恢复全量用户不可见) */
347
+ function promotePendingTemp() {
348
+ if (!pendingTempPromote) return
349
+ pendingTempPromote = false
350
+ temOptionsToCreatedOptions()
351
+ }
352
+
344
353
  watch(dropdownVisible, (visible) => {
345
354
  if (visible) {
355
+ // 关闭动画期间被重新打开时补一次转正,保证已选临时项落到完整列表
356
+ promotePendingTemp()
346
357
  // 面板展开后进入查询态并聚焦输入框,可以立即输入查询
347
358
  if (filterable.value) {
348
359
  queryString.value = ''
@@ -350,17 +361,13 @@ watch(dropdownVisible, (visible) => {
350
361
  nextTick(() => inputRef.value?.el?.focus())
351
362
  }
352
363
  } else {
364
+ // 面板完全关闭(动画结束、DOM 卸载)后才清空查询串并转正,关闭动画期间列表保持过滤态
353
365
  querying.value = false
354
366
  queryString.value = ''
367
+ promotePendingTemp()
355
368
  }
356
369
  })
357
370
 
358
- const handleDropdownVisible = (visible: boolean) => {
359
- if (!visible) {
360
- queryString.value = ''
361
- }
362
- }
363
-
364
371
  /** 单选:用户动作内 O(1) 写入值与文案,跳过 modelValue 回显的 O(n) 查找 */
365
372
  const handleSelect = userAction((option: Record<string, any>, _index: number) => {
366
373
  const value = option?.[valueKey.value]
@@ -370,13 +377,12 @@ const handleSelect = userAction((option: Record<string, any>, _index: number) =>
370
377
  setLabel(option?.[labelKey.value])
371
378
  emit('change', option)
372
379
  if (option.__isTemp) {
373
- // 转正后列表同步重建为完整源(临时项置顶索引失效),必须按 value 重定位
374
- temOptionsToCreatedOptions()
380
+ // 转正延迟到面板关闭后:动画期间保持过滤列表,避免列表瞬间恢复全量导致面板变长闪烁
381
+ pendingTempPromote = true
375
382
  }
376
- // 立即退出查询态,输入框同步恢复显示选中标签
383
+ // 立即退出查询态,输入框同步恢复显示选中标签;查询串保留至面板关闭,避免关闭动画期间列表恢复全量
377
384
  querying.value = false
378
- queryString.value = ''
379
- // 按当前列表定位高亮,避免创建项转正后仍停留在点击时的临时索引(通常为 0)
385
+ // 按当前列表定位高亮(临时项尚未转正,仍在过滤列表中,通常为 0)
380
386
  currentIndex.value = options.value.findIndex((item) => o(item).get(valueKey.value) === value)
381
387
  if (currentIndex.value >= 0) {
382
388
  selected.value = options.value[currentIndex.value]