niuma-ui 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/CHANGELOG.md +50 -1
  2. package/NOTICE +2 -2
  3. package/package.json +13 -5
  4. package/src/__tests__/RsButton.spec.ts +47 -0
  5. package/src/__tests__/RsCard.spec.ts +160 -1
  6. package/src/__tests__/RsConfirmDialog.spec.ts +127 -0
  7. package/src/__tests__/RsContextMenu.spec.ts +12 -0
  8. package/src/__tests__/RsDateTimePicker.spec.ts +11 -0
  9. package/src/__tests__/RsDescriptions.spec.ts +50 -0
  10. package/src/__tests__/RsDialog.spec.ts +117 -0
  11. package/src/__tests__/RsDrawer.spec.ts +96 -3
  12. package/src/__tests__/RsDynamicTags.spec.ts +52 -0
  13. package/src/__tests__/RsForm.spec.ts +245 -1
  14. package/src/__tests__/RsInput.spec.ts +15 -1
  15. package/src/__tests__/RsMenu.spec.ts +406 -14
  16. package/src/__tests__/RsPagination.spec.ts +67 -2
  17. package/src/__tests__/RsSelect.spec.ts +42 -1
  18. package/src/__tests__/RsSwitch.spec.ts +44 -0
  19. package/src/__tests__/RsTable.a11y.spec.ts +83 -0
  20. package/src/__tests__/RsTable.dom-baseline.spec.ts +87 -0
  21. package/src/__tests__/RsTable.perf.spec.ts +23 -4
  22. package/src/__tests__/RsTable.spec.ts +63 -8
  23. package/src/__tests__/RsTable.ssr.spec.ts +71 -0
  24. package/src/__tests__/RsTable.summary.spec.ts +45 -0
  25. package/src/__tests__/RsTabs.spec.ts +102 -4
  26. package/src/__tests__/RsTerminal.spec.ts +1 -1
  27. package/src/__tests__/RsTimePicker.spec.ts +11 -0
  28. package/src/__tests__/RsUpload.spec.ts +62 -6
  29. package/src/__tests__/createRsDialog.spec.ts +145 -0
  30. package/src/__tests__/form-rules.spec.ts +76 -0
  31. package/src/__tests__/perf-budgets.ts +32 -0
  32. package/src/__tests__/rs-table-api.spec.ts +206 -0
  33. package/src/__tests__/rs-table-compat-matrix.spec.ts +108 -0
  34. package/src/__tests__/rs-table-echarts-adapter.spec.ts +43 -0
  35. package/src/__tests__/rs-table-grid-nav.spec.ts +63 -0
  36. package/src/__tests__/rs-table-isolation.spec.ts +158 -0
  37. package/src/__tests__/split-pane-utils.spec.ts +21 -0
  38. package/src/__tests__/table-chart-utils.spec.ts +88 -0
  39. package/src/__tests__/table-summary-utils.spec.ts +62 -0
  40. package/src/__tests__/table-utils.spec.ts +124 -0
  41. package/src/__tests__/theme.spec.ts +25 -0
  42. package/src/__tests__/useRsTableColumnResize.spec.ts +25 -0
  43. package/src/__tests__/useRsTableCore.perf.spec.ts +170 -0
  44. package/src/components/RsAlert.vue +212 -0
  45. package/src/components/RsButton.vue +145 -21
  46. package/src/components/RsCard.vue +200 -16
  47. package/src/components/RsConfirmDialog.vue +215 -28
  48. package/src/components/RsContextMenu.vue +51 -13
  49. package/src/components/RsDatePicker.vue +267 -34
  50. package/src/components/RsDateTimePicker.vue +19 -2
  51. package/src/components/RsDescriptions.vue +186 -0
  52. package/src/components/RsDescriptionsItem.vue +32 -0
  53. package/src/components/RsDialog.vue +363 -51
  54. package/src/components/RsDivider.vue +89 -0
  55. package/src/components/RsDrawer.vue +558 -33
  56. package/src/components/RsDropdown.vue +33 -2
  57. package/src/components/RsDynamicTags.vue +517 -0
  58. package/src/components/RsForm.vue +97 -8
  59. package/src/components/RsInput.vue +80 -82
  60. package/src/components/RsInputNumber.vue +64 -23
  61. package/src/components/RsLabel.vue +20 -5
  62. package/src/components/RsLoadingBar.vue +151 -0
  63. package/src/components/RsMenu.vue +73 -15
  64. package/src/components/RsMenuItems.vue +147 -9
  65. package/src/components/RsPagination.vue +93 -14
  66. package/src/components/RsRadio.vue +81 -0
  67. package/src/components/RsRadioItem.vue +156 -0
  68. package/src/components/RsSelect.vue +67 -58
  69. package/src/components/RsSplitPane.vue +69 -1
  70. package/src/components/RsSwitch.vue +179 -0
  71. package/src/components/RsTable.vue +653 -2555
  72. package/src/components/RsTabs.vue +415 -83
  73. package/src/components/RsTag.vue +191 -0
  74. package/src/components/RsTerminal.vue +3 -3
  75. package/src/components/RsTimePicker.vue +44 -5
  76. package/src/components/RsTimePickerColumns.vue +136 -77
  77. package/src/components/RsToaster.vue +58 -18
  78. package/src/components/RsTree.vue +39 -21
  79. package/src/components/RsUpload.vue +145 -12
  80. package/src/components/RsVNodeHost.vue +20 -0
  81. package/src/components/button-utils.ts +39 -0
  82. package/src/components/context-menu-utils.ts +6 -0
  83. package/src/components/date-picker-utils.ts +84 -4
  84. package/src/components/descriptions-utils.ts +24 -0
  85. package/src/components/dialog-utils.ts +147 -0
  86. package/src/components/dialog-window.ts +74 -9
  87. package/src/components/drawer-utils.ts +73 -0
  88. package/src/components/form-rules.ts +257 -0
  89. package/src/components/form-utils.ts +52 -2
  90. package/src/components/menu-utils.ts +11 -0
  91. package/src/components/overlay-utils.ts +3 -0
  92. package/src/components/radio-utils.ts +12 -0
  93. package/src/components/reka.ts +5 -0
  94. package/src/components/select-utils.ts +21 -2
  95. package/src/components/split-pane-utils.ts +65 -12
  96. package/src/components/table/RsTableBody.vue +217 -0
  97. package/src/components/table/RsTableBodyRow.vue +90 -1
  98. package/src/components/table/RsTableColGroup.vue +41 -0
  99. package/src/components/table/RsTableHeader.vue +158 -0
  100. package/src/components/table/RsTableSummaryRow.vue +52 -0
  101. package/src/components/table/rs-table-api.ts +99 -0
  102. package/src/components/table/rs-table-compat-matrix.ts +71 -0
  103. package/src/components/table/rs-table-echarts-adapter.ts +81 -0
  104. package/src/components/table/rs-table-feature-host.ts +178 -0
  105. package/src/components/table/rs-table-grid-nav.ts +122 -0
  106. package/src/components/table/rs-table-header-types.ts +47 -0
  107. package/src/components/table/rs-table-module-registry.ts +72 -0
  108. package/src/components/table/rs-table-props.ts +274 -0
  109. package/src/components/table/rs-table-view-context.ts +258 -0
  110. package/src/components/table/rs-table.css +84 -6
  111. package/src/components/table/table-chart-utils.ts +165 -0
  112. package/src/components/table/table-features.ts +284 -0
  113. package/src/components/table/table-summary-utils.ts +194 -0
  114. package/src/components/table-utils.ts +452 -2
  115. package/src/components/tabs-utils.ts +142 -2
  116. package/src/components/terminal-utils.ts +1 -1
  117. package/src/components/terminal-wheel.ts +1 -1
  118. package/src/components/time-picker-utils.ts +76 -3
  119. package/src/components/upload-utils.ts +122 -0
  120. package/src/components/virtual-list-utils.ts +15 -2
  121. package/src/composables/assembleRsTableApi.ts +79 -0
  122. package/src/composables/bindRsTableViewContext.ts +269 -0
  123. package/src/composables/createRsDialog.ts +357 -0
  124. package/src/composables/createRsTableEditEmitBridge.ts +38 -0
  125. package/src/composables/useRsLoadingBar.ts +36 -0
  126. package/src/composables/useRsTable.ts +134 -0
  127. package/src/composables/useRsTableA11y.ts +52 -0
  128. package/src/composables/useRsTableChartBridge.ts +79 -0
  129. package/src/composables/useRsTableColumnLayout.ts +320 -0
  130. package/src/composables/useRsTableColumnResize.ts +244 -0
  131. package/src/composables/useRsTableColumnVirtual.ts +180 -0
  132. package/src/composables/useRsTableColumns.ts +200 -0
  133. package/src/composables/useRsTableContextMenu.ts +257 -0
  134. package/src/composables/useRsTableCore.ts +256 -0
  135. package/src/composables/useRsTableEditActions.ts +606 -0
  136. package/src/composables/useRsTableEditLayer.ts +251 -0
  137. package/src/composables/useRsTableEngine.ts +357 -0
  138. package/src/composables/useRsTableGridKeyboard.ts +123 -0
  139. package/src/composables/useRsTableHeadless.ts +137 -0
  140. package/src/composables/useRsTableInteraction.ts +433 -0
  141. package/src/composables/useRsTableScrollHost.ts +75 -0
  142. package/src/composables/useRsTableScrollLayout.ts +255 -0
  143. package/src/composables/useRsTableSelectionSource.ts +89 -0
  144. package/src/composables/useRsTableShell.ts +416 -0
  145. package/src/composables/useRsTableShellChrome.ts +170 -0
  146. package/src/composables/useRsTableSummary.ts +113 -0
  147. package/src/composables/useRsTableViewProvide.ts +263 -0
  148. package/src/composables/useRsTableVirtual.ts +154 -0
  149. package/src/composables/useRsTabsNav.ts +4 -0
  150. package/src/composables/useTableEdit.ts +1 -1
  151. package/src/icons/lucide.ts +2 -2
  152. package/src/index.ts +309 -9
  153. package/src/locale/messages.ts +30 -0
  154. package/src/styles.css +305 -52
  155. package/src/theme/brand.example.css +21 -2
  156. package/src/theme/presets.ts +4 -0
  157. package/src/theme/types.ts +67 -0
  158. package/src/components/RsTableColgroup.vue +0 -17
  159. package/src/components/RsTableHeaderRow.vue +0 -30
@@ -275,9 +275,14 @@ describe('RsDialog', () => {
275
275
  header.dispatchEvent(
276
276
  new PointerEvent('pointerdown', { clientX: 120, clientY: 80, bubbles: true, cancelable: true }),
277
277
  )
278
+ expect(document.body.style.cursor).toBe('move')
278
279
  window.dispatchEvent(
279
280
  new PointerEvent('pointermove', { clientX: 180, clientY: 120, bubbles: true, cancelable: true }),
280
281
  )
282
+ // 拖拽过程中应即时位移,不能等到 pointerup
283
+ expect(content.style.left).not.toBe(startLeft)
284
+ expect(content.style.top).not.toBe(startTop)
285
+
281
286
  window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true }))
282
287
  await flushPromises()
283
288
 
@@ -401,4 +406,116 @@ describe('RsDialog', () => {
401
406
  expect(wrapper.props('open')).toBe(true)
402
407
  wrapper.unmount()
403
408
  })
409
+
410
+ it('beforeClose returning false keeps dialog open', async () => {
411
+ const Host = defineComponent({
412
+ components: { RsDialog },
413
+ setup() {
414
+ const open = ref(true)
415
+ const beforeClose = () => false
416
+ return { open, beforeClose }
417
+ },
418
+ template:
419
+ '<RsDialog v-model:open="open" title="拦截关闭" :fullscreenable="false" :before-close="beforeClose" />',
420
+ })
421
+ const wrapper = mount(Host, { attachTo: document.body })
422
+ await flushPromises()
423
+ const closeBtn = document.body.querySelector('.rs-dialog__actions button') as HTMLElement
424
+ await closeBtn.click()
425
+ await flushPromises()
426
+ expect(wrapper.findComponent(RsDialog).props('open')).toBe(true)
427
+ wrapper.unmount()
428
+ })
429
+
430
+ it('renders builtin footer when showFooter is true and no footer slot', async () => {
431
+ const wrapper = mount(RsDialog, {
432
+ props: {
433
+ open: true,
434
+ title: '内置页脚',
435
+ showFooter: true,
436
+ layout: 'confirm',
437
+ resizable: false,
438
+ fullscreenable: false,
439
+ },
440
+ attachTo: document.body,
441
+ })
442
+ await flushPromises()
443
+ const footer = document.body.querySelector('.rs-dialog__footer')
444
+ expect(footer).not.toBeNull()
445
+ expect(footer?.querySelectorAll('button').length).toBe(2)
446
+ wrapper.unmount()
447
+ })
448
+
449
+ it('does not render builtin footer by default', async () => {
450
+ const wrapper = mount(RsDialog, {
451
+ props: {
452
+ open: true,
453
+ title: '无页脚',
454
+ layout: 'confirm',
455
+ resizable: false,
456
+ fullscreenable: false,
457
+ },
458
+ attachTo: document.body,
459
+ })
460
+ await flushPromises()
461
+ expect(document.body.querySelector('.rs-dialog__footer')).toBeNull()
462
+ wrapper.unmount()
463
+ })
464
+
465
+ it('applies custom numeric width on confirm layout', async () => {
466
+ const wrapper = mount(RsDialog, {
467
+ props: {
468
+ open: true,
469
+ title: '自定义宽度',
470
+ layout: 'confirm',
471
+ width: 520,
472
+ resizable: false,
473
+ fullscreenable: false,
474
+ },
475
+ attachTo: document.body,
476
+ })
477
+ await flushPromises()
478
+ const content = document.body.querySelector('.rs-dialog__content') as HTMLElement
479
+ expect(content.classList.contains('rs-dialog__content--custom-width')).toBe(true)
480
+ expect(content.style.maxWidth).toBe('520px')
481
+ wrapper.unmount()
482
+ })
483
+
484
+ it('title slot overrides title prop text', async () => {
485
+ const wrapper = mount(RsDialog, {
486
+ props: { open: true, title: '属性标题', fullscreenable: false },
487
+ slots: { title: '<span class="slot-title">插槽标题</span>' },
488
+ attachTo: document.body,
489
+ })
490
+ await flushPromises()
491
+ expect(document.body.querySelector('.slot-title')?.textContent).toBe('插槽标题')
492
+ wrapper.unmount()
493
+ })
494
+
495
+ it('emits afterClose when closed via close button', async () => {
496
+ const Host = defineComponent({
497
+ components: { RsDialog },
498
+ setup() {
499
+ const open = ref(true)
500
+ const reason = ref('')
501
+ return { open, reason }
502
+ },
503
+ template: `
504
+ <RsDialog
505
+ v-model:open="open"
506
+ title="关闭事件"
507
+ :fullscreenable="false"
508
+ @after-close="reason = $event"
509
+ />
510
+ `,
511
+ })
512
+ const wrapper = mount(Host, { attachTo: document.body })
513
+ await flushPromises()
514
+ const closeBtn = document.body.querySelector('.rs-dialog__actions button') as HTMLElement
515
+ await closeBtn.click()
516
+ await flushPromises()
517
+ expect(wrapper.vm.reason).toBe('close')
518
+ expect(wrapper.findComponent(RsDialog).props('open')).toBe(false)
519
+ wrapper.unmount()
520
+ })
404
521
  })
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, h, ref } from 'vue'
2
- import { afterEach, describe, expect, it } from 'vitest'
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
3
  import { flushPromises, mount } from '@vue/test-utils'
4
4
  import RsConfigProvider from '../components/RsConfigProvider.vue'
5
5
  import RsDrawer from '../components/RsDrawer.vue'
@@ -51,6 +51,17 @@ describe('RsDrawer', () => {
51
51
  wrapper.unmount()
52
52
  })
53
53
 
54
+ it('applies custom width via CSS variable', async () => {
55
+ const wrapper = mount(RsDrawer, {
56
+ props: { open: true, title: '自定义宽', width: 420 },
57
+ attachTo: document.body,
58
+ })
59
+ await flushPromises()
60
+ const content = document.body.querySelector('.rs-drawer__content') as HTMLElement
61
+ expect(content.style.getPropertyValue('--rs-drawer-panel-size')).toBe('420px')
62
+ wrapper.unmount()
63
+ })
64
+
54
65
  it('renders footer slot', async () => {
55
66
  const wrapper = mount(RsDrawer, {
56
67
  props: { open: true, title: '保存' },
@@ -62,7 +73,7 @@ describe('RsDrawer', () => {
62
73
  wrapper.unmount()
63
74
  })
64
75
 
65
- it('renders custom header slot', async () => {
76
+ it('provides visually hidden DialogTitle when using header slot', async () => {
66
77
  const wrapper = mount(RsDrawer, {
67
78
  props: { open: true },
68
79
  slots: {
@@ -72,8 +83,9 @@ describe('RsDrawer', () => {
72
83
  attachTo: document.body,
73
84
  })
74
85
  await flushPromises()
86
+ const hiddenTitle = document.body.querySelector('.rs-drawer__title--sr-only')
87
+ expect(hiddenTitle).not.toBeNull()
75
88
  expect(document.body.querySelector('.custom-header')?.textContent).toBe('自定义标题')
76
- expect(document.body.querySelector('.rs-drawer__title')).toBeNull()
77
89
  wrapper.unmount()
78
90
  })
79
91
 
@@ -95,6 +107,44 @@ describe('RsDrawer', () => {
95
107
  wrapper.unmount()
96
108
  })
97
109
 
110
+ it('beforeClose returning false keeps drawer open', async () => {
111
+ const Host = defineComponent({
112
+ components: { RsDrawer },
113
+ setup() {
114
+ const open = ref(true)
115
+ const beforeClose = () => false
116
+ return { open, beforeClose }
117
+ },
118
+ template: '<RsDrawer v-model:open="open" title="拦截" :before-close="beforeClose" />',
119
+ })
120
+ const wrapper = mount(Host, { attachTo: document.body })
121
+ await flushPromises()
122
+ const closeBtn = document.body.querySelector('.rs-drawer__header button') as HTMLElement
123
+ await closeBtn.click()
124
+ await flushPromises()
125
+ expect(wrapper.findComponent(RsDrawer).props('open')).toBe(true)
126
+ wrapper.unmount()
127
+ })
128
+
129
+ it('emits afterClose when closed via close button', async () => {
130
+ const onAfterClose = vi.fn()
131
+ const Host = defineComponent({
132
+ components: { RsDrawer },
133
+ setup() {
134
+ const open = ref(true)
135
+ return { open, onAfterClose }
136
+ },
137
+ template: '<RsDrawer v-model:open="open" title="关闭" @after-close="onAfterClose" />',
138
+ })
139
+ const wrapper = mount(Host, { attachTo: document.body })
140
+ await flushPromises()
141
+ const closeBtn = document.body.querySelector('.rs-drawer__header button') as HTMLElement
142
+ await closeBtn.click()
143
+ await flushPromises()
144
+ expect(onAfterClose).toHaveBeenCalledWith('close')
145
+ wrapper.unmount()
146
+ })
147
+
98
148
  it('hides overlay when showOverlay is false', async () => {
99
149
  const wrapper = mount(RsDrawer, {
100
150
  props: { open: true, title: '无遮罩', showOverlay: false },
@@ -105,6 +155,49 @@ describe('RsDrawer', () => {
105
155
  wrapper.unmount()
106
156
  })
107
157
 
158
+ it('does not close on outside when closeOnOverlayClick is false', async () => {
159
+ const Host = defineComponent({
160
+ components: { RsDrawer },
161
+ setup() {
162
+ const open = ref(true)
163
+ return { open }
164
+ },
165
+ template:
166
+ '<RsDrawer v-model:open="open" title="非点外关" :show-overlay="false" :close-on-overlay-click="false" />',
167
+ })
168
+ const wrapper = mount(Host, { attachTo: document.body })
169
+ await flushPromises()
170
+ document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
171
+ await flushPromises()
172
+ expect(wrapper.findComponent(RsDrawer).props('open')).toBe(true)
173
+ wrapper.unmount()
174
+ })
175
+
176
+ it('teleports into a container and uses contained positioning', async () => {
177
+ const host = document.createElement('div')
178
+ host.id = 'drawer-host'
179
+ host.style.position = 'relative'
180
+ document.body.appendChild(host)
181
+
182
+ const wrapper = mount(RsDrawer, {
183
+ props: {
184
+ open: true,
185
+ title: '容器内抽屉',
186
+ teleportTo: '#drawer-host',
187
+ },
188
+ attachTo: document.body,
189
+ })
190
+ await flushPromises()
191
+
192
+ const content = host.querySelector('.rs-drawer__content') as HTMLElement | null
193
+ expect(content).not.toBeNull()
194
+ expect(content?.classList.contains('rs-drawer__content--contained')).toBe(true)
195
+ expect(document.body.querySelector(':scope > .rs-drawer__content')).toBeNull()
196
+
197
+ wrapper.unmount()
198
+ host.remove()
199
+ })
200
+
108
201
  it('hides close button when showClose is false', async () => {
109
202
  const wrapper = mount(RsDrawer, {
110
203
  props: { open: true, title: '无关闭', showClose: false },
@@ -0,0 +1,52 @@
1
+ import { mount } from '@vue/test-utils'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { defineComponent, nextTick } from 'vue'
4
+ import RsDynamicTags from '../components/RsDynamicTags.vue'
5
+
6
+ describe('RsDynamicTags', () => {
7
+ it('opens input from trigger and creates tag on Enter', async () => {
8
+ const Host = defineComponent({
9
+ components: { RsDynamicTags },
10
+ data: () => ({ tags: [] as string[] }),
11
+ template: '<RsDynamicTags v-model="tags" />',
12
+ })
13
+ const wrapper = mount(Host)
14
+
15
+ await wrapper.find('.rs-dynamic-tags__trigger').trigger('click')
16
+ await nextTick()
17
+ const input = wrapper.find('.rs-dynamic-tags__input')
18
+ expect(input.exists()).toBe(true)
19
+
20
+ await input.setValue('alpha')
21
+ await input.trigger('keydown', { key: 'Enter' })
22
+ await nextTick()
23
+ expect((wrapper.vm as { tags: string[] }).tags).toEqual(['alpha'])
24
+ })
25
+
26
+ it('rejects duplicate tags', async () => {
27
+ const wrapper = mount(RsDynamicTags, {
28
+ props: {
29
+ modelValue: ['a'],
30
+ inputMode: 'always',
31
+ },
32
+ })
33
+
34
+ const input = wrapper.find('.rs-dynamic-tags__input')
35
+ await input.setValue('a')
36
+ await input.trigger('keydown', { key: 'Enter' })
37
+ await nextTick()
38
+ expect(wrapper.emitted('reject')?.[0]).toEqual(['duplicate', 'a'])
39
+ expect(wrapper.emitted('update:modelValue')).toBeUndefined()
40
+ })
41
+
42
+ it('hides trigger when max reached', async () => {
43
+ const wrapper = mount(RsDynamicTags, {
44
+ props: {
45
+ modelValue: ['a', 'b'],
46
+ max: 2,
47
+ },
48
+ })
49
+ expect(wrapper.find('.rs-dynamic-tags__trigger').exists()).toBe(false)
50
+ expect(wrapper.find('.rs-dynamic-tags__input').exists()).toBe(false)
51
+ })
52
+ })
@@ -1,4 +1,4 @@
1
- import { defineComponent, ref } from 'vue'
1
+ import { defineComponent, h, ref } from 'vue'
2
2
  import { afterEach, describe, expect, it } from 'vitest'
3
3
  import { flushPromises, mount } from '@vue/test-utils'
4
4
  import RsForm from '../components/RsForm.vue'
@@ -33,6 +33,88 @@ describe('RsForm', () => {
33
33
  expect(wrapper.classes()).toContain('rs-form--label-left')
34
34
  })
35
35
 
36
+ it('defaults labelPosition class to top', () => {
37
+ const wrapper = mount(RsForm)
38
+ expect(wrapper.classes()).toContain('rs-form--label-top')
39
+ })
40
+
41
+ it('propagates labelPosition left to child inputs', async () => {
42
+ const wrapper = mount(RsForm, {
43
+ props: { labelPosition: 'left', labelWidth: '5rem' },
44
+ slots: { default: '<RsInput model-value="" label="名称" />' },
45
+ global: { components: { RsInput } },
46
+ })
47
+ await flushPromises()
48
+ const field = wrapper.find('.rs-field')
49
+ expect(field.classes()).toContain('rs-field--label-left')
50
+ expect(field.attributes('style') ?? '').toContain('--rs-field-label-width: 5rem')
51
+ wrapper.unmount()
52
+ })
53
+
54
+ it('propagates labelAlign end only when labelPosition is left', async () => {
55
+ const leftAlign = mount(RsForm, {
56
+ props: { labelPosition: 'left', labelAlign: 'end', labelWidth: '6rem' },
57
+ slots: { default: '<RsInput model-value="" label="邮箱" />' },
58
+ global: { components: { RsInput } },
59
+ })
60
+ await flushPromises()
61
+ expect(leftAlign.find('.rs-field').classes()).toContain('rs-field--label-align-end')
62
+ leftAlign.unmount()
63
+
64
+ const topAlign = mount(RsForm, {
65
+ props: { labelPosition: 'top', labelAlign: 'end' },
66
+ slots: { default: '<RsInput model-value="" label="邮箱" />' },
67
+ global: { components: { RsInput } },
68
+ })
69
+ await flushPromises()
70
+ expect(topAlign.find('.rs-field').classes()).toContain('rs-field--label-top')
71
+ expect(topAlign.find('.rs-field').classes()).not.toContain('rs-field--label-align-end')
72
+ topAlign.unmount()
73
+ })
74
+
75
+ it('allows field labelPosition to override Form', async () => {
76
+ const Host = defineComponent({
77
+ components: { RsForm, RsInput },
78
+ template: `
79
+ <RsForm label-position="left" label-width="5rem">
80
+ <RsInput model-value="" label="继承" />
81
+ <RsInput model-value="" label="覆盖" label-position="top" />
82
+ </RsForm>
83
+ `,
84
+ })
85
+ const wrapper = mount(Host)
86
+ await flushPromises()
87
+ const fields = wrapper.findAll('.rs-field')
88
+ expect(fields[0]!.classes()).toContain('rs-field--label-left')
89
+ expect(fields[1]!.classes()).toContain('rs-field--label-top')
90
+ wrapper.unmount()
91
+ })
92
+
93
+ it('updates child label layout when Form labelPosition changes', async () => {
94
+ const Host = defineComponent({
95
+ components: { RsForm, RsInput },
96
+ setup() {
97
+ const labelPosition = ref<'top' | 'left'>('top')
98
+ return { labelPosition }
99
+ },
100
+ template: `
101
+ <RsForm :label-position="labelPosition" label-width="5rem">
102
+ <RsInput model-value="" label="名称" />
103
+ </RsForm>
104
+ `,
105
+ })
106
+ const wrapper = mount(Host)
107
+ await flushPromises()
108
+ expect(wrapper.find('.rs-form').classes()).toContain('rs-form--label-top')
109
+ expect(wrapper.find('.rs-field').classes()).toContain('rs-field--label-top')
110
+
111
+ wrapper.vm.labelPosition = 'left'
112
+ await flushPromises()
113
+ expect(wrapper.find('.rs-form').classes()).toContain('rs-form--label-left')
114
+ expect(wrapper.find('.rs-field').classes()).toContain('rs-field--label-left')
115
+ wrapper.unmount()
116
+ })
117
+
36
118
  it('validate returns valid when all fields pass', async () => {
37
119
  const Host = defineComponent({
38
120
  components: { RsForm, RsInput },
@@ -170,4 +252,166 @@ describe('RsForm', () => {
170
252
  expect(valid.valid).toBe(true)
171
253
  wrapper.unmount()
172
254
  })
255
+
256
+ it('supports centralized rules by field name', async () => {
257
+ const Host = defineComponent({
258
+ components: { RsForm, RsInput },
259
+ setup() {
260
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
261
+ const userId = ref('')
262
+ const rules = {
263
+ userId: [
264
+ { required: true, message: '必填' },
265
+ { min: 3, message: '至少 3 位' },
266
+ ],
267
+ }
268
+ return { formRef, userId, rules }
269
+ },
270
+ template: `
271
+ <RsForm ref="formRef" :rules="rules">
272
+ <RsInput v-model="userId" name="userId" />
273
+ </RsForm>
274
+ `,
275
+ })
276
+ const wrapper = mount(Host)
277
+ await flushPromises()
278
+
279
+ const empty = await wrapper.vm.formRef!.validate()
280
+ expect(empty.valid).toBe(false)
281
+ expect(empty.errors.userId).toBe('必填')
282
+
283
+ wrapper.vm.userId = 'ab'
284
+ await flushPromises()
285
+ const short = await wrapper.vm.formRef!.validate()
286
+ expect(short.valid).toBe(false)
287
+ expect(short.errors.userId).toBe('至少 3 位')
288
+
289
+ wrapper.vm.userId = 'abc'
290
+ await flushPromises()
291
+ const ok = await wrapper.vm.formRef!.validate()
292
+ expect(ok.valid).toBe(true)
293
+ expect(ok.errors).toEqual({})
294
+ wrapper.unmount()
295
+ })
296
+
297
+ it('validateField only checks one named field', async () => {
298
+ const Host = defineComponent({
299
+ components: { RsForm, RsInput },
300
+ setup() {
301
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
302
+ const userId = ref('')
303
+ const email = ref('bad')
304
+ const rules = {
305
+ userId: [{ required: true, message: 'user required' }],
306
+ email: [{ type: 'email' as const, message: 'bad email' }],
307
+ }
308
+ return { formRef, userId, email, rules }
309
+ },
310
+ template: `
311
+ <RsForm ref="formRef" :rules="rules">
312
+ <RsInput v-model="userId" name="userId" />
313
+ <RsInput v-model="email" name="email" />
314
+ </RsForm>
315
+ `,
316
+ })
317
+ const wrapper = mount(Host)
318
+ await flushPromises()
319
+ const result = await wrapper.vm.formRef!.validateField('userId')
320
+ expect(result.valid).toBe(false)
321
+ expect(result.message).toBe('user required')
322
+ expect(result.name).toBe('userId')
323
+ wrapper.unmount()
324
+ })
325
+
326
+ it('supports async validator in rules', async () => {
327
+ const Host = defineComponent({
328
+ components: { RsForm, RsInput },
329
+ setup() {
330
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
331
+ const username = ref('admin')
332
+ const rules = {
333
+ username: [
334
+ {
335
+ async validator(value: unknown) {
336
+ await Promise.resolve()
337
+ if (String(value) === 'admin') return '占用'
338
+ return true
339
+ },
340
+ },
341
+ ],
342
+ }
343
+ return { formRef, username, rules }
344
+ },
345
+ template: `
346
+ <RsForm ref="formRef" :rules="rules">
347
+ <RsInput v-model="username" name="username" />
348
+ </RsForm>
349
+ `,
350
+ })
351
+ const wrapper = mount(Host)
352
+ await flushPromises()
353
+ const result = await wrapper.vm.formRef!.validate()
354
+ expect(result.valid).toBe(false)
355
+ expect(result.errors.username).toBe('占用')
356
+ wrapper.unmount()
357
+ })
358
+
359
+ it('renders custom Form #error slot content', async () => {
360
+ const Host = defineComponent({
361
+ components: { RsForm, RsInput },
362
+ setup() {
363
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
364
+ const name = ref('')
365
+ const rules = {
366
+ name: [{ required: true, message: '必填昵称' }],
367
+ }
368
+ return { formRef, name, rules }
369
+ },
370
+ template: `
371
+ <RsForm ref="formRef" :rules="rules">
372
+ <template #error="{ message }">
373
+ <span class="custom-form-error">CUSTOM:{{ message }}</span>
374
+ </template>
375
+ <RsInput v-model="name" name="name" />
376
+ </RsForm>
377
+ `,
378
+ })
379
+ const wrapper = mount(Host)
380
+ await flushPromises()
381
+ await wrapper.vm.formRef!.validate()
382
+ await flushPromises()
383
+ expect(wrapper.find('.custom-form-error').text()).toBe('CUSTOM:必填昵称')
384
+ wrapper.unmount()
385
+ })
386
+
387
+ it('field #error slot overrides Form errorRender', async () => {
388
+ const Host = defineComponent({
389
+ components: { RsForm, RsInput },
390
+ setup() {
391
+ const formRef = ref<InstanceType<typeof RsForm> | null>(null)
392
+ const name = ref('')
393
+ const rules = {
394
+ name: [{ required: true, message: '必填' }],
395
+ }
396
+ const errorRender = () => h('span', { class: 'from-form' }, 'FORM')
397
+ return { formRef, name, rules, errorRender }
398
+ },
399
+ template: `
400
+ <RsForm ref="formRef" :rules="rules" :error-render="errorRender">
401
+ <RsInput v-model="name" name="name">
402
+ <template #error="{ message }">
403
+ <span class="from-field">FIELD:{{ message }}</span>
404
+ </template>
405
+ </RsInput>
406
+ </RsForm>
407
+ `,
408
+ })
409
+ const wrapper = mount(Host)
410
+ await flushPromises()
411
+ await wrapper.vm.formRef!.validate()
412
+ await flushPromises()
413
+ expect(wrapper.find('.from-field').text()).toBe('FIELD:必填')
414
+ expect(wrapper.find('.from-form').exists()).toBe(false)
415
+ wrapper.unmount()
416
+ })
173
417
  })
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { mount } from '@vue/test-utils'
2
+ import { flushPromises, mount } from '@vue/test-utils'
3
3
  import { h } from 'vue'
4
4
  import RsConfigProvider from '../components/RsConfigProvider.vue'
5
5
  import RsInput from '../components/RsInput.vue'
@@ -35,6 +35,16 @@ describe('RsInput', () => {
35
35
  expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['world'])
36
36
  })
37
37
 
38
+ it('defaults autocomplete to off (new-password for password)', () => {
39
+ const text = mount(RsInput, { props: { modelValue: '' } })
40
+ expect(text.find('input').attributes('autocomplete')).toBe('off')
41
+ text.unmount()
42
+
43
+ const password = mount(RsInput, { props: { modelValue: '', type: 'password' } })
44
+ expect(password.find('input').attributes('autocomplete')).toBe('new-password')
45
+ password.unmount()
46
+ })
47
+
38
48
  it('marks invalid state on group', () => {
39
49
  const wrapper = mount(RsInput, { props: { invalid: true } })
40
50
  expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--invalid')
@@ -154,6 +164,7 @@ describe('RsInput', () => {
154
164
  props: { modelValue: 'bad-email', rule: 'email', id: 'e1' },
155
165
  })
156
166
  await wrapper.find('input').trigger('blur')
167
+ await flushPromises()
157
168
  expect(wrapper.find('.rs-input-group').classes()).toContain('rs-input-group--invalid')
158
169
  expect(wrapper.find('.rs-input-field__error').text()).toContain('邮箱')
159
170
  expect(wrapper.emitted('validate')?.[0]?.[0]).toEqual({
@@ -167,6 +178,7 @@ describe('RsInput', () => {
167
178
  props: { modelValue: 'user@example.com', rule: 'email' },
168
179
  })
169
180
  await wrapper.find('input').trigger('blur')
181
+ await flushPromises()
170
182
  expect(wrapper.find('.rs-input-group').classes()).not.toContain('rs-input-group--invalid')
171
183
  expect(wrapper.emitted('validate')?.[0]?.[0]).toEqual({ valid: true })
172
184
  })
@@ -179,6 +191,7 @@ describe('RsInput', () => {
179
191
  },
180
192
  })
181
193
  await wrapper.find('input').trigger('blur')
194
+ await flushPromises()
182
195
  expect(wrapper.find('.rs-input-field__error').text()).toBe('至少 3 个字符')
183
196
  })
184
197
 
@@ -196,6 +209,7 @@ describe('RsInput', () => {
196
209
  },
197
210
  })
198
211
  await wrapper.find('input').trigger('blur')
212
+ await flushPromises()
199
213
  expect(wrapper.find('.rs-input-field__error').text()).toContain('email')
200
214
  })
201
215