niuma-ui 1.0.1 → 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 (190) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/package.json +10 -2
  3. package/src/__tests__/RsButton.spec.ts +47 -0
  4. package/src/__tests__/RsCard.spec.ts +160 -1
  5. package/src/__tests__/RsConfirmDialog.spec.ts +127 -0
  6. package/src/__tests__/RsContextMenu.spec.ts +12 -0
  7. package/src/__tests__/RsDatePicker.spec.ts +21 -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 +178 -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 +250 -6
  14. package/src/__tests__/RsFormItem.spec.ts +478 -0
  15. package/src/__tests__/RsFormList.spec.ts +109 -0
  16. package/src/__tests__/RsInput.spec.ts +108 -1
  17. package/src/__tests__/RsInputNumber.spec.ts +33 -0
  18. package/src/__tests__/RsMenu.spec.ts +406 -14
  19. package/src/__tests__/RsPagination.spec.ts +67 -2
  20. package/src/__tests__/RsSelect.spec.ts +465 -2
  21. package/src/__tests__/RsSwitch.spec.ts +74 -0
  22. package/src/__tests__/RsTable.a11y.spec.ts +83 -0
  23. package/src/__tests__/RsTable.dom-baseline.spec.ts +87 -0
  24. package/src/__tests__/RsTable.perf.spec.ts +23 -4
  25. package/src/__tests__/RsTable.spec.ts +63 -8
  26. package/src/__tests__/RsTable.ssr.spec.ts +71 -0
  27. package/src/__tests__/RsTable.summary.spec.ts +45 -0
  28. package/src/__tests__/RsTabs.spec.ts +102 -4
  29. package/src/__tests__/RsTimePicker.spec.ts +20 -0
  30. package/src/__tests__/RsTooltip.spec.ts +43 -0
  31. package/src/__tests__/RsUpload.spec.ts +62 -6
  32. package/src/__tests__/createRsDialog.spec.ts +145 -0
  33. package/src/__tests__/css-token.spec.ts +51 -0
  34. package/src/__tests__/date-picker-utils.spec.ts +49 -0
  35. package/src/__tests__/dialog-utils.spec.ts +43 -0
  36. package/src/__tests__/form-path.spec.ts +46 -0
  37. package/src/__tests__/form-rules.spec.ts +139 -0
  38. package/src/__tests__/perf-budgets.ts +32 -0
  39. package/src/__tests__/rs-table-api.spec.ts +206 -0
  40. package/src/__tests__/rs-table-compat-matrix.spec.ts +108 -0
  41. package/src/__tests__/rs-table-echarts-adapter.spec.ts +43 -0
  42. package/src/__tests__/rs-table-grid-nav.spec.ts +63 -0
  43. package/src/__tests__/rs-table-isolation.spec.ts +158 -0
  44. package/src/__tests__/split-pane-utils.spec.ts +21 -0
  45. package/src/__tests__/table-chart-utils.spec.ts +88 -0
  46. package/src/__tests__/table-summary-utils.spec.ts +62 -0
  47. package/src/__tests__/table-utils.spec.ts +124 -0
  48. package/src/__tests__/theme.spec.ts +27 -0
  49. package/src/__tests__/useRsTableColumnResize.spec.ts +25 -0
  50. package/src/__tests__/useRsTableCore.perf.spec.ts +170 -0
  51. package/src/components/RsAlert.vue +212 -0
  52. package/src/components/RsAvatar.vue +1 -1
  53. package/src/components/RsBadge.vue +1 -1
  54. package/src/components/RsBreadcrumb.vue +1 -1
  55. package/src/components/RsButton.vue +147 -23
  56. package/src/components/RsCalendarGrid.vue +3 -3
  57. package/src/components/RsCard.vue +204 -20
  58. package/src/components/RsCodeBlock.vue +4 -4
  59. package/src/components/RsCodeEditor.vue +8 -8
  60. package/src/components/RsConfirmDialog.vue +217 -30
  61. package/src/components/RsContextMenu.vue +57 -17
  62. package/src/components/RsDatePicker.vue +316 -39
  63. package/src/components/RsDateTimePicker.vue +27 -2
  64. package/src/components/RsDescriptions.vue +186 -0
  65. package/src/components/RsDescriptionsItem.vue +32 -0
  66. package/src/components/RsDialog.vue +374 -52
  67. package/src/components/RsDivider.vue +89 -0
  68. package/src/components/RsDrawer.vue +559 -34
  69. package/src/components/RsDropdown.vue +34 -3
  70. package/src/components/RsDynamicTags.vue +531 -0
  71. package/src/components/RsEmpty.vue +1 -1
  72. package/src/components/RsForm.vue +164 -8
  73. package/src/components/RsFormItem.vue +423 -0
  74. package/src/components/RsFormItemControl.vue +21 -0
  75. package/src/components/RsFormList.vue +117 -0
  76. package/src/components/RsInput.vue +491 -100
  77. package/src/components/RsInputNumber.vue +77 -27
  78. package/src/components/RsLabel.vue +20 -5
  79. package/src/components/RsLink.vue +1 -1
  80. package/src/components/RsLoadingBar.vue +151 -0
  81. package/src/components/RsMarkdown.vue +5 -5
  82. package/src/components/RsMenu.vue +74 -16
  83. package/src/components/RsMenuItems.vue +147 -9
  84. package/src/components/RsMonacoEditor.vue +9 -3
  85. package/src/components/RsPagination.vue +93 -14
  86. package/src/components/RsProseEditor.vue +3 -3
  87. package/src/components/RsRadio.vue +81 -0
  88. package/src/components/RsRadioItem.vue +156 -0
  89. package/src/components/RsSelect.vue +372 -642
  90. package/src/components/RsSidebarGroup.vue +1 -1
  91. package/src/components/RsSplitPane.vue +90 -1
  92. package/src/components/RsStatCard.vue +2 -2
  93. package/src/components/RsSteps.vue +2 -2
  94. package/src/components/RsSwitch.vue +192 -0
  95. package/src/components/RsTable.vue +653 -2555
  96. package/src/components/RsTabs.vue +422 -90
  97. package/src/components/RsTag.vue +191 -0
  98. package/src/components/RsTerminal.vue +58 -19
  99. package/src/components/RsTimePicker.vue +72 -9
  100. package/src/components/RsTimePickerColumns.vue +137 -78
  101. package/src/components/RsToaster.vue +64 -24
  102. package/src/components/RsTooltip.vue +10 -1
  103. package/src/components/RsTooltipProvider.vue +11 -1
  104. package/src/components/RsTree.vue +45 -27
  105. package/src/components/RsUpload.vue +147 -14
  106. package/src/components/RsVNodeHost.vue +20 -0
  107. package/src/components/button-utils.ts +39 -0
  108. package/src/components/context-menu-utils.ts +6 -0
  109. package/src/components/date-picker-utils.ts +195 -4
  110. package/src/components/descriptions-utils.ts +24 -0
  111. package/src/components/dialog-utils.ts +182 -0
  112. package/src/components/dialog-window.ts +74 -9
  113. package/src/components/drawer-utils.ts +73 -0
  114. package/src/components/form-item-control.ts +55 -0
  115. package/src/components/form-path.ts +92 -0
  116. package/src/components/form-rules.ts +311 -0
  117. package/src/components/form-utils.ts +145 -3
  118. package/src/components/menu-utils.ts +11 -0
  119. package/src/components/overlay-utils.ts +3 -0
  120. package/src/components/radio-utils.ts +12 -0
  121. package/src/components/reka.ts +5 -0
  122. package/src/components/select-utils.ts +285 -6
  123. package/src/components/split-pane-utils.ts +65 -12
  124. package/src/components/table/RsTableBody.vue +217 -0
  125. package/src/components/table/RsTableBodyRow.vue +90 -1
  126. package/src/components/table/RsTableColGroup.vue +41 -0
  127. package/src/components/table/RsTableHeader.vue +158 -0
  128. package/src/components/table/RsTableHeaderFilter.vue +1 -1
  129. package/src/components/table/RsTableSummaryRow.vue +52 -0
  130. package/src/components/table/rs-table-api.ts +99 -0
  131. package/src/components/table/rs-table-compat-matrix.ts +71 -0
  132. package/src/components/table/rs-table-echarts-adapter.ts +81 -0
  133. package/src/components/table/rs-table-feature-host.ts +178 -0
  134. package/src/components/table/rs-table-grid-nav.ts +122 -0
  135. package/src/components/table/rs-table-header-types.ts +47 -0
  136. package/src/components/table/rs-table-module-registry.ts +72 -0
  137. package/src/components/table/rs-table-props.ts +274 -0
  138. package/src/components/table/rs-table-view-context.ts +258 -0
  139. package/src/components/table/rs-table.css +85 -7
  140. package/src/components/table/table-chart-utils.ts +165 -0
  141. package/src/components/table/table-features.ts +284 -0
  142. package/src/components/table/table-summary-utils.ts +194 -0
  143. package/src/components/table-utils.ts +452 -2
  144. package/src/components/tabs-utils.ts +142 -2
  145. package/src/components/time-picker-utils.ts +76 -3
  146. package/src/components/upload-utils.ts +122 -0
  147. package/src/components/use-rs-select.ts +436 -0
  148. package/src/components/virtual-list-utils.ts +15 -2
  149. package/src/composables/assembleRsTableApi.ts +79 -0
  150. package/src/composables/bindRsTableViewContext.ts +269 -0
  151. package/src/composables/createRsDialog.ts +357 -0
  152. package/src/composables/createRsTableEditEmitBridge.ts +38 -0
  153. package/src/composables/useRsConfig.ts +10 -3
  154. package/src/composables/useRsI18n.ts +16 -5
  155. package/src/composables/useRsLoadingBar.ts +36 -0
  156. package/src/composables/useRsTable.ts +134 -0
  157. package/src/composables/useRsTableA11y.ts +52 -0
  158. package/src/composables/useRsTableChartBridge.ts +79 -0
  159. package/src/composables/useRsTableColumnLayout.ts +320 -0
  160. package/src/composables/useRsTableColumnResize.ts +244 -0
  161. package/src/composables/useRsTableColumnVirtual.ts +180 -0
  162. package/src/composables/useRsTableColumns.ts +200 -0
  163. package/src/composables/useRsTableContextMenu.ts +257 -0
  164. package/src/composables/useRsTableCore.ts +256 -0
  165. package/src/composables/useRsTableEditActions.ts +606 -0
  166. package/src/composables/useRsTableEditLayer.ts +251 -0
  167. package/src/composables/useRsTableEngine.ts +357 -0
  168. package/src/composables/useRsTableGridKeyboard.ts +123 -0
  169. package/src/composables/useRsTableHeadless.ts +137 -0
  170. package/src/composables/useRsTableInteraction.ts +433 -0
  171. package/src/composables/useRsTableScrollHost.ts +75 -0
  172. package/src/composables/useRsTableScrollLayout.ts +255 -0
  173. package/src/composables/useRsTableSelectionSource.ts +89 -0
  174. package/src/composables/useRsTableShell.ts +416 -0
  175. package/src/composables/useRsTableShellChrome.ts +170 -0
  176. package/src/composables/useRsTableSummary.ts +113 -0
  177. package/src/composables/useRsTableViewProvide.ts +263 -0
  178. package/src/composables/useRsTableVirtual.ts +154 -0
  179. package/src/composables/useRsTabsNav.ts +4 -0
  180. package/src/composables/useTableEdit.ts +1 -1
  181. package/src/index.ts +378 -10
  182. package/src/locale/interpolate.ts +32 -0
  183. package/src/locale/messages.ts +40 -0
  184. package/src/styles.css +365 -56
  185. package/src/theme/brand.example.css +24 -2
  186. package/src/theme/css-token.ts +107 -0
  187. package/src/theme/presets.ts +4 -0
  188. package/src/theme/types.ts +68 -0
  189. package/src/components/RsTableColgroup.vue +0 -17
  190. package/src/components/RsTableHeaderRow.vue +0 -30
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { mount } from '@vue/test-utils'
2
+ import { flushPromises, mount } from '@vue/test-utils'
3
3
  import { h } from 'vue'
4
4
  import RsConfigProvider from '../components/RsConfigProvider.vue'
5
5
  import RsInput from '../components/RsInput.vue'
@@ -35,6 +35,16 @@ describe('RsInput', () => {
35
35
  expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['world'])
36
36
  })
37
37
 
38
+ it('defaults autocomplete to off (new-password for password)', () => {
39
+ const text = mount(RsInput, { props: { modelValue: '' } })
40
+ expect(text.find('input').attributes('autocomplete')).toBe('off')
41
+ text.unmount()
42
+
43
+ const password = mount(RsInput, { props: { modelValue: '', type: 'password' } })
44
+ expect(password.find('input').attributes('autocomplete')).toBe('new-password')
45
+ password.unmount()
46
+ })
47
+
38
48
  it('marks invalid state on group', () => {
39
49
  const wrapper = mount(RsInput, { props: { invalid: true } })
40
50
  expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--invalid')
@@ -154,6 +164,7 @@ describe('RsInput', () => {
154
164
  props: { modelValue: 'bad-email', rule: 'email', id: 'e1' },
155
165
  })
156
166
  await wrapper.find('input').trigger('blur')
167
+ await flushPromises()
157
168
  expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--invalid')
158
169
  expect(wrapper.find('.rs-input-field__error').text()).toContain('邮箱')
159
170
  expect(wrapper.emitted('validate')?.[0]?.[0]).toEqual({
@@ -167,6 +178,7 @@ describe('RsInput', () => {
167
178
  props: { modelValue: 'user@example.com', rule: 'email' },
168
179
  })
169
180
  await wrapper.find('input').trigger('blur')
181
+ await flushPromises()
170
182
  expect(wrapper.find('.rs-input-group').classes()).not.toContain('rs-input-group--invalid')
171
183
  expect(wrapper.emitted('validate')?.[0]?.[0]).toEqual({ valid: true })
172
184
  })
@@ -179,6 +191,7 @@ describe('RsInput', () => {
179
191
  },
180
192
  })
181
193
  await wrapper.find('input').trigger('blur')
194
+ await flushPromises()
182
195
  expect(wrapper.find('.rs-input-field__error').text()).toBe('至少 3 个字符')
183
196
  })
184
197
 
@@ -196,6 +209,7 @@ describe('RsInput', () => {
196
209
  },
197
210
  })
198
211
  await wrapper.find('input').trigger('blur')
212
+ await flushPromises()
199
213
  expect(wrapper.find('.rs-input-field__error').text()).toContain('email')
200
214
  })
201
215
 
@@ -266,4 +280,97 @@ describe('RsInput', () => {
266
280
  })
267
281
  expect((wrapper.find('input').element as HTMLInputElement).value).toBe('256')
268
282
  })
283
+
284
+ it('places clear before custom suffix inside the affix', () => {
285
+ const wrapper = mount(RsInput, {
286
+ props: { modelValue: 'hub', clearable: true },
287
+ slots: { suffix: () => '选' },
288
+ })
289
+ const affix = wrapper.find('.rs-input-group__affix--suffix')
290
+ const children = [...affix.element.children] as HTMLElement[]
291
+ expect(children.map((el) => el.className)).toEqual([
292
+ 'rs-input-group__action',
293
+ 'rs-input-group__custom-suffix',
294
+ ])
295
+ })
296
+
297
+ it('renders addonAfterIcon as an owned addon button and emits addonAfterClick', async () => {
298
+ const wrapper = mount(RsInput, {
299
+ props: {
300
+ modelValue: 'hub',
301
+ clearable: true,
302
+ addonAfterIcon: 'ellipsis',
303
+ addonAfterIconLabel: '选择',
304
+ },
305
+ })
306
+ expect(wrapper.find('.rs-input-shell--combined').exists()).toBe(true)
307
+ expect(wrapper.find('.rs-input-addon--icon').exists()).toBe(true)
308
+ const button = wrapper.find('button.rs-input-addon__button')
309
+ expect(button.attributes('aria-label')).toBe('选择')
310
+ await button.trigger('click')
311
+ expect(wrapper.emitted('addonAfterClick')).toHaveLength(1)
312
+ })
313
+
314
+ it('prefers addonAfter content over addonAfterIcon', () => {
315
+ const wrapper = mount(RsInput, {
316
+ props: {
317
+ modelValue: '',
318
+ addonAfter: '.com',
319
+ addonAfterIcon: 'ellipsis',
320
+ },
321
+ })
322
+ expect(wrapper.find('.rs-input-addon--after').text()).toBe('.com')
323
+ expect(wrapper.find('button.rs-input-addon__button').exists()).toBe(false)
324
+ })
325
+
326
+ it('renders addonBefore and addonAfter as a combined shell', () => {
327
+ const wrapper = mount(RsInput, {
328
+ props: { modelValue: '', addonBefore: 'https://', addonAfter: '.com' },
329
+ })
330
+ expect(wrapper.find('.rs-input-shell--combined').exists()).toBe(true)
331
+ expect(wrapper.find('.rs-input-addon--before').text()).toBe('https://')
332
+ expect(wrapper.find('.rs-input-addon--after').text()).toBe('.com')
333
+ expect(wrapper.find('.rs-input-group--combined-before').exists()).toBe(true)
334
+ expect(wrapper.find('.rs-input-group--combined-after').exists()).toBe(true)
335
+ })
336
+
337
+ it('renders addonAfter slot as a combined shell', () => {
338
+ const wrapper = mount(RsInput, {
339
+ props: { modelValue: '' },
340
+ slots: { addonAfter: () => h('span', { class: 'slot-addon' }, '选') },
341
+ })
342
+ expect(wrapper.find('.rs-input-addon--after .slot-addon').text()).toBe('选')
343
+ expect(wrapper.find('.rs-input-group--combined-after').exists()).toBe(true)
344
+ })
345
+
346
+ it('does not emit pressEnter while IME is composing', async () => {
347
+ const wrapper = mount(RsInput, { props: { modelValue: '' } })
348
+ const input = wrapper.find('input')
349
+ await input.trigger('compositionstart')
350
+ await input.trigger('keydown', { key: 'Enter' })
351
+ expect(wrapper.emitted('pressEnter')).toBeFalsy()
352
+ await input.trigger('compositionend')
353
+ await input.trigger('keydown', { key: 'Enter' })
354
+ expect(wrapper.emitted('pressEnter')).toHaveLength(1)
355
+ })
356
+
357
+ it('does not emit validate while IME is composing', async () => {
358
+ const wrapper = mount(RsInput, {
359
+ props: {
360
+ modelValue: '',
361
+ required: true,
362
+ validateTrigger: 'input',
363
+ },
364
+ })
365
+ const input = wrapper.find('input')
366
+ await input.trigger('blur')
367
+ await input.trigger('compositionstart')
368
+ const before = wrapper.emitted('validate')?.length ?? 0
369
+ await input.trigger('input')
370
+ await flushPromises()
371
+ expect(wrapper.emitted('validate')?.length ?? 0).toBe(before)
372
+ await input.trigger('compositionend')
373
+ await flushPromises()
374
+ expect((wrapper.emitted('validate')?.length ?? 0)).toBeGreaterThan(before)
375
+ })
269
376
  })
@@ -108,6 +108,39 @@ describe('RsInputNumber', () => {
108
108
  expect(state.value).toBe(5)
109
109
  })
110
110
 
111
+ it('ignores wheel when changeOnWheel is off', async () => {
112
+ const { wrapper, state } = mountNumber({ modelValue: 5, step: 1 })
113
+ const input = wrapper.find('input')
114
+ await input.trigger('focus')
115
+ await input.trigger('wheel', { deltaY: -100 })
116
+ expect(state.value).toBe(5)
117
+ })
118
+
119
+ it('steps on wheel when changeOnWheel is on and focused', async () => {
120
+ const { wrapper, state } = mountNumber({
121
+ modelValue: 5,
122
+ step: 1,
123
+ changeOnWheel: true,
124
+ })
125
+ const input = wrapper.find('input')
126
+ await input.trigger('focus')
127
+ await input.trigger('wheel', { deltaY: -100 })
128
+ expect(state.value).toBe(6)
129
+ await input.trigger('wheel', { deltaY: 100 })
130
+ expect(state.value).toBe(5)
131
+ })
132
+
133
+ it('ignores wheel when changeOnWheel is on but not focused', async () => {
134
+ const { wrapper, state } = mountNumber({
135
+ modelValue: 5,
136
+ step: 1,
137
+ changeOnWheel: true,
138
+ })
139
+ const input = wrapper.find('input')
140
+ await input.trigger('wheel', { deltaY: -100 })
141
+ expect(state.value).toBe(5)
142
+ })
143
+
111
144
  it('clears to null on empty blur', async () => {
112
145
  const { wrapper, state } = mountNumber({ modelValue: 3 })
113
146
  const input = wrapper.find('input')
@@ -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
  })