niuma-ui 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +25 -0
  4. package/README.en.md +99 -0
  5. package/README.md +161 -0
  6. package/package.json +106 -0
  7. package/src/__tests__/RsBadge.spec.ts +40 -0
  8. package/src/__tests__/RsBreadcrumb.spec.ts +172 -0
  9. package/src/__tests__/RsButton.spec.ts +108 -0
  10. package/src/__tests__/RsCalendarGrid.spec.ts +82 -0
  11. package/src/__tests__/RsCard.spec.ts +97 -0
  12. package/src/__tests__/RsCheckbox.spec.ts +36 -0
  13. package/src/__tests__/RsCodeEditor.spec.ts +111 -0
  14. package/src/__tests__/RsConfirmDialog.spec.ts +158 -0
  15. package/src/__tests__/RsContainer.spec.ts +89 -0
  16. package/src/__tests__/RsContextMenu.spec.ts +118 -0
  17. package/src/__tests__/RsDatePicker.spec.ts +88 -0
  18. package/src/__tests__/RsDateTimePicker.spec.ts +69 -0
  19. package/src/__tests__/RsDialog.spec.ts +404 -0
  20. package/src/__tests__/RsDrawer.spec.ts +145 -0
  21. package/src/__tests__/RsDropdown.spec.ts +135 -0
  22. package/src/__tests__/RsEmpty.spec.ts +99 -0
  23. package/src/__tests__/RsForm.spec.ts +173 -0
  24. package/src/__tests__/RsIcon.spec.ts +244 -0
  25. package/src/__tests__/RsInput.spec.ts +269 -0
  26. package/src/__tests__/RsInputNumber.spec.ts +118 -0
  27. package/src/__tests__/RsLabel.spec.ts +106 -0
  28. package/src/__tests__/RsLink.spec.ts +49 -0
  29. package/src/__tests__/RsLoading.spec.ts +84 -0
  30. package/src/__tests__/RsMarkdown.spec.ts +143 -0
  31. package/src/__tests__/RsMenu.spec.ts +119 -0
  32. package/src/__tests__/RsNewComponents.spec.ts +17 -0
  33. package/src/__tests__/RsPagination.spec.ts +158 -0
  34. package/src/__tests__/RsPopover.spec.ts +126 -0
  35. package/src/__tests__/RsScrollbar.spec.ts +53 -0
  36. package/src/__tests__/RsSelect.spec.ts +291 -0
  37. package/src/__tests__/RsSidebar.spec.ts +119 -0
  38. package/src/__tests__/RsSplitPane.spec.ts +177 -0
  39. package/src/__tests__/RsStatCard.spec.ts +79 -0
  40. package/src/__tests__/RsSteps.spec.ts +125 -0
  41. package/src/__tests__/RsTable.perf.spec.ts +103 -0
  42. package/src/__tests__/RsTable.spec.ts +1135 -0
  43. package/src/__tests__/RsTabs.spec.ts +328 -0
  44. package/src/__tests__/RsTerminal.spec.ts +218 -0
  45. package/src/__tests__/RsTimePicker.spec.ts +77 -0
  46. package/src/__tests__/RsToaster.spec.ts +95 -0
  47. package/src/__tests__/RsToolbar.spec.ts +75 -0
  48. package/src/__tests__/RsTooltip.spec.ts +111 -0
  49. package/src/__tests__/RsTree.spec.ts +572 -0
  50. package/src/__tests__/RsUpload.spec.ts +160 -0
  51. package/src/__tests__/RsVirtualList.spec.ts +77 -0
  52. package/src/__tests__/date-picker-utils.spec.ts +70 -0
  53. package/src/__tests__/dialog-viewport.spec.ts +38 -0
  54. package/src/__tests__/iso-local-datetime.spec.ts +58 -0
  55. package/src/__tests__/rs-clipboard.spec.ts +142 -0
  56. package/src/__tests__/setup.ts +12 -0
  57. package/src/__tests__/split-pane-utils.spec.ts +136 -0
  58. package/src/__tests__/table-drag.spec.ts +35 -0
  59. package/src/__tests__/table-edit-utils.spec.ts +194 -0
  60. package/src/__tests__/table-utils.spec.ts +286 -0
  61. package/src/__tests__/theme.spec.ts +22 -0
  62. package/src/__tests__/tree-utils.spec.ts +234 -0
  63. package/src/__tests__/useTableCellTooltip.spec.ts +101 -0
  64. package/src/components/RsAvatar.vue +115 -0
  65. package/src/components/RsBadge.vue +59 -0
  66. package/src/components/RsBreadcrumb.vue +65 -0
  67. package/src/components/RsBreadcrumbItems.vue +36 -0
  68. package/src/components/RsButton.vue +393 -0
  69. package/src/components/RsCalendarGrid.vue +284 -0
  70. package/src/components/RsCard.vue +237 -0
  71. package/src/components/RsCheckbox.vue +189 -0
  72. package/src/components/RsCodeBlock.vue +278 -0
  73. package/src/components/RsCodeEditor.vue +597 -0
  74. package/src/components/RsConfigProvider.vue +92 -0
  75. package/src/components/RsConfirmDialog.vue +178 -0
  76. package/src/components/RsContainer.vue +235 -0
  77. package/src/components/RsContextMenu.vue +238 -0
  78. package/src/components/RsContextMenuItems.vue +77 -0
  79. package/src/components/RsDatePicker.vue +756 -0
  80. package/src/components/RsDateTimePicker.vue +42 -0
  81. package/src/components/RsDialog.vue +486 -0
  82. package/src/components/RsDrawer.vue +175 -0
  83. package/src/components/RsDropdown.vue +252 -0
  84. package/src/components/RsDropdownItems.vue +90 -0
  85. package/src/components/RsEmpty.vue +107 -0
  86. package/src/components/RsForm.vue +131 -0
  87. package/src/components/RsIcon.vue +104 -0
  88. package/src/components/RsInput.vue +1101 -0
  89. package/src/components/RsInputNumber.vue +532 -0
  90. package/src/components/RsLabel.vue +54 -0
  91. package/src/components/RsLink.vue +106 -0
  92. package/src/components/RsLoading.vue +171 -0
  93. package/src/components/RsMarkdown.vue +329 -0
  94. package/src/components/RsMenu.vue +256 -0
  95. package/src/components/RsMenuItems.vue +125 -0
  96. package/src/components/RsMonacoEditor.vue +541 -0
  97. package/src/components/RsPagination.vue +184 -0
  98. package/src/components/RsPopover.vue +77 -0
  99. package/src/components/RsProseEditor.vue +160 -0
  100. package/src/components/RsScrollbar.vue +171 -0
  101. package/src/components/RsSelect.vue +1045 -0
  102. package/src/components/RsSidebar.vue +121 -0
  103. package/src/components/RsSidebarGroup.vue +32 -0
  104. package/src/components/RsSidebarItem.vue +72 -0
  105. package/src/components/RsSplitPane.vue +619 -0
  106. package/src/components/RsStatCard.vue +85 -0
  107. package/src/components/RsSteps.vue +138 -0
  108. package/src/components/RsTable.vue +2664 -0
  109. package/src/components/RsTableColgroup.vue +17 -0
  110. package/src/components/RsTableHeaderRow.vue +30 -0
  111. package/src/components/RsTabs.vue +951 -0
  112. package/src/components/RsTerminal.vue +697 -0
  113. package/src/components/RsTimePicker.vue +554 -0
  114. package/src/components/RsTimePickerColumns.vue +226 -0
  115. package/src/components/RsToaster.vue +396 -0
  116. package/src/components/RsToolbar.vue +135 -0
  117. package/src/components/RsTooltip.vue +142 -0
  118. package/src/components/RsTooltipProvider.vue +20 -0
  119. package/src/components/RsTree.vue +1248 -0
  120. package/src/components/RsUpload.vue +279 -0
  121. package/src/components/RsVirtualList.vue +217 -0
  122. package/src/components/avatar-utils.ts +19 -0
  123. package/src/components/breadcrumb-utils.ts +19 -0
  124. package/src/components/class-utils.ts +29 -0
  125. package/src/components/code-editor-utils.ts +106 -0
  126. package/src/components/code-mirror-completion.ts +40 -0
  127. package/src/components/code-mirror-diagnostics.ts +52 -0
  128. package/src/components/code-mirror-ghost.ts +158 -0
  129. package/src/components/code-mirror-inline-edit.ts +55 -0
  130. package/src/components/code-mirror-intel.ts +81 -0
  131. package/src/components/code-mirror-lang.ts +128 -0
  132. package/src/components/context-menu-utils.ts +20 -0
  133. package/src/components/date-picker-utils.ts +284 -0
  134. package/src/components/date-range-validation.ts +29 -0
  135. package/src/components/date-validation.ts +38 -0
  136. package/src/components/dialog-viewport.ts +48 -0
  137. package/src/components/dialog-window.ts +253 -0
  138. package/src/components/dropdown-utils.ts +31 -0
  139. package/src/components/form-utils.ts +77 -0
  140. package/src/components/input-number-utils.ts +128 -0
  141. package/src/components/input-rules.ts +84 -0
  142. package/src/components/loading-utils.ts +8 -0
  143. package/src/components/markdown-utils.ts +126 -0
  144. package/src/components/menu-utils.ts +48 -0
  145. package/src/components/overlay-utils.ts +47 -0
  146. package/src/components/pagination-utils.ts +38 -0
  147. package/src/components/popover-utils.ts +8 -0
  148. package/src/components/reka.ts +106 -0
  149. package/src/components/resolve-radius.ts +22 -0
  150. package/src/components/resolve-size.ts +29 -0
  151. package/src/components/scrollbar-utils.ts +10 -0
  152. package/src/components/select-utils.ts +72 -0
  153. package/src/components/split-pane-utils.ts +187 -0
  154. package/src/components/steps-utils.ts +22 -0
  155. package/src/components/table/RsTableBodyRow.vue +287 -0
  156. package/src/components/table/RsTableCell.vue +239 -0
  157. package/src/components/table/RsTableCellContent.ts +18 -0
  158. package/src/components/table/RsTableCellEditor.vue +351 -0
  159. package/src/components/table/RsTableEditGutter.vue +54 -0
  160. package/src/components/table/RsTableHeaderFilter.vue +107 -0
  161. package/src/components/table/RsTableRowStatus.vue +69 -0
  162. package/src/components/table/rs-table.css +1141 -0
  163. package/src/components/table/table-context-menu.ts +53 -0
  164. package/src/components/table/table-edit-utils.ts +496 -0
  165. package/src/components/table/table-keys.ts +12 -0
  166. package/src/components/table-drag.ts +215 -0
  167. package/src/components/table-utils.ts +950 -0
  168. package/src/components/tabs-utils.ts +107 -0
  169. package/src/components/terminal-utils.ts +301 -0
  170. package/src/components/terminal-wheel.ts +49 -0
  171. package/src/components/time-picker-utils.ts +167 -0
  172. package/src/components/time-range-validation.ts +23 -0
  173. package/src/components/time-validation.ts +20 -0
  174. package/src/components/tree-utils.ts +518 -0
  175. package/src/components/upload-utils.ts +55 -0
  176. package/src/components/virtual-list-utils.ts +17 -0
  177. package/src/composables/useClickOutside.ts +22 -0
  178. package/src/composables/useRsConfig.ts +91 -0
  179. package/src/composables/useRsI18n.ts +22 -0
  180. package/src/composables/useRsTabsNav.ts +187 -0
  181. package/src/composables/useRsToast.ts +48 -0
  182. package/src/composables/useTableCellTooltip.ts +239 -0
  183. package/src/composables/useTableEdit.ts +271 -0
  184. package/src/composables/useTableRowHighlight.ts +50 -0
  185. package/src/dev/vite-codemirror-deps.ts +20 -0
  186. package/src/icons/custom/api.ts +54 -0
  187. package/src/icons/custom/clickhouse.ts +52 -0
  188. package/src/icons/custom/dameng.ts +83 -0
  189. package/src/icons/custom/ftp.ts +61 -0
  190. package/src/icons/custom/kingbase.ts +52 -0
  191. package/src/icons/custom/mongodb.ts +50 -0
  192. package/src/icons/custom/mysql.ts +51 -0
  193. package/src/icons/custom/oracle.ts +52 -0
  194. package/src/icons/custom/redis.ts +51 -0
  195. package/src/icons/custom/sqlite.ts +50 -0
  196. package/src/icons/custom/sqlserver.ts +52 -0
  197. package/src/icons/custom/vastbase.ts +57 -0
  198. package/src/icons/lucide.ts +78 -0
  199. package/src/icons/registry.ts +35 -0
  200. package/src/index.ts +425 -0
  201. package/src/lib/iso-local-datetime.ts +152 -0
  202. package/src/lib/rs-dayjs.ts +56 -0
  203. package/src/locale/messages.ts +362 -0
  204. package/src/locale/types.ts +5 -0
  205. package/src/monaco/debug-decorations.css +20 -0
  206. package/src/monaco/debug-decorations.ts +70 -0
  207. package/src/monaco/index.ts +22 -0
  208. package/src/monaco/languages/index.ts +21 -0
  209. package/src/monaco/languages/mongodb-shell.ts +125 -0
  210. package/src/monaco/languages/types.ts +55 -0
  211. package/src/monaco/setup.ts +101 -0
  212. package/src/styles.css +1040 -0
  213. package/src/theme/apply.ts +12 -0
  214. package/src/theme/brand.example.css +37 -0
  215. package/src/theme/presets.ts +69 -0
  216. package/src/theme/types.ts +81 -0
  217. package/src/utils/rs-clipboard.ts +272 -0
  218. package/src/vite-env.d.ts +7 -0
  219. package/vite-plugins/monaco-zh-nls.ts +101 -0
  220. package/vite-plugins/silence-antlr-parse-console.ts +29 -0
@@ -0,0 +1,269 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { h } from 'vue'
4
+ import RsConfigProvider from '../components/RsConfigProvider.vue'
5
+ import RsInput from '../components/RsInput.vue'
6
+ import {
7
+ runInputValidation,
8
+ validateInputRule,
9
+ } from '../components/input-rules'
10
+
11
+ describe('input-rules', () => {
12
+ it('validates email rule', () => {
13
+ expect(validateInputRule('a@b.com', 'email')).toBe(true)
14
+ expect(validateInputRule('bad', 'email')).toBe(false)
15
+ expect(validateInputRule('', 'email')).toBe(true)
16
+ })
17
+
18
+ it('validates number and integer', () => {
19
+ expect(validateInputRule('12.5', 'number')).toBe(true)
20
+ expect(validateInputRule('12a', 'number')).toBe(false)
21
+ expect(validateInputRule('42', 'integer')).toBe(true)
22
+ expect(validateInputRule('3.14', 'integer')).toBe(false)
23
+ })
24
+
25
+ it('validates required in runInputValidation', () => {
26
+ expect(runInputValidation('', { required: true }).valid).toBe(false)
27
+ expect(runInputValidation('x', { required: true }).valid).toBe(true)
28
+ })
29
+ })
30
+
31
+ describe('RsInput', () => {
32
+ it('emits update:modelValue on input', async () => {
33
+ const wrapper = mount(RsInput, { props: { modelValue: '' } })
34
+ await wrapper.find('input').setValue('world')
35
+ expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['world'])
36
+ })
37
+
38
+ it('marks invalid state on group', () => {
39
+ const wrapper = mount(RsInput, { props: { invalid: true } })
40
+ expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--invalid')
41
+ })
42
+
43
+ it('applies size class on group', () => {
44
+ const wrapper = mount(RsInput, { props: { size: 'lg' } })
45
+ expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--lg')
46
+ })
47
+
48
+ it('defaults to md size', () => {
49
+ const wrapper = mount(RsInput)
50
+ expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--md')
51
+ })
52
+
53
+ it('binds id and type attributes', () => {
54
+ const wrapper = mount(RsInput, {
55
+ props: { id: 'email', type: 'email' },
56
+ })
57
+ const input = wrapper.find('input')
58
+ expect(input.attributes('id')).toBe('email')
59
+ expect(input.attributes('type')).toBe('email')
60
+ })
61
+
62
+ it('auto-generates id when not provided', () => {
63
+ const wrapper = mount(RsInput)
64
+ const input = wrapper.find('input')
65
+ expect(input.attributes('id')).toBeTruthy()
66
+ })
67
+
68
+ it('renders label, hint, and required marker', () => {
69
+ const wrapper = mount(RsInput, {
70
+ props: {
71
+ label: '邮箱',
72
+ hint: '用于登录与通知',
73
+ required: true,
74
+ },
75
+ })
76
+ expect(wrapper.classes()).toContain('rs-field')
77
+ expect(wrapper.find('.rs-field__label').text()).toContain('邮箱')
78
+ expect(wrapper.find('.rs-field__required').exists()).toBe(true)
79
+ expect(wrapper.find('.rs-field__hint').text()).toBe('用于登录与通知')
80
+ })
81
+
82
+ it('associates label with input via for/id', () => {
83
+ const wrapper = mount(RsInput, {
84
+ props: { label: '名称', id: 'name-field' },
85
+ })
86
+ expect(wrapper.find('label').attributes('for')).toBe('name-field')
87
+ expect(wrapper.find('input').attributes('id')).toBe('name-field')
88
+ })
89
+
90
+ it('associates left label with input via for/id', () => {
91
+ const wrapper = mount(RsInput, {
92
+ props: { label: '名称', labelPosition: 'left', id: 'name-field' },
93
+ })
94
+ expect(wrapper.classes()).toContain('rs-field--label-left')
95
+ expect(wrapper.find('label').attributes('for')).toBe('name-field')
96
+ expect(wrapper.find('input').attributes('id')).toBe('name-field')
97
+ })
98
+
99
+ it('keeps left label aligned when validation error appears', async () => {
100
+ const wrapper = mount(RsInput, {
101
+ props: {
102
+ label: '邮箱',
103
+ labelPosition: 'left',
104
+ modelValue: '',
105
+ required: true,
106
+ },
107
+ })
108
+ await wrapper.find('input').trigger('blur')
109
+ const error = wrapper.find('.rs-input-field__error')
110
+ expect(error.exists()).toBe(true)
111
+ expect(error.element.parentElement?.classList.contains('rs-input-field')).toBe(false)
112
+ })
113
+
114
+ it('sets placeholder', () => {
115
+ const wrapper = mount(RsInput, {
116
+ props: { placeholder: '请输入邮箱' },
117
+ })
118
+ expect(wrapper.find('input').attributes('placeholder')).toBe('请输入邮箱')
119
+ })
120
+
121
+ it('disables input when disabled', () => {
122
+ const wrapper = mount(RsInput, { props: { disabled: true } })
123
+ expect(wrapper.find('input').attributes('disabled')).toBeDefined()
124
+ })
125
+
126
+ it('reflects external modelValue', () => {
127
+ const wrapper = mount(RsInput, {
128
+ props: { modelValue: '初始值' },
129
+ })
130
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('初始值')
131
+ })
132
+
133
+ it('renders prefix and suffix props', () => {
134
+ const wrapper = mount(RsInput, {
135
+ props: { prefix: '¥', suffix: '元' },
136
+ })
137
+ expect(wrapper.text()).toContain('¥')
138
+ expect(wrapper.text()).toContain('元')
139
+ expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--has-prefix')
140
+ expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--has-suffix')
141
+ })
142
+
143
+ it('renders prefix slot', () => {
144
+ const wrapper = mount(RsInput, {
145
+ slots: {
146
+ prefix: () => h('span', { class: 'custom-prefix' }, '@'),
147
+ },
148
+ })
149
+ expect(wrapper.find('.custom-prefix').exists()).toBe(true)
150
+ })
151
+
152
+ it('validates email on blur', async () => {
153
+ const wrapper = mount(RsInput, {
154
+ props: { modelValue: 'bad-email', rule: 'email', id: 'e1' },
155
+ })
156
+ await wrapper.find('input').trigger('blur')
157
+ expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--invalid')
158
+ expect(wrapper.find('.rs-input-field__error').text()).toContain('邮箱')
159
+ expect(wrapper.emitted('validate')?.[0]?.[0]).toEqual({
160
+ valid: false,
161
+ message: '请输入有效的邮箱地址',
162
+ })
163
+ })
164
+
165
+ it('passes email validation on blur', async () => {
166
+ const wrapper = mount(RsInput, {
167
+ props: { modelValue: 'user@example.com', rule: 'email' },
168
+ })
169
+ await wrapper.find('input').trigger('blur')
170
+ expect(wrapper.find('.rs-input-group').classes()).not.toContain('rs-input-group--invalid')
171
+ expect(wrapper.emitted('validate')?.[0]?.[0]).toEqual({ valid: true })
172
+ })
173
+
174
+ it('uses custom validator', async () => {
175
+ const wrapper = mount(RsInput, {
176
+ props: {
177
+ modelValue: 'ab',
178
+ validator: (v: string) => v.length >= 3 || '至少 3 个字符',
179
+ },
180
+ })
181
+ await wrapper.find('input').trigger('blur')
182
+ expect(wrapper.find('.rs-input-field__error').text()).toBe('至少 3 个字符')
183
+ })
184
+
185
+ it('validates email on blur with en-US locale', async () => {
186
+ const wrapper = mount(RsConfigProvider, {
187
+ props: { locale: 'en-US' },
188
+ slots: {
189
+ default: () =>
190
+ h(RsInput, {
191
+ modelValue: 'bad-email',
192
+ rule: 'email',
193
+ id: 'e2',
194
+ 'onUpdate:modelValue': () => {},
195
+ }),
196
+ },
197
+ })
198
+ await wrapper.find('input').trigger('blur')
199
+ expect(wrapper.find('.rs-input-field__error').text()).toContain('email')
200
+ })
201
+
202
+ it('clears value when clearable button is clicked', async () => {
203
+ const wrapper = mount(RsInput, {
204
+ props: { modelValue: 'hello', clearable: true },
205
+ })
206
+ await wrapper.find('.rs-input-group__action').trigger('click')
207
+ expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([''])
208
+ expect(wrapper.emitted('clear')).toHaveLength(1)
209
+ })
210
+
211
+ it('shows character count when showCount and maxlength are set', () => {
212
+ const wrapper = mount(RsInput, {
213
+ props: { modelValue: 'abc', maxlength: 10, showCount: true },
214
+ })
215
+ expect(wrapper.find('.rs-input-group__count').text()).toBe('3 / 10')
216
+ expect(wrapper.find('input').attributes('maxlength')).toBe('10')
217
+ })
218
+
219
+ it('sets readonly on input and group class', () => {
220
+ const wrapper = mount(RsInput, { props: { readonly: true, modelValue: 'x' } })
221
+ expect(wrapper.find('input').attributes('readonly')).toBeDefined()
222
+ expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--readonly')
223
+ })
224
+
225
+ it('toggles password visibility', async () => {
226
+ const wrapper = mount(RsInput, {
227
+ props: { modelValue: 'secret', type: 'password' as const },
228
+ })
229
+ expect(wrapper.find('input').attributes('type')).toBe('password')
230
+ expect(wrapper.find('.rs-input-group--has-suffix').exists()).toBe(true)
231
+ const toggle = wrapper.find('button.rs-input-group__action')
232
+ expect(toggle.exists()).toBe(true)
233
+ await toggle.trigger('click')
234
+ expect(wrapper.find('input').attributes('type')).toBe('text')
235
+ })
236
+
237
+ it('hides password toggle when visibilityToggle is false', () => {
238
+ const wrapper = mount(RsInput, {
239
+ props: { modelValue: 'secret', type: 'password', visibilityToggle: false },
240
+ })
241
+ expect(wrapper.find('button.rs-input-group__action').exists()).toBe(false)
242
+ })
243
+
244
+ it('emits pressEnter on Enter key', async () => {
245
+ const wrapper = mount(RsInput, { props: { modelValue: '' } })
246
+ await wrapper.find('input').trigger('keydown', { key: 'Enter' })
247
+ expect(wrapper.emitted('pressEnter')).toHaveLength(1)
248
+ })
249
+
250
+ it('keeps modelValue as string for type=number', async () => {
251
+ const wrapper = mount(RsInput, {
252
+ props: { modelValue: '10', type: 'number' },
253
+ })
254
+ const input = wrapper.find('input')
255
+ expect(input.attributes('type')).toBe('number')
256
+ await input.setValue('256')
257
+ const emitted = wrapper.emitted('update:modelValue')
258
+ expect(emitted?.at(-1)).toEqual(['256'])
259
+ expect(typeof emitted?.at(-1)?.[0]).toBe('string')
260
+ })
261
+
262
+ it('normalizes numeric modelValue prop to string display', () => {
263
+ const wrapper = mount(RsInput, {
264
+ // 模拟错误调用方传入 number;契约仍按 string 渲染
265
+ props: { modelValue: 256 as unknown as string, type: 'number' },
266
+ })
267
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('256')
268
+ })
269
+ })
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { nextTick } from 'vue'
4
+ import RsInputNumber from '../components/RsInputNumber.vue'
5
+ import {
6
+ clampNumber,
7
+ formatNumberValue,
8
+ fromModelValue,
9
+ isNumberInputInterim,
10
+ normalizeCommittedNumber,
11
+ parseNumberInput,
12
+ stepNumberValue,
13
+ toModelValue,
14
+ } from '../components/input-number-utils'
15
+
16
+ describe('input-number-utils', () => {
17
+ it('parses and rejects invalid text', () => {
18
+ expect(parseNumberInput('12.5')).toBe(12.5)
19
+ expect(parseNumberInput('')).toBeNull()
20
+ expect(parseNumberInput('-')).toBeNull()
21
+ expect(parseNumberInput('abc')).toBeNull()
22
+ })
23
+
24
+ it('detects interim typing states', () => {
25
+ expect(isNumberInputInterim('-')).toBe(true)
26
+ expect(isNumberInputInterim('+')).toBe(true)
27
+ expect(isNumberInputInterim('1.')).toBe(true)
28
+ expect(isNumberInputInterim('1e')).toBe(true)
29
+ expect(isNumberInputInterim('12')).toBe(false)
30
+ })
31
+
32
+ it('steps with precision and clamps', () => {
33
+ expect(stepNumberValue(1, 1, { step: 0.1, precision: 1 })).toBe(1.1)
34
+ expect(stepNumberValue(9, 1, { step: 1, max: 9 })).toBe(9)
35
+ expect(stepNumberValue(null, 1, { step: 1 })).toBe(1)
36
+ })
37
+
38
+ it('normalizes commit with min/max/precision', () => {
39
+ expect(normalizeCommittedNumber('3.456', { precision: 2 })).toBe(3.46)
40
+ expect(normalizeCommittedNumber('100', { max: 10 })).toBe(10)
41
+ expect(normalizeCommittedNumber('-', {})).toBeNull()
42
+ })
43
+
44
+ it('converts model modes', () => {
45
+ expect(toModelValue(3, false)).toBe(3)
46
+ expect(toModelValue(3, true)).toBe('3')
47
+ expect(fromModelValue('2.5')).toBe(2.5)
48
+ expect(clampNumber(20, 0, 10)).toBe(10)
49
+ expect(formatNumberValue(1.2, { precision: 2 })).toBe('1.20')
50
+ })
51
+ })
52
+
53
+ describe('RsInputNumber', () => {
54
+ function mountNumber(props: Record<string, unknown> = {}) {
55
+ const state = { value: (props.modelValue ?? null) as unknown }
56
+ const wrapper = mount(RsInputNumber, {
57
+ props: {
58
+ ...props,
59
+ modelValue: state.value as never,
60
+ 'onUpdate:modelValue': (v: unknown) => {
61
+ state.value = v
62
+ void wrapper.setProps({ modelValue: v as never })
63
+ },
64
+ },
65
+ })
66
+ return { wrapper, state }
67
+ }
68
+
69
+ it('emits number by default', async () => {
70
+ const { wrapper, state } = mountNumber({ modelValue: 1 })
71
+ const input = wrapper.find('input')
72
+ await input.setValue('42')
73
+ await input.trigger('blur')
74
+ expect(state.value).toBe(42)
75
+ })
76
+
77
+ it('emits string in stringMode', async () => {
78
+ const { wrapper, state } = mountNumber({ modelValue: '1', stringMode: true })
79
+ const input = wrapper.find('input')
80
+ await input.setValue('256')
81
+ await input.trigger('blur')
82
+ expect(state.value).toBe('256')
83
+ expect(typeof state.value).toBe('string')
84
+ })
85
+
86
+ it('steps via controls and clamps to max', async () => {
87
+ const { wrapper, state } = mountNumber({
88
+ modelValue: 9,
89
+ step: 1,
90
+ max: 10,
91
+ controls: true,
92
+ })
93
+ const up = wrapper.find('.rs-input-number__handler--up')
94
+ await up.trigger('click')
95
+ await nextTick()
96
+ expect(state.value).toBe(10)
97
+ await up.trigger('click')
98
+ await nextTick()
99
+ expect(state.value).toBe(10)
100
+ })
101
+
102
+ it('supports keyboard arrow steps', async () => {
103
+ const { wrapper, state } = mountNumber({ modelValue: 5, step: 2, keyboard: true })
104
+ const input = wrapper.find('input')
105
+ await input.trigger('keydown', { key: 'ArrowUp' })
106
+ expect(state.value).toBe(7)
107
+ await input.trigger('keydown', { key: 'ArrowDown' })
108
+ expect(state.value).toBe(5)
109
+ })
110
+
111
+ it('clears to null on empty blur', async () => {
112
+ const { wrapper, state } = mountNumber({ modelValue: 3 })
113
+ const input = wrapper.find('input')
114
+ await input.setValue('')
115
+ await input.trigger('blur')
116
+ expect(state.value).toBeNull()
117
+ })
118
+ })
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import RsInput from '../components/RsInput.vue'
4
+ import RsLabel from '../components/RsLabel.vue'
5
+
6
+ describe('RsLabel', () => {
7
+ it('renders slot text', () => {
8
+ const wrapper = mount(RsLabel, {
9
+ slots: { default: '邮箱' },
10
+ })
11
+ expect(wrapper.text()).toContain('邮箱')
12
+ })
13
+
14
+ it('shows required marker when required is true', () => {
15
+ const wrapper = mount(RsLabel, {
16
+ props: { required: true },
17
+ slots: { default: '名称' },
18
+ })
19
+ expect(wrapper.find('.rs-label__required').exists()).toBe(true)
20
+ expect(wrapper.find('.rs-label__required').text()).toBe('*')
21
+ })
22
+
23
+ it('hides required marker by default', () => {
24
+ const wrapper = mount(RsLabel, {
25
+ slots: { default: '名称' },
26
+ })
27
+ expect(wrapper.find('.rs-label__required').exists()).toBe(false)
28
+ })
29
+
30
+ it('marks required asterisk as aria-hidden', () => {
31
+ const wrapper = mount(RsLabel, {
32
+ props: { required: true },
33
+ slots: { default: '密码' },
34
+ })
35
+ expect(wrapper.find('.rs-label__required').attributes('aria-hidden')).toBe('true')
36
+ })
37
+
38
+ it('binds for attribute when forId is set', () => {
39
+ const wrapper = mount(RsLabel, {
40
+ props: { forId: 'email' },
41
+ slots: { default: '邮箱' },
42
+ })
43
+ expect(wrapper.attributes('for')).toBe('email')
44
+ })
45
+
46
+ it('omits for attribute when forId is not set', () => {
47
+ const wrapper = mount(RsLabel, {
48
+ slots: { default: '说明' },
49
+ })
50
+ expect(wrapper.attributes('for')).toBeUndefined()
51
+ })
52
+
53
+ it('renders hint text', () => {
54
+ const wrapper = mount(RsLabel, {
55
+ props: { hint: '用于登录与通知' },
56
+ slots: { default: '邮箱地址' },
57
+ })
58
+ expect(wrapper.find('.rs-label__hint').text()).toBe('用于登录与通知')
59
+ })
60
+
61
+ it('does not render hint element when hint is omitted', () => {
62
+ const wrapper = mount(RsLabel, {
63
+ slots: { default: '邮箱地址' },
64
+ })
65
+ expect(wrapper.find('.rs-label__hint').exists()).toBe(false)
66
+ })
67
+
68
+ it('applies disabled class when disabled', () => {
69
+ const wrapper = mount(RsLabel, {
70
+ props: { disabled: true },
71
+ slots: { default: '已禁用字段' },
72
+ })
73
+ expect(wrapper.classes()).toContain('rs-label--disabled')
74
+ })
75
+
76
+ it('supports required, hint, forId and disabled together', () => {
77
+ const wrapper = mount(RsLabel, {
78
+ props: {
79
+ forId: 'username',
80
+ required: true,
81
+ hint: '3–20 个字符',
82
+ disabled: true,
83
+ },
84
+ slots: { default: '用户名' },
85
+ })
86
+ expect(wrapper.attributes('for')).toBe('username')
87
+ expect(wrapper.classes()).toContain('rs-label--disabled')
88
+ expect(wrapper.find('.rs-label__required').exists()).toBe(true)
89
+ expect(wrapper.find('.rs-label__hint').text()).toBe('3–20 个字符')
90
+ expect(wrapper.text()).toContain('用户名')
91
+ })
92
+
93
+ it('links label to input when forId matches input id', () => {
94
+ const wrapper = mount({
95
+ components: { RsLabel, RsInput },
96
+ template: `
97
+ <div>
98
+ <RsLabel for-id="pg-label-input">可点击聚焦</RsLabel>
99
+ <RsInput id="pg-label-input" />
100
+ </div>
101
+ `,
102
+ })
103
+ expect(wrapper.find('label').attributes('for')).toBe('pg-label-input')
104
+ expect(wrapper.find('input').attributes('id')).toBe('pg-label-input')
105
+ })
106
+ })
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import RsLink from '../components/RsLink.vue'
4
+
5
+ describe('RsLink', () => {
6
+ it('renders slot text and href', () => {
7
+ const wrapper = mount(RsLink, {
8
+ props: { href: '/docs' },
9
+ slots: { default: '查看文档' },
10
+ })
11
+ expect(wrapper.text()).toContain('查看文档')
12
+ expect(wrapper.attributes('href')).toBe('/docs')
13
+ })
14
+
15
+ it('adds secure rel for target blank', () => {
16
+ const wrapper = mount(RsLink, {
17
+ props: { href: 'https://example.com', target: '_blank' },
18
+ })
19
+ const rel = wrapper.attributes('rel')
20
+ expect(rel).toContain('noopener')
21
+ expect(rel).toContain('noreferrer')
22
+ })
23
+
24
+ it('merges custom rel with secure rel for target blank', () => {
25
+ const wrapper = mount(RsLink, {
26
+ props: { href: 'https://example.com', target: '_blank', rel: 'external' },
27
+ })
28
+ const rel = wrapper.attributes('rel')
29
+ expect(rel).toContain('external')
30
+ expect(rel).toContain('noopener')
31
+ expect(rel).toContain('noreferrer')
32
+ })
33
+
34
+ it('sets disabled accessibility attributes when disabled', () => {
35
+ const wrapper = mount(RsLink, {
36
+ props: { href: '/docs', disabled: true },
37
+ })
38
+ expect(wrapper.attributes('aria-disabled')).toBe('true')
39
+ expect(wrapper.attributes('tabindex')).toBe('-1')
40
+ expect(wrapper.attributes('href')).toBeUndefined()
41
+ })
42
+
43
+ it('applies underline variant class', () => {
44
+ const wrapper = mount(RsLink, {
45
+ props: { href: '/docs', underline: 'always' },
46
+ })
47
+ expect(wrapper.classes()).toContain('rs-link--underline-always')
48
+ })
49
+ })
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { h } from 'vue'
3
+ import { mount } from '@vue/test-utils'
4
+ import RsConfigProvider from '../components/RsConfigProvider.vue'
5
+ import RsLoading from '../components/RsLoading.vue'
6
+
7
+ describe('RsLoading', () => {
8
+ it('renders spinner by default', () => {
9
+ const wrapper = mount(RsLoading)
10
+ expect(wrapper.find('.rs-loading__spinner').exists()).toBe(true)
11
+ expect(wrapper.classes()).toContain('rs-loading')
12
+ })
13
+
14
+ it('renders dots variant', () => {
15
+ const wrapper = mount(RsLoading, { props: { variant: 'dots' } })
16
+ expect(wrapper.find('.rs-loading__dots').exists()).toBe(true)
17
+ expect(wrapper.findAll('.rs-loading__dot')).toHaveLength(3)
18
+ })
19
+
20
+ it('renders skeleton variant with default line count', () => {
21
+ const wrapper = mount(RsLoading, { props: { variant: 'skeleton' } })
22
+ expect(wrapper.find('.rs-loading__skeleton').exists()).toBe(true)
23
+ expect(wrapper.findAll('.rs-loading__skeleton-line')).toHaveLength(4)
24
+ })
25
+
26
+ it('respects skeletonLines prop', () => {
27
+ const wrapper = mount(RsLoading, {
28
+ props: { variant: 'skeleton', skeletonLines: 2 },
29
+ })
30
+ expect(wrapper.findAll('.rs-loading__skeleton-line')).toHaveLength(2)
31
+ })
32
+
33
+ it.each(['sm', 'md', 'lg'] as const)('applies %s size class on spinner', (size) => {
34
+ const wrapper = mount(RsLoading, { props: { size } })
35
+ expect(wrapper.find('.rs-loading__spinner').classes()).toContain(`rs-loading__spinner--${size}`)
36
+ })
37
+
38
+ it.each(['default', 'primary'] as const)('applies %s tone class on spinner', (tone) => {
39
+ const wrapper = mount(RsLoading, { props: { tone } })
40
+ expect(wrapper.find('.rs-loading__spinner').classes()).toContain(`rs-loading__spinner--${tone}`)
41
+ })
42
+
43
+ it('hides root when loading is false', () => {
44
+ const wrapper = mount(RsLoading, { props: { loading: false } })
45
+ expect(wrapper.find('.rs-loading').exists()).toBe(false)
46
+ })
47
+
48
+ it('applies block and overlay modifier classes', () => {
49
+ const wrapper = mount(RsLoading, { props: { block: true, overlay: true } })
50
+ expect(wrapper.classes()).toContain('rs-loading--block')
51
+ expect(wrapper.classes()).toContain('rs-loading--overlay')
52
+ })
53
+
54
+ it('exposes status role and default zh-CN aria-label', () => {
55
+ const wrapper = mount(RsLoading)
56
+ expect(wrapper.attributes('role')).toBe('status')
57
+ expect(wrapper.attributes('aria-label')).toBe('加载中')
58
+ })
59
+
60
+ it('shows label when showLabel is true', () => {
61
+ const wrapper = mount(RsLoading, { props: { showLabel: true } })
62
+ expect(wrapper.find('.rs-loading__label').text()).toBe('加载中')
63
+ })
64
+
65
+ it('uses custom label for visible text and aria-label', () => {
66
+ const wrapper = mount(RsLoading, {
67
+ props: { showLabel: true, label: '正在同步…' },
68
+ })
69
+ expect(wrapper.find('.rs-loading__label').text()).toBe('正在同步…')
70
+ expect(wrapper.attributes('aria-label')).toBe('正在同步…')
71
+ })
72
+
73
+ it('uses en-US aria-label inside RsConfigProvider', () => {
74
+ const wrapper = mount(RsConfigProvider, {
75
+ props: { locale: 'en-US' },
76
+ slots: {
77
+ default: () => h(RsLoading, { showLabel: true }),
78
+ },
79
+ })
80
+ const loading = wrapper.find('.rs-loading')
81
+ expect(loading.attributes('aria-label')).toBe('Loading')
82
+ expect(loading.find('.rs-loading__label').text()).toBe('Loading')
83
+ })
84
+ })