niuma-ui 1.0.2 → 1.1.1

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 (88) hide show
  1. package/CHANGELOG.md +37 -1
  2. package/package.json +1 -1
  3. package/src/__tests__/RsDatePicker.spec.ts +21 -0
  4. package/src/__tests__/RsDialog.spec.ts +61 -0
  5. package/src/__tests__/RsForm.spec.ts +5 -5
  6. package/src/__tests__/RsFormItem.spec.ts +478 -0
  7. package/src/__tests__/RsFormList.spec.ts +109 -0
  8. package/src/__tests__/RsInput.spec.ts +93 -0
  9. package/src/__tests__/RsInputNumber.spec.ts +33 -0
  10. package/src/__tests__/RsSelect.spec.ts +423 -1
  11. package/src/__tests__/RsSwitch.spec.ts +30 -0
  12. package/src/__tests__/RsTimePicker.spec.ts +9 -0
  13. package/src/__tests__/RsTooltip.spec.ts +43 -0
  14. package/src/__tests__/css-token.spec.ts +51 -0
  15. package/src/__tests__/date-picker-utils.spec.ts +49 -0
  16. package/src/__tests__/dialog-utils.spec.ts +43 -0
  17. package/src/__tests__/form-path.spec.ts +46 -0
  18. package/src/__tests__/form-rules.spec.ts +63 -0
  19. package/src/__tests__/theme.spec.ts +2 -0
  20. package/src/components/RsAlert.vue +1 -1
  21. package/src/components/RsAvatar.vue +1 -1
  22. package/src/components/RsBadge.vue +1 -1
  23. package/src/components/RsBreadcrumb.vue +1 -1
  24. package/src/components/RsButton.vue +4 -4
  25. package/src/components/RsCalendarGrid.vue +3 -3
  26. package/src/components/RsCard.vue +4 -4
  27. package/src/components/RsCodeBlock.vue +4 -4
  28. package/src/components/RsCodeEditor.vue +8 -8
  29. package/src/components/RsConfirmDialog.vue +2 -2
  30. package/src/components/RsContextMenu.vue +6 -4
  31. package/src/components/RsDatePicker.vue +75 -31
  32. package/src/components/RsDateTimePicker.vue +9 -1
  33. package/src/components/RsDescriptions.vue +2 -2
  34. package/src/components/RsDialog.vue +26 -16
  35. package/src/components/RsDrawer.vue +1 -1
  36. package/src/components/RsDropdown.vue +1 -1
  37. package/src/components/RsDynamicTags.vue +18 -4
  38. package/src/components/RsEmpty.vue +1 -1
  39. package/src/components/RsForm.vue +68 -1
  40. package/src/components/RsFormItem.vue +423 -0
  41. package/src/components/RsFormItemControl.vue +21 -0
  42. package/src/components/RsFormList.vue +117 -0
  43. package/src/components/RsInput.vue +411 -18
  44. package/src/components/RsInputNumber.vue +13 -4
  45. package/src/components/RsLink.vue +1 -1
  46. package/src/components/RsMarkdown.vue +5 -5
  47. package/src/components/RsMenu.vue +2 -2
  48. package/src/components/RsMonacoEditor.vue +9 -3
  49. package/src/components/RsPagination.vue +1 -1
  50. package/src/components/RsProseEditor.vue +3 -3
  51. package/src/components/RsSelect.vue +332 -611
  52. package/src/components/RsSidebarGroup.vue +1 -1
  53. package/src/components/RsSplitPane.vue +22 -1
  54. package/src/components/RsStatCard.vue +2 -2
  55. package/src/components/RsSteps.vue +2 -2
  56. package/src/components/RsSwitch.vue +20 -7
  57. package/src/components/RsTabs.vue +9 -9
  58. package/src/components/RsTag.vue +1 -1
  59. package/src/components/RsTerminal.vue +58 -19
  60. package/src/components/RsTimePicker.vue +28 -4
  61. package/src/components/RsTimePickerColumns.vue +4 -4
  62. package/src/components/RsToaster.vue +7 -7
  63. package/src/components/RsTooltip.vue +10 -1
  64. package/src/components/RsTooltipProvider.vue +11 -1
  65. package/src/components/RsTree.vue +6 -6
  66. package/src/components/RsUpload.vue +2 -2
  67. package/src/components/date-picker-utils.ts +113 -2
  68. package/src/components/dialog-utils.ts +37 -2
  69. package/src/components/form-item-control.ts +55 -0
  70. package/src/components/form-path.ts +92 -0
  71. package/src/components/form-rules.ts +64 -10
  72. package/src/components/form-utils.ts +94 -2
  73. package/src/components/select-utils.ts +272 -12
  74. package/src/components/table/RsTableCellEditor.vue +13 -5
  75. package/src/components/table/RsTableHeaderFilter.vue +1 -1
  76. package/src/components/table/rs-table.css +1 -1
  77. package/src/components/use-rs-select.ts +436 -0
  78. package/src/composables/useRsConfig.ts +10 -3
  79. package/src/composables/useRsI18n.ts +16 -5
  80. package/src/composables/useRsTableCore.ts +3 -1
  81. package/src/composables/useRsTableHeadless.ts +10 -2
  82. package/src/index.ts +69 -1
  83. package/src/locale/interpolate.ts +32 -0
  84. package/src/locale/messages.ts +10 -0
  85. package/src/styles.css +68 -12
  86. package/src/theme/brand.example.css +4 -1
  87. package/src/theme/css-token.ts +107 -0
  88. package/src/theme/types.ts +1 -0
@@ -0,0 +1,478 @@
1
+ import { defineComponent, ref } from 'vue'
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { flushPromises, mount } from '@vue/test-utils'
4
+ import RsConfigProvider from '../components/RsConfigProvider.vue'
5
+ import RsForm from '../components/RsForm.vue'
6
+ import RsFormItem from '../components/RsFormItem.vue'
7
+ import RsDatePicker from '../components/RsDatePicker.vue'
8
+ import RsInput from '../components/RsInput.vue'
9
+ import RsSelect from '../components/RsSelect.vue'
10
+ import RsSwitch from '../components/RsSwitch.vue'
11
+ import RsTimePicker from '../components/RsTimePicker.vue'
12
+
13
+ describe('RsFormItem', () => {
14
+ afterEach(() => {
15
+ document.body.innerHTML = ''
16
+ })
17
+
18
+ it('validates custom slot content without rendering an input', async () => {
19
+ const Host = defineComponent({
20
+ components: { RsForm, RsFormItem },
21
+ setup() {
22
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
23
+ const service = ref('')
24
+ return { formRef, service }
25
+ },
26
+ template: `
27
+ <RsForm ref="formRef">
28
+ <RsFormItem v-model="service" name="service" required>
29
+ <button type="button" class="picker">pick</button>
30
+ </RsFormItem>
31
+ </RsForm>
32
+ `,
33
+ })
34
+ const wrapper = mount(Host)
35
+ await flushPromises()
36
+
37
+ const result = await wrapper.vm.formRef!.validate()
38
+ expect(result.valid).toBe(false)
39
+ expect(result.errors.service).toBe('此项为必填')
40
+ expect(wrapper.find('input').exists()).toBe(false)
41
+ expect(wrapper.find('.rs-form-item__error').text()).toBe('此项为必填')
42
+ expect(wrapper.find('.picker').exists()).toBe(true)
43
+ wrapper.unmount()
44
+ })
45
+
46
+ it('uses en-US required copy inside RsConfigProvider', async () => {
47
+ const Host = defineComponent({
48
+ components: { RsConfigProvider, RsForm, RsFormItem },
49
+ setup() {
50
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
51
+ const service = ref('')
52
+ return { formRef, service }
53
+ },
54
+ template: `
55
+ <RsConfigProvider locale="en-US">
56
+ <RsForm ref="formRef">
57
+ <RsFormItem v-model="service" name="service" required>
58
+ <button type="button">pick</button>
59
+ </RsFormItem>
60
+ </RsForm>
61
+ </RsConfigProvider>
62
+ `,
63
+ })
64
+ const wrapper = mount(Host)
65
+ await flushPromises()
66
+ const result = await wrapper.vm.formRef!.validate()
67
+ expect(result.valid).toBe(false)
68
+ expect(result.errors.service).toBe('This field is required')
69
+ wrapper.unmount()
70
+ })
71
+
72
+ it('clears error when custom value changes after submit', async () => {
73
+ const Host = defineComponent({
74
+ components: { RsForm, RsFormItem },
75
+ setup() {
76
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
77
+ const service = ref('')
78
+ return { formRef, service }
79
+ },
80
+ template: `
81
+ <RsForm ref="formRef">
82
+ <RsFormItem v-model="service" name="service" required>
83
+ <button type="button" class="picker">pick</button>
84
+ </RsFormItem>
85
+ </RsForm>
86
+ `,
87
+ })
88
+ const wrapper = mount(Host)
89
+ await flushPromises()
90
+ await wrapper.vm.formRef!.validate()
91
+ await flushPromises()
92
+ expect(wrapper.find('.rs-form-item__error').exists()).toBe(true)
93
+
94
+ wrapper.vm.service = 'svc-1'
95
+ await flushPromises()
96
+ expect(wrapper.find('.rs-form-item__error').exists()).toBe(false)
97
+
98
+ wrapper.vm.service = ''
99
+ await flushPromises()
100
+ expect(wrapper.find('.rs-form-item__error').text()).toBe('此项为必填')
101
+ wrapper.unmount()
102
+ })
103
+
104
+ it('resetFields restores the item model', async () => {
105
+ const Host = defineComponent({
106
+ components: { RsForm, RsFormItem },
107
+ setup() {
108
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
109
+ const service = ref('initial')
110
+ return { formRef, service }
111
+ },
112
+ template: `
113
+ <RsForm ref="formRef">
114
+ <RsFormItem v-model="service" name="service">
115
+ <span class="value">{{ service }}</span>
116
+ </RsFormItem>
117
+ </RsForm>
118
+ `,
119
+ })
120
+ const wrapper = mount(Host)
121
+ await flushPromises()
122
+ wrapper.vm.service = 'changed'
123
+ await flushPromises()
124
+ wrapper.vm.formRef!.resetFields()
125
+ await flushPromises()
126
+ expect(wrapper.vm.service).toBe('initial')
127
+ wrapper.unmount()
128
+ })
129
+
130
+ it('exposes invalid via default slot props', async () => {
131
+ const Host = defineComponent({
132
+ components: { RsForm, RsFormItem },
133
+ setup() {
134
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
135
+ const service = ref('')
136
+ return { formRef, service }
137
+ },
138
+ template: `
139
+ <RsForm ref="formRef">
140
+ <RsFormItem v-model="service" name="service" required>
141
+ <template #default="{ invalid }">
142
+ <button type="button" class="picker" :data-invalid="invalid">pick</button>
143
+ </template>
144
+ </RsFormItem>
145
+ </RsForm>
146
+ `,
147
+ })
148
+ const wrapper = mount(Host)
149
+ await flushPromises()
150
+ expect(wrapper.find('.picker').attributes('data-invalid')).toBe('false')
151
+ await wrapper.vm.formRef!.validate()
152
+ await flushPromises()
153
+ expect(wrapper.find('.picker').attributes('data-invalid')).toBe('true')
154
+ wrapper.unmount()
155
+ })
156
+
157
+ it('renders Form #error slot for item messages', async () => {
158
+ const Host = defineComponent({
159
+ components: { RsForm, RsFormItem },
160
+ setup() {
161
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
162
+ const service = ref('')
163
+ const rules = {
164
+ service: [{ required: true, message: 'pick a service' }],
165
+ }
166
+ return { formRef, service, rules }
167
+ },
168
+ template: `
169
+ <RsForm ref="formRef" :rules="rules">
170
+ <template #error="{ message }">
171
+ <span class="custom-form-error">CUSTOM:{{ message }}</span>
172
+ </template>
173
+ <RsFormItem v-model="service" name="service">
174
+ <button type="button">pick</button>
175
+ </RsFormItem>
176
+ </RsForm>
177
+ `,
178
+ })
179
+ const wrapper = mount(Host)
180
+ await flushPromises()
181
+ await wrapper.vm.formRef!.validate()
182
+ await flushPromises()
183
+ expect(wrapper.find('.custom-form-error').text()).toBe('CUSTOM:pick a service')
184
+ wrapper.unmount()
185
+ })
186
+
187
+ it('does not register when name is omitted', async () => {
188
+ const Host = defineComponent({
189
+ components: { RsForm, RsFormItem },
190
+ setup() {
191
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
192
+ return { formRef }
193
+ },
194
+ template: `
195
+ <RsForm ref="formRef">
196
+ <RsFormItem required>
197
+ <button type="button">layout only</button>
198
+ </RsFormItem>
199
+ </RsForm>
200
+ `,
201
+ })
202
+ const wrapper = mount(Host)
203
+ await flushPromises()
204
+ const result = await wrapper.vm.formRef!.validate()
205
+ expect(result.valid).toBe(true)
206
+ expect(result.errors).toEqual({})
207
+ wrapper.unmount()
208
+ })
209
+
210
+ it('interpolates required message with label', async () => {
211
+ const Host = defineComponent({
212
+ components: { RsForm, RsFormItem },
213
+ setup() {
214
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
215
+ const service = ref('')
216
+ return { formRef, service }
217
+ },
218
+ template: `
219
+ <RsForm ref="formRef">
220
+ <RsFormItem v-model="service" name="service" label="关联服务" required>
221
+ <button type="button">pick</button>
222
+ </RsFormItem>
223
+ </RsForm>
224
+ `,
225
+ })
226
+ const wrapper = mount(Host)
227
+ await flushPromises()
228
+ const result = await wrapper.vm.formRef!.validate()
229
+ expect(result.errors.service).toBe('请填写关联服务')
230
+ wrapper.unmount()
231
+ })
232
+
233
+ it('lets Form.Item own the field so nested RsInput does not show a second error', async () => {
234
+ const Host = defineComponent({
235
+ components: { RsForm, RsFormItem, RsInput },
236
+ setup() {
237
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
238
+ const email = ref('')
239
+ return { formRef, email }
240
+ },
241
+ template: `
242
+ <RsForm ref="formRef">
243
+ <RsFormItem v-model="email" name="email" required>
244
+ <RsInput />
245
+ </RsFormItem>
246
+ </RsForm>
247
+ `,
248
+ })
249
+ const wrapper = mount(Host)
250
+ await flushPromises()
251
+ await wrapper.vm.formRef!.validate()
252
+ await flushPromises()
253
+ expect(wrapper.findAll('.rs-form-item__error')).toHaveLength(1)
254
+ expect(wrapper.find('.rs-input-field__error').exists()).toBe(false)
255
+ expect(wrapper.find('.rs-input-group--invalid').exists()).toBe(true)
256
+ wrapper.unmount()
257
+ })
258
+
259
+ it('binds Form.model into nested input without child v-model', async () => {
260
+ const Host = defineComponent({
261
+ components: { RsForm, RsFormItem, RsInput },
262
+ setup() {
263
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
264
+ const model = ref({ email: '' })
265
+ return { formRef, model }
266
+ },
267
+ template: `
268
+ <RsForm ref="formRef" :model="model">
269
+ <RsFormItem name="email" required>
270
+ <RsInput />
271
+ </RsFormItem>
272
+ </RsForm>
273
+ `,
274
+ })
275
+ const wrapper = mount(Host)
276
+ await flushPromises()
277
+ wrapper.find('input').setValue('a@b.com')
278
+ await flushPromises()
279
+ expect(wrapper.vm.model.email).toBe('a@b.com')
280
+ wrapper.vm.formRef!.setFieldsValue({ email: 'c@d.com' })
281
+ await flushPromises()
282
+ expect(wrapper.vm.model.email).toBe('c@d.com')
283
+ expect((wrapper.find('input').element as HTMLInputElement).value).toBe('c@d.com')
284
+ wrapper.unmount()
285
+ })
286
+
287
+ it('renders help and extra', () => {
288
+ const wrapper = mount(RsFormItem, {
289
+ props: { help: '辅助说明', extra: '额外提示' },
290
+ slots: { default: '<span class="ctrl">x</span>' },
291
+ })
292
+ expect(wrapper.find('.rs-form-item__help').text()).toBe('辅助说明')
293
+ expect(wrapper.find('.rs-form-item__extra').text()).toBe('额外提示')
294
+ wrapper.unmount()
295
+ })
296
+
297
+ it('revalidates when a dependency field changes', async () => {
298
+ const Host = defineComponent({
299
+ components: { RsForm, RsFormItem, RsInput },
300
+ setup() {
301
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
302
+ const model = ref({ password: 'aa', confirm: 'bb' })
303
+ return { formRef, model }
304
+ },
305
+ template: `
306
+ <RsForm ref="formRef" :model="model">
307
+ <RsFormItem name="password" label="密码" required>
308
+ <RsInput />
309
+ </RsFormItem>
310
+ <RsFormItem
311
+ name="confirm"
312
+ label="确认密码"
313
+ :dependencies="['password']"
314
+ :rules="[{
315
+ validator(value, ctx) {
316
+ if (String(value) !== String(ctx.getFieldValue('password'))) return '两次密码不一致'
317
+ return true
318
+ }
319
+ }]"
320
+ >
321
+ <RsInput />
322
+ </RsFormItem>
323
+ </RsForm>
324
+ `,
325
+ })
326
+ const wrapper = mount(Host)
327
+ await flushPromises()
328
+ const result = await wrapper.vm.formRef!.validate()
329
+ expect(result.valid).toBe(false)
330
+ expect(result.errors.confirm).toBe('两次密码不一致')
331
+
332
+ wrapper.vm.model.password = 'bb'
333
+ await flushPromises()
334
+ expect(wrapper.findAll('.rs-form-item__error').length).toBe(0)
335
+ wrapper.unmount()
336
+ })
337
+
338
+ it('does not overwrite a child that already binds v-model', async () => {
339
+ const Host = defineComponent({
340
+ components: { RsForm, RsFormItem, RsSwitch },
341
+ setup() {
342
+ const model = ref({ deptAdminFlag: 'N' })
343
+ const toBool = (v: string) => v === 'Y'
344
+ const fromBool = (v: boolean) => (v ? 'Y' : 'N')
345
+ return { model, toBool, fromBool }
346
+ },
347
+ template: `
348
+ <RsForm :model="model">
349
+ <RsFormItem name="deptAdminFlag">
350
+ <RsSwitch
351
+ :model-value="toBool(model.deptAdminFlag)"
352
+ @update:model-value="(v) => (model.deptAdminFlag = fromBool(v))"
353
+ />
354
+ </RsFormItem>
355
+ </RsForm>
356
+ `,
357
+ })
358
+ const wrapper = mount(Host)
359
+ await flushPromises()
360
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
361
+ await wrapper.find('.rs-switch__root').trigger('click')
362
+ await flushPromises()
363
+ expect(wrapper.vm.model.deptAdminFlag).toBe('Y')
364
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(true)
365
+ wrapper.unmount()
366
+ })
367
+
368
+ it('lets Form.Item inject Y/N into Switch via checkedValue', async () => {
369
+ const Host = defineComponent({
370
+ components: { RsForm, RsFormItem, RsSwitch },
371
+ setup() {
372
+ const model = ref({ deptAdminFlag: 'N' })
373
+ return { model }
374
+ },
375
+ template: `
376
+ <RsForm :model="model">
377
+ <RsFormItem name="deptAdminFlag">
378
+ <RsSwitch checked-value="Y" unchecked-value="N" />
379
+ </RsFormItem>
380
+ </RsForm>
381
+ `,
382
+ })
383
+ const wrapper = mount(Host)
384
+ await flushPromises()
385
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
386
+ await wrapper.find('.rs-switch__root').trigger('click')
387
+ await flushPromises()
388
+ expect(wrapper.vm.model.deptAdminFlag).toBe('Y')
389
+ expect(wrapper.find('.rs-switch--checked').exists()).toBe(true)
390
+ wrapper.unmount()
391
+ })
392
+
393
+ it('paints DatePicker invalid on the trigger, not the field wrapper', async () => {
394
+ const Host = defineComponent({
395
+ components: { RsForm, RsFormItem, RsDatePicker },
396
+ setup() {
397
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
398
+ const expire = ref('')
399
+ return { formRef, expire }
400
+ },
401
+ template: `
402
+ <RsForm ref="formRef">
403
+ <RsFormItem v-model="expire" name="expire" required>
404
+ <RsDatePicker />
405
+ </RsFormItem>
406
+ </RsForm>
407
+ `,
408
+ })
409
+ const wrapper = mount(Host)
410
+ await flushPromises()
411
+ await wrapper.vm.formRef!.validate()
412
+ await flushPromises()
413
+
414
+ const trigger = wrapper.find('.rs-date-picker__trigger')
415
+ expect(trigger.classes()).toContain('rs-date-picker__trigger--invalid')
416
+ expect(trigger.attributes('aria-invalid')).toBe('true')
417
+ expect(wrapper.find('.rs-date-picker').attributes('aria-invalid')).toBeUndefined()
418
+ expect(wrapper.find('.rs-field').attributes('aria-invalid')).toBeUndefined()
419
+ wrapper.unmount()
420
+ })
421
+
422
+ it('paints TimePicker invalid on the trigger, not the field wrapper', async () => {
423
+ const Host = defineComponent({
424
+ components: { RsForm, RsFormItem, RsTimePicker },
425
+ setup() {
426
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
427
+ const time = ref('')
428
+ return { formRef, time }
429
+ },
430
+ template: `
431
+ <RsForm ref="formRef">
432
+ <RsFormItem v-model="time" name="time" required>
433
+ <RsTimePicker />
434
+ </RsFormItem>
435
+ </RsForm>
436
+ `,
437
+ })
438
+ const wrapper = mount(Host)
439
+ await flushPromises()
440
+ await wrapper.vm.formRef!.validate()
441
+ await flushPromises()
442
+
443
+ const trigger = wrapper.find('.rs-time-picker__trigger')
444
+ expect(trigger.classes()).toContain('rs-time-picker__trigger--invalid')
445
+ expect(trigger.attributes('aria-invalid')).toBe('true')
446
+ expect(wrapper.find('.rs-field').attributes('aria-invalid')).toBeUndefined()
447
+ wrapper.unmount()
448
+ })
449
+
450
+ it('paints Select invalid on the trigger, not the combobox root', async () => {
451
+ const Host = defineComponent({
452
+ components: { RsForm, RsFormItem, RsSelect },
453
+ setup() {
454
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
455
+ const role = ref('')
456
+ const options = [{ label: '管理员', value: 'admin' }]
457
+ return { formRef, role, options }
458
+ },
459
+ template: `
460
+ <RsForm ref="formRef">
461
+ <RsFormItem v-model="role" name="role" required>
462
+ <RsSelect :options="options" />
463
+ </RsFormItem>
464
+ </RsForm>
465
+ `,
466
+ })
467
+ const wrapper = mount(Host)
468
+ await flushPromises()
469
+ await wrapper.vm.formRef!.validate()
470
+ await flushPromises()
471
+
472
+ const trigger = wrapper.find('.rs-select__trigger')
473
+ expect(trigger.classes()).toContain('rs-select__trigger--invalid')
474
+ expect(trigger.attributes('aria-invalid')).toBe('true')
475
+ expect(wrapper.find('.rs-select').attributes('aria-invalid')).toBeUndefined()
476
+ wrapper.unmount()
477
+ })
478
+ })
@@ -0,0 +1,109 @@
1
+ import { defineComponent, ref } from 'vue'
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { flushPromises, mount } from '@vue/test-utils'
4
+ import RsForm from '../components/RsForm.vue'
5
+ import RsFormItem from '../components/RsFormItem.vue'
6
+ import RsFormList from '../components/RsFormList.vue'
7
+ import RsInput from '../components/RsInput.vue'
8
+
9
+ describe('RsFormList', () => {
10
+ afterEach(() => {
11
+ document.body.innerHTML = ''
12
+ })
13
+
14
+ it('adds and removes rows on Form.model', async () => {
15
+ const Host = defineComponent({
16
+ components: { RsForm, RsFormItem, RsFormList, RsInput },
17
+ setup() {
18
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
19
+ const model = ref<{ users: Array<{ name: string }> }>({ users: [] })
20
+ return { formRef, model }
21
+ },
22
+ template: `
23
+ <RsForm ref="formRef" :model="model">
24
+ <RsFormList name="users">
25
+ <template #default="{ fields, add, remove }">
26
+ <div v-for="field in fields" :key="field.key" class="row">
27
+ <RsFormItem :name="[field.name, 'name']" required>
28
+ <RsInput />
29
+ </RsFormItem>
30
+ <button type="button" class="remove" @click="remove(field.index)">x</button>
31
+ </div>
32
+ <button type="button" class="add" @click="add({ name: '' })">add</button>
33
+ </template>
34
+ </RsFormList>
35
+ </RsForm>
36
+ `,
37
+ })
38
+ const wrapper = mount(Host)
39
+ await flushPromises()
40
+ await wrapper.find('.add').trigger('click')
41
+ await flushPromises()
42
+ expect(wrapper.vm.model.users).toEqual([{ name: '' }])
43
+
44
+ const invalid = await wrapper.vm.formRef!.validate()
45
+ expect(invalid.valid).toBe(false)
46
+ expect(invalid.errors['users.0.name']).toBe('此项为必填')
47
+
48
+ wrapper.find('input').setValue('Ada')
49
+ await flushPromises()
50
+ const ok = await wrapper.vm.formRef!.validate()
51
+ expect(ok.valid).toBe(true)
52
+ expect(wrapper.vm.model.users[0]?.name).toBe('Ada')
53
+
54
+ await wrapper.find('.remove').trigger('click')
55
+ await flushPromises()
56
+ expect(wrapper.vm.model.users).toEqual([])
57
+ wrapper.unmount()
58
+ })
59
+
60
+ it('resolves list-relative dependencies and getFieldValue', async () => {
61
+ const Host = defineComponent({
62
+ components: { RsForm, RsFormItem, RsFormList, RsInput },
63
+ setup() {
64
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
65
+ const model = ref({
66
+ users: [{ password: 'aa', confirm: 'bb' }],
67
+ })
68
+ return { formRef, model }
69
+ },
70
+ template: `
71
+ <RsForm ref="formRef" :model="model">
72
+ <RsFormList name="users">
73
+ <template #default="{ fields }">
74
+ <div v-for="field in fields" :key="field.key">
75
+ <RsFormItem :name="[field.name, 'password']">
76
+ <RsInput />
77
+ </RsFormItem>
78
+ <RsFormItem
79
+ :name="[field.name, 'confirm']"
80
+ :dependencies="[[field.name, 'password']]"
81
+ :rules="[{
82
+ validator(value, ctx) {
83
+ if (String(value) !== String(ctx.getFieldValue([field.name, 'password']))) {
84
+ return '两次密码不一致'
85
+ }
86
+ return true
87
+ }
88
+ }]"
89
+ >
90
+ <RsInput />
91
+ </RsFormItem>
92
+ </div>
93
+ </template>
94
+ </RsFormList>
95
+ </RsForm>
96
+ `,
97
+ })
98
+ const wrapper = mount(Host)
99
+ await flushPromises()
100
+ const invalid = await wrapper.vm.formRef!.validate()
101
+ expect(invalid.valid).toBe(false)
102
+ expect(invalid.errors['users.0.confirm']).toBe('两次密码不一致')
103
+
104
+ wrapper.vm.model.users[0]!.password = 'bb'
105
+ await flushPromises()
106
+ expect(wrapper.findAll('.rs-form-item__error').length).toBe(0)
107
+ wrapper.unmount()
108
+ })
109
+ })
@@ -280,4 +280,97 @@ describe('RsInput', () => {
280
280
  })
281
281
  expect((wrapper.find('input').element as HTMLInputElement).value).toBe('256')
282
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
+ })
283
376
  })