@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,278 @@
1
+ <template>
2
+ <!-- 只读态:纯展示 -->
3
+ <template v-if="readonly">{{ displayedValue || FORM_EMPTY_CONTENT }}</template>
4
+
5
+ <div
6
+ v-else
7
+ v-bind="$attrs"
8
+ :class="[cls.b, cls.m(size), bem.is('disabled', disabled), bem.is('active', sheetVisible)]"
9
+ :aria-expanded="sheetVisible"
10
+ aria-haspopup="dialog"
11
+ role="combobox"
12
+ @click="handleTriggerClick"
13
+ >
14
+ <span v-if="$slots.prefix" :class="cls.e('prefix')">
15
+ <slot name="prefix" />
16
+ </span>
17
+
18
+ <span :class="[cls.e('value'), bem.is('placeholder', !displayedValue)]">
19
+ {{ displayedValue || placeholder }}
20
+ </span>
21
+
22
+ <span :class="cls.e('icons')">
23
+ <button
24
+ v-if="showClear"
25
+ :class="cls.e('clear')"
26
+ type="button"
27
+ aria-label="清除"
28
+ @click.stop="handleClear"
29
+ >
30
+ <Close />
31
+ </button>
32
+ <span v-else :class="cls.e('arrow')" aria-hidden="true">
33
+ <ArrowDown />
34
+ </span>
35
+ </span>
36
+ </div>
37
+
38
+ <!-- 底部选择面板(内部实现,不进公开导出) -->
39
+ <BottomSheet
40
+ v-model:visible="sheetVisible"
41
+ :title="placeholder"
42
+ :content-class="contentClass"
43
+ :content-style="contentStyle"
44
+ >
45
+ <div v-if="filterable" :class="cls.e('search')">
46
+ <input
47
+ ref="searchRef"
48
+ v-model="queryString"
49
+ :class="cls.e('search-input')"
50
+ type="text"
51
+ placeholder="搜索"
52
+ enterkeyhint="search"
53
+ />
54
+ </div>
55
+
56
+ <div v-if="loading" :class="cls.e('loading')">加载中...</div>
57
+
58
+ <ul
59
+ v-else-if="options.length"
60
+ :class="[cls.e('options'), bem.is('grid', !!grid)]"
61
+ :style="gridStyle"
62
+ >
63
+ <li
64
+ v-for="(option, index) of options"
65
+ :key="getOptionField(option, valueKey)"
66
+ :class="[cls.e('option'), bem.is('selected', isSelected(option))]"
67
+ role="option"
68
+ :aria-selected="isSelected(option)"
69
+ @click="handleSelect(option)"
70
+ >
71
+ <slot :option="option" :index="index">
72
+ <span :class="cls.e('option-label')">
73
+ {{ getOptionField(option, labelKey) }}
74
+ </span>
75
+ </slot>
76
+
77
+ <span v-if="isSelected(option) && !grid" :class="cls.e('check')" aria-hidden="true">
78
+ <Check />
79
+ </span>
80
+ </li>
81
+ </ul>
82
+
83
+ <div v-else :class="cls.e('empty')">暂无数据</div>
84
+ </BottomSheet>
85
+ </template>
86
+
87
+ <script lang="ts" setup>
88
+ import { ArrowDown, Check, Close } from '@veltra/icons/normal'
89
+ import { FORM_EMPTY_CONTENT, injectFormContext } from '@veltra/utils'
90
+ import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
91
+
92
+ import { bem } from '../../shared/bem'
93
+ import type { SelectEmits, SelectProps } from '../../types/select'
94
+ import { BottomSheet } from '../_internal/bottom-sheet'
95
+ import { getOptionField, useOptions } from './use-options'
96
+
97
+ defineOptions({ name: 'USelect', inheritAttrs: false })
98
+
99
+ const props = withDefaults(defineProps<SelectProps>(), {
100
+ labelKey: 'label',
101
+ valueKey: 'value',
102
+ placeholder: '请选择',
103
+ clearable: true,
104
+ disabled: undefined,
105
+ readonly: undefined
106
+ })
107
+
108
+ const emit = defineEmits<SelectEmits>()
109
+
110
+ defineSlots<{
111
+ /** 前缀插槽 */
112
+ prefix?: () => any
113
+ /** 默认插槽 */
114
+ default?: (scope: { option: Record<string, any>; index: number }) => any
115
+ }>()
116
+
117
+ const cls = bem('select')
118
+
119
+ const { formProps } = injectFormContext()
120
+
121
+ // 表单回滚属性:组件自身优先于表单(与 desktop 行为一致)
122
+ const size = computed(() => props.size ?? formProps?.size ?? 'default')
123
+ const disabled = computed(() => props.disabled ?? formProps?.disabled ?? false)
124
+ const readonly = computed(() => props.readonly ?? formProps?.readonly ?? false)
125
+
126
+ const labelKey = computed(() => props.labelKey || 'label')
127
+ const valueKey = computed(() => props.valueKey || 'value')
128
+
129
+ const filterable = computed(() => props.filterable || typeof props.options === 'function')
130
+
131
+ const {
132
+ queryString,
133
+ loading,
134
+ options,
135
+ allOptions,
136
+ temOptionsToCreatedOptions,
137
+ clearCreatedOptions
138
+ } = useOptions({ props })
139
+
140
+ /** 内部展示文案,仅由选项推导(未命中时展示 text 兜底);经 update:text 通知父级 */
141
+ const label = shallowRef<string>()
142
+ const selected = shallowRef<Record<string, any>>()
143
+
144
+ const displayedValue = computed(() => {
145
+ if (label.value) return label.value
146
+
147
+ if (selected.value) return getOptionField(selected.value, labelKey.value)
148
+ // 未命中选项:优先展示 text 兜底文案,避免露出不可读的编码
149
+ return props.text ?? String(props.modelValue ?? '')
150
+ })
151
+
152
+ const sheetVisible = shallowRef(false)
153
+ const searchRef = useTemplateRef<HTMLInputElement>('searchRef')
154
+
155
+ /** 移动端无 hover:有值且可清除即展示清除按钮 */
156
+ const showClear = computed(() => {
157
+ return props.clearable && !disabled.value && !!displayedValue.value
158
+ })
159
+
160
+ /** 选中临时选项后待转正标记:面板关闭后再落定,与 desktop 行为一致 */
161
+ let pendingTempPromote = false
162
+
163
+ /** 抑制内部写入引起的外部回显同步 */
164
+ let suppressEcho = false
165
+
166
+ /** 更新内部文案;值变化时 emit update:text(readonly 纯展示,不通知父级) */
167
+ function setLabel(next?: string) {
168
+ if (label.value === next) return
169
+ label.value = next
170
+ if (!readonly.value) emit('update:text', next)
171
+ }
172
+
173
+ /** 按 modelValue 与完整选项列表同步选中项与显示标签(外部回显用,O(n)) */
174
+ function syncSelected(modelValue: any, sourceOptions: Record<string, any>[] | undefined) {
175
+ if (!sourceOptions?.length) return
176
+
177
+ if (modelValue !== undefined && modelValue !== null && modelValue !== '') {
178
+ const index = sourceOptions.findIndex(
179
+ (option) => getOptionField(option, valueKey.value) === modelValue
180
+ )
181
+ selected.value = index >= 0 ? sourceOptions[index] : undefined
182
+ if (selected.value) {
183
+ setLabel(getOptionField(selected.value, labelKey.value))
184
+ } else if (props.text) {
185
+ // 未命中但有 text 兜底:文案以父级为准,不回发
186
+ label.value = undefined
187
+ } else {
188
+ // 未命中且无兜底:如实通知父级当前没有可展示文案
189
+ setLabel(undefined)
190
+ }
191
+ } else {
192
+ selected.value = undefined
193
+ setLabel(undefined)
194
+ }
195
+ }
196
+
197
+ // 回显:以 allOptions 匹配,避免过滤列表缺项时清掉已选文案
198
+ watch(
199
+ [() => props.modelValue, allOptions],
200
+ ([modelValue]) => {
201
+ if (suppressEcho) {
202
+ suppressEcho = false
203
+ return
204
+ }
205
+ syncSelected(modelValue, allOptions.value)
206
+ },
207
+ { immediate: true }
208
+ )
209
+
210
+ watch(sheetVisible, (visible) => {
211
+ if (visible) {
212
+ // 关闭动画期间被重新打开时补一次转正,保证已选临时项落到完整列表
213
+ promotePendingTemp()
214
+ // 面板展开后清空查询并聚焦搜索框,可立即输入查询
215
+ if (filterable.value) {
216
+ queryString.value = ''
217
+ nextTick(() => searchRef.value?.focus())
218
+ }
219
+ } else {
220
+ // 面板关闭后再清空查询并转正临时项,避免列表瞬间恢复全量导致面板闪烁
221
+ queryString.value = ''
222
+ promotePendingTemp()
223
+ suppressEcho = false
224
+ }
225
+ })
226
+
227
+ /** 落定待转正的临时选项 */
228
+ function promotePendingTemp() {
229
+ if (!pendingTempPromote) return
230
+ pendingTempPromote = false
231
+ temOptionsToCreatedOptions()
232
+ }
233
+
234
+ function isSelected(option: Record<string, any>) {
235
+ if (selected.value === option) return true
236
+ return getOptionField(option, valueKey.value) === props.modelValue
237
+ }
238
+
239
+ function handleTriggerClick() {
240
+ if (disabled.value) return
241
+ sheetVisible.value = true
242
+ }
243
+
244
+ /** 单选:写入值与文案后收起面板 */
245
+ function handleSelect(option: Record<string, any>) {
246
+ const value = getOptionField(option, valueKey.value)
247
+ selected.value = option
248
+
249
+ suppressEcho = true
250
+ emit('update:modelValue', value)
251
+ setLabel(getOptionField(option, labelKey.value))
252
+ emit('change', option)
253
+
254
+ if (option.__isTemp) {
255
+ // 转正延迟到面板关闭后:动画期间保持过滤列表
256
+ pendingTempPromote = true
257
+ }
258
+
259
+ sheetVisible.value = false
260
+ }
261
+
262
+ /** 清除选项 */
263
+ function handleClear() {
264
+ selected.value = undefined
265
+ setLabel(undefined)
266
+ clearCreatedOptions()
267
+ suppressEcho = true
268
+ emit('update:modelValue', undefined)
269
+ emit('change', undefined)
270
+ }
271
+
272
+ const gridStyle = computed(() => {
273
+ if (!props.grid) return undefined
274
+ const cols = props.grid.cols
275
+ const gap = props.grid.gap !== undefined ? `${props.grid.gap}px` : undefined
276
+ return { gridTemplateColumns: `repeat(${cols}, minmax(0px, 1fr))`, rowGap: gap, columnGap: gap }
277
+ })
278
+ </script>
@@ -0,0 +1,237 @@
1
+ @use 'pkg:@veltra/styles/mixins' as m with ($namespace: um);
2
+ @use 'pkg:@veltra/styles/functions' as fn;
3
+ @use '../../styles/tokens' as t;
4
+
5
+ $root-name: select;
6
+
7
+ @include m.b($root-name) {
8
+ display: flex;
9
+ align-items: center;
10
+ width: 100%;
11
+ box-sizing: border-box;
12
+ gap: t.use-var('spacing-sm');
13
+ padding: 0 t.use-var('spacing-md');
14
+ border: fn.use-var(border);
15
+ border-radius: t.control-radius(default);
16
+ background-color: fn.use-var(bg-color, top);
17
+ color: fn.use-var(text-color, main);
18
+ font-size: t.use-var('font-size-main');
19
+ cursor: pointer;
20
+ -webkit-tap-highlight-color: transparent;
21
+ transition:
22
+ border-color fn.use-var(transition, fast) fn.use-var(transition, ease),
23
+ box-shadow fn.use-var(transition, fast) fn.use-var(transition, ease);
24
+
25
+ // 触控热区:任何尺寸的可点击区域不小于 44×44,高度走移动端密度 token
26
+ @include m.size using ($size) {
27
+ min-height: max(t.control-height($size), t.use-var('touch-target'));
28
+ }
29
+
30
+ &:active {
31
+ border-color: fn.use-var(color, primary);
32
+ }
33
+
34
+ &:focus-visible {
35
+ box-shadow: fn.use-var(focus-ring);
36
+ }
37
+
38
+ @include m.is(active) {
39
+ border-color: fn.use-var(color, primary);
40
+
41
+ @include m.bem($root-name, arrow) {
42
+ transform: rotate(180deg);
43
+ }
44
+ }
45
+
46
+ @include m.is(disabled) {
47
+ cursor: not-allowed;
48
+ color: fn.use-var(text-color, disabled);
49
+ background-color: fn.use-var(bg-color, middle);
50
+
51
+ @include m.bem($root-name, arrow) {
52
+ color: fn.use-var(text-color, disabled);
53
+ }
54
+ }
55
+
56
+ @include m.e(prefix) {
57
+ display: inline-flex;
58
+ align-items: center;
59
+ color: fn.use-var(text-color, second);
60
+ font-size: 1em;
61
+
62
+ svg {
63
+ width: 1em;
64
+ height: 1em;
65
+ }
66
+ }
67
+
68
+ @include m.e(value) {
69
+ flex: 1;
70
+ min-width: 0;
71
+ @include m.ellipsis;
72
+
73
+ @include m.is(placeholder) {
74
+ color: fn.use-var(text-color, placeholder);
75
+ }
76
+ }
77
+
78
+ @include m.e(icons) {
79
+ display: inline-flex;
80
+ align-items: center;
81
+ align-self: stretch;
82
+ flex-shrink: 0;
83
+ }
84
+
85
+ @include m.e(clear) {
86
+ // 清除热区 ≥44×44(高度受行高约束,用外扩补足)
87
+ display: inline-flex;
88
+ align-items: center;
89
+ justify-content: center;
90
+ min-width: t.use-var('touch-target');
91
+ min-height: t.use-var('touch-target');
92
+ // 热区外扩量(spacing-sm):补偿清除钮居中图标的两侧留白,不挤压视觉边距
93
+ margin: calc(-1 * #{t.use-var('spacing-sm')}) calc(-1 * #{t.use-var('spacing-sm')}) calc(-1 * #{t.use-var('spacing-sm')}) 0;
94
+ padding: 0;
95
+ border: none;
96
+ background: none;
97
+ font-size: t.use-var('font-size-main');
98
+ color: fn.use-var(text-color, second);
99
+ cursor: pointer;
100
+
101
+ svg {
102
+ width: 1em;
103
+ height: 1em;
104
+ }
105
+
106
+ &:active {
107
+ color: fn.use-var(text-color, title);
108
+ }
109
+ }
110
+
111
+ @include m.e(arrow) {
112
+ // 箭头热区 ≥44×44:随行高拉伸、宽度不小于触控热区下限
113
+ display: inline-flex;
114
+ align-items: center;
115
+ justify-content: center;
116
+ align-self: stretch;
117
+ min-width: t.use-var('touch-target');
118
+ color: fn.use-var(text-color, second);
119
+ font-size: t.use-var('font-size-secondary');
120
+ transition: transform fn.use-var(transition, fast) fn.use-var(transition, ease);
121
+
122
+ svg {
123
+ width: 1em;
124
+ height: 1em;
125
+ }
126
+ }
127
+
128
+ // 面板内搜索行
129
+ @include m.e(search) {
130
+ padding: t.use-var('spacing-sm') 0;
131
+ }
132
+
133
+ @include m.e(search-input) {
134
+ // 字号 ≥16px(placeholder 继承同字号):iOS Safari 聚焦不触发页面缩放
135
+ width: 100%;
136
+ box-sizing: border-box;
137
+ min-height: t.use-var('touch-target');
138
+ padding: 0 t.use-var('spacing-md');
139
+ border: fn.use-var(border);
140
+ border-radius: t.control-radius(default);
141
+ background-color: fn.use-var(bg-color, middle);
142
+ color: fn.use-var(text-color, main);
143
+ font-size: t.use-var('font-size-main');
144
+ outline: none;
145
+ -webkit-appearance: none;
146
+
147
+ &::placeholder {
148
+ color: fn.use-var(text-color, placeholder);
149
+ }
150
+
151
+ &:focus {
152
+ border-color: fn.use-var(color, primary);
153
+ }
154
+ }
155
+
156
+ // 选项列表
157
+ @include m.e(options) {
158
+ list-style: none;
159
+ margin: 0;
160
+ padding: 0;
161
+ display: flex;
162
+ flex-direction: column;
163
+ gap: t.use-var('spacing-xs');
164
+
165
+ @include m.is(grid) {
166
+ display: grid;
167
+ }
168
+ }
169
+
170
+ @include m.e(option) {
171
+ // 选项整行热区 ≥44×44
172
+ display: flex;
173
+ align-items: center;
174
+ min-height: t.use-var('touch-target');
175
+ gap: t.use-var('spacing-sm');
176
+ padding: 0 t.use-var('spacing-md');
177
+ border-radius: t.control-radius(default);
178
+ color: fn.use-var(text-color, main);
179
+ user-select: none;
180
+ cursor: pointer;
181
+
182
+ &:active {
183
+ background-color: fn.use-var(bg-color, hover);
184
+ }
185
+
186
+ @include m.is(grid) {
187
+ justify-content: center;
188
+ text-align: center;
189
+ }
190
+
191
+ // 选中态:网格布局用底色强调(无对钩),列表布局再由对钩指示
192
+ @include m.is(selected) {
193
+ color: fn.component-var(select, option-selected-color, fn.use-var(color, primary));
194
+ font-weight: 600;
195
+ background-color: fn.component-var(
196
+ select,
197
+ option-selected-bg,
198
+ fn.use-var(color, primary, light-9)
199
+ );
200
+
201
+ &:active {
202
+ background-color: fn.use-var(color, primary, light-7);
203
+ }
204
+ }
205
+ }
206
+
207
+ @include m.e(option-label) {
208
+ flex: 1;
209
+ min-width: 0;
210
+ @include m.ellipsis;
211
+ }
212
+
213
+ @include m.e(check) {
214
+ display: inline-flex;
215
+ align-items: center;
216
+ flex-shrink: 0;
217
+ color: fn.use-var(color, primary);
218
+ font-size: t.use-var('font-size-main');
219
+
220
+ svg {
221
+ width: 1em;
222
+ height: 1em;
223
+ }
224
+ }
225
+
226
+ @include m.e(empty) {
227
+ padding: t.use-var('spacing-lg') 0;
228
+ text-align: center;
229
+ color: fn.use-var(text-color, second);
230
+ }
231
+
232
+ @include m.e(loading) {
233
+ padding: t.use-var('spacing-lg') 0;
234
+ text-align: center;
235
+ color: fn.use-var(text-color, second);
236
+ }
237
+ }
@@ -0,0 +1,2 @@
1
+ import '../_internal/bottom-sheet/style'
2
+ import './style.scss'
@@ -0,0 +1,195 @@
1
+ import { computed, shallowRef, watch } from 'vue'
2
+ import type { ComputedRef, ShallowRef } from 'vue'
3
+
4
+ import type { SelectProps } from '../../types/select'
5
+
6
+ /**
7
+ * 按 key 读取选项字段
8
+ * @description 支持 `a.b` 链式 key,语义与 desktop 的链式取值一致
9
+ */
10
+ export function getOptionField(option: Record<string, any>, key: string): any {
11
+ if (!key.includes('.')) return option?.[key]
12
+ return key.split('.').reduce<any>((cur, k) => cur?.[k], option)
13
+ }
14
+
15
+ /** 最小防抖:仅本文件内的查询过滤使用 */
16
+ function debounce<A extends unknown[]>(fn: (...args: A) => void, delay: number) {
17
+ let timer: ReturnType<typeof setTimeout> | undefined
18
+ return (...args: A) => {
19
+ clearTimeout(timer)
20
+ timer = setTimeout(() => fn(...args), delay)
21
+ }
22
+ }
23
+
24
+ interface Options {
25
+ props: SelectProps
26
+ }
27
+
28
+ export interface UseOptionsReturned {
29
+ /** 查询字符串 */
30
+ queryString: ShallowRef<string>
31
+ /** 远程搜索加载中 */
32
+ loading: ShallowRef<boolean>
33
+ /** 选项(含待创建临时项) */
34
+ options: ComputedRef<Record<string, any>[]>
35
+ /** 所有选项(含已创建项,不含临时项) */
36
+ allOptions: ComputedRef<Record<string, any>[]>
37
+ /** 将临时选项转正为已创建选项 */
38
+ temOptionsToCreatedOptions: () => void
39
+ /** 清空用户创建的选项 */
40
+ clearCreatedOptions: () => void
41
+ }
42
+
43
+ // 允许创建的实现
44
+ // 1. 在过滤时将选项加入待选列表中
45
+ // 2. 如果选择了该临时选项,则取消该选项的临时标记并并入已创建列表
46
+ // 3. 多次创建累计保留,已创建项参与后续过滤匹配
47
+
48
+ export function useOptions(o: Options): UseOptionsReturned {
49
+ const { props } = o
50
+
51
+ /** 查询串 */
52
+ const queryString = shallowRef('')
53
+
54
+ const loading = shallowRef(false)
55
+ /** 远程请求序号;响应返回时序号不一致说明已有更新的请求,结果作废 */
56
+ let remoteSeq = 0
57
+
58
+ const remoteOptions = shallowRef<Record<string, any>[]>([])
59
+ const filteredOptions = shallowRef<Record<string, any>[]>([])
60
+ // 临时选项(待创建)
61
+ const tempOptions = shallowRef<({ __isTemp: true } & Record<string, any>)[]>([])
62
+ // 创建的选项
63
+ const createdOptions = shallowRef<({ __isTemp: false } & Record<string, any>)[]>([])
64
+
65
+ function getSourceOptions(propsOptions: SelectProps['options']) {
66
+ if (typeof propsOptions === 'function') return remoteOptions.value
67
+ return [...createdOptions.value, ...(propsOptions ?? [])]
68
+ }
69
+
70
+ /** 无查询时同步展示完整列表(含已创建项) */
71
+ function resetToSourceOptions() {
72
+ const sourceOptions = getSourceOptions(props.options)
73
+ if (typeof props.options === 'function') {
74
+ setTempOption('', remoteOptions.value)
75
+ return
76
+ }
77
+ filteredOptions.value = sourceOptions
78
+ tempOptions.value = []
79
+ }
80
+
81
+ function temOptionsToCreatedOptions() {
82
+ if (!tempOptions.value.length) return
83
+
84
+ createdOptions.value = [
85
+ ...createdOptions.value,
86
+ ...tempOptions.value.map((item) => ({ ...item, __isTemp: false as const }))
87
+ ]
88
+ resetToSourceOptions()
89
+ }
90
+
91
+ function clearCreatedOptions() {
92
+ createdOptions.value = []
93
+ resetToSourceOptions()
94
+ }
95
+
96
+ const setTempOption = (qs: string, options?: Record<string, any>[]) => {
97
+ if (!qs?.trim() || !props.creatable) {
98
+ tempOptions.value = []
99
+ return
100
+ }
101
+ const labelKey = props.labelKey || 'label'
102
+ const valueKey = props.valueKey || 'value'
103
+ const exactMatch = options
104
+ ? options.some((option) => getOptionField(option, labelKey) === qs)
105
+ : false
106
+
107
+ tempOptions.value = exactMatch ? [] : [{ [labelKey]: qs, [valueKey]: qs, __isTemp: true }]
108
+ }
109
+
110
+ const allOptions = computed(() => {
111
+ const { options } = props
112
+ if (typeof options === 'function') return remoteOptions.value
113
+ return [...createdOptions.value, ...(options ?? [])]
114
+ })
115
+
116
+ /** 展示的选项:临时选项(待创建)固定置顶 */
117
+ const options = computed(() => {
118
+ const { options } = props
119
+ if (typeof options === 'function') return remoteOptions.value
120
+
121
+ return [...tempOptions.value, ...filteredOptions.value]
122
+ })
123
+
124
+ const applyLocalFilter = (qs: string, propsOptions: Record<string, any>[] | undefined) => {
125
+ const labelKey = props.labelKey || 'label'
126
+ const sourceOptions = [...createdOptions.value, ...(propsOptions ?? [])]
127
+
128
+ if (!qs) {
129
+ filteredOptions.value = sourceOptions
130
+ setTempOption(qs, filteredOptions.value)
131
+ return
132
+ }
133
+
134
+ const _filteredOptions = sourceOptions.filter((item) => {
135
+ return getOptionField(item, labelKey)?.includes(qs) ?? false
136
+ })
137
+
138
+ setTempOption(qs, _filteredOptions)
139
+ filteredOptions.value = _filteredOptions
140
+ }
141
+
142
+ // 仅对「有查询词」的本地过滤做防抖;清空查询同步生效,避免选中后列表延迟重建
143
+ const debouncedLocalFilter = debounce(
144
+ (qs: string, propsOptions: Record<string, any>[] | undefined) => {
145
+ // 防抖回调可能晚于清空查询到达,以当前 queryString 为准
146
+ if (!queryString.value) {
147
+ applyLocalFilter('', propsOptions)
148
+ return
149
+ }
150
+ applyLocalFilter(qs, propsOptions)
151
+ },
152
+ 200
153
+ )
154
+
155
+ const debouncedRemoteFilter = debounce(async (qs: string, propsOptions: Function) => {
156
+ const seq = ++remoteSeq
157
+ loading.value = true
158
+ try {
159
+ const options = await propsOptions(qs)
160
+ // 响应到达时查询词可能已变化或已有更新的请求,过期结果直接丢弃
161
+ if (seq !== remoteSeq || qs !== queryString.value) return
162
+ remoteOptions.value = options ?? []
163
+ setTempOption(qs, remoteOptions.value)
164
+ } finally {
165
+ if (seq === remoteSeq) loading.value = false
166
+ }
167
+ }, 200)
168
+
169
+ watch(
170
+ [queryString, () => props.options, createdOptions],
171
+ ([qs, propsOptions]) => {
172
+ if (typeof propsOptions === 'function') {
173
+ debouncedRemoteFilter(qs, propsOptions)
174
+ return
175
+ }
176
+
177
+ if (!qs) {
178
+ applyLocalFilter('', propsOptions)
179
+ return
180
+ }
181
+
182
+ debouncedLocalFilter(qs, propsOptions)
183
+ },
184
+ { immediate: true }
185
+ )
186
+
187
+ return {
188
+ queryString,
189
+ loading,
190
+ options,
191
+ allOptions,
192
+ temOptionsToCreatedOptions,
193
+ clearCreatedOptions
194
+ }
195
+ }