niuma-ui 1.0.0 → 1.0.2

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 (159) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/NOTICE +2 -2
  3. package/package.json +13 -5
  4. package/src/__tests__/RsButton.spec.ts +47 -0
  5. package/src/__tests__/RsCard.spec.ts +160 -1
  6. package/src/__tests__/RsConfirmDialog.spec.ts +127 -0
  7. package/src/__tests__/RsContextMenu.spec.ts +12 -0
  8. package/src/__tests__/RsDateTimePicker.spec.ts +11 -0
  9. package/src/__tests__/RsDescriptions.spec.ts +50 -0
  10. package/src/__tests__/RsDialog.spec.ts +117 -0
  11. package/src/__tests__/RsDrawer.spec.ts +96 -3
  12. package/src/__tests__/RsDynamicTags.spec.ts +52 -0
  13. package/src/__tests__/RsForm.spec.ts +245 -1
  14. package/src/__tests__/RsInput.spec.ts +15 -1
  15. package/src/__tests__/RsMenu.spec.ts +406 -14
  16. package/src/__tests__/RsPagination.spec.ts +67 -2
  17. package/src/__tests__/RsSelect.spec.ts +42 -1
  18. package/src/__tests__/RsSwitch.spec.ts +44 -0
  19. package/src/__tests__/RsTable.a11y.spec.ts +83 -0
  20. package/src/__tests__/RsTable.dom-baseline.spec.ts +87 -0
  21. package/src/__tests__/RsTable.perf.spec.ts +23 -4
  22. package/src/__tests__/RsTable.spec.ts +63 -8
  23. package/src/__tests__/RsTable.ssr.spec.ts +71 -0
  24. package/src/__tests__/RsTable.summary.spec.ts +45 -0
  25. package/src/__tests__/RsTabs.spec.ts +102 -4
  26. package/src/__tests__/RsTerminal.spec.ts +1 -1
  27. package/src/__tests__/RsTimePicker.spec.ts +11 -0
  28. package/src/__tests__/RsUpload.spec.ts +62 -6
  29. package/src/__tests__/createRsDialog.spec.ts +145 -0
  30. package/src/__tests__/form-rules.spec.ts +76 -0
  31. package/src/__tests__/perf-budgets.ts +32 -0
  32. package/src/__tests__/rs-table-api.spec.ts +206 -0
  33. package/src/__tests__/rs-table-compat-matrix.spec.ts +108 -0
  34. package/src/__tests__/rs-table-echarts-adapter.spec.ts +43 -0
  35. package/src/__tests__/rs-table-grid-nav.spec.ts +63 -0
  36. package/src/__tests__/rs-table-isolation.spec.ts +158 -0
  37. package/src/__tests__/split-pane-utils.spec.ts +21 -0
  38. package/src/__tests__/table-chart-utils.spec.ts +88 -0
  39. package/src/__tests__/table-summary-utils.spec.ts +62 -0
  40. package/src/__tests__/table-utils.spec.ts +124 -0
  41. package/src/__tests__/theme.spec.ts +25 -0
  42. package/src/__tests__/useRsTableColumnResize.spec.ts +25 -0
  43. package/src/__tests__/useRsTableCore.perf.spec.ts +170 -0
  44. package/src/components/RsAlert.vue +212 -0
  45. package/src/components/RsButton.vue +145 -21
  46. package/src/components/RsCard.vue +200 -16
  47. package/src/components/RsConfirmDialog.vue +215 -28
  48. package/src/components/RsContextMenu.vue +51 -13
  49. package/src/components/RsDatePicker.vue +267 -34
  50. package/src/components/RsDateTimePicker.vue +19 -2
  51. package/src/components/RsDescriptions.vue +186 -0
  52. package/src/components/RsDescriptionsItem.vue +32 -0
  53. package/src/components/RsDialog.vue +363 -51
  54. package/src/components/RsDivider.vue +89 -0
  55. package/src/components/RsDrawer.vue +558 -33
  56. package/src/components/RsDropdown.vue +33 -2
  57. package/src/components/RsDynamicTags.vue +517 -0
  58. package/src/components/RsForm.vue +97 -8
  59. package/src/components/RsInput.vue +80 -82
  60. package/src/components/RsInputNumber.vue +64 -23
  61. package/src/components/RsLabel.vue +20 -5
  62. package/src/components/RsLoadingBar.vue +151 -0
  63. package/src/components/RsMenu.vue +73 -15
  64. package/src/components/RsMenuItems.vue +147 -9
  65. package/src/components/RsPagination.vue +93 -14
  66. package/src/components/RsRadio.vue +81 -0
  67. package/src/components/RsRadioItem.vue +156 -0
  68. package/src/components/RsSelect.vue +67 -58
  69. package/src/components/RsSplitPane.vue +69 -1
  70. package/src/components/RsSwitch.vue +179 -0
  71. package/src/components/RsTable.vue +653 -2555
  72. package/src/components/RsTabs.vue +415 -83
  73. package/src/components/RsTag.vue +191 -0
  74. package/src/components/RsTerminal.vue +3 -3
  75. package/src/components/RsTimePicker.vue +44 -5
  76. package/src/components/RsTimePickerColumns.vue +136 -77
  77. package/src/components/RsToaster.vue +58 -18
  78. package/src/components/RsTree.vue +39 -21
  79. package/src/components/RsUpload.vue +145 -12
  80. package/src/components/RsVNodeHost.vue +20 -0
  81. package/src/components/button-utils.ts +39 -0
  82. package/src/components/context-menu-utils.ts +6 -0
  83. package/src/components/date-picker-utils.ts +84 -4
  84. package/src/components/descriptions-utils.ts +24 -0
  85. package/src/components/dialog-utils.ts +147 -0
  86. package/src/components/dialog-window.ts +74 -9
  87. package/src/components/drawer-utils.ts +73 -0
  88. package/src/components/form-rules.ts +257 -0
  89. package/src/components/form-utils.ts +52 -2
  90. package/src/components/menu-utils.ts +11 -0
  91. package/src/components/overlay-utils.ts +3 -0
  92. package/src/components/radio-utils.ts +12 -0
  93. package/src/components/reka.ts +5 -0
  94. package/src/components/select-utils.ts +21 -2
  95. package/src/components/split-pane-utils.ts +65 -12
  96. package/src/components/table/RsTableBody.vue +217 -0
  97. package/src/components/table/RsTableBodyRow.vue +90 -1
  98. package/src/components/table/RsTableColGroup.vue +41 -0
  99. package/src/components/table/RsTableHeader.vue +158 -0
  100. package/src/components/table/RsTableSummaryRow.vue +52 -0
  101. package/src/components/table/rs-table-api.ts +99 -0
  102. package/src/components/table/rs-table-compat-matrix.ts +71 -0
  103. package/src/components/table/rs-table-echarts-adapter.ts +81 -0
  104. package/src/components/table/rs-table-feature-host.ts +178 -0
  105. package/src/components/table/rs-table-grid-nav.ts +122 -0
  106. package/src/components/table/rs-table-header-types.ts +47 -0
  107. package/src/components/table/rs-table-module-registry.ts +72 -0
  108. package/src/components/table/rs-table-props.ts +274 -0
  109. package/src/components/table/rs-table-view-context.ts +258 -0
  110. package/src/components/table/rs-table.css +84 -6
  111. package/src/components/table/table-chart-utils.ts +165 -0
  112. package/src/components/table/table-features.ts +284 -0
  113. package/src/components/table/table-summary-utils.ts +194 -0
  114. package/src/components/table-utils.ts +452 -2
  115. package/src/components/tabs-utils.ts +142 -2
  116. package/src/components/terminal-utils.ts +1 -1
  117. package/src/components/terminal-wheel.ts +1 -1
  118. package/src/components/time-picker-utils.ts +76 -3
  119. package/src/components/upload-utils.ts +122 -0
  120. package/src/components/virtual-list-utils.ts +15 -2
  121. package/src/composables/assembleRsTableApi.ts +79 -0
  122. package/src/composables/bindRsTableViewContext.ts +269 -0
  123. package/src/composables/createRsDialog.ts +357 -0
  124. package/src/composables/createRsTableEditEmitBridge.ts +38 -0
  125. package/src/composables/useRsLoadingBar.ts +36 -0
  126. package/src/composables/useRsTable.ts +134 -0
  127. package/src/composables/useRsTableA11y.ts +52 -0
  128. package/src/composables/useRsTableChartBridge.ts +79 -0
  129. package/src/composables/useRsTableColumnLayout.ts +320 -0
  130. package/src/composables/useRsTableColumnResize.ts +244 -0
  131. package/src/composables/useRsTableColumnVirtual.ts +180 -0
  132. package/src/composables/useRsTableColumns.ts +200 -0
  133. package/src/composables/useRsTableContextMenu.ts +257 -0
  134. package/src/composables/useRsTableCore.ts +256 -0
  135. package/src/composables/useRsTableEditActions.ts +606 -0
  136. package/src/composables/useRsTableEditLayer.ts +251 -0
  137. package/src/composables/useRsTableEngine.ts +357 -0
  138. package/src/composables/useRsTableGridKeyboard.ts +123 -0
  139. package/src/composables/useRsTableHeadless.ts +137 -0
  140. package/src/composables/useRsTableInteraction.ts +433 -0
  141. package/src/composables/useRsTableScrollHost.ts +75 -0
  142. package/src/composables/useRsTableScrollLayout.ts +255 -0
  143. package/src/composables/useRsTableSelectionSource.ts +89 -0
  144. package/src/composables/useRsTableShell.ts +416 -0
  145. package/src/composables/useRsTableShellChrome.ts +170 -0
  146. package/src/composables/useRsTableSummary.ts +113 -0
  147. package/src/composables/useRsTableViewProvide.ts +263 -0
  148. package/src/composables/useRsTableVirtual.ts +154 -0
  149. package/src/composables/useRsTabsNav.ts +4 -0
  150. package/src/composables/useTableEdit.ts +1 -1
  151. package/src/icons/lucide.ts +2 -2
  152. package/src/index.ts +309 -9
  153. package/src/locale/messages.ts +30 -0
  154. package/src/styles.css +305 -52
  155. package/src/theme/brand.example.css +21 -2
  156. package/src/theme/presets.ts +4 -0
  157. package/src/theme/types.ts +67 -0
  158. package/src/components/RsTableColgroup.vue +0 -17
  159. package/src/components/RsTableHeaderRow.vue +0 -30
@@ -1,42 +1,72 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { mount } from '@vue/test-utils'
3
- import { h } from 'vue'
2
+ import { flushPromises, mount } from '@vue/test-utils'
3
+ import { h, nextTick } from 'vue'
4
4
  import RsMenu from '../components/RsMenu.vue'
5
5
  import RsConfigProvider from '../components/RsConfigProvider.vue'
6
- import type { RsMenuItemGroup } from '../components/menu-utils'
6
+ import type { RsMenuItem, RsMenuItemGroup } from '../components/menu-utils'
7
7
 
8
8
  describe('RsMenu', () => {
9
- const flatItems = [
10
- { key: 'dashboard', label: '仪表盘', icon: 'layout-dashboard' as const },
11
- { key: 'chat', label: '对话', icon: 'message-square' as const },
12
- { key: 'settings', label: '设置', icon: 'settings' as const },
9
+ const flatItems: RsMenuItem[] = [
10
+ { key: 'dashboard', label: '仪表盘', icon: 'layout-dashboard' },
11
+ { key: 'chat', label: '对话', icon: 'message-square' },
12
+ { key: 'settings', label: '设置', icon: 'settings' },
13
13
  ]
14
14
 
15
15
  const groupedItems: RsMenuItemGroup[] = [
16
16
  {
17
17
  label: '工作区',
18
18
  children: [
19
- { key: 'projects', label: '项目', icon: 'folder' as const },
20
- { key: 'docs', label: '文档', icon: 'search' as const },
19
+ { key: 'projects', label: '项目', icon: 'folder' },
20
+ { key: 'docs', label: '文档', icon: 'search' },
21
21
  ],
22
22
  },
23
23
  {
24
24
  label: '系统',
25
- children: [{ key: 'settings', label: '设置', icon: 'settings' as const }],
25
+ children: [{ key: 'settings', label: '设置', icon: 'settings' }],
26
26
  },
27
27
  ]
28
28
 
29
- const nestedItems = [
29
+ const nestedItems: RsMenuItem[] = [
30
30
  {
31
31
  key: 'workspace',
32
32
  label: '工作区',
33
- icon: 'folder' as const,
33
+ icon: 'folder',
34
34
  children: [
35
35
  { key: 'projects', label: '项目' },
36
36
  { key: 'archived', label: '归档', disabled: true },
37
37
  ],
38
38
  },
39
- { key: 'settings', label: '设置', icon: 'settings' as const },
39
+ { key: 'settings', label: '设置', icon: 'settings' },
40
+ ]
41
+
42
+ const deepNestedItems: RsMenuItem[] = [
43
+ {
44
+ key: 'workspace',
45
+ label: '工作区',
46
+ children: [
47
+ {
48
+ key: 'team',
49
+ label: '团队',
50
+ children: [
51
+ { key: 'members', label: '成员' },
52
+ { key: 'permissions', label: '权限' },
53
+ ],
54
+ },
55
+ ],
56
+ },
57
+ ]
58
+
59
+ const groupedNestedItems: RsMenuItemGroup[] = [
60
+ {
61
+ label: '产品',
62
+ children: [
63
+ {
64
+ key: 'product-line',
65
+ label: '产品线',
66
+ children: [{ key: 'saas-billing', label: '计费' }],
67
+ },
68
+ ],
69
+ },
40
70
  ]
41
71
 
42
72
  it('renders nav with menu aria-label', () => {
@@ -60,6 +90,82 @@ describe('RsMenu', () => {
60
90
  expect(wrapper.find('nav.rs-menu').attributes('aria-label')).toBe('Navigation menu')
61
91
  })
62
92
 
93
+ it('applies vertical mode class by default', () => {
94
+ const wrapper = mount(RsMenu, {
95
+ props: { items: flatItems, modelValue: 'dashboard' },
96
+ })
97
+ expect(wrapper.classes()).toContain('rs-menu--vertical')
98
+ })
99
+
100
+ it('applies horizontal mode class', () => {
101
+ const wrapper = mount(RsMenu, {
102
+ props: { items: flatItems, modelValue: 'dashboard', mode: 'horizontal' },
103
+ })
104
+ expect(wrapper.classes()).toContain('rs-menu--horizontal')
105
+ })
106
+
107
+ it('marks active leaf with aria-current and active class', () => {
108
+ const wrapper = mount(RsMenu, {
109
+ props: { items: flatItems, modelValue: 'chat' },
110
+ })
111
+ const active = wrapper.find('.rs-menu__item--active')
112
+ expect(active.exists()).toBe(true)
113
+ expect(active.attributes('aria-current')).toBe('page')
114
+ expect(active.text()).toContain('对话')
115
+ })
116
+
117
+ it('does not highlight parent by default when nested leaf is active', () => {
118
+ const wrapper = mount(RsMenu, {
119
+ props: {
120
+ items: nestedItems,
121
+ modelValue: 'projects',
122
+ openKeys: ['workspace'],
123
+ },
124
+ })
125
+ const actives = wrapper.findAll('.rs-menu__item--active')
126
+ expect(actives).toHaveLength(1)
127
+ expect(actives[0]!.text()).toContain('项目')
128
+ expect(actives[0]!.attributes('aria-current')).toBe('page')
129
+ })
130
+
131
+ it('highlights ancestor submenu triggers when highlightParent is true', () => {
132
+ const wrapper = mount(RsMenu, {
133
+ props: {
134
+ items: nestedItems,
135
+ modelValue: 'projects',
136
+ openKeys: ['workspace'],
137
+ highlightParent: true,
138
+ },
139
+ })
140
+ const leaf = wrapper.findAll('.rs-menu__item--active').find((n) => n.text().includes('项目'))
141
+ expect(leaf).toBeDefined()
142
+ expect(leaf?.attributes('aria-current')).toBe('page')
143
+ const parent = wrapper
144
+ .findAll('.rs-menu__item--active-parent')
145
+ .find((n) => n.text().includes('工作区'))
146
+ expect(parent).toBeDefined()
147
+ expect(parent?.attributes('aria-current')).toBeUndefined()
148
+ expect(parent!.classes()).not.toContain('rs-menu__item--active')
149
+ })
150
+
151
+ it('highlights collapsed parent icon when highlightParent is true', () => {
152
+ const wrapper = mount(RsMenu, {
153
+ props: {
154
+ items: nestedItems,
155
+ modelValue: 'projects',
156
+ openKeys: [],
157
+ collapsed: true,
158
+ highlightParent: true,
159
+ },
160
+ })
161
+ const parent = wrapper
162
+ .findAll('.rs-menu__item--submenu-trigger')
163
+ .find((btn) => btn.attributes('aria-label') === '工作区')
164
+ expect(parent).toBeDefined()
165
+ expect(parent!.classes()).toContain('rs-menu__item--active-parent')
166
+ expect(parent!.classes()).not.toContain('rs-menu__item--active')
167
+ })
168
+
63
169
  it('emits select and updates model when item clicked', async () => {
64
170
  const wrapper = mount(RsMenu, {
65
171
  props: { items: flatItems, modelValue: 'dashboard' },
@@ -78,6 +184,10 @@ describe('RsMenu', () => {
78
184
  props: { items: groupedItems, modelValue: 'projects' },
79
185
  })
80
186
  expect(wrapper.find('.rs-menu__group-label').text()).toBe('工作区')
187
+ expect(wrapper.findAll('.rs-menu__group-label').map((n) => n.text())).toEqual([
188
+ '工作区',
189
+ '系统',
190
+ ])
81
191
  })
82
192
 
83
193
  it('auto-expands parent submenu for active nested item', () => {
@@ -91,6 +201,76 @@ describe('RsMenu', () => {
91
201
  expect(wrapper.emitted('update:openKeys')?.pop()?.[0]).toContain('workspace')
92
202
  })
93
203
 
204
+ it('auto-expands all ancestor keys for deeply nested active item', () => {
205
+ const wrapper = mount(RsMenu, {
206
+ props: {
207
+ items: deepNestedItems,
208
+ modelValue: 'permissions',
209
+ openKeys: [],
210
+ },
211
+ })
212
+ const keys = wrapper.emitted('update:openKeys')?.pop()?.[0] as string[]
213
+ expect(keys).toEqual(expect.arrayContaining(['workspace', 'team']))
214
+ })
215
+
216
+ it('auto-expands ancestors inside grouped nested items', () => {
217
+ const wrapper = mount(RsMenu, {
218
+ props: {
219
+ items: groupedNestedItems,
220
+ modelValue: 'saas-billing',
221
+ openKeys: [],
222
+ },
223
+ })
224
+ expect(wrapper.emitted('update:openKeys')?.pop()?.[0]).toContain('product-line')
225
+ })
226
+
227
+ it('toggles openKeys when submenu trigger is clicked', async () => {
228
+ const wrapper = mount(RsMenu, {
229
+ props: {
230
+ items: nestedItems,
231
+ modelValue: 'settings',
232
+ openKeys: [],
233
+ },
234
+ })
235
+ const trigger = wrapper.find('.rs-menu__item--submenu-trigger')
236
+ expect(trigger.exists()).toBe(true)
237
+ await trigger.trigger('click')
238
+ await nextTick()
239
+ expect(wrapper.emitted('update:openKeys')?.pop()?.[0]).toContain('workspace')
240
+ })
241
+
242
+ it('closes submenu when open trigger is clicked again', async () => {
243
+ const wrapper = mount(RsMenu, {
244
+ props: {
245
+ items: nestedItems,
246
+ modelValue: 'settings',
247
+ openKeys: ['workspace'],
248
+ },
249
+ })
250
+ const trigger = wrapper.find('.rs-menu__item--submenu-trigger')
251
+ await trigger.trigger('click')
252
+ await nextTick()
253
+ const keys = wrapper.emitted('update:openKeys')?.pop()?.[0] as string[]
254
+ expect(keys).not.toContain('workspace')
255
+ })
256
+
257
+ it('selects nested leaf under open submenu', async () => {
258
+ const wrapper = mount(RsMenu, {
259
+ props: {
260
+ items: nestedItems,
261
+ modelValue: 'settings',
262
+ openKeys: ['workspace'],
263
+ },
264
+ })
265
+ const projects = wrapper
266
+ .findAll('.rs-menu__item')
267
+ .find((btn) => btn.text().includes('项目'))
268
+ expect(projects).toBeDefined()
269
+ await projects!.trigger('click')
270
+ expect(wrapper.emitted('update:modelValue')?.pop()).toEqual(['projects'])
271
+ expect(wrapper.emitted('select')?.[0]).toEqual(['projects'])
272
+ })
273
+
94
274
  it('does not emit select for disabled item', async () => {
95
275
  const wrapper = mount(RsMenu, {
96
276
  props: {
@@ -105,7 +285,76 @@ describe('RsMenu', () => {
105
285
  expect(wrapper.emitted('select')).toBeUndefined()
106
286
  })
107
287
 
108
- it('does not render nested submenu list when collapsed', () => {
288
+ it('renders nested submenu as popup list in horizontal mode', () => {
289
+ const wrapper = mount(RsMenu, {
290
+ props: {
291
+ items: nestedItems,
292
+ modelValue: 'projects',
293
+ openKeys: ['workspace'],
294
+ mode: 'horizontal',
295
+ },
296
+ })
297
+ expect(wrapper.classes()).toContain('rs-menu--horizontal')
298
+ expect(wrapper.find('.rs-menu__list--nested').exists()).toBe(true)
299
+ expect(wrapper.find('.rs-menu__entry--submenu').exists()).toBe(true)
300
+ })
301
+
302
+ it('applies collapsed class and hides labels', () => {
303
+ const wrapper = mount(RsMenu, {
304
+ props: {
305
+ items: flatItems,
306
+ modelValue: 'dashboard',
307
+ collapsed: true,
308
+ },
309
+ })
310
+ expect(wrapper.classes()).toContain('rs-menu--collapsed')
311
+ expect(wrapper.find('.rs-menu__label').exists()).toBe(false)
312
+ })
313
+
314
+ it('hides group labels when collapsed', () => {
315
+ const wrapper = mount(RsMenu, {
316
+ props: {
317
+ items: groupedItems,
318
+ modelValue: 'projects',
319
+ collapsed: true,
320
+ },
321
+ })
322
+ expect(wrapper.find('.rs-menu__group-label').exists()).toBe(false)
323
+ })
324
+
325
+ it('sets title and aria-label on items when collapsed', () => {
326
+ const wrapper = mount(RsMenu, {
327
+ props: {
328
+ items: flatItems,
329
+ modelValue: 'dashboard',
330
+ collapsed: true,
331
+ },
332
+ })
333
+ const settings = wrapper
334
+ .findAll('.rs-menu__item')
335
+ .find((btn) => btn.attributes('title') === '设置')
336
+ expect(settings).toBeDefined()
337
+ expect(settings!.attributes('aria-label')).toBe('设置')
338
+ })
339
+
340
+ it('still selects leaf items when collapsed', async () => {
341
+ const wrapper = mount(RsMenu, {
342
+ props: {
343
+ items: flatItems,
344
+ modelValue: 'dashboard',
345
+ collapsed: true,
346
+ },
347
+ })
348
+ const chat = wrapper
349
+ .findAll('.rs-menu__item')
350
+ .find((btn) => btn.attributes('title') === '对话')
351
+ expect(chat).toBeDefined()
352
+ await chat!.trigger('click')
353
+ expect(wrapper.emitted('update:modelValue')?.pop()).toEqual(['chat'])
354
+ expect(wrapper.emitted('select')?.[0]).toEqual(['chat'])
355
+ })
356
+
357
+ it('does not render inline nested submenu list when collapsed', () => {
109
358
  const wrapper = mount(RsMenu, {
110
359
  props: {
111
360
  items: nestedItems,
@@ -113,7 +362,150 @@ describe('RsMenu', () => {
113
362
  openKeys: ['workspace'],
114
363
  collapsed: true,
115
364
  },
365
+ attachTo: document.body,
116
366
  })
117
367
  expect(wrapper.find('.rs-menu__list--nested').exists()).toBe(false)
368
+ expect(wrapper.find('.rs-menu__item--submenu-trigger').exists()).toBe(true)
369
+ expect(document.body.querySelector('.rs-menu__flyout')).toBeNull()
370
+ wrapper.unmount()
371
+ })
372
+
373
+ it('shows flyout submenu on hover when collapsed vertically', async () => {
374
+ const wrapper = mount(RsMenu, {
375
+ props: {
376
+ items: nestedItems,
377
+ modelValue: 'settings',
378
+ openKeys: [],
379
+ collapsed: true,
380
+ },
381
+ attachTo: document.body,
382
+ })
383
+ const parent = wrapper
384
+ .findAll('.rs-menu__item')
385
+ .find((btn) => btn.attributes('aria-label') === '工作区')
386
+ expect(parent).toBeDefined()
387
+
388
+ await parent!.trigger('mouseenter')
389
+ await nextTick()
390
+ await flushPromises()
391
+
392
+ const flyout = document.body.querySelector('.rs-menu__flyout')
393
+ expect(flyout).not.toBeNull()
394
+ expect(flyout?.textContent).toContain('工作区')
395
+ expect(flyout?.textContent).toContain('项目')
396
+ expect(wrapper.emitted('update:openKeys')).toBeUndefined()
397
+ // 折叠态父级点击不选中父 key,仅用于打开浮层
398
+ await parent!.trigger('click')
399
+ await nextTick()
400
+ expect(wrapper.emitted('select')).toBeUndefined()
401
+ wrapper.unmount()
402
+ })
403
+
404
+ it('selects nested leaf from collapsed flyout', async () => {
405
+ const wrapper = mount(RsMenu, {
406
+ props: {
407
+ items: nestedItems,
408
+ modelValue: 'settings',
409
+ openKeys: [],
410
+ collapsed: true,
411
+ },
412
+ attachTo: document.body,
413
+ })
414
+ const parent = wrapper
415
+ .findAll('.rs-menu__item')
416
+ .find((btn) => btn.attributes('aria-label') === '工作区')
417
+ expect(parent).toBeDefined()
418
+ await parent!.trigger('mouseenter')
419
+ await nextTick()
420
+ await flushPromises()
421
+
422
+ const projects = Array.from(
423
+ document.body.querySelectorAll('.rs-menu__flyout .rs-menu__item'),
424
+ ).find((el) => el.textContent?.includes('项目')) as HTMLButtonElement | undefined
425
+ expect(projects).toBeDefined()
426
+ projects!.click()
427
+ await nextTick()
428
+ await flushPromises()
429
+
430
+ expect(wrapper.emitted('update:modelValue')?.pop()).toEqual(['projects'])
431
+ expect(wrapper.emitted('select')?.[0]).toEqual(['projects'])
432
+ expect(document.body.querySelector('.rs-menu__flyout')).toBeNull()
433
+ wrapper.unmount()
434
+ })
435
+
436
+ it('renders icons on collapsed triggers and flyout children', async () => {
437
+ const iconNestedItems: RsMenuItem[] = [
438
+ {
439
+ key: 'workspace',
440
+ label: '工作区',
441
+ icon: 'folder',
442
+ children: [
443
+ { key: 'projects', label: '项目', icon: 'box' },
444
+ { key: 'docs', label: '文档', icon: 'file-text' },
445
+ ],
446
+ },
447
+ { key: 'settings', label: '设置', icon: 'settings' },
448
+ ]
449
+ const wrapper = mount(RsMenu, {
450
+ props: {
451
+ items: iconNestedItems,
452
+ modelValue: 'settings',
453
+ openKeys: [],
454
+ collapsed: true,
455
+ },
456
+ attachTo: document.body,
457
+ })
458
+
459
+ const triggers = wrapper.findAll('.rs-menu__item')
460
+ expect(triggers.every((btn) => btn.find('.rs-menu__icon').exists())).toBe(true)
461
+
462
+ const parent = triggers.find((btn) => btn.attributes('aria-label') === '工作区')
463
+ expect(parent).toBeDefined()
464
+ await parent!.trigger('mouseenter')
465
+ await nextTick()
466
+ await flushPromises()
467
+
468
+ const flyout = document.body.querySelector('.rs-menu__flyout')
469
+ expect(flyout).not.toBeNull()
470
+ expect(flyout!.querySelectorAll('.rs-menu__icon').length).toBeGreaterThanOrEqual(2)
471
+ expect(flyout!.textContent).toContain('项目')
472
+ expect(flyout!.textContent).toContain('文档')
473
+ wrapper.unmount()
474
+ })
475
+
476
+ it('keeps horizontal submenu expandable even when collapsed prop is true', async () => {
477
+ const wrapper = mount(RsMenu, {
478
+ props: {
479
+ items: nestedItems,
480
+ modelValue: 'settings',
481
+ openKeys: [],
482
+ mode: 'horizontal',
483
+ collapsed: true,
484
+ },
485
+ })
486
+ // 水平模式忽略 collapsed:仍显示文案,不挂 rs-menu--collapsed
487
+ expect(wrapper.classes()).not.toContain('rs-menu--collapsed')
488
+ expect(wrapper.text()).toContain('工作区')
489
+ const trigger = wrapper.find('.rs-menu__item--submenu-trigger')
490
+ expect(trigger.exists()).toBe(true)
491
+ await trigger.trigger('click')
492
+ await nextTick()
493
+ expect(wrapper.emitted('update:openKeys')?.pop()?.[0]).toContain('workspace')
494
+ // 子菜单内容可见,而非空气泡
495
+ expect(wrapper.find('.rs-menu__list--nested').exists()).toBe(true)
496
+ expect(wrapper.text()).toContain('项目')
497
+ })
498
+
499
+ it('does not hide horizontal labels when collapsed is true', () => {
500
+ const wrapper = mount(RsMenu, {
501
+ props: {
502
+ items: nestedItems,
503
+ modelValue: 'settings',
504
+ mode: 'horizontal',
505
+ collapsed: true,
506
+ },
507
+ })
508
+ expect(wrapper.find('.rs-menu__label').exists()).toBe(true)
509
+ expect(wrapper.find('.rs-menu__submenu-arrow').exists()).toBe(true)
118
510
  })
119
511
  })
@@ -131,7 +131,13 @@ describe('RsPagination', () => {
131
131
 
132
132
  it('jumps to page when confirm is clicked', async () => {
133
133
  const wrapper = mount(RsPagination, {
134
- props: { total: 100, page: 1, pageSize: 10, showQuickJumper: true },
134
+ props: {
135
+ total: 100,
136
+ page: 1,
137
+ pageSize: 10,
138
+ showQuickJumper: true,
139
+ showJumpConfirm: true,
140
+ },
135
141
  })
136
142
  const input = wrapper.find('.rs-pagination__jumper-input input')
137
143
  await input.setValue('7')
@@ -143,9 +149,68 @@ describe('RsPagination', () => {
143
149
  expect(wrapper.emitted('update:page')?.pop()).toEqual([7])
144
150
  })
145
151
 
152
+ it('hides jump confirm button by default', () => {
153
+ const wrapper = mount(RsPagination, {
154
+ props: {
155
+ total: 100,
156
+ page: 1,
157
+ pageSize: 10,
158
+ showQuickJumper: true,
159
+ },
160
+ })
161
+ const jumperBtn = wrapper.findAll('button').find((btn) => btn.text() === '确定')
162
+ expect(jumperBtn).toBeUndefined()
163
+ })
164
+
165
+ it('hides jump confirm button when showJumpConfirm is false', () => {
166
+ const wrapper = mount(RsPagination, {
167
+ props: {
168
+ total: 100,
169
+ page: 1,
170
+ pageSize: 10,
171
+ showQuickJumper: true,
172
+ showJumpConfirm: false,
173
+ },
174
+ })
175
+ const jumperBtn = wrapper.findAll('button').find((btn) => btn.text() === '确定')
176
+ expect(jumperBtn).toBeUndefined()
177
+ })
178
+
179
+ it('jumps to page on enter when confirm is hidden', async () => {
180
+ const wrapper = mount(RsPagination, {
181
+ props: {
182
+ total: 100,
183
+ page: 1,
184
+ pageSize: 10,
185
+ showQuickJumper: true,
186
+ showJumpConfirm: false,
187
+ },
188
+ })
189
+ const input = wrapper.find('.rs-pagination__jumper-input input')
190
+ await input.setValue('4')
191
+ // RsInput 对 Enter 发 pressEnter;需带 key 才能命中 onKeydown
192
+ await input.trigger('keydown', { key: 'Enter' })
193
+ expect(wrapper.emitted('update:page')?.pop()).toEqual([4])
194
+ })
195
+
196
+ it('applies size class for sm / md / lg', () => {
197
+ for (const size of ['sm', 'md', 'lg'] as const) {
198
+ const wrapper = mount(RsPagination, {
199
+ props: { total: 40, page: 1, pageSize: 10, size },
200
+ })
201
+ expect(wrapper.find('nav').classes()).toContain(`rs-pagination--${size}`)
202
+ }
203
+ })
204
+
146
205
  it('clamps jump target to valid page range', async () => {
147
206
  const wrapper = mount(RsPagination, {
148
- props: { total: 100, page: 1, pageSize: 10, showQuickJumper: true },
207
+ props: {
208
+ total: 100,
209
+ page: 1,
210
+ pageSize: 10,
211
+ showQuickJumper: true,
212
+ showJumpConfirm: true,
213
+ },
149
214
  })
150
215
  const input = wrapper.find('.rs-pagination__jumper-input input')
151
216
  await input.setValue('99')
@@ -4,7 +4,12 @@ import { defineComponent, h, ref } from 'vue'
4
4
  import RsConfigProvider from '../components/RsConfigProvider.vue'
5
5
  import RsForm from '../components/RsForm.vue'
6
6
  import RsSelect from '../components/RsSelect.vue'
7
- import { filterSelectOptions } from '../components/select-utils'
7
+ import {
8
+ filterSelectOptions,
9
+ fromComboboxValue,
10
+ toComboboxValue,
11
+ RS_SELECT_EMPTY_VALUE,
12
+ } from '../components/select-utils'
8
13
 
9
14
  describe('RsSelect', () => {
10
15
  const options = [
@@ -91,6 +96,35 @@ describe('RsSelect', () => {
91
96
  expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
92
97
  })
93
98
 
99
+ it('maps empty option value through sentinel without crashing', async () => {
100
+ const withEmpty = [
101
+ { label: '空串项', value: '' },
102
+ { label: 'Claude', value: 'claude' },
103
+ ]
104
+ const wrapper = mount(RsSelect, {
105
+ props: { options: withEmpty, modelValue: '', clearable: true },
106
+ attachTo: document.body,
107
+ })
108
+ expect(wrapper.find('.rs-select__placeholder').exists()).toBe(true)
109
+ await wrapper.find('.rs-select__trigger').trigger('click')
110
+ await flushPromises()
111
+ expect(document.body.textContent).toContain('空串项')
112
+ wrapper.unmount()
113
+ })
114
+
115
+ it('selecting empty sentinel emits empty string', async () => {
116
+ const withEmpty = [
117
+ { label: '空串项', value: '' },
118
+ { label: 'Claude', value: 'claude' },
119
+ ]
120
+ const wrapper = mount(RsSelect, {
121
+ props: { options: withEmpty, modelValue: 'claude', clearable: true },
122
+ })
123
+ const root = wrapper.getComponent({ name: 'ComboboxRoot' })
124
+ await root.vm.$emit('update:modelValue', '__rs_select_empty__')
125
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
126
+ })
127
+
94
128
  it('supports multiple mode tags', () => {
95
129
  const wrapper = mount(RsSelect, {
96
130
  props: {
@@ -266,6 +300,13 @@ describe('select-utils', () => {
266
300
  const contains = (text: string, search: string) =>
267
301
  text.toLowerCase().includes(search.toLowerCase())
268
302
 
303
+ it('maps empty string to combobox sentinel and back', () => {
304
+ expect(toComboboxValue('')).toBe(RS_SELECT_EMPTY_VALUE)
305
+ expect(toComboboxValue('claude')).toBe('claude')
306
+ expect(fromComboboxValue(RS_SELECT_EMPTY_VALUE)).toBe('')
307
+ expect(fromComboboxValue('claude')).toBe('claude')
308
+ })
309
+
269
310
  it('filterSelectOptions filters flat and grouped options', () => {
270
311
  const grouped = [
271
312
  {
@@ -0,0 +1,44 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { defineComponent, nextTick } from 'vue'
4
+ import RsSwitch from '../components/RsSwitch.vue'
5
+
6
+ describe('RsSwitch', () => {
7
+ it('toggles v-model on click', async () => {
8
+ const Host = defineComponent({
9
+ components: { RsSwitch },
10
+ data: () => ({ on: false }),
11
+ template: '<RsSwitch v-model="on" />',
12
+ })
13
+ const wrapper = mount(Host)
14
+
15
+ await wrapper.find('.rs-switch__root').trigger('click')
16
+ await nextTick()
17
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(true)
18
+ expect((wrapper.vm as { on: boolean }).on).toBe(true)
19
+
20
+ await wrapper.find('.rs-switch__root').trigger('click')
21
+ await nextTick()
22
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
23
+ expect((wrapper.vm as { on: boolean }).on).toBe(false)
24
+ })
25
+
26
+ it('emits change when toggled', async () => {
27
+ const wrapper = mount(RsSwitch, {
28
+ props: { modelValue: false },
29
+ })
30
+
31
+ await wrapper.find('.rs-switch__root').trigger('click')
32
+ expect(wrapper.emitted('change')?.[0]).toEqual([true])
33
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([true])
34
+ })
35
+
36
+ it('does not toggle when disabled', async () => {
37
+ const wrapper = mount(RsSwitch, {
38
+ props: { modelValue: false, disabled: true },
39
+ })
40
+
41
+ await wrapper.find('.rs-switch__root').trigger('click')
42
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
43
+ })
44
+ })