@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,595 @@
1
+ <template>
2
+ <!-- 只读态:单选纯文本,多选纯标签 -->
3
+ <template v-if="readonly">
4
+ <template v-if="multiple">
5
+ <div v-if="allTagNodes.length" :class="[cls.b, cls.m(size), bem.is('readonly', true)]">
6
+ <div :class="cls.e('tags')">
7
+ <span v-for="tag of allTagNodes" :key="tag.value" :class="cls.e('tag')">
8
+ {{ tag.label }}
9
+ </span>
10
+ </div>
11
+ </div>
12
+ <template v-else>{{ FORM_EMPTY_CONTENT }}</template>
13
+ </template>
14
+ <template v-else>{{ displayedValue || FORM_EMPTY_CONTENT }}</template>
15
+ </template>
16
+
17
+ <template v-else>
18
+ <div
19
+ v-bind="$attrs"
20
+ :class="[cls.b, cls.m(size), bem.is('disabled', disabled), bem.is('active', sheetVisible)]"
21
+ :aria-expanded="sheetVisible"
22
+ aria-haspopup="dialog"
23
+ role="combobox"
24
+ @click="handleTriggerClick"
25
+ >
26
+ <template v-if="multiple">
27
+ <span v-if="!modelValues.length" :class="[cls.e('value'), bem.is('placeholder', true)]">
28
+ {{ placeholder }}
29
+ </span>
30
+
31
+ <div v-if="modelValues.length" ref="tagsRef" :class="cls.e('tags')">
32
+ <span v-for="tag of tags" :key="tag.value" :class="cls.e('tag')">
33
+ <span :class="cls.e('tag-label')">{{ tag.label }}</span>
34
+
35
+ <button
36
+ v-if="!disabled"
37
+ :class="cls.e('tag-close')"
38
+ type="button"
39
+ :aria-label="`移除 ${tag.label}`"
40
+ @click.stop="handleTagClose(tag)"
41
+ >
42
+ <Close />
43
+ </button>
44
+ </span>
45
+
46
+ <span v-if="restTag" :class="[cls.e('tag'), bem.is('rest', true)]"> {{ restTag }}+ </span>
47
+ </div>
48
+ </template>
49
+
50
+ <span v-else :class="[cls.e('value'), bem.is('placeholder', !displayedValue)]">
51
+ {{ displayedValue || placeholder }}
52
+ </span>
53
+
54
+ <span :class="cls.e('icons')">
55
+ <button
56
+ v-if="showClear"
57
+ :class="cls.e('clear')"
58
+ type="button"
59
+ aria-label="清除"
60
+ @click.stop="handleClear"
61
+ >
62
+ <Close />
63
+ </button>
64
+ <span v-else :class="cls.e('arrow')" aria-hidden="true">
65
+ <ArrowDown />
66
+ </span>
67
+ </span>
68
+ </div>
69
+
70
+ <!-- 底部级联面板:单选选中即落值收起;多选暂存后点确定落盘 -->
71
+ <BottomSheet
72
+ v-model:visible="sheetVisible"
73
+ :title="placeholder"
74
+ :content-class="contentClass"
75
+ :content-style="contentStyle"
76
+ >
77
+ <template v-if="multiple" #header>
78
+ <button :class="cls.e('cancel')" type="button" @click="sheetVisible = false">取消</button>
79
+ <span :class="cls.e('title')">{{ placeholder }}</span>
80
+ <button :class="cls.e('confirm')" type="button" @click="handleConfirm">确定</button>
81
+ </template>
82
+
83
+ <div v-if="filterable" :class="cls.e('search')">
84
+ <input
85
+ ref="searchRef"
86
+ v-model="qs"
87
+ :class="cls.e('search-input')"
88
+ type="text"
89
+ placeholder="搜索"
90
+ enterkeyhint="search"
91
+ />
92
+ </div>
93
+
94
+ <!-- 搜索结果:平铺命中项,路径作为副标题 -->
95
+ <template v-if="searching">
96
+ <ul v-if="filteredNodes.length" :class="cls.e('options')">
97
+ <li
98
+ v-for="node of filteredNodes"
99
+ :key="node.value"
100
+ :class="getOptionCls(node)"
101
+ role="option"
102
+ :aria-selected="isSelected(node)"
103
+ @click="handleSearchClick(node)"
104
+ >
105
+ <span
106
+ v-if="multiple"
107
+ :class="[cls.e('checkbox'), bem.is('checked', checkedSet.has(node))]"
108
+ @click.stop="toggleCheck(node)"
109
+ >
110
+ <Check v-if="checkedSet.has(node)" />
111
+ </span>
112
+
113
+ <span :class="cls.e('option-main')">
114
+ <span :class="cls.e('option-label')">{{ node.label }}</span>
115
+ <span v-if="parentLabel(node)" :class="cls.e('option-path')">
116
+ {{ parentLabel(node) }}
117
+ </span>
118
+ </span>
119
+
120
+ <span v-if="node.children?.length" :class="cls.e('arrow-right')" aria-hidden="true">
121
+ <ArrowRight />
122
+ </span>
123
+ <span
124
+ v-else-if="!multiple && isSelected(node)"
125
+ :class="cls.e('check')"
126
+ aria-hidden="true"
127
+ >
128
+ <Check />
129
+ </span>
130
+ </li>
131
+ </ul>
132
+
133
+ <div v-else :class="cls.e('empty')">暂无数据</div>
134
+ </template>
135
+
136
+ <template v-else>
137
+ <div :class="cls.e('tabs')" role="tablist">
138
+ <button
139
+ v-for="(label, index) of tabs"
140
+ :key="index"
141
+ type="button"
142
+ role="tab"
143
+ :aria-selected="index === activeTabIndex"
144
+ :class="[cls.e('tab'), bem.is('active', index === activeTabIndex)]"
145
+ @click="handleTabClick(index)"
146
+ >
147
+ {{ label }}
148
+ </button>
149
+ </div>
150
+
151
+ <ul v-if="currentNodes.length" :class="cls.e('options')">
152
+ <li
153
+ v-for="node of currentNodes"
154
+ :key="node.value"
155
+ :class="getOptionCls(node)"
156
+ role="option"
157
+ :aria-selected="isSelected(node)"
158
+ @click="handleOptionClick(node)"
159
+ >
160
+ <span
161
+ v-if="multiple"
162
+ :class="[cls.e('checkbox'), bem.is('checked', checkedSet.has(node))]"
163
+ @click.stop="toggleCheck(node)"
164
+ >
165
+ <Check v-if="checkedSet.has(node)" />
166
+ </span>
167
+
168
+ <span :class="cls.e('option-label')">{{ node.label }}</span>
169
+
170
+ <span v-if="node.children?.length" :class="cls.e('arrow-right')" aria-hidden="true">
171
+ <ArrowRight />
172
+ </span>
173
+ <span
174
+ v-else-if="!multiple && isSelected(node)"
175
+ :class="cls.e('check')"
176
+ aria-hidden="true"
177
+ >
178
+ <Check />
179
+ </span>
180
+ </li>
181
+ </ul>
182
+
183
+ <div v-else :class="cls.e('empty')">暂无数据</div>
184
+ </template>
185
+ </BottomSheet>
186
+ </template>
187
+ </template>
188
+
189
+ <script lang="ts" setup>
190
+ import { Forest, dfs, o } from '@cat-kit/core'
191
+ import { useResizeObserver } from '@veltra/compositions'
192
+ import { ArrowDown, ArrowRight, Check, Close } from '@veltra/icons/normal'
193
+ import { FORM_EMPTY_CONTENT, fieldKey, injectFormContext } from '@veltra/utils'
194
+ import { computed, nextTick, shallowRef, useTemplateRef, watch } from 'vue'
195
+
196
+ import { bem } from '../../shared/bem'
197
+ import type { CascadeEmits, CascadeProps } from '../../types/cascade'
198
+ import { BottomSheet } from '../_internal/bottom-sheet'
199
+ import { CascadeItem } from './node'
200
+
201
+ defineOptions({ name: 'UCascade', inheritAttrs: false })
202
+
203
+ const props = withDefaults(defineProps<CascadeProps>(), {
204
+ labelKey: 'label',
205
+ valueKey: 'value',
206
+ placeholder: '请选择',
207
+ separator: '/',
208
+ clearable: true,
209
+ disabled: undefined,
210
+ readonly: undefined,
211
+ childrenKey: 'children',
212
+ filterable: false,
213
+ multiple: false,
214
+ data: () => [],
215
+ visibilityLimit: 3,
216
+ showFullPath: true
217
+ })
218
+
219
+ const emit = defineEmits<CascadeEmits>()
220
+
221
+ const cls = bem('cascade')
222
+
223
+ const { formProps } = injectFormContext()
224
+
225
+ // 表单回滚属性:组件自身优先于表单(与 desktop 行为一致)
226
+ const size = computed(() => props.size ?? formProps?.size ?? 'default')
227
+ const disabled = computed(() => props.disabled ?? formProps?.disabled ?? false)
228
+ const readonly = computed(() => props.readonly ?? formProps?.readonly ?? false)
229
+
230
+ const labelKey = computed(() => fieldKey(props.labelKey, 'label'))
231
+ const valueKey = computed(() => fieldKey(props.valueKey, 'value'))
232
+
233
+ const multiple = computed(() => props.multiple)
234
+
235
+ // 值
236
+ const modelValues = computed(() => (Array.isArray(props.modelValue) ? props.modelValue : []))
237
+
238
+ // 树
239
+ const forest = computed(() => {
240
+ return new Forest<Record<string, unknown>, any>({
241
+ data: (props.data ?? []) as Record<string, unknown>[],
242
+ childrenKey: props.childrenKey,
243
+ createNode: (data, index, depth, _forest, parent) => {
244
+ if (!data) {
245
+ return new CascadeItem({ data, index, depth, value: '', label: '', parent })
246
+ }
247
+
248
+ return new CascadeItem({
249
+ data,
250
+ index,
251
+ depth,
252
+ value: o(data).get(valueKey.value) ?? '',
253
+ label: o(data).get(labelKey.value) ?? '',
254
+ parent
255
+ })
256
+ }
257
+ })
258
+ })
259
+
260
+ const dataMap = computed(() => {
261
+ const map = new Map<string, CascadeItem>()
262
+ forest.value.dfs((node) => {
263
+ if (node.value !== undefined && node.value !== null && node.value !== '') {
264
+ map.set(node.value, node)
265
+ }
266
+ })
267
+ return map
268
+ })
269
+
270
+ /** 当前层级的面包屑路径(不含当前层节点) */
271
+ const path = shallowRef<CascadeItem[]>([])
272
+
273
+ /** 单选中高亮的节点 */
274
+ const currentSelected = shallowRef<CascadeItem>()
275
+
276
+ const currentNodes = computed(() => {
277
+ const parent = path.value[path.value.length - 1]
278
+ return parent ? ((parent.children ?? []) as CascadeItem[]) : (forest.value.roots as CascadeItem[])
279
+ })
280
+
281
+ const tabs = computed(() => [props.placeholder, ...path.value.map((node) => node.label)])
282
+ const activeTabIndex = computed(() => path.value.length)
283
+
284
+ // 搜索
285
+ const qs = shallowRef('')
286
+ const searching = computed(() => props.filterable && !!qs.value.trim())
287
+ const searchRef = useTemplateRef<HTMLInputElement>('searchRef')
288
+
289
+ const filteredNodes = computed(() => {
290
+ const keyword = qs.value.trim().toLowerCase()
291
+ if (!keyword) return [] as CascadeItem[]
292
+
293
+ const result: CascadeItem[] = []
294
+ forest.value.dfs((node) => {
295
+ if (node.label?.toLowerCase().includes(keyword)) result.push(node)
296
+ })
297
+ return result
298
+ })
299
+
300
+ // 多选暂存
301
+ const checkedSet = shallowRef(new Set<CascadeItem>())
302
+
303
+ const sheetVisible = shallowRef(false)
304
+
305
+ /** 单选展示文案:按完整路径或叶子值解析,未命中回退 text */
306
+ const displayedValue = computed(() => {
307
+ const { modelValue, separator, showFullPath, text } = props
308
+ if (!modelValue || typeof modelValue !== 'string') return ''
309
+
310
+ if (showFullPath) {
311
+ const keys = modelValue.split(separator)
312
+ // 末级未命中视为整条路径无效,回退 text 兜底
313
+ if (!dataMap.value.has(keys[keys.length - 1]!)) return text ?? modelValue
314
+ return keys.map((key) => dataMap.value.get(key)?.label ?? key).join(separator)
315
+ }
316
+
317
+ return dataMap.value.get(modelValue)?.label ?? text ?? modelValue
318
+ })
319
+
320
+ /** 触发器标签上限:禁用/只读时展示全部 */
321
+ const maxVisible = computed(() => {
322
+ let limit = props.visibilityLimit
323
+ if (limit < 0) limit = 0
324
+ if (disabled.value || readonly.value) limit = modelValues.value.length
325
+ return limit
326
+ })
327
+
328
+ const allTagNodes = computed(() => {
329
+ return modelValues.value
330
+ .map((value) => dataMap.value.get(value))
331
+ .filter((node): node is CascadeItem => !!node)
332
+ })
333
+
334
+ /** 当前宽度内放得下的标签数:初值不设限,挂载后按实际宽度收敛 */
335
+ const fitCount = shallowRef(Number.POSITIVE_INFINITY)
336
+
337
+ const tagsRef = useTemplateRef<HTMLDivElement>('tagsRef')
338
+
339
+ let measuring = false
340
+
341
+ /** 是否存在被省略号截断的标签文本(截断则折叠进 +N,保证标签完整可读) */
342
+ function hasTruncatedLabel(el: HTMLElement) {
343
+ return [...el.querySelectorAll<HTMLElement>('.um-cascade__tag-label')].some(
344
+ (label) => label.scrollWidth > label.clientWidth + 1
345
+ )
346
+ }
347
+
348
+ /** 先放开到上限,再逐个折叠到标签完整可读;空间不足时保底 1 个,其余计入 +N */
349
+ async function measureFit() {
350
+ const el = tagsRef.value
351
+ if (!el || measuring) return
352
+ measuring = true
353
+ try {
354
+ if (fitCount.value !== maxVisible.value) {
355
+ fitCount.value = maxVisible.value
356
+ await nextTick()
357
+ }
358
+ while (hasTruncatedLabel(el) && fitCount.value > 1) {
359
+ fitCount.value -= 1
360
+ await nextTick()
361
+ }
362
+ } finally {
363
+ measuring = false
364
+ }
365
+ }
366
+
367
+ useResizeObserver({ targets: tagsRef, onResize: () => measureFit() })
368
+
369
+ watch([allTagNodes, maxVisible, size, disabled, readonly, () => props.visibilityLimit], () =>
370
+ nextTick(measureFit)
371
+ )
372
+
373
+ const tags = computed(() => allTagNodes.value.slice(0, Math.min(maxVisible.value, fitCount.value)))
374
+
375
+ const restTag = computed(() => modelValues.value.length - tags.value.length)
376
+
377
+ const showClear = computed(() => {
378
+ if (!props.clearable || disabled.value) return false
379
+ return multiple.value ? modelValues.value.length > 0 : !!displayedValue.value
380
+ })
381
+
382
+ // 导航 / 选择
383
+ function ancestorsOf(node: CascadeItem): CascadeItem[] {
384
+ const result: CascadeItem[] = []
385
+ let parent = node.parent
386
+ while (parent) {
387
+ result.unshift(parent)
388
+ parent = parent.parent
389
+ }
390
+ return result
391
+ }
392
+
393
+ /** 搜索结果副标题:仅展示祖先路径,避免与主标签重复 */
394
+ function parentLabel(node: CascadeItem): string {
395
+ return ancestorsOf(node)
396
+ .map((item) => item.label)
397
+ .join(props.separator)
398
+ }
399
+
400
+ function initSingle() {
401
+ path.value = []
402
+ currentSelected.value = undefined
403
+
404
+ const { modelValue, separator, showFullPath } = props
405
+ if (!modelValue || typeof modelValue !== 'string') return
406
+
407
+ let node: CascadeItem | undefined
408
+ if (showFullPath) {
409
+ const keys = modelValue.split(separator)
410
+ node = dataMap.value.get(keys[keys.length - 1]!)
411
+ } else {
412
+ node = dataMap.value.get(modelValue)
413
+ }
414
+ if (!node) return
415
+
416
+ const ancestors = ancestorsOf(node)
417
+ if (node.children?.length) {
418
+ // 已选中非叶子:直接展开其下级
419
+ path.value = [...ancestors, node]
420
+ } else {
421
+ path.value = ancestors
422
+ currentSelected.value = node
423
+ }
424
+ }
425
+
426
+ function commitSingle(node: CascadeItem) {
427
+ const nodes = [...path.value, node]
428
+ const keys = nodes.map((item) => item.value)
429
+ const labels = nodes.map((item) => item.label)
430
+ const targetValue = props.showFullPath ? keys.join(props.separator) : node.value
431
+ const targetLabel = props.showFullPath ? labels.join(props.separator) : node.label
432
+
433
+ emit('update:modelValue', targetValue)
434
+ emit('update:label', targetLabel)
435
+ emit('change', node.data ? { ...node.data, fullLabel: targetLabel } : undefined, targetLabel)
436
+ }
437
+
438
+ function emitMultipleValues(values: string[]) {
439
+ const nodes = values
440
+ .map((value) => dataMap.value.get(value))
441
+ .filter((node): node is CascadeItem => !!node)
442
+ const labels = nodes.map((node) => node.label)
443
+
444
+ emit('update:modelValue', values)
445
+ emit('update:label', labels.length ? labels : undefined)
446
+ emit(
447
+ 'change',
448
+ nodes.map((node) => ({ ...node.data, fullLabel: node.label })),
449
+ labels.length ? labels : undefined
450
+ )
451
+ }
452
+
453
+ function isSelected(node: CascadeItem) {
454
+ return multiple.value ? checkedSet.value.has(node) : currentSelected.value?.value === node.value
455
+ }
456
+
457
+ function getOptionCls(node: CascadeItem) {
458
+ return [cls.e('option'), bem.is('active', isSelected(node))]
459
+ }
460
+
461
+ function handleTriggerClick() {
462
+ if (disabled.value) return
463
+ sheetVisible.value = true
464
+ }
465
+
466
+ function handleOptionClick(node: CascadeItem) {
467
+ if (multiple.value) {
468
+ if (node.children?.length) {
469
+ path.value = [...path.value, node]
470
+ } else {
471
+ toggleCheck(node)
472
+ }
473
+ return
474
+ }
475
+
476
+ const hasChildren = !!node.children?.length
477
+
478
+ // strict:非叶子只展开不提交
479
+ if (!props.strict || !hasChildren) commitSingle(node)
480
+
481
+ if (hasChildren) {
482
+ path.value = [...path.value, node]
483
+ currentSelected.value = undefined
484
+ } else {
485
+ currentSelected.value = node
486
+ sheetVisible.value = false
487
+ }
488
+ }
489
+
490
+ function handleSearchClick(node: CascadeItem) {
491
+ const hasChildren = !!node.children?.length
492
+
493
+ if (multiple.value) {
494
+ if (hasChildren) {
495
+ path.value = [...ancestorsOf(node), node]
496
+ currentSelected.value = undefined
497
+ qs.value = ''
498
+ } else {
499
+ toggleCheck(node)
500
+ }
501
+ return
502
+ }
503
+
504
+ if (hasChildren) {
505
+ path.value = [...ancestorsOf(node), node]
506
+ currentSelected.value = undefined
507
+ qs.value = ''
508
+ return
509
+ }
510
+
511
+ path.value = ancestorsOf(node)
512
+ currentSelected.value = node
513
+ commitSingle(node)
514
+ sheetVisible.value = false
515
+ }
516
+
517
+ function handleTabClick(index: number) {
518
+ if (index >= path.value.length) return
519
+ path.value = path.value.slice(0, index)
520
+ currentSelected.value = undefined
521
+ }
522
+
523
+ function toggleCheck(node: CascadeItem) {
524
+ const next = new Set(checkedSet.value)
525
+ const checked = !next.has(node)
526
+
527
+ dfs(
528
+ node as unknown as Record<string, unknown>,
529
+ (item) => {
530
+ if (checked) {
531
+ next.add(item as unknown as CascadeItem)
532
+ } else {
533
+ next.delete(item as unknown as CascadeItem)
534
+ }
535
+ },
536
+ props.childrenKey
537
+ )
538
+
539
+ checkedSet.value = next
540
+ }
541
+
542
+ function handleConfirm() {
543
+ const nodes = Array.from(checkedSet.value)
544
+ const values = nodes.map((node) => node.value)
545
+ const labels = nodes.map((node) => node.label)
546
+
547
+ emit('update:modelValue', values)
548
+ emit('update:label', labels.length ? labels : undefined)
549
+ emit(
550
+ 'change',
551
+ nodes.map((node) => ({ ...node.data, fullLabel: node.label })),
552
+ labels.length ? labels : undefined
553
+ )
554
+ sheetVisible.value = false
555
+ }
556
+
557
+ function handleTagClose(tag: CascadeItem) {
558
+ emitMultipleValues(modelValues.value.filter((value) => value !== tag.value))
559
+ }
560
+
561
+ function handleClear() {
562
+ path.value = []
563
+ currentSelected.value = undefined
564
+ checkedSet.value = new Set()
565
+ qs.value = ''
566
+
567
+ emit('update:modelValue', multiple.value ? [] : undefined)
568
+ emit('update:label', undefined)
569
+ emit('change', multiple.value ? [] : undefined, undefined)
570
+ emit('clear')
571
+ }
572
+
573
+ watch(sheetVisible, (visible) => {
574
+ if (visible) {
575
+ qs.value = ''
576
+ if (multiple.value) {
577
+ path.value = []
578
+ currentSelected.value = undefined
579
+ checkedSet.value = new Set(
580
+ modelValues.value
581
+ .map((value) => dataMap.value.get(value))
582
+ .filter((node): node is CascadeItem => !!node)
583
+ )
584
+ } else {
585
+ initSingle()
586
+ }
587
+
588
+ if (props.filterable) {
589
+ nextTick(() => searchRef.value?.focus())
590
+ }
591
+ } else {
592
+ qs.value = ''
593
+ }
594
+ })
595
+ </script>
@@ -0,0 +1 @@
1
+ export { default as UCascade } from './cascade.vue'
@@ -0,0 +1,29 @@
1
+ import { TreeNode } from '@cat-kit/core'
2
+ import { shallowReactive } from 'vue'
3
+
4
+ /** 级联节点(内部实现):承载展示字段与可见性 */
5
+ export class CascadeItem<T extends Record<string, any> = Record<string, any>> extends TreeNode<
6
+ T,
7
+ CascadeItem<T>
8
+ > {
9
+ visible = true
10
+
11
+ value: string
12
+ label: string
13
+
14
+ constructor(options: {
15
+ data: Record<string, any>
16
+ index: number
17
+ depth: number
18
+ value: string
19
+ label: string
20
+ parent?: CascadeItem<T>
21
+ }) {
22
+ const { data, index, depth, parent, value, label } = options
23
+ super(data as T, index, depth, parent)
24
+ this.value = value
25
+ this.label = label
26
+
27
+ return shallowReactive(this)
28
+ }
29
+ }