@veltra/mobile 1.0.0 → 1.1.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 (245) hide show
  1. package/package.json +32 -6
  2. package/src/components/_internal/bottom-sheet/bottom-sheet.vue +81 -0
  3. package/src/components/_internal/bottom-sheet/index.ts +1 -0
  4. package/src/components/_internal/bottom-sheet/style.scss +116 -0
  5. package/src/components/_internal/bottom-sheet/style.ts +1 -0
  6. package/src/components/_internal/overlay-container.ts +34 -0
  7. package/src/components/avatar/avatar.vue +44 -0
  8. package/src/components/avatar/index.ts +1 -0
  9. package/src/components/avatar/style.scss +53 -0
  10. package/src/components/avatar/style.ts +1 -0
  11. package/src/components/badge/badge.vue +59 -0
  12. package/src/components/badge/index.ts +1 -0
  13. package/src/components/badge/style.scss +62 -0
  14. package/src/components/badge/style.ts +1 -0
  15. package/src/components/button/button.vue +76 -0
  16. package/src/components/button/index.ts +1 -0
  17. package/src/components/button/style.scss +207 -0
  18. package/src/components/button/style.ts +1 -0
  19. package/src/components/card/card.vue +24 -0
  20. package/src/components/card/index.ts +1 -0
  21. package/src/components/card/style.scss +35 -0
  22. package/src/components/card/style.ts +1 -0
  23. package/src/components/cascade/cascade.vue +595 -0
  24. package/src/components/cascade/index.ts +1 -0
  25. package/src/components/cascade/node.ts +29 -0
  26. package/src/components/cascade/style.scss +424 -0
  27. package/src/components/cascade/style.ts +2 -0
  28. package/src/components/check-tag/check-tag.vue +35 -0
  29. package/src/components/check-tag/index.ts +1 -0
  30. package/src/components/check-tag/style.scss +46 -0
  31. package/src/components/check-tag/style.ts +1 -0
  32. package/src/components/checkbox/checkbox.vue +67 -0
  33. package/src/components/checkbox/index.ts +1 -0
  34. package/src/components/checkbox/style.scss +132 -0
  35. package/src/components/checkbox/style.ts +2 -0
  36. package/src/components/checkbox-group/checkbox-group.vue +94 -0
  37. package/src/components/checkbox-group/index.ts +1 -0
  38. package/src/components/checkbox-group/style.scss +57 -0
  39. package/src/components/checkbox-group/style.ts +2 -0
  40. package/src/components/collapse/collapse-item.vue +134 -0
  41. package/src/components/collapse/collapse.vue +117 -0
  42. package/src/components/collapse/di.ts +18 -0
  43. package/src/components/collapse/index.ts +3 -0
  44. package/src/components/collapse/style.scss +157 -0
  45. package/src/components/collapse/style.ts +1 -0
  46. package/src/components/date-picker/date-picker.vue +326 -0
  47. package/src/components/date-picker/index.ts +1 -0
  48. package/src/components/date-picker/style.scss +236 -0
  49. package/src/components/date-picker/style.ts +2 -0
  50. package/src/components/descriptions/descriptions-item.vue +12 -0
  51. package/src/components/descriptions/descriptions.vue +50 -0
  52. package/src/components/descriptions/index.ts +2 -0
  53. package/src/components/descriptions/style.scss +120 -0
  54. package/src/components/descriptions/style.ts +1 -0
  55. package/src/components/dialog/dialog.vue +143 -0
  56. package/src/components/dialog/index.ts +1 -0
  57. package/src/components/dialog/style.scss +163 -0
  58. package/src/components/dialog/style.ts +4 -0
  59. package/src/components/divider/divider.vue +29 -0
  60. package/src/components/divider/index.ts +1 -0
  61. package/src/components/divider/style.scss +91 -0
  62. package/src/components/divider/style.ts +1 -0
  63. package/src/components/drawer/drawer.vue +168 -0
  64. package/src/components/drawer/index.ts +1 -0
  65. package/src/components/drawer/style.scss +240 -0
  66. package/src/components/drawer/style.ts +2 -0
  67. package/src/components/empty/empty.vue +22 -0
  68. package/src/components/empty/index.ts +1 -0
  69. package/src/components/empty/style.scss +36 -0
  70. package/src/components/empty/style.ts +1 -0
  71. package/src/components/form/form.vue +119 -0
  72. package/src/components/form/index.ts +1 -0
  73. package/src/components/form/is-field-modified.ts +16 -0
  74. package/src/components/form/style.scss +58 -0
  75. package/src/components/form/style.ts +2 -0
  76. package/src/components/form/use-form-fields.ts +115 -0
  77. package/src/components/form/use-node-interceptor.ts +101 -0
  78. package/src/components/form-item/form-item.vue +171 -0
  79. package/src/components/form-item/helper.ts +9 -0
  80. package/src/components/form-item/index.ts +1 -0
  81. package/src/components/form-item/style.scss +121 -0
  82. package/src/components/form-item/style.ts +1 -0
  83. package/src/components/form-item/validate.ts +131 -0
  84. package/src/components/icon/icon.vue +23 -0
  85. package/src/components/icon/index.ts +1 -0
  86. package/src/components/icon/style.scss +37 -0
  87. package/src/components/icon/style.ts +1 -0
  88. package/src/components/input/index.ts +1 -0
  89. package/src/components/input/input.vue +187 -0
  90. package/src/components/input/style.scss +121 -0
  91. package/src/components/input/style.ts +1 -0
  92. package/src/components/list/index.ts +3 -0
  93. package/src/components/list/list-item.vue +20 -0
  94. package/src/components/list/list.vue +20 -0
  95. package/src/components/list/style.scss +50 -0
  96. package/src/components/list/style.ts +1 -0
  97. package/src/components/loading/directive.ts +34 -0
  98. package/src/components/loading/index.ts +3 -0
  99. package/src/components/loading/loading.vue +25 -0
  100. package/src/components/loading/style.scss +179 -0
  101. package/src/components/loading/style.ts +2 -0
  102. package/src/components/message/helper.ts +28 -0
  103. package/src/components/message/index.ts +3 -0
  104. package/src/components/message/message-box.vue +31 -0
  105. package/src/components/message/message.ts +122 -0
  106. package/src/components/message/message.vue +54 -0
  107. package/src/components/message/style.scss +130 -0
  108. package/src/components/message/style.ts +1 -0
  109. package/src/components/message-confirm/index.ts +3 -0
  110. package/src/components/message-confirm/message-confirm-box.vue +36 -0
  111. package/src/components/message-confirm/message-confirm.ts +139 -0
  112. package/src/components/message-confirm/message-confirm.vue +53 -0
  113. package/src/components/message-confirm/style.scss +138 -0
  114. package/src/components/message-confirm/style.ts +1 -0
  115. package/src/components/multi-select/index.ts +1 -0
  116. package/src/components/multi-select/multi-select.vue +410 -0
  117. package/src/components/multi-select/style.scss +376 -0
  118. package/src/components/multi-select/style.ts +2 -0
  119. package/src/components/number-input/index.ts +1 -0
  120. package/src/components/number-input/number-input.vue +407 -0
  121. package/src/components/number-input/style.scss +118 -0
  122. package/src/components/number-input/style.ts +2 -0
  123. package/src/components/password-input/index.ts +1 -0
  124. package/src/components/password-input/password-input.vue +134 -0
  125. package/src/components/password-input/style.scss +40 -0
  126. package/src/components/password-input/style.ts +2 -0
  127. package/src/components/progress/index.ts +1 -0
  128. package/src/components/progress/progress.vue +90 -0
  129. package/src/components/progress/style.scss +73 -0
  130. package/src/components/progress/style.ts +1 -0
  131. package/src/components/radio/index.ts +1 -0
  132. package/src/components/radio/radio.vue +57 -0
  133. package/src/components/radio/style.scss +113 -0
  134. package/src/components/radio/style.ts +1 -0
  135. package/src/components/radio-group/index.ts +1 -0
  136. package/src/components/radio-group/radio-group.vue +60 -0
  137. package/src/components/radio-group/style.scss +29 -0
  138. package/src/components/radio-group/style.ts +2 -0
  139. package/src/components/segment/index.ts +1 -0
  140. package/src/components/segment/segment.vue +121 -0
  141. package/src/components/segment/style.scss +118 -0
  142. package/src/components/segment/style.ts +1 -0
  143. package/src/components/select/index.ts +1 -0
  144. package/src/components/select/select.vue +278 -0
  145. package/src/components/select/style.scss +237 -0
  146. package/src/components/select/style.ts +2 -0
  147. package/src/components/select/use-options.ts +195 -0
  148. package/src/components/skeleton/index.ts +1 -0
  149. package/src/components/skeleton/skeleton.vue +37 -0
  150. package/src/components/skeleton/style.scss +95 -0
  151. package/src/components/skeleton/style.ts +1 -0
  152. package/src/components/space/index.ts +1 -0
  153. package/src/components/space/space.vue +45 -0
  154. package/src/components/space/style.scss +41 -0
  155. package/src/components/space/style.ts +1 -0
  156. package/src/components/steps/index.ts +1 -0
  157. package/src/components/steps/steps.vue +114 -0
  158. package/src/components/steps/style.scss +231 -0
  159. package/src/components/steps/style.ts +1 -0
  160. package/src/components/switch/index.ts +1 -0
  161. package/src/components/switch/style.scss +137 -0
  162. package/src/components/switch/style.ts +1 -0
  163. package/src/components/switch/switch.vue +63 -0
  164. package/src/components/tabs/index.ts +1 -0
  165. package/src/components/tabs/style.scss +285 -0
  166. package/src/components/tabs/style.ts +1 -0
  167. package/src/components/tabs/tabs.vue +127 -0
  168. package/src/components/tag/index.ts +1 -0
  169. package/src/components/tag/style.scss +114 -0
  170. package/src/components/tag/style.ts +1 -0
  171. package/src/components/tag/tag.vue +49 -0
  172. package/src/components/text/index.ts +1 -0
  173. package/src/components/text/style.scss +63 -0
  174. package/src/components/text/style.ts +1 -0
  175. package/src/components/text/text.vue +63 -0
  176. package/src/components/textarea/index.ts +1 -0
  177. package/src/components/textarea/style.scss +124 -0
  178. package/src/components/textarea/style.ts +1 -0
  179. package/src/components/textarea/textarea.vue +121 -0
  180. package/src/components/textarea/utils.ts +11 -0
  181. package/src/components/time-picker/index.ts +1 -0
  182. package/src/components/time-picker/style.scss +216 -0
  183. package/src/components/time-picker/style.ts +2 -0
  184. package/src/components/time-picker/time-picker.vue +263 -0
  185. package/src/components/timeline/index.ts +2 -0
  186. package/src/components/timeline/style.scss +107 -0
  187. package/src/components/timeline/style.ts +1 -0
  188. package/src/components/timeline/timeline-item.vue +43 -0
  189. package/src/components/timeline/timeline.vue +16 -0
  190. package/src/components/uploader/index.ts +1 -0
  191. package/src/components/uploader/style.scss +220 -0
  192. package/src/components/uploader/style.ts +1 -0
  193. package/src/components/uploader/uploader.vue +306 -0
  194. package/src/env.d.ts +7 -0
  195. package/src/index.ts +99 -1
  196. package/src/shared/bem.ts +12 -0
  197. package/src/shared/use-scroll-lock.ts +111 -0
  198. package/src/shared/use-touch-gesture.ts +150 -0
  199. package/src/styles/_tokens.scss +129 -0
  200. package/src/types/avatar.ts +31 -0
  201. package/src/types/badge.ts +28 -0
  202. package/src/types/button.ts +48 -0
  203. package/src/types/card.ts +16 -0
  204. package/src/types/cascade.ts +87 -0
  205. package/src/types/check-tag.ts +24 -0
  206. package/src/types/checkbox-group.ts +20 -0
  207. package/src/types/checkbox.ts +15 -0
  208. package/src/types/collapse.ts +79 -0
  209. package/src/types/date-picker.ts +39 -0
  210. package/src/types/descriptions.ts +22 -0
  211. package/src/types/dialog.ts +54 -0
  212. package/src/types/divider.ts +14 -0
  213. package/src/types/drawer.ts +38 -0
  214. package/src/types/empty.ts +19 -0
  215. package/src/types/form-item.ts +22 -0
  216. package/src/types/form.ts +54 -0
  217. package/src/types/icon.ts +10 -0
  218. package/src/types/input.ts +51 -0
  219. package/src/types/list.ts +13 -0
  220. package/src/types/loading.ts +20 -0
  221. package/src/types/message-confirm.ts +72 -0
  222. package/src/types/message.ts +71 -0
  223. package/src/types/multi-select.ts +38 -0
  224. package/src/types/number-input.ts +32 -0
  225. package/src/types/password-input.ts +19 -0
  226. package/src/types/progress.ts +22 -0
  227. package/src/types/radio-group.ts +33 -0
  228. package/src/types/radio.ts +18 -0
  229. package/src/types/segment.ts +42 -0
  230. package/src/types/select.ts +63 -0
  231. package/src/types/skeleton.ts +29 -0
  232. package/src/types/space.ts +22 -0
  233. package/src/types/steps.ts +67 -0
  234. package/src/types/switch.ts +17 -0
  235. package/src/types/tabs.ts +63 -0
  236. package/src/types/tag.ts +19 -0
  237. package/src/types/text.ts +28 -0
  238. package/src/types/textarea.ts +76 -0
  239. package/src/types/time-picker.ts +40 -0
  240. package/src/types/timeline.ts +15 -0
  241. package/src/types/uploader.ts +50 -0
  242. package/.turbo/turbo-build.log +0 -2
  243. package/.turbo/turbo-check-types.log +0 -1
  244. package/tsconfig.json +0 -12
  245. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,410 @@
1
+ <template>
2
+ <!-- 只读态:纯展示标签 -->
3
+ <div v-if="readonly && model?.length" :class="[cls.b, cls.m(size), bem.is('readonly', true)]">
4
+ <div :class="cls.e('tags')">
5
+ <span v-for="option of tags" :key="getOptionField(option, valueKey)" :class="cls.e('tag')">
6
+ {{ getOptionField(option, labelKey) }}
7
+ </span>
8
+ </div>
9
+ </div>
10
+
11
+ <template v-else-if="readonly">{{ FORM_EMPTY_CONTENT }}</template>
12
+
13
+ <div
14
+ v-else
15
+ v-bind="$attrs"
16
+ :class="[cls.b, cls.m(size), bem.is('disabled', disabled), bem.is('active', sheetVisible)]"
17
+ :aria-expanded="sheetVisible"
18
+ aria-haspopup="dialog"
19
+ @click="handleTriggerClick"
20
+ >
21
+ <span v-if="!model?.length" :class="[cls.e('value'), bem.is('placeholder', true)]">
22
+ {{ placeholder }}
23
+ </span>
24
+
25
+ <div v-if="model?.length" ref="tagsRef" :class="cls.e('tags')">
26
+ <span v-for="option of tags" :key="getOptionField(option, valueKey)" :class="cls.e('tag')">
27
+ <span :class="cls.e('tag-label')">
28
+ {{ getOptionField(option, labelKey) }}
29
+ </span>
30
+
31
+ <button
32
+ v-if="!disabled"
33
+ :class="cls.e('tag-close')"
34
+ type="button"
35
+ :aria-label="`移除 ${getOptionField(option, labelKey)}`"
36
+ @click.stop="handleTagClose(option)"
37
+ >
38
+ <Close />
39
+ </button>
40
+ </span>
41
+
42
+ <span v-if="restTag" :class="[cls.e('tag'), bem.is('rest', true)]"> {{ restTag }}+ </span>
43
+ </div>
44
+
45
+ <span :class="cls.e('icons')">
46
+ <button
47
+ v-if="clearable && model?.length && !disabled"
48
+ :class="cls.e('clear')"
49
+ type="button"
50
+ aria-label="清除"
51
+ @click.stop="handleClear"
52
+ >
53
+ <Close />
54
+ </button>
55
+ <span v-else :class="cls.e('arrow')" aria-hidden="true">
56
+ <ArrowDown />
57
+ </span>
58
+ </span>
59
+ </div>
60
+
61
+ <!-- 底部多选面板(内部实现,不进公开导出):确定/取消,勾选结果暂存至确认 -->
62
+ <BottomSheet
63
+ v-model:visible="sheetVisible"
64
+ :title="placeholder"
65
+ :content-class="contentClass"
66
+ :content-style="contentStyle"
67
+ >
68
+ <template #header>
69
+ <button :class="cls.e('cancel')" type="button" @click="sheetVisible = false">取消</button>
70
+ <span :class="cls.e('title')">{{ placeholder }}</span>
71
+ <button :class="cls.e('confirm')" type="button" @click="handleConfirm">确定</button>
72
+ </template>
73
+
74
+ <div v-if="filterable" :class="cls.e('search')">
75
+ <input
76
+ ref="searchRef"
77
+ v-model="queryString"
78
+ :class="cls.e('search-input')"
79
+ type="text"
80
+ placeholder="搜索"
81
+ enterkeyhint="search"
82
+ @keydown.enter.prevent="handleCreateByEnter"
83
+ />
84
+ </div>
85
+
86
+ <template v-if="!loading && options.length">
87
+ <div :class="cls.e('content-header')">
88
+ <button
89
+ :class="[cls.e('check-all'), bem.is('disabled', max !== undefined)]"
90
+ type="button"
91
+ @click="handleCheckAll"
92
+ >
93
+ <span :class="[cls.e('checkbox'), bem.is('checked', allChecked)]">
94
+ <Check v-if="allChecked" />
95
+ <Minus v-else-if="indeterminate" />
96
+ </span>
97
+ 全选
98
+ </button>
99
+
100
+ <span :class="cls.e('count')"> 已选 {{ staged.length }}/{{ max ?? options.length }} </span>
101
+ </div>
102
+
103
+ <ul :class="cls.e('options')">
104
+ <li
105
+ v-for="(option, index) of options"
106
+ :key="getOptionField(option, valueKey)"
107
+ :class="[cls.e('option'), bem.is('disabled', isDisabled(option))]"
108
+ role="option"
109
+ :aria-selected="isChecked(option)"
110
+ @click="handleCheck(option)"
111
+ >
112
+ <span :class="[cls.e('checkbox'), bem.is('checked', isChecked(option))]">
113
+ <Check v-if="isChecked(option)" />
114
+ </span>
115
+
116
+ <slot :option="option" :index="index">
117
+ <span :class="cls.e('option-label')">
118
+ {{ getOptionField(option, labelKey) }}
119
+ </span>
120
+ </slot>
121
+ </li>
122
+ </ul>
123
+ </template>
124
+
125
+ <div v-else-if="loading" :class="cls.e('loading')">加载中...</div>
126
+ <div v-else :class="cls.e('empty')">暂无数据</div>
127
+ </BottomSheet>
128
+ </template>
129
+
130
+ <script lang="ts" setup>
131
+ import { useResizeObserver } from '@veltra/compositions'
132
+ import { ArrowDown, Check, Close, Minus } from '@veltra/icons/normal'
133
+ import { FORM_EMPTY_CONTENT, injectFormContext } from '@veltra/utils'
134
+ import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
135
+
136
+ import { bem } from '../../shared/bem'
137
+ import type { MultiSelectEmits, MultiSelectProps } from '../../types/multi-select'
138
+ import { BottomSheet } from '../_internal/bottom-sheet'
139
+ import { getOptionField, useOptions } from '../select/use-options'
140
+
141
+ defineOptions({ name: 'UMultiSelect', inheritAttrs: false })
142
+
143
+ const props = withDefaults(defineProps<MultiSelectProps>(), {
144
+ labelKey: 'label',
145
+ valueKey: 'value',
146
+ placeholder: '请选择',
147
+ clearable: true,
148
+ visibilityLimit: 3,
149
+ disabled: undefined,
150
+ readonly: undefined
151
+ })
152
+
153
+ const emit = defineEmits<MultiSelectEmits>()
154
+
155
+ defineSlots<{
156
+ /** 默认插槽 */
157
+ default?: (scope: { option: Record<string, any>; index: number }) => any
158
+ }>()
159
+
160
+ const cls = bem('multi-select')
161
+
162
+ const { formProps } = injectFormContext()
163
+
164
+ // 表单回滚属性:组件自身优先于表单(与 desktop 行为一致)
165
+ const size = computed(() => props.size ?? formProps?.size ?? 'default')
166
+ const disabled = computed(() => props.disabled ?? formProps?.disabled ?? false)
167
+ const readonly = computed(() => props.readonly ?? formProps?.readonly ?? false)
168
+
169
+ const labelKey = computed(() => props.labelKey || 'label')
170
+ const valueKey = computed(() => props.valueKey || 'value')
171
+
172
+ const model = defineModel<Array<any>>()
173
+
174
+ const filterable = computed(() => {
175
+ return props.filterable || props.creatable || typeof props.options === 'function'
176
+ })
177
+
178
+ const {
179
+ options: rawOptions,
180
+ queryString,
181
+ loading,
182
+ allOptions: rawAllOptions
183
+ } = useOptions({ props })
184
+
185
+ // 已创建选项:勾选临时项时立即转正(与 desktop 行为一致),取消不回退已创建项
186
+ const createdOptions = shallowRef<Record<string, any>[]>([])
187
+
188
+ const options = computed(() => {
189
+ const base = rawOptions.value
190
+ if (!props.creatable || !createdOptions.value.length) return base
191
+
192
+ const createdValues = new Set(createdOptions.value.map((o) => getOptionField(o, valueKey.value)))
193
+ const deduped = base.filter(
194
+ (o) => !(o.__isTemp && createdValues.has(getOptionField(o, valueKey.value)))
195
+ )
196
+ const dedupedValues = new Set(deduped.map((o) => getOptionField(o, valueKey.value)))
197
+ const toAdd = createdOptions.value.filter(
198
+ (o) => !dedupedValues.has(getOptionField(o, valueKey.value))
199
+ )
200
+ return [...toAdd, ...deduped]
201
+ })
202
+
203
+ const allOptions = computed(() => {
204
+ if (!props.creatable || !createdOptions.value.length) return rawAllOptions.value
205
+ return [...createdOptions.value, ...rawAllOptions.value]
206
+ })
207
+
208
+ const optionsMap = computed(() => {
209
+ return new Map<string | number, Record<string, any>>(
210
+ allOptions.value.map((option) => [getOptionField(option, valueKey.value), option])
211
+ )
212
+ })
213
+
214
+ /** 面板打开期间的暂存勾选值:确定时落盘,取消时丢弃 */
215
+ const staged = shallowRef<Array<any>>([])
216
+
217
+ const sheetVisible = shallowRef(false)
218
+ const searchRef = useTemplateRef<HTMLInputElement>('searchRef')
219
+
220
+ watch(sheetVisible, (visible) => {
221
+ if (!visible) {
222
+ queryString.value = ''
223
+ return
224
+ }
225
+ staged.value = [...(model.value ?? [])]
226
+ if (filterable.value) {
227
+ queryString.value = ''
228
+ nextTick(() => searchRef.value?.focus())
229
+ }
230
+ })
231
+
232
+ const allChecked = computed(() => {
233
+ const selectable = options.value.filter((o) => !o.__isTemp)
234
+ return (
235
+ selectable.length > 0 &&
236
+ selectable.every((o) => staged.value.includes(getOptionField(o, valueKey.value)))
237
+ )
238
+ })
239
+
240
+ const indeterminate = computed(() => {
241
+ return staged.value.length > 0 && !allChecked.value
242
+ })
243
+
244
+ function isChecked(option: Record<string, any>) {
245
+ return staged.value.includes(getOptionField(option, valueKey.value))
246
+ }
247
+
248
+ /** 达到 max 且未勾选的选项禁用(与 desktop 行为一致) */
249
+ function isDisabled(option: Record<string, any>) {
250
+ const { max } = props
251
+ return max !== undefined && staged.value.length >= max && !isChecked(option)
252
+ }
253
+
254
+ function handleTriggerClick() {
255
+ if (disabled.value) return
256
+ sheetVisible.value = true
257
+ }
258
+
259
+ /** 面板内勾选/取消勾选:只写暂存,待确定落盘 */
260
+ function handleCheck(option: Record<string, any>) {
261
+ if (isDisabled(option)) return
262
+
263
+ const value = getOptionField(option, valueKey.value)
264
+
265
+ if (option.__isTemp && props.creatable) {
266
+ // 勾选临时项:立即转正为已创建选项并加入暂存
267
+ const created: Record<string, any> = {
268
+ [labelKey.value]: getOptionField(option, labelKey.value),
269
+ [valueKey.value]: value
270
+ }
271
+ createdOptions.value = [...createdOptions.value, created]
272
+ staged.value = [...staged.value, value]
273
+ queryString.value = ''
274
+ return
275
+ }
276
+
277
+ staged.value = staged.value.includes(value)
278
+ ? staged.value.filter((v) => v !== value)
279
+ : [...staged.value, value]
280
+ }
281
+
282
+ /** 全选/清空暂存(受 max 限制时不可用,与 desktop 行为一致;临时项不参与全选) */
283
+ function handleCheckAll() {
284
+ if (props.max !== undefined) return
285
+
286
+ staged.value = allChecked.value
287
+ ? []
288
+ : options.value.filter((o) => !o.__isTemp).map((o) => getOptionField(o, valueKey.value))
289
+ }
290
+
291
+ /** 搜索框回车创建选项(与 desktop 行为一致) */
292
+ function handleCreateByEnter() {
293
+ if (!props.creatable) return
294
+ const qs = queryString.value?.trim()
295
+ if (!qs) return
296
+
297
+ const label = labelKey.value
298
+ const existing =
299
+ createdOptions.value.find((o) => getOptionField(o, valueKey.value) === qs) ??
300
+ rawOptions.value.find((o) => !o.__isTemp && getOptionField(o, label) === qs)
301
+
302
+ if (existing) {
303
+ const value = getOptionField(existing, valueKey.value)
304
+ if (!staged.value.includes(value)) staged.value = [...staged.value, value]
305
+ } else {
306
+ const created: Record<string, any> = { [label]: qs, [valueKey.value]: qs }
307
+ createdOptions.value = [...createdOptions.value, created]
308
+ staged.value = [...staged.value, qs]
309
+ }
310
+
311
+ queryString.value = ''
312
+ }
313
+
314
+ /** 确定:暂存落盘并通知父级 */
315
+ function handleConfirm() {
316
+ model.value = [...staged.value]
317
+ emit('change', staged.value.map((v) => optionsMap.value.get(v)).filter(Boolean))
318
+ sheetVisible.value = false
319
+ }
320
+
321
+ /** 移除触发器上的单个标签(立即生效) */
322
+ function handleTagClose(option: Record<string, any>) {
323
+ const value = getOptionField(option, valueKey.value)
324
+ model.value = (model.value ?? []).filter((v) => v !== value)
325
+ createdOptions.value = createdOptions.value.filter(
326
+ (o) => getOptionField(o, valueKey.value) !== value
327
+ )
328
+ staged.value = staged.value.filter((v) => v !== value)
329
+ emitChange()
330
+ }
331
+
332
+ /** 清除全部(立即生效) */
333
+ function handleClear() {
334
+ model.value = []
335
+ createdOptions.value = []
336
+ staged.value = []
337
+ emitChange()
338
+ }
339
+
340
+ function emitChange() {
341
+ emit('change', (model.value ?? []).map((v) => optionsMap.value.get(v)).filter(Boolean))
342
+ }
343
+
344
+ /** 触发器标签数量上限:禁用/只读时显示全部(与 desktop 行为一致) */
345
+ const maxVisible = computed(() => {
346
+ let limit = props.visibilityLimit
347
+ if (limit < 0) {
348
+ limit = 0
349
+ }
350
+
351
+ if (disabled.value || readonly.value) {
352
+ limit = model.value?.length ?? 0
353
+ }
354
+
355
+ return limit
356
+ })
357
+
358
+ /** 当前宽度内放得下的标签数:初值不设限(与 visibilityLimit 行为一致),挂载后按宽度收敛 */
359
+ const fitCount = shallowRef(Number.POSITIVE_INFINITY)
360
+
361
+ const tagsRef = useTemplateRef<HTMLDivElement>('tagsRef')
362
+
363
+ let measuring = false
364
+
365
+ /** 是否存在被省略号截断的标签文本(此时折叠进 +N,保留完整可读的标签) */
366
+ function hasTruncatedLabel(el: HTMLElement) {
367
+ return [...el.querySelectorAll<HTMLElement>('.um-multi-select__tag-label')].some(
368
+ (label) => label.scrollWidth > label.clientWidth + 1
369
+ )
370
+ }
371
+
372
+ /** 收敛可见标签数:先放开到上限,再逐个折叠到标签完整可读;空间不足时保底 1 个(+N 兜底计数) */
373
+ async function measureFit() {
374
+ const el = tagsRef.value
375
+ if (!el || measuring) return
376
+ measuring = true
377
+ try {
378
+ if (fitCount.value !== maxVisible.value) {
379
+ fitCount.value = maxVisible.value
380
+ await nextTick()
381
+ }
382
+ while (hasTruncatedLabel(el) && fitCount.value > 1) {
383
+ fitCount.value -= 1
384
+ await nextTick()
385
+ }
386
+ } finally {
387
+ measuring = false
388
+ }
389
+ }
390
+
391
+ useResizeObserver({ targets: tagsRef, onResize: () => measureFit() })
392
+
393
+ watch([model, options, size, disabled, readonly, () => props.visibilityLimit], () =>
394
+ nextTick(measureFit)
395
+ )
396
+
397
+ const tags = computed(() => {
398
+ const tags: Record<string, any>[] = []
399
+ model.value?.slice(0, Math.min(maxVisible.value, fitCount.value)).forEach((k) => {
400
+ const option = optionsMap.value.get(k)
401
+ option && tags.push(option)
402
+ })
403
+
404
+ return tags
405
+ })
406
+
407
+ const restTag = computed(() => {
408
+ return (model.value?.length ?? 0) - tags.value.length
409
+ })
410
+ </script>