niuma-ui 1.0.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 (220) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +25 -0
  4. package/README.en.md +99 -0
  5. package/README.md +161 -0
  6. package/package.json +106 -0
  7. package/src/__tests__/RsBadge.spec.ts +40 -0
  8. package/src/__tests__/RsBreadcrumb.spec.ts +172 -0
  9. package/src/__tests__/RsButton.spec.ts +108 -0
  10. package/src/__tests__/RsCalendarGrid.spec.ts +82 -0
  11. package/src/__tests__/RsCard.spec.ts +97 -0
  12. package/src/__tests__/RsCheckbox.spec.ts +36 -0
  13. package/src/__tests__/RsCodeEditor.spec.ts +111 -0
  14. package/src/__tests__/RsConfirmDialog.spec.ts +158 -0
  15. package/src/__tests__/RsContainer.spec.ts +89 -0
  16. package/src/__tests__/RsContextMenu.spec.ts +118 -0
  17. package/src/__tests__/RsDatePicker.spec.ts +88 -0
  18. package/src/__tests__/RsDateTimePicker.spec.ts +69 -0
  19. package/src/__tests__/RsDialog.spec.ts +404 -0
  20. package/src/__tests__/RsDrawer.spec.ts +145 -0
  21. package/src/__tests__/RsDropdown.spec.ts +135 -0
  22. package/src/__tests__/RsEmpty.spec.ts +99 -0
  23. package/src/__tests__/RsForm.spec.ts +173 -0
  24. package/src/__tests__/RsIcon.spec.ts +244 -0
  25. package/src/__tests__/RsInput.spec.ts +269 -0
  26. package/src/__tests__/RsInputNumber.spec.ts +118 -0
  27. package/src/__tests__/RsLabel.spec.ts +106 -0
  28. package/src/__tests__/RsLink.spec.ts +49 -0
  29. package/src/__tests__/RsLoading.spec.ts +84 -0
  30. package/src/__tests__/RsMarkdown.spec.ts +143 -0
  31. package/src/__tests__/RsMenu.spec.ts +119 -0
  32. package/src/__tests__/RsNewComponents.spec.ts +17 -0
  33. package/src/__tests__/RsPagination.spec.ts +158 -0
  34. package/src/__tests__/RsPopover.spec.ts +126 -0
  35. package/src/__tests__/RsScrollbar.spec.ts +53 -0
  36. package/src/__tests__/RsSelect.spec.ts +291 -0
  37. package/src/__tests__/RsSidebar.spec.ts +119 -0
  38. package/src/__tests__/RsSplitPane.spec.ts +177 -0
  39. package/src/__tests__/RsStatCard.spec.ts +79 -0
  40. package/src/__tests__/RsSteps.spec.ts +125 -0
  41. package/src/__tests__/RsTable.perf.spec.ts +103 -0
  42. package/src/__tests__/RsTable.spec.ts +1135 -0
  43. package/src/__tests__/RsTabs.spec.ts +328 -0
  44. package/src/__tests__/RsTerminal.spec.ts +218 -0
  45. package/src/__tests__/RsTimePicker.spec.ts +77 -0
  46. package/src/__tests__/RsToaster.spec.ts +95 -0
  47. package/src/__tests__/RsToolbar.spec.ts +75 -0
  48. package/src/__tests__/RsTooltip.spec.ts +111 -0
  49. package/src/__tests__/RsTree.spec.ts +572 -0
  50. package/src/__tests__/RsUpload.spec.ts +160 -0
  51. package/src/__tests__/RsVirtualList.spec.ts +77 -0
  52. package/src/__tests__/date-picker-utils.spec.ts +70 -0
  53. package/src/__tests__/dialog-viewport.spec.ts +38 -0
  54. package/src/__tests__/iso-local-datetime.spec.ts +58 -0
  55. package/src/__tests__/rs-clipboard.spec.ts +142 -0
  56. package/src/__tests__/setup.ts +12 -0
  57. package/src/__tests__/split-pane-utils.spec.ts +136 -0
  58. package/src/__tests__/table-drag.spec.ts +35 -0
  59. package/src/__tests__/table-edit-utils.spec.ts +194 -0
  60. package/src/__tests__/table-utils.spec.ts +286 -0
  61. package/src/__tests__/theme.spec.ts +22 -0
  62. package/src/__tests__/tree-utils.spec.ts +234 -0
  63. package/src/__tests__/useTableCellTooltip.spec.ts +101 -0
  64. package/src/components/RsAvatar.vue +115 -0
  65. package/src/components/RsBadge.vue +59 -0
  66. package/src/components/RsBreadcrumb.vue +65 -0
  67. package/src/components/RsBreadcrumbItems.vue +36 -0
  68. package/src/components/RsButton.vue +393 -0
  69. package/src/components/RsCalendarGrid.vue +284 -0
  70. package/src/components/RsCard.vue +237 -0
  71. package/src/components/RsCheckbox.vue +189 -0
  72. package/src/components/RsCodeBlock.vue +278 -0
  73. package/src/components/RsCodeEditor.vue +597 -0
  74. package/src/components/RsConfigProvider.vue +92 -0
  75. package/src/components/RsConfirmDialog.vue +178 -0
  76. package/src/components/RsContainer.vue +235 -0
  77. package/src/components/RsContextMenu.vue +238 -0
  78. package/src/components/RsContextMenuItems.vue +77 -0
  79. package/src/components/RsDatePicker.vue +756 -0
  80. package/src/components/RsDateTimePicker.vue +42 -0
  81. package/src/components/RsDialog.vue +486 -0
  82. package/src/components/RsDrawer.vue +175 -0
  83. package/src/components/RsDropdown.vue +252 -0
  84. package/src/components/RsDropdownItems.vue +90 -0
  85. package/src/components/RsEmpty.vue +107 -0
  86. package/src/components/RsForm.vue +131 -0
  87. package/src/components/RsIcon.vue +104 -0
  88. package/src/components/RsInput.vue +1101 -0
  89. package/src/components/RsInputNumber.vue +532 -0
  90. package/src/components/RsLabel.vue +54 -0
  91. package/src/components/RsLink.vue +106 -0
  92. package/src/components/RsLoading.vue +171 -0
  93. package/src/components/RsMarkdown.vue +329 -0
  94. package/src/components/RsMenu.vue +256 -0
  95. package/src/components/RsMenuItems.vue +125 -0
  96. package/src/components/RsMonacoEditor.vue +541 -0
  97. package/src/components/RsPagination.vue +184 -0
  98. package/src/components/RsPopover.vue +77 -0
  99. package/src/components/RsProseEditor.vue +160 -0
  100. package/src/components/RsScrollbar.vue +171 -0
  101. package/src/components/RsSelect.vue +1045 -0
  102. package/src/components/RsSidebar.vue +121 -0
  103. package/src/components/RsSidebarGroup.vue +32 -0
  104. package/src/components/RsSidebarItem.vue +72 -0
  105. package/src/components/RsSplitPane.vue +619 -0
  106. package/src/components/RsStatCard.vue +85 -0
  107. package/src/components/RsSteps.vue +138 -0
  108. package/src/components/RsTable.vue +2664 -0
  109. package/src/components/RsTableColgroup.vue +17 -0
  110. package/src/components/RsTableHeaderRow.vue +30 -0
  111. package/src/components/RsTabs.vue +951 -0
  112. package/src/components/RsTerminal.vue +697 -0
  113. package/src/components/RsTimePicker.vue +554 -0
  114. package/src/components/RsTimePickerColumns.vue +226 -0
  115. package/src/components/RsToaster.vue +396 -0
  116. package/src/components/RsToolbar.vue +135 -0
  117. package/src/components/RsTooltip.vue +142 -0
  118. package/src/components/RsTooltipProvider.vue +20 -0
  119. package/src/components/RsTree.vue +1248 -0
  120. package/src/components/RsUpload.vue +279 -0
  121. package/src/components/RsVirtualList.vue +217 -0
  122. package/src/components/avatar-utils.ts +19 -0
  123. package/src/components/breadcrumb-utils.ts +19 -0
  124. package/src/components/class-utils.ts +29 -0
  125. package/src/components/code-editor-utils.ts +106 -0
  126. package/src/components/code-mirror-completion.ts +40 -0
  127. package/src/components/code-mirror-diagnostics.ts +52 -0
  128. package/src/components/code-mirror-ghost.ts +158 -0
  129. package/src/components/code-mirror-inline-edit.ts +55 -0
  130. package/src/components/code-mirror-intel.ts +81 -0
  131. package/src/components/code-mirror-lang.ts +128 -0
  132. package/src/components/context-menu-utils.ts +20 -0
  133. package/src/components/date-picker-utils.ts +284 -0
  134. package/src/components/date-range-validation.ts +29 -0
  135. package/src/components/date-validation.ts +38 -0
  136. package/src/components/dialog-viewport.ts +48 -0
  137. package/src/components/dialog-window.ts +253 -0
  138. package/src/components/dropdown-utils.ts +31 -0
  139. package/src/components/form-utils.ts +77 -0
  140. package/src/components/input-number-utils.ts +128 -0
  141. package/src/components/input-rules.ts +84 -0
  142. package/src/components/loading-utils.ts +8 -0
  143. package/src/components/markdown-utils.ts +126 -0
  144. package/src/components/menu-utils.ts +48 -0
  145. package/src/components/overlay-utils.ts +47 -0
  146. package/src/components/pagination-utils.ts +38 -0
  147. package/src/components/popover-utils.ts +8 -0
  148. package/src/components/reka.ts +106 -0
  149. package/src/components/resolve-radius.ts +22 -0
  150. package/src/components/resolve-size.ts +29 -0
  151. package/src/components/scrollbar-utils.ts +10 -0
  152. package/src/components/select-utils.ts +72 -0
  153. package/src/components/split-pane-utils.ts +187 -0
  154. package/src/components/steps-utils.ts +22 -0
  155. package/src/components/table/RsTableBodyRow.vue +287 -0
  156. package/src/components/table/RsTableCell.vue +239 -0
  157. package/src/components/table/RsTableCellContent.ts +18 -0
  158. package/src/components/table/RsTableCellEditor.vue +351 -0
  159. package/src/components/table/RsTableEditGutter.vue +54 -0
  160. package/src/components/table/RsTableHeaderFilter.vue +107 -0
  161. package/src/components/table/RsTableRowStatus.vue +69 -0
  162. package/src/components/table/rs-table.css +1141 -0
  163. package/src/components/table/table-context-menu.ts +53 -0
  164. package/src/components/table/table-edit-utils.ts +496 -0
  165. package/src/components/table/table-keys.ts +12 -0
  166. package/src/components/table-drag.ts +215 -0
  167. package/src/components/table-utils.ts +950 -0
  168. package/src/components/tabs-utils.ts +107 -0
  169. package/src/components/terminal-utils.ts +301 -0
  170. package/src/components/terminal-wheel.ts +49 -0
  171. package/src/components/time-picker-utils.ts +167 -0
  172. package/src/components/time-range-validation.ts +23 -0
  173. package/src/components/time-validation.ts +20 -0
  174. package/src/components/tree-utils.ts +518 -0
  175. package/src/components/upload-utils.ts +55 -0
  176. package/src/components/virtual-list-utils.ts +17 -0
  177. package/src/composables/useClickOutside.ts +22 -0
  178. package/src/composables/useRsConfig.ts +91 -0
  179. package/src/composables/useRsI18n.ts +22 -0
  180. package/src/composables/useRsTabsNav.ts +187 -0
  181. package/src/composables/useRsToast.ts +48 -0
  182. package/src/composables/useTableCellTooltip.ts +239 -0
  183. package/src/composables/useTableEdit.ts +271 -0
  184. package/src/composables/useTableRowHighlight.ts +50 -0
  185. package/src/dev/vite-codemirror-deps.ts +20 -0
  186. package/src/icons/custom/api.ts +54 -0
  187. package/src/icons/custom/clickhouse.ts +52 -0
  188. package/src/icons/custom/dameng.ts +83 -0
  189. package/src/icons/custom/ftp.ts +61 -0
  190. package/src/icons/custom/kingbase.ts +52 -0
  191. package/src/icons/custom/mongodb.ts +50 -0
  192. package/src/icons/custom/mysql.ts +51 -0
  193. package/src/icons/custom/oracle.ts +52 -0
  194. package/src/icons/custom/redis.ts +51 -0
  195. package/src/icons/custom/sqlite.ts +50 -0
  196. package/src/icons/custom/sqlserver.ts +52 -0
  197. package/src/icons/custom/vastbase.ts +57 -0
  198. package/src/icons/lucide.ts +78 -0
  199. package/src/icons/registry.ts +35 -0
  200. package/src/index.ts +425 -0
  201. package/src/lib/iso-local-datetime.ts +152 -0
  202. package/src/lib/rs-dayjs.ts +56 -0
  203. package/src/locale/messages.ts +362 -0
  204. package/src/locale/types.ts +5 -0
  205. package/src/monaco/debug-decorations.css +20 -0
  206. package/src/monaco/debug-decorations.ts +70 -0
  207. package/src/monaco/index.ts +22 -0
  208. package/src/monaco/languages/index.ts +21 -0
  209. package/src/monaco/languages/mongodb-shell.ts +125 -0
  210. package/src/monaco/languages/types.ts +55 -0
  211. package/src/monaco/setup.ts +101 -0
  212. package/src/styles.css +1040 -0
  213. package/src/theme/apply.ts +12 -0
  214. package/src/theme/brand.example.css +37 -0
  215. package/src/theme/presets.ts +69 -0
  216. package/src/theme/types.ts +81 -0
  217. package/src/utils/rs-clipboard.ts +272 -0
  218. package/src/vite-env.d.ts +7 -0
  219. package/vite-plugins/monaco-zh-nls.ts +101 -0
  220. package/vite-plugins/silence-antlr-parse-console.ts +29 -0
@@ -0,0 +1,572 @@
1
+ import { mount, flushPromises } from '@vue/test-utils'
2
+ import { nextTick } from 'vue'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import RsTree from '../components/RsTree.vue'
5
+ import {
6
+ collectHalfCheckedKeys,
7
+ flattenTreeNodeIds,
8
+ getTreeChildren,
9
+ getTreeKey,
10
+ getTreeLabel,
11
+ resolveTreeFieldNames,
12
+ resolveTreeFocusKey,
13
+ splitTreeLabelHighlight,
14
+ } from '../components/tree-utils'
15
+
16
+ const nodes = [
17
+ { key: 'root', label: '根节点', children: [{ key: 'child', label: '子节点' }] },
18
+ { id: 'alt', title: '备用字段' },
19
+ ]
20
+
21
+ /**
22
+ * jsdom 不做布局,直接给 scrollTop 赋值会被钳成 0,无法模拟滚动。
23
+ * 覆盖成可写的自有属性后再派发 scroll,组件才能读到真实偏移。
24
+ */
25
+ async function scrollViewportTo(element: Element, top: number): Promise<void> {
26
+ Object.defineProperty(element, 'scrollTop', { configurable: true, writable: true, value: top })
27
+ element.dispatchEvent(new Event('scroll'))
28
+ await nextTick()
29
+ }
30
+
31
+ const checkNodes = [
32
+ {
33
+ key: 'parent',
34
+ label: '父节点',
35
+ children: [
36
+ { key: 'child-a', label: '子 A' },
37
+ { key: 'child-b', label: '子 B' },
38
+ ],
39
+ },
40
+ ]
41
+
42
+ describe('RsTree', () => {
43
+ it('renders nodes when defaultExpandAll is true', () => {
44
+ const wrapper = mount(RsTree, {
45
+ props: { nodes, defaultExpandAll: true },
46
+ })
47
+ expect(wrapper.text()).toContain('根节点')
48
+ expect(wrapper.text()).toContain('子节点')
49
+ expect(wrapper.text()).toContain('备用字段')
50
+ })
51
+
52
+ it('hides children until expanded', async () => {
53
+ const wrapper = mount(RsTree, {
54
+ props: { nodes: [nodes[0]] },
55
+ })
56
+ expect(wrapper.text()).not.toContain('子节点')
57
+ await wrapper.find('.rs-tree__toggle').trigger('click')
58
+ expect(wrapper.text()).toContain('子节点')
59
+ })
60
+
61
+ it('selects a single node via v-model', async () => {
62
+ const wrapper = mount(RsTree, {
63
+ props: {
64
+ nodes: [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }],
65
+ modelValue: '',
66
+ 'onUpdate:modelValue': (value: string | string[]) => wrapper.setProps({ modelValue: value }),
67
+ },
68
+ })
69
+ await wrapper.findAll('.rs-tree__label')[1].trigger('click')
70
+ expect(wrapper.props('modelValue')).toBe('b')
71
+ const selected = wrapper.find('.rs-tree__row--selected')
72
+ expect(selected.exists()).toBe(true)
73
+ expect(selected.text()).toContain('B')
74
+ })
75
+
76
+ it('highlights multiple selected rows in multiple mode', async () => {
77
+ const wrapper = mount(RsTree, {
78
+ props: {
79
+ nodes: [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }, { key: 'c', label: 'C' }],
80
+ multiple: true,
81
+ modelValue: ['a', 'c'],
82
+ },
83
+ })
84
+ expect(wrapper.findAll('.rs-tree__row--selected')).toHaveLength(2)
85
+ })
86
+
87
+ it('supports fieldNames mapping', () => {
88
+ const apiNodes = [
89
+ {
90
+ id: '1',
91
+ name: '部门',
92
+ subList: [{ id: '1-1', name: '研发' }],
93
+ },
94
+ ]
95
+ const wrapper = mount(RsTree, {
96
+ props: {
97
+ nodes: apiNodes,
98
+ fieldNames: { key: 'id', label: 'name', children: 'subList' },
99
+ defaultExpandAll: true,
100
+ },
101
+ })
102
+ expect(wrapper.text()).toContain('部门')
103
+ expect(wrapper.text()).toContain('研发')
104
+ })
105
+
106
+ it('syncs halfCheckedKeys when partially checked', async () => {
107
+ const updates: string[][] = []
108
+ mount(RsTree, {
109
+ props: {
110
+ nodes: checkNodes,
111
+ checkable: true,
112
+ defaultExpandAll: true,
113
+ checkedKeys: ['child-a'],
114
+ halfCheckedKeys: [],
115
+ 'onUpdate:halfCheckedKeys': (value: string[] | undefined) => {
116
+ if (value !== undefined) updates.push(value)
117
+ },
118
+ },
119
+ })
120
+ await flushPromises()
121
+ expect(updates.at(-1)).toEqual(expect.arrayContaining(['parent']))
122
+ })
123
+
124
+ it('checks on node click when checkOnClickNode is true', async () => {
125
+ const wrapper = mount(RsTree, {
126
+ props: {
127
+ nodes: [{ key: 'a', label: 'A' }],
128
+ checkable: true,
129
+ checkOnClickNode: true,
130
+ selectable: false,
131
+ checkedKeys: [] as string[],
132
+ 'onUpdate:checkedKeys': (value: string[] | undefined) => {
133
+ if (value !== undefined) void wrapper.setProps({ checkedKeys: value })
134
+ },
135
+ },
136
+ })
137
+ await wrapper.find('.rs-tree__label').trigger('click')
138
+ expect(wrapper.props('checkedKeys')).toEqual(['a'])
139
+ })
140
+
141
+ it('filters nodes with filter prop and highlights matches', () => {
142
+ const wrapper = mount(RsTree, {
143
+ props: {
144
+ nodes,
145
+ filter: '子节点',
146
+ autoExpandParent: true,
147
+ },
148
+ })
149
+ expect(wrapper.find('.rs-tree__highlight').exists()).toBe(true)
150
+ expect(wrapper.text()).not.toContain('备用字段')
151
+ })
152
+
153
+ it('does not render keyword highlight when highlight is false', () => {
154
+ const wrapper = mount(RsTree, {
155
+ props: {
156
+ nodes,
157
+ filter: '子节点',
158
+ highlight: false,
159
+ autoExpandParent: true,
160
+ },
161
+ })
162
+ expect(wrapper.find('.rs-tree__highlight').exists()).toBe(false)
163
+ expect(wrapper.text()).toContain('子节点')
164
+ })
165
+
166
+ it('shows empty state when filter has no matches', () => {
167
+ const wrapper = mount(RsTree, {
168
+ props: { nodes, filter: '不存在的内容' },
169
+ })
170
+ expect(wrapper.find('.rs-tree__empty').exists()).toBe(true)
171
+ })
172
+
173
+ it('restores expanded keys after clearing a filter with no matches', async () => {
174
+ const wrapper = mount(RsTree, {
175
+ props: {
176
+ nodes: [nodes[0]],
177
+ autoExpandParent: true,
178
+ expandedKeys: ['root'] as string[],
179
+ 'onUpdate:expandedKeys': (value: string[] | undefined) => {
180
+ if (value !== undefined) void wrapper.setProps({ expandedKeys: value })
181
+ },
182
+ },
183
+ })
184
+ expect(wrapper.props('expandedKeys')).toEqual(['root'])
185
+ await wrapper.setProps({ filter: '不存在的内容' })
186
+ expect(wrapper.find('.rs-tree__empty').exists()).toBe(true)
187
+ expect(wrapper.props('expandedKeys')).toEqual(['root'])
188
+ await wrapper.setProps({ filter: '' })
189
+ expect(wrapper.props('expandedKeys')).toEqual(['root'])
190
+ })
191
+
192
+ it('restores pre-filter expanded keys after clearing a matching filter', async () => {
193
+ const treeNodes = [
194
+ {
195
+ key: 'root',
196
+ label: '根',
197
+ children: [
198
+ { key: 'keep', label: '保留', children: [{ key: 'keep-1', label: '保留子' }] },
199
+ { key: 'hide', label: '隐藏', children: [{ key: 'hide-1', label: '隐藏子' }] },
200
+ ],
201
+ },
202
+ ]
203
+ const wrapper = mount(RsTree, {
204
+ props: {
205
+ nodes: treeNodes,
206
+ autoExpandParent: true,
207
+ expandedKeys: ['root', 'hide'] as string[],
208
+ 'onUpdate:expandedKeys': (value: string[] | undefined) => {
209
+ if (value !== undefined) void wrapper.setProps({ expandedKeys: value })
210
+ },
211
+ },
212
+ })
213
+ await wrapper.setProps({ filter: '保留' })
214
+ expect(wrapper.props('expandedKeys')).toEqual(expect.arrayContaining(['root', 'keep']))
215
+ await wrapper.setProps({ filter: '' })
216
+ expect(wrapper.props('expandedKeys')).toEqual(['root', 'hide'])
217
+ })
218
+
219
+ it('loads children lazily on expand', async () => {
220
+ const loadData = vi.fn(async (node: { key?: string }) => {
221
+ if (node.key === 'lazy-root') {
222
+ ;(node as { children?: unknown[] }).children = [{ key: 'lazy-child', label: '异步子节点' }]
223
+ }
224
+ })
225
+ const lazyNodes = [{ key: 'lazy-root', label: '异步根', isLeaf: false }]
226
+ const wrapper = mount(RsTree, {
227
+ props: {
228
+ nodes: lazyNodes,
229
+ lazy: true,
230
+ loadData,
231
+ },
232
+ })
233
+ await wrapper.find('.rs-tree__toggle').trigger('click')
234
+ await vi.waitFor(() => expect(loadData).toHaveBeenCalled())
235
+ await wrapper.setProps({ nodes: [{ key: 'lazy-root', label: '异步根', children: [{ key: 'lazy-child', label: '异步子节点' }] }] })
236
+ await wrapper.vm.$nextTick()
237
+ expect(wrapper.text()).toContain('异步子节点')
238
+ })
239
+
240
+ it('supports keyboard navigation', async () => {
241
+ const wrapper = mount(RsTree, {
242
+ props: {
243
+ nodes: [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }],
244
+ },
245
+ attachTo: document.body,
246
+ })
247
+ await flushPromises()
248
+ const treeEl = wrapper.get('.rs-tree').element as HTMLElement
249
+ treeEl.focus()
250
+ treeEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
251
+ await flushPromises()
252
+ expect(wrapper.find('.rs-tree__row--focused').text()).toContain('B')
253
+ wrapper.unmount()
254
+ })
255
+
256
+ it('exposes focusNode', async () => {
257
+ const wrapper = mount(RsTree, {
258
+ props: {
259
+ nodes: [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }],
260
+ },
261
+ })
262
+ const vm = wrapper.vm as { focusNode: (key: string) => void }
263
+ vm.focusNode('b')
264
+ await flushPromises()
265
+ expect(wrapper.find('.rs-tree__row--focused').text()).toContain('B')
266
+ })
267
+
268
+ it('collapses siblings in accordion mode', async () => {
269
+ const wrapper = mount(RsTree, {
270
+ props: {
271
+ nodes: [
272
+ {
273
+ key: 'root',
274
+ label: '根',
275
+ children: [
276
+ { key: 'a', label: 'A', children: [{ key: 'a-1', label: 'A1' }] },
277
+ { key: 'b', label: 'B', children: [{ key: 'b-1', label: 'B1' }] },
278
+ ],
279
+ },
280
+ ],
281
+ accordion: true,
282
+ expandedKeys: ['root', 'a'],
283
+ 'onUpdate:expandedKeys': (value: string[] | undefined) => {
284
+ if (value !== undefined) void wrapper.setProps({ expandedKeys: value })
285
+ },
286
+ },
287
+ })
288
+ const toggles = wrapper.findAll('.rs-tree__toggle')
289
+ await toggles[2].trigger('click')
290
+ expect(wrapper.props('expandedKeys')).toEqual(expect.arrayContaining(['root', 'b']))
291
+ expect(wrapper.props('expandedKeys')).not.toContain('a')
292
+ })
293
+
294
+ it('emits node-drop when draggable', async () => {
295
+ const onDrop = vi.fn()
296
+ const wrapper = mount(RsTree, {
297
+ props: {
298
+ nodes: [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }],
299
+ draggable: true,
300
+ onNodeDrop: onDrop,
301
+ },
302
+ attachTo: document.body,
303
+ })
304
+ const dataTransfer = { setData: vi.fn(), effectAllowed: '' }
305
+ const items = wrapper.findAll('.rs-tree__row')
306
+ await items[0].trigger('dragstart', { dataTransfer })
307
+ await items[1].trigger('drop', { dataTransfer, clientY: 20 })
308
+ expect(onDrop).toHaveBeenCalledWith('a', 'b', expect.any(String))
309
+ wrapper.unmount()
310
+ })
311
+
312
+ it('blocks dropping a node into its own descendant but allows dropping onto an ancestor', async () => {
313
+ const onDrop = vi.fn()
314
+ const allowDrop = vi.fn(() => true)
315
+ const treeNodes = [
316
+ {
317
+ key: 'parent',
318
+ label: '父',
319
+ children: [{ key: 'child', label: '子' }],
320
+ },
321
+ ]
322
+ const wrapper = mount(RsTree, {
323
+ props: {
324
+ nodes: treeNodes,
325
+ draggable: true,
326
+ defaultExpandAll: true,
327
+ allowDrop,
328
+ onNodeDrop: onDrop,
329
+ },
330
+ attachTo: document.body,
331
+ })
332
+ const dataTransfer = { setData: vi.fn(), effectAllowed: '' }
333
+ const rows = wrapper.findAll('.rs-tree__row')
334
+
335
+ // 父 → 子:会成环,组件内直接否决,allowDrop 都不该被问到
336
+ await rows[0].trigger('dragstart', { dataTransfer })
337
+ await rows[1].trigger('drop', { dataTransfer, clientY: 20 })
338
+ expect(onDrop).not.toHaveBeenCalled()
339
+ expect(allowDrop).not.toHaveBeenCalled()
340
+
341
+ // 子 → 父:合法(如拖出所在文件夹),交由 allowDrop 裁决
342
+ await rows[1].trigger('dragstart', { dataTransfer })
343
+ await rows[0].trigger('drop', { dataTransfer, clientY: 20 })
344
+ expect(allowDrop).toHaveBeenCalled()
345
+ expect(onDrop).toHaveBeenCalledWith('child', 'parent', expect.any(String))
346
+ wrapper.unmount()
347
+ })
348
+
349
+ it('hides drag handle when dragTrigger is row', () => {
350
+ const wrapper = mount(RsTree, {
351
+ props: {
352
+ nodes: [{ key: 'a', label: 'A' }],
353
+ draggable: true,
354
+ dragTrigger: 'row',
355
+ },
356
+ })
357
+ expect(wrapper.find('.rs-tree__drag-handle').exists()).toBe(false)
358
+ expect(wrapper.classes()).toContain('rs-tree--drag-row')
359
+ })
360
+
361
+ it('auto virtualizes large flat trees', () => {
362
+ const largeNodes = Array.from({ length: 150 }, (_, index) => ({
363
+ key: String(index),
364
+ label: `Node ${index}`,
365
+ }))
366
+ const wrapper = mount(RsTree, {
367
+ props: { nodes: largeNodes, height: 200 },
368
+ })
369
+ expect(wrapper.classes()).toContain('rs-tree--virtual')
370
+ expect(wrapper.findAll('.rs-tree__item').length).toBeLessThan(largeNodes.length)
371
+ })
372
+
373
+ it('reports aria-posinset from the slice offset while scrolled', async () => {
374
+ const largeNodes = Array.from({ length: 600 }, (_, index) => ({
375
+ key: `n-${index}`,
376
+ label: `Node ${index}`,
377
+ }))
378
+ const wrapper = mount(RsTree, {
379
+ props: { nodes: largeNodes, virtual: true, height: 320, itemHeight: 32, overscan: 2 },
380
+ })
381
+ const viewport = wrapper.find('.rs-tree__viewport')
382
+ await scrollViewportTo(viewport.element, 3200)
383
+ const firstRow = wrapper.find('.rs-tree__row')
384
+ // scrollTop 3200 / 32 = 第 100 行,减 overscan 2 → 首个渲染行是第 99 项(posinset 从 1 起算)
385
+ expect(firstRow.attributes('aria-posinset')).toBe('99')
386
+ expect(firstRow.attributes('aria-setsize')).toBe('600')
387
+ expect(firstRow.text()).toContain('Node 98')
388
+ })
389
+
390
+ it('keeps virtual rows visible after filtering a scrolled large tree', async () => {
391
+ const largeNodes = Array.from({ length: 1200 }, (_, index) => ({
392
+ key: `t-${index}`,
393
+ label: index === 50 ? 'bas_sku_match' : `TABLE_${index}`,
394
+ }))
395
+ const wrapper = mount(RsTree, {
396
+ props: { nodes: largeNodes, virtual: true, height: 240, itemHeight: 28 },
397
+ })
398
+ const viewport = wrapper.find('.rs-tree__viewport')
399
+ expect(viewport.exists()).toBe(true)
400
+ // 已滚到列表中部:过滤后内容骤短,若不归零/钳位,切片 start 会越界并渲染出空白
401
+ await scrollViewportTo(viewport.element, 8000)
402
+ expect(wrapper.findAll('.rs-tree__item').length).toBeGreaterThan(0)
403
+ await wrapper.setProps({ filter: 'bas_sku' })
404
+ await nextTick()
405
+ await nextTick()
406
+ expect(wrapper.findAll('.rs-tree__item').length).toBeGreaterThan(0)
407
+ expect(wrapper.text()).toContain('bas_sku_match')
408
+ expect(viewport.element.scrollTop).toBe(0)
409
+ })
410
+
411
+ it('expands node on label click when expandOnClickNode is true', async () => {
412
+ const wrapper = mount(RsTree, {
413
+ props: {
414
+ nodes: [nodes[0]],
415
+ expandOnClickNode: true,
416
+ selectable: false,
417
+ expandedKeys: [] as string[],
418
+ 'onUpdate:expandedKeys': (value: string[] | undefined) => {
419
+ if (value !== undefined) void wrapper.setProps({ expandedKeys: value })
420
+ },
421
+ },
422
+ })
423
+ await wrapper.find('.rs-tree__label').trigger('click')
424
+ expect(wrapper.props('expandedKeys')).toEqual(['root'])
425
+ })
426
+
427
+ it('renders showLine and blockNode classes', () => {
428
+ const wrapper = mount(RsTree, {
429
+ props: { nodes, showLine: true, blockNode: true, defaultExpandAll: true },
430
+ })
431
+ expect(wrapper.classes()).toContain('rs-tree--line')
432
+ expect(wrapper.classes()).toContain('rs-tree--block')
433
+ expect(wrapper.find('.rs-tree__lines').exists()).toBe(true)
434
+ expect(wrapper.find('.rs-tree__line-vert').exists()).toBe(true)
435
+ expect(wrapper.find('.rs-tree__line-horz').exists()).toBe(true)
436
+ })
437
+
438
+ it('applies focused row class to the focused node', async () => {
439
+ const wrapper = mount(RsTree, {
440
+ props: {
441
+ nodes: [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }],
442
+ },
443
+ })
444
+ const vm = wrapper.vm as { focusNode: (key: string) => void }
445
+ vm.focusNode('b')
446
+ await flushPromises()
447
+ expect(wrapper.find('.rs-tree__row--focused').text()).toContain('B')
448
+ })
449
+
450
+ it('emits node-dblclick on a row that was not focused before the gesture', async () => {
451
+ const onNodeDblclick = vi.fn()
452
+ const wrapper = mount(RsTree, {
453
+ props: {
454
+ nodes: [{ key: 'a', label: 'A' }, { key: 'b', label: 'B' }],
455
+ blockNode: true,
456
+ selectable: false,
457
+ onNodeDblclick,
458
+ },
459
+ })
460
+ const rows = wrapper.findAll('.rs-tree__row')
461
+ await rows[0].trigger('click')
462
+ await rows[1].trigger('dblclick')
463
+ expect(onNodeDblclick).toHaveBeenCalledTimes(1)
464
+ expect(onNodeDblclick).toHaveBeenCalledWith(
465
+ expect.objectContaining({ key: 'b', label: 'B' }),
466
+ 'b',
467
+ )
468
+ })
469
+ })
470
+
471
+ describe('tree-utils basics', () => {
472
+ it('getTreeKey prefers key then id then label', () => {
473
+ expect(getTreeKey({ key: 'a' })).toBe('a')
474
+ expect(getTreeKey({ id: 'b' })).toBe('b')
475
+ expect(getTreeKey({ label: 'c' })).toBe('c')
476
+ })
477
+
478
+ it('supports custom fieldNames', () => {
479
+ const fields = resolveTreeFieldNames({ key: 'id', label: 'name', children: 'subList' })
480
+ expect(getTreeKey({ id: 'x' }, fields)).toBe('x')
481
+ expect(getTreeLabel({ name: '标题' }, fields)).toBe('标题')
482
+ expect(getTreeChildren({ subList: [{ id: '1' }] }, fields)).toHaveLength(1)
483
+ })
484
+
485
+ it('splitTreeLabelHighlight marks matched segment', () => {
486
+ const parts = splitTreeLabelHighlight('Hello World', 'wor')
487
+ expect(parts.some((part) => part.highlight && part.text === 'Wor')).toBe(true)
488
+ })
489
+
490
+ it('collectHalfCheckedKeys returns parent for partial selection', () => {
491
+ const index = new Map([
492
+ ['parent', { node: {}, parentKey: null, childrenKeys: ['child-a', 'child-b'] }],
493
+ ['child-a', { node: {}, parentKey: 'parent', childrenKeys: [] }],
494
+ ['child-b', { node: {}, parentKey: 'parent', childrenKeys: [] }],
495
+ ])
496
+ expect(collectHalfCheckedKeys(index, new Set(['child-a']), false)).toEqual(['parent'])
497
+ })
498
+
499
+ it('resolveTreeFocusKey moves across flat nodes', () => {
500
+ const flat = [
501
+ { key: 'a', node: {}, depth: 0, hasChildren: false, isLast: false, parentKey: null, levelLines: [] },
502
+ { key: 'b', node: {}, depth: 0, hasChildren: false, isLast: true, parentKey: null, levelLines: [] },
503
+ ]
504
+ const index = new Map([
505
+ ['a', { node: {}, parentKey: null, childrenKeys: [] }],
506
+ ['b', { node: {}, parentKey: null, childrenKeys: [] }],
507
+ ])
508
+ expect(resolveTreeFocusKey(flat, 'a', 'next', index)).toBe('b')
509
+ })
510
+
511
+ it('flattenTreeNodeIds walks the full tree', () => {
512
+ expect(flattenTreeNodeIds(nodes)).toEqual(['root', 'child', 'alt'])
513
+ })
514
+
515
+ it('onlyCheckLeaf hides parent checkbox and checks leaves only', async () => {
516
+ const halfUpdates: string[][] = []
517
+ const wrapper = mount(RsTree, {
518
+ props: {
519
+ nodes: checkNodes,
520
+ checkable: true,
521
+ onlyCheckLeaf: true,
522
+ defaultExpandAll: true,
523
+ checkedKeys: [] as string[],
524
+ 'onUpdate:checkedKeys': (value: string[] | undefined) => {
525
+ if (value !== undefined) void wrapper.setProps({ checkedKeys: value })
526
+ },
527
+ 'onUpdate:halfCheckedKeys': (value: string[] | undefined) => {
528
+ if (value !== undefined) halfUpdates.push(value)
529
+ },
530
+ },
531
+ })
532
+ expect(wrapper.findAll('.rs-tree__checkbox')).toHaveLength(2)
533
+ expect(wrapper.find('.rs-tree__checkbox-spacer').exists()).toBe(true)
534
+ await wrapper.findAll('.rs-tree__checkbox-input')[0].trigger('change')
535
+ expect(wrapper.props('checkedKeys')).toEqual(['child-a'])
536
+ await flushPromises()
537
+ expect(halfUpdates.at(-1)).toEqual(['parent'])
538
+ })
539
+
540
+ it('enables fill-capture layer when height or virtual is set', () => {
541
+ const withHeight = mount(RsTree, {
542
+ props: { nodes, height: 200 },
543
+ })
544
+ expect(withHeight.classes()).toContain('rs-tree--fill-capture')
545
+
546
+ const withVirtual = mount(RsTree, {
547
+ props: { nodes, virtual: true },
548
+ })
549
+ expect(withVirtual.classes()).toContain('rs-tree--fill-capture')
550
+
551
+ const plain = mount(RsTree, {
552
+ props: { nodes },
553
+ })
554
+ expect(plain.classes()).not.toContain('rs-tree--fill-capture')
555
+ })
556
+
557
+ it('does not stop contextmenu propagation for parent handlers', async () => {
558
+ const onContextmenu = vi.fn()
559
+ const wrapper = mount({
560
+ components: { RsTree },
561
+ template: '<div class="host" @contextmenu="onContextmenu"><RsTree :nodes="nodes" :height="200" /></div>',
562
+ setup() {
563
+ return {
564
+ nodes: [{ key: 'only', label: '唯一节点' }],
565
+ onContextmenu,
566
+ }
567
+ },
568
+ })
569
+ await wrapper.find('.rs-tree').trigger('contextmenu')
570
+ expect(onContextmenu).toHaveBeenCalledTimes(1)
571
+ })
572
+ })