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
@@ -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,18 @@ 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
+ normalizeSelectOptions,
11
+ optionDisplayLabel,
12
+ packSelectModel,
13
+ restoreSelectValue,
14
+ sortSelectOptions,
15
+ splitByTokenSeparators,
16
+ toComboboxValue,
17
+ RS_SELECT_EMPTY_VALUE,
18
+ } from '../components/select-utils'
8
19
 
9
20
  describe('RsSelect', () => {
10
21
  const options = [
@@ -35,6 +46,59 @@ describe('RsSelect', () => {
35
46
  expect(wrapper.find('.rs-select__placeholder').text()).toBe('请选择')
36
47
  })
37
48
 
49
+ it('keeps numeric option values on v-model including 0', async () => {
50
+ const numeric = [
51
+ { label: '静态配置', value: 0 },
52
+ { label: '服务发现', value: 1 },
53
+ ]
54
+ const wrapper = mount(RsSelect, {
55
+ props: { options: numeric, modelValue: 0 },
56
+ })
57
+ expect(wrapper.find('.rs-select__single-label').text()).toBe('静态配置')
58
+ const root = wrapper.getComponent({ name: 'ComboboxRoot' })
59
+ await root.vm.$emit('update:modelValue', '1')
60
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([1])
61
+ })
62
+
63
+ it('collapses overflow tags with maxTagCount', () => {
64
+ const wrapper = mount(RsSelect, {
65
+ props: {
66
+ options,
67
+ modelValue: ['gpt-4o', 'claude'],
68
+ multiple: true,
69
+ maxTagCount: 1,
70
+ },
71
+ })
72
+ expect(wrapper.findAll('.rs-select__tag')).toHaveLength(2)
73
+ expect(wrapper.find('.rs-select__tag--rest').text()).toBe('+1')
74
+ })
75
+
76
+ it('renders prefix and option slots', async () => {
77
+ const wrapper = mount(RsSelect, {
78
+ props: { options, modelValue: '' },
79
+ slots: {
80
+ prefix: () => h('span', { class: 'pref' }, 'P'),
81
+ option: ({ option }: { option: { label: string } }) =>
82
+ h('span', { class: 'opt' }, option.label),
83
+ },
84
+ attachTo: document.body,
85
+ })
86
+ expect(wrapper.find('.pref').text()).toBe('P')
87
+ await wrapper.find('.rs-select__trigger').trigger('click')
88
+ await flushPromises()
89
+ expect(document.body.querySelector('.opt')?.textContent).toContain('GPT-4o')
90
+ wrapper.unmount()
91
+ })
92
+
93
+ it('marks invalid on the trigger instead of the combobox root', () => {
94
+ const wrapper = mount(RsSelect, {
95
+ props: { options, modelValue: '', invalid: true },
96
+ })
97
+ expect(wrapper.find('.rs-select__trigger').classes()).toContain('rs-select__trigger--invalid')
98
+ expect(wrapper.find('.rs-select__trigger').attributes('aria-invalid')).toBe('true')
99
+ expect(wrapper.find('.rs-select').attributes('aria-invalid')).toBeUndefined()
100
+ })
101
+
38
102
  it('uses en-US default placeholder inside RsConfigProvider', () => {
39
103
  const wrapper = mount(RsConfigProvider, {
40
104
  props: { locale: 'en-US' },
@@ -91,6 +155,35 @@ describe('RsSelect', () => {
91
155
  expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
92
156
  })
93
157
 
158
+ it('maps empty option value through sentinel without crashing', async () => {
159
+ const withEmpty = [
160
+ { label: '空串项', value: '' },
161
+ { label: 'Claude', value: 'claude' },
162
+ ]
163
+ const wrapper = mount(RsSelect, {
164
+ props: { options: withEmpty, modelValue: '', clearable: true },
165
+ attachTo: document.body,
166
+ })
167
+ expect(wrapper.find('.rs-select__placeholder').exists()).toBe(true)
168
+ await wrapper.find('.rs-select__trigger').trigger('click')
169
+ await flushPromises()
170
+ expect(document.body.textContent).toContain('空串项')
171
+ wrapper.unmount()
172
+ })
173
+
174
+ it('selecting empty sentinel emits empty string', async () => {
175
+ const withEmpty = [
176
+ { label: '空串项', value: '' },
177
+ { label: 'Claude', value: 'claude' },
178
+ ]
179
+ const wrapper = mount(RsSelect, {
180
+ props: { options: withEmpty, modelValue: 'claude', clearable: true },
181
+ })
182
+ const root = wrapper.getComponent({ name: 'ComboboxRoot' })
183
+ await root.vm.$emit('update:modelValue', '__rs_select_empty__')
184
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
185
+ })
186
+
94
187
  it('supports multiple mode tags', () => {
95
188
  const wrapper = mount(RsSelect, {
96
189
  props: {
@@ -180,7 +273,7 @@ describe('RsSelect', () => {
180
273
  },
181
274
  template: `
182
275
  <RsForm ref="formRef">
183
- <RsSelect v-model="role" :options="roleOptions" required />
276
+ <RsSelect v-model="role" name="role" :options="roleOptions" required />
184
277
  </RsForm>
185
278
  `,
186
279
  })
@@ -260,12 +353,382 @@ describe('RsSelect', () => {
260
353
  expect(matched!.classList.contains('rs-select__content--match-trigger')).toBe(true)
261
354
  wrapper.unmount()
262
355
  })
356
+
357
+ it('packs labelInValue including numeric 0', async () => {
358
+ const numeric = [
359
+ { label: '静态配置', value: 0 },
360
+ { label: '服务发现', value: 1 },
361
+ ]
362
+ const wrapper = mount(RsSelect, {
363
+ props: { options: numeric, modelValue: '', labelInValue: true },
364
+ })
365
+ expect(wrapper.find('.rs-select__placeholder').exists()).toBe(true)
366
+ const root = wrapper.getComponent({ name: 'ComboboxRoot' })
367
+ await root.vm.$emit('update:modelValue', '0')
368
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
369
+ { label: '静态配置', value: 0 },
370
+ ])
371
+ })
372
+
373
+ it('echoes labelInValue snapshot for number 0', () => {
374
+ const numeric = [
375
+ { label: '静态配置', value: 0 },
376
+ { label: '服务发现', value: 1 },
377
+ ]
378
+ const wrapper = mount(RsSelect, {
379
+ props: {
380
+ options: numeric,
381
+ modelValue: { label: '静态配置', value: 0 },
382
+ labelInValue: true,
383
+ },
384
+ })
385
+ expect(wrapper.find('.rs-select__single-label').text()).toBe('静态配置')
386
+ })
387
+
388
+ it('packs labelInValue arrays in multiple mode', async () => {
389
+ const wrapper = mount(RsSelect, {
390
+ props: { options, modelValue: [], multiple: true, labelInValue: true },
391
+ })
392
+ const root = wrapper.getComponent({ name: 'ComboboxRoot' })
393
+ await root.vm.$emit('update:modelValue', ['gpt-4o'])
394
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([
395
+ [{ label: 'GPT-4o', value: 'gpt-4o' }],
396
+ ])
397
+ })
398
+
399
+ it('emits select, deselect and clear', async () => {
400
+ const wrapper = mount(RsSelect, {
401
+ props: { options, modelValue: '', clearable: true },
402
+ })
403
+ const root = wrapper.getComponent({ name: 'ComboboxRoot' })
404
+ await root.vm.$emit('update:modelValue', 'claude')
405
+ expect(wrapper.emitted('select')?.at(-1)?.[0]).toBe('claude')
406
+
407
+ await wrapper.setProps({ modelValue: 'claude' })
408
+ await root.vm.$emit('update:modelValue', 'gpt-4o')
409
+ expect(wrapper.emitted('deselect')?.at(-1)?.[0]).toBe('claude')
410
+ expect(wrapper.emitted('select')?.at(-1)?.[0]).toBe('gpt-4o')
411
+
412
+ await wrapper.setProps({ modelValue: 'gpt-4o' })
413
+ await wrapper.find('.rs-select__clear').trigger('click')
414
+ expect(wrapper.emitted('clear')).toHaveLength(1)
415
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
416
+ })
417
+
418
+ it('truncates tag text with maxTagTextLength', () => {
419
+ const wrapper = mount(RsSelect, {
420
+ props: {
421
+ options: [{ label: 'TypeScript', value: 'ts' }],
422
+ modelValue: ['ts'],
423
+ multiple: true,
424
+ maxTagTextLength: 4,
425
+ },
426
+ })
427
+ expect(wrapper.find('.rs-select__tag-label').text()).toBe('Type…')
428
+ })
429
+
430
+ it('applies warning status on the trigger', () => {
431
+ const wrapper = mount(RsSelect, {
432
+ props: { options, modelValue: '', status: 'warning' },
433
+ })
434
+ expect(wrapper.find('.rs-select__trigger').classes()).toContain('rs-select__trigger--warning')
435
+ expect(wrapper.find('.rs-select__trigger').classes()).not.toContain(
436
+ 'rs-select__trigger--invalid',
437
+ )
438
+ })
439
+
440
+ it('treats status error as invalid', () => {
441
+ const wrapper = mount(RsSelect, {
442
+ props: { options, modelValue: '', status: 'error' },
443
+ })
444
+ expect(wrapper.find('.rs-select__trigger').classes()).toContain('rs-select__trigger--invalid')
445
+ expect(wrapper.find('.rs-select__trigger').classes()).not.toContain(
446
+ 'rs-select__trigger--warning',
447
+ )
448
+ })
449
+
450
+ it('hides arrow when showArrow is false', () => {
451
+ const wrapper = mount(RsSelect, {
452
+ props: { options, modelValue: '', showArrow: false },
453
+ })
454
+ expect(wrapper.find('.rs-select__suffix').exists()).toBe(false)
455
+ })
456
+
457
+ it('sorts options with filterSort', async () => {
458
+ const unsorted = [
459
+ { label: 'Zulu', value: 'z' },
460
+ { label: 'Alpha', value: 'a' },
461
+ ]
462
+ const wrapper = mount(RsSelect, {
463
+ props: {
464
+ options: unsorted,
465
+ modelValue: '',
466
+ filterSort: (a, b) => String(a.label).localeCompare(String(b.label)),
467
+ },
468
+ attachTo: document.body,
469
+ })
470
+ await wrapper.find('.rs-select__trigger').trigger('click')
471
+ await flushPromises()
472
+ const items = [...document.body.querySelectorAll('.rs-select__item-label')].map(
473
+ (el) => el.textContent?.trim(),
474
+ )
475
+ expect(items[0]).toBe('Alpha')
476
+ expect(items[1]).toBe('Zulu')
477
+ wrapper.unmount()
478
+ })
479
+
480
+ it('renders header and footer slots', async () => {
481
+ const wrapper = mount(RsSelect, {
482
+ props: { options, modelValue: '' },
483
+ slots: {
484
+ header: () => h('div', { class: 'sel-head' }, 'HEAD'),
485
+ footer: () => h('div', { class: 'sel-foot' }, 'FOOT'),
486
+ },
487
+ attachTo: document.body,
488
+ })
489
+ await wrapper.find('.rs-select__trigger').trigger('click')
490
+ await flushPromises()
491
+ expect(document.body.querySelector('.sel-head')?.textContent).toBe('HEAD')
492
+ expect(document.body.querySelector('.sel-foot')?.textContent).toBe('FOOT')
493
+ wrapper.unmount()
494
+ })
495
+
496
+ it('applies popupClassName and listHeight on the panel', async () => {
497
+ const wrapper = mount(RsSelect, {
498
+ props: {
499
+ options,
500
+ modelValue: '',
501
+ popupClassName: 'extra-popup',
502
+ listHeight: 120,
503
+ },
504
+ attachTo: document.body,
505
+ })
506
+ await wrapper.find('.rs-select__trigger').trigger('click')
507
+ await flushPromises()
508
+ const content = document.body.querySelector('.rs-select__content') as HTMLElement | null
509
+ expect(content?.classList.contains('extra-popup')).toBe(true)
510
+ expect(content?.style.getPropertyValue('--rs-select-list-height')).toBe('120px')
511
+ wrapper.unmount()
512
+ })
513
+
514
+ it('commits tokens when typing a separator', async () => {
515
+ const wrapper = mount(RsSelect, {
516
+ props: {
517
+ options,
518
+ modelValue: [],
519
+ multiple: true,
520
+ searchable: true,
521
+ tokenSeparators: [','],
522
+ },
523
+ attachTo: document.body,
524
+ })
525
+ await wrapper.find('.rs-select__trigger').trigger('click')
526
+ await flushPromises()
527
+ const input = document.querySelector('.rs-select__search') as HTMLInputElement | null
528
+ expect(input).toBeTruthy()
529
+ input!.value = 'gpt-4o,'
530
+ input!.dispatchEvent(new Event('input', { bubbles: true }))
531
+ await flushPromises()
532
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([['gpt-4o']])
533
+ wrapper.unmount()
534
+ })
535
+
536
+ it('maps fieldNames including numeric 0', async () => {
537
+ const raw = [
538
+ { name: '静态配置', id: 0 },
539
+ { name: '服务发现', id: 1 },
540
+ ]
541
+ const wrapper = mount(RsSelect, {
542
+ props: {
543
+ options: raw,
544
+ modelValue: 0,
545
+ fieldNames: { label: 'name', value: 'id' },
546
+ },
547
+ })
548
+ expect(wrapper.find('.rs-select__single-label').text()).toBe('静态配置')
549
+ const root = wrapper.getComponent({ name: 'ComboboxRoot' })
550
+ await root.vm.$emit('update:modelValue', '1')
551
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([1])
552
+ })
553
+
554
+ it('shows optionLabelProp on the trigger', () => {
555
+ const wrapper = mount(RsSelect, {
556
+ props: { options, modelValue: 'claude', optionLabelProp: 'value' },
557
+ })
558
+ expect(wrapper.find('.rs-select__single-label').text()).toBe('claude')
559
+ })
560
+
561
+ it('binds searchValue and emits updates', async () => {
562
+ const wrapper = mount(RsSelect, {
563
+ props: { options, modelValue: '', searchable: true, searchValue: 'cla' },
564
+ attachTo: document.body,
565
+ })
566
+ await wrapper.find('.rs-select__trigger').trigger('click')
567
+ await flushPromises()
568
+ const input = document.querySelector('.rs-select__search') as HTMLInputElement | null
569
+ expect(input?.value).toBe('cla')
570
+ input!.value = 'gpt'
571
+ input!.dispatchEvent(new Event('input', { bubbles: true }))
572
+ await flushPromises()
573
+ expect(wrapper.emitted('update:searchValue')?.at(-1)).toEqual(['gpt'])
574
+ wrapper.unmount()
575
+ })
576
+
577
+ it('renders dropdownRender instead of the default panel body', async () => {
578
+ const wrapper = mount(RsSelect, {
579
+ props: { options, modelValue: '' },
580
+ slots: {
581
+ dropdownRender: () => h('div', { class: 'custom-dd' }, 'CUSTOM'),
582
+ },
583
+ attachTo: document.body,
584
+ })
585
+ await wrapper.find('.rs-select__trigger').trigger('click')
586
+ await flushPromises()
587
+ expect(document.body.querySelector('.custom-dd')?.textContent).toBe('CUSTOM')
588
+ expect(document.body.querySelector('.rs-select__item')).toBeNull()
589
+ wrapper.unmount()
590
+ })
591
+
592
+ it('mounts the panel into getPopupContainer', async () => {
593
+ const host = document.createElement('div')
594
+ host.id = 'rs-select-portal-host'
595
+ document.body.appendChild(host)
596
+ const wrapper = mount(RsSelect, {
597
+ props: {
598
+ options,
599
+ modelValue: '',
600
+ getPopupContainer: () => host,
601
+ },
602
+ attachTo: document.body,
603
+ })
604
+ await wrapper.find('.rs-select__trigger').trigger('click')
605
+ await flushPromises()
606
+ expect(host.querySelector('.rs-select__content')).toBeTruthy()
607
+ wrapper.unmount()
608
+ host.remove()
609
+ })
263
610
  })
264
611
 
265
612
  describe('select-utils', () => {
266
613
  const contains = (text: string, search: string) =>
267
614
  text.toLowerCase().includes(search.toLowerCase())
268
615
 
616
+ it('maps empty string to combobox sentinel and back', () => {
617
+ expect(toComboboxValue('')).toBe(RS_SELECT_EMPTY_VALUE)
618
+ expect(toComboboxValue('claude')).toBe('claude')
619
+ expect(toComboboxValue(0)).toBe('0')
620
+ expect(fromComboboxValue(RS_SELECT_EMPTY_VALUE)).toBe('')
621
+ expect(fromComboboxValue('claude')).toBe('claude')
622
+ })
623
+
624
+ it('restoreSelectValue keeps option number type', () => {
625
+ const numeric = [
626
+ { label: '静态配置', value: 0 },
627
+ { label: '服务发现', value: 1 },
628
+ ]
629
+ expect(restoreSelectValue('0', numeric)).toBe(0)
630
+ expect(restoreSelectValue('1', numeric)).toBe(1)
631
+ expect(restoreSelectValue('custom', numeric)).toBe('custom')
632
+ })
633
+
634
+ it('filterSelectOptions honors filterOption and optionFilterProp', () => {
635
+ const numeric = [
636
+ { label: '静态配置', value: 0 },
637
+ { label: '服务发现', value: 1 },
638
+ ]
639
+ const byValue = filterSelectOptions(numeric, '0', contains, true, 'value')
640
+ expect(byValue).toHaveLength(1)
641
+ expect(byValue[0]).toMatchObject({ value: 0 })
642
+
643
+ const custom = filterSelectOptions(numeric, '发现', contains, (q, opt) =>
644
+ String(opt.label).includes(q),
645
+ )
646
+ expect(custom).toHaveLength(1)
647
+ expect(custom[0]).toMatchObject({ value: 1 })
648
+
649
+ const unfiltered = filterSelectOptions(numeric, 'zzz', contains, false)
650
+ expect(unfiltered).toHaveLength(2)
651
+ })
652
+
653
+ it('packSelectModel keeps number 0 and labelInValue', () => {
654
+ const numeric = [
655
+ { label: '静态配置', value: 0 },
656
+ { label: '服务发现', value: 1 },
657
+ ]
658
+ expect(packSelectModel([0], numeric, false, false)).toBe(0)
659
+ expect(packSelectModel([0], numeric, false, true)).toEqual({
660
+ label: '静态配置',
661
+ value: 0,
662
+ })
663
+ expect(packSelectModel([0, 1], numeric, true, true)).toEqual([
664
+ { label: '静态配置', value: 0 },
665
+ { label: '服务发现', value: 1 },
666
+ ])
667
+ expect(packSelectModel([], numeric, false, true)).toBe('')
668
+ })
669
+
670
+ it('sortSelectOptions sorts flat and grouped lists', () => {
671
+ const flat = [
672
+ { label: 'Zulu', value: 'z' },
673
+ { label: 'Alpha', value: 'a' },
674
+ ]
675
+ const sorted = sortSelectOptions(flat, (a, b) => String(a.label).localeCompare(String(b.label)))
676
+ expect(sorted.map((item) => ('value' in item ? item.value : ''))).toEqual(['a', 'z'])
677
+
678
+ const grouped = [
679
+ {
680
+ label: '后端',
681
+ options: [
682
+ { label: 'Go', value: 'go' },
683
+ { label: 'Ada', value: 'ada' },
684
+ ],
685
+ },
686
+ ]
687
+ const groupedSorted = sortSelectOptions(grouped, (a, b) =>
688
+ String(a.label).localeCompare(String(b.label)),
689
+ )
690
+ expect(groupedSorted[0]).toMatchObject({ label: '后端' })
691
+ if ('options' in groupedSorted[0]) {
692
+ expect(groupedSorted[0].options.map((opt) => opt.value)).toEqual(['ada', 'go'])
693
+ }
694
+ })
695
+
696
+ it('splitByTokenSeparators splits on the first matching separator', () => {
697
+ expect(splitByTokenSeparators('a,b,c', [','])).toEqual(['a', 'b', 'c'])
698
+ expect(splitByTokenSeparators('ab', [','])).toBeNull()
699
+ expect(splitByTokenSeparators('a;b', [',', ';'])).toEqual(['a', 'b'])
700
+ })
701
+
702
+ it('normalizeSelectOptions maps fieldNames and keeps number 0', () => {
703
+ const raw = [
704
+ { name: '静态配置', id: 0 },
705
+ {
706
+ name: '后端',
707
+ children: [
708
+ { name: 'Go', id: 'go' },
709
+ { name: 'Ada', id: 'ada' },
710
+ ],
711
+ },
712
+ ]
713
+ const normalized = normalizeSelectOptions(raw, {
714
+ label: 'name',
715
+ value: 'id',
716
+ options: 'children',
717
+ })
718
+ expect(normalized[0]).toMatchObject({ label: '静态配置', value: 0 })
719
+ expect(normalized[1]).toMatchObject({ label: '后端' })
720
+ if ('options' in normalized[1]) {
721
+ expect(normalized[1].options.map((opt) => opt.value)).toEqual(['go', 'ada'])
722
+ }
723
+ })
724
+
725
+ it('optionDisplayLabel reads optionLabelProp', () => {
726
+ const option = { label: 'Claude', value: 'claude', code: 'CL' }
727
+ expect(optionDisplayLabel(option)).toBe('Claude')
728
+ expect(optionDisplayLabel(option, 'value')).toBe('claude')
729
+ expect(optionDisplayLabel(option, 'code')).toBe('CL')
730
+ })
731
+
269
732
  it('filterSelectOptions filters flat and grouped options', () => {
270
733
  const grouped = [
271
734
  {
@@ -0,0 +1,74 @@
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
+
45
+ it('writes custom checkedValue / uncheckedValue to v-model', async () => {
46
+ const Host = defineComponent({
47
+ components: { RsSwitch },
48
+ data: () => ({ flag: 'N' }),
49
+ template: '<RsSwitch v-model="flag" checked-value="Y" unchecked-value="N" />',
50
+ })
51
+ const wrapper = mount(Host)
52
+
53
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
54
+ await wrapper.find('.rs-switch__root').trigger('click')
55
+ await nextTick()
56
+ expect((wrapper.vm as { flag: string }).flag).toBe('Y')
57
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(true)
58
+
59
+ await wrapper.find('.rs-switch__root').trigger('click')
60
+ await nextTick()
61
+ expect((wrapper.vm as { flag: string }).flag).toBe('N')
62
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
63
+ })
64
+
65
+ it('treats numeric 1 / 0 as checked and unchecked', async () => {
66
+ const wrapper = mount(RsSwitch, {
67
+ props: { modelValue: 0, checkedValue: 1, uncheckedValue: 0 },
68
+ })
69
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
70
+ await wrapper.find('.rs-switch__root').trigger('click')
71
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual([1])
72
+ expect(wrapper.emitted('change')?.[0]).toEqual([1])
73
+ })
74
+ })