niuma-ui 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/CHANGELOG.md +65 -1
  2. package/package.json +10 -2
  3. package/src/__tests__/RsButton.spec.ts +47 -0
  4. package/src/__tests__/RsCard.spec.ts +160 -1
  5. package/src/__tests__/RsConfirmDialog.spec.ts +127 -0
  6. package/src/__tests__/RsContextMenu.spec.ts +12 -0
  7. package/src/__tests__/RsDatePicker.spec.ts +21 -0
  8. package/src/__tests__/RsDateTimePicker.spec.ts +11 -0
  9. package/src/__tests__/RsDescriptions.spec.ts +50 -0
  10. package/src/__tests__/RsDialog.spec.ts +178 -0
  11. package/src/__tests__/RsDrawer.spec.ts +96 -3
  12. package/src/__tests__/RsDynamicTags.spec.ts +52 -0
  13. package/src/__tests__/RsForm.spec.ts +250 -6
  14. package/src/__tests__/RsFormItem.spec.ts +478 -0
  15. package/src/__tests__/RsFormList.spec.ts +109 -0
  16. package/src/__tests__/RsInput.spec.ts +108 -1
  17. package/src/__tests__/RsInputNumber.spec.ts +33 -0
  18. package/src/__tests__/RsMenu.spec.ts +406 -14
  19. package/src/__tests__/RsPagination.spec.ts +67 -2
  20. package/src/__tests__/RsSelect.spec.ts +465 -2
  21. package/src/__tests__/RsSwitch.spec.ts +74 -0
  22. package/src/__tests__/RsTable.a11y.spec.ts +83 -0
  23. package/src/__tests__/RsTable.dom-baseline.spec.ts +87 -0
  24. package/src/__tests__/RsTable.perf.spec.ts +23 -4
  25. package/src/__tests__/RsTable.spec.ts +63 -8
  26. package/src/__tests__/RsTable.ssr.spec.ts +71 -0
  27. package/src/__tests__/RsTable.summary.spec.ts +45 -0
  28. package/src/__tests__/RsTabs.spec.ts +102 -4
  29. package/src/__tests__/RsTimePicker.spec.ts +20 -0
  30. package/src/__tests__/RsTooltip.spec.ts +43 -0
  31. package/src/__tests__/RsUpload.spec.ts +62 -6
  32. package/src/__tests__/createRsDialog.spec.ts +145 -0
  33. package/src/__tests__/css-token.spec.ts +51 -0
  34. package/src/__tests__/date-picker-utils.spec.ts +49 -0
  35. package/src/__tests__/dialog-utils.spec.ts +43 -0
  36. package/src/__tests__/form-path.spec.ts +46 -0
  37. package/src/__tests__/form-rules.spec.ts +139 -0
  38. package/src/__tests__/perf-budgets.ts +32 -0
  39. package/src/__tests__/rs-table-api.spec.ts +206 -0
  40. package/src/__tests__/rs-table-compat-matrix.spec.ts +108 -0
  41. package/src/__tests__/rs-table-echarts-adapter.spec.ts +43 -0
  42. package/src/__tests__/rs-table-grid-nav.spec.ts +63 -0
  43. package/src/__tests__/rs-table-isolation.spec.ts +158 -0
  44. package/src/__tests__/split-pane-utils.spec.ts +21 -0
  45. package/src/__tests__/table-chart-utils.spec.ts +88 -0
  46. package/src/__tests__/table-summary-utils.spec.ts +62 -0
  47. package/src/__tests__/table-utils.spec.ts +124 -0
  48. package/src/__tests__/theme.spec.ts +27 -0
  49. package/src/__tests__/useRsTableColumnResize.spec.ts +25 -0
  50. package/src/__tests__/useRsTableCore.perf.spec.ts +170 -0
  51. package/src/components/RsAlert.vue +212 -0
  52. package/src/components/RsAvatar.vue +1 -1
  53. package/src/components/RsBadge.vue +1 -1
  54. package/src/components/RsBreadcrumb.vue +1 -1
  55. package/src/components/RsButton.vue +147 -23
  56. package/src/components/RsCalendarGrid.vue +3 -3
  57. package/src/components/RsCard.vue +204 -20
  58. package/src/components/RsCodeBlock.vue +4 -4
  59. package/src/components/RsCodeEditor.vue +8 -8
  60. package/src/components/RsConfirmDialog.vue +217 -30
  61. package/src/components/RsContextMenu.vue +57 -17
  62. package/src/components/RsDatePicker.vue +316 -39
  63. package/src/components/RsDateTimePicker.vue +27 -2
  64. package/src/components/RsDescriptions.vue +186 -0
  65. package/src/components/RsDescriptionsItem.vue +32 -0
  66. package/src/components/RsDialog.vue +374 -52
  67. package/src/components/RsDivider.vue +89 -0
  68. package/src/components/RsDrawer.vue +559 -34
  69. package/src/components/RsDropdown.vue +34 -3
  70. package/src/components/RsDynamicTags.vue +531 -0
  71. package/src/components/RsEmpty.vue +1 -1
  72. package/src/components/RsForm.vue +164 -8
  73. package/src/components/RsFormItem.vue +423 -0
  74. package/src/components/RsFormItemControl.vue +21 -0
  75. package/src/components/RsFormList.vue +117 -0
  76. package/src/components/RsInput.vue +491 -100
  77. package/src/components/RsInputNumber.vue +77 -27
  78. package/src/components/RsLabel.vue +20 -5
  79. package/src/components/RsLink.vue +1 -1
  80. package/src/components/RsLoadingBar.vue +151 -0
  81. package/src/components/RsMarkdown.vue +5 -5
  82. package/src/components/RsMenu.vue +74 -16
  83. package/src/components/RsMenuItems.vue +147 -9
  84. package/src/components/RsMonacoEditor.vue +9 -3
  85. package/src/components/RsPagination.vue +93 -14
  86. package/src/components/RsProseEditor.vue +3 -3
  87. package/src/components/RsRadio.vue +81 -0
  88. package/src/components/RsRadioItem.vue +156 -0
  89. package/src/components/RsSelect.vue +372 -642
  90. package/src/components/RsSidebarGroup.vue +1 -1
  91. package/src/components/RsSplitPane.vue +90 -1
  92. package/src/components/RsStatCard.vue +2 -2
  93. package/src/components/RsSteps.vue +2 -2
  94. package/src/components/RsSwitch.vue +192 -0
  95. package/src/components/RsTable.vue +653 -2555
  96. package/src/components/RsTabs.vue +422 -90
  97. package/src/components/RsTag.vue +191 -0
  98. package/src/components/RsTerminal.vue +58 -19
  99. package/src/components/RsTimePicker.vue +72 -9
  100. package/src/components/RsTimePickerColumns.vue +137 -78
  101. package/src/components/RsToaster.vue +64 -24
  102. package/src/components/RsTooltip.vue +10 -1
  103. package/src/components/RsTooltipProvider.vue +11 -1
  104. package/src/components/RsTree.vue +45 -27
  105. package/src/components/RsUpload.vue +147 -14
  106. package/src/components/RsVNodeHost.vue +20 -0
  107. package/src/components/button-utils.ts +39 -0
  108. package/src/components/context-menu-utils.ts +6 -0
  109. package/src/components/date-picker-utils.ts +195 -4
  110. package/src/components/descriptions-utils.ts +24 -0
  111. package/src/components/dialog-utils.ts +182 -0
  112. package/src/components/dialog-window.ts +74 -9
  113. package/src/components/drawer-utils.ts +73 -0
  114. package/src/components/form-item-control.ts +55 -0
  115. package/src/components/form-path.ts +92 -0
  116. package/src/components/form-rules.ts +311 -0
  117. package/src/components/form-utils.ts +145 -3
  118. package/src/components/menu-utils.ts +11 -0
  119. package/src/components/overlay-utils.ts +3 -0
  120. package/src/components/radio-utils.ts +12 -0
  121. package/src/components/reka.ts +5 -0
  122. package/src/components/select-utils.ts +285 -6
  123. package/src/components/split-pane-utils.ts +65 -12
  124. package/src/components/table/RsTableBody.vue +217 -0
  125. package/src/components/table/RsTableBodyRow.vue +90 -1
  126. package/src/components/table/RsTableColGroup.vue +41 -0
  127. package/src/components/table/RsTableHeader.vue +158 -0
  128. package/src/components/table/RsTableHeaderFilter.vue +1 -1
  129. package/src/components/table/RsTableSummaryRow.vue +52 -0
  130. package/src/components/table/rs-table-api.ts +99 -0
  131. package/src/components/table/rs-table-compat-matrix.ts +71 -0
  132. package/src/components/table/rs-table-echarts-adapter.ts +81 -0
  133. package/src/components/table/rs-table-feature-host.ts +178 -0
  134. package/src/components/table/rs-table-grid-nav.ts +122 -0
  135. package/src/components/table/rs-table-header-types.ts +47 -0
  136. package/src/components/table/rs-table-module-registry.ts +72 -0
  137. package/src/components/table/rs-table-props.ts +274 -0
  138. package/src/components/table/rs-table-view-context.ts +258 -0
  139. package/src/components/table/rs-table.css +85 -7
  140. package/src/components/table/table-chart-utils.ts +165 -0
  141. package/src/components/table/table-features.ts +284 -0
  142. package/src/components/table/table-summary-utils.ts +194 -0
  143. package/src/components/table-utils.ts +452 -2
  144. package/src/components/tabs-utils.ts +142 -2
  145. package/src/components/time-picker-utils.ts +76 -3
  146. package/src/components/upload-utils.ts +122 -0
  147. package/src/components/use-rs-select.ts +436 -0
  148. package/src/components/virtual-list-utils.ts +15 -2
  149. package/src/composables/assembleRsTableApi.ts +79 -0
  150. package/src/composables/bindRsTableViewContext.ts +269 -0
  151. package/src/composables/createRsDialog.ts +357 -0
  152. package/src/composables/createRsTableEditEmitBridge.ts +38 -0
  153. package/src/composables/useRsConfig.ts +10 -3
  154. package/src/composables/useRsI18n.ts +16 -5
  155. package/src/composables/useRsLoadingBar.ts +36 -0
  156. package/src/composables/useRsTable.ts +134 -0
  157. package/src/composables/useRsTableA11y.ts +52 -0
  158. package/src/composables/useRsTableChartBridge.ts +79 -0
  159. package/src/composables/useRsTableColumnLayout.ts +320 -0
  160. package/src/composables/useRsTableColumnResize.ts +244 -0
  161. package/src/composables/useRsTableColumnVirtual.ts +180 -0
  162. package/src/composables/useRsTableColumns.ts +200 -0
  163. package/src/composables/useRsTableContextMenu.ts +257 -0
  164. package/src/composables/useRsTableCore.ts +256 -0
  165. package/src/composables/useRsTableEditActions.ts +606 -0
  166. package/src/composables/useRsTableEditLayer.ts +251 -0
  167. package/src/composables/useRsTableEngine.ts +357 -0
  168. package/src/composables/useRsTableGridKeyboard.ts +123 -0
  169. package/src/composables/useRsTableHeadless.ts +137 -0
  170. package/src/composables/useRsTableInteraction.ts +433 -0
  171. package/src/composables/useRsTableScrollHost.ts +75 -0
  172. package/src/composables/useRsTableScrollLayout.ts +255 -0
  173. package/src/composables/useRsTableSelectionSource.ts +89 -0
  174. package/src/composables/useRsTableShell.ts +416 -0
  175. package/src/composables/useRsTableShellChrome.ts +170 -0
  176. package/src/composables/useRsTableSummary.ts +113 -0
  177. package/src/composables/useRsTableViewProvide.ts +263 -0
  178. package/src/composables/useRsTableVirtual.ts +154 -0
  179. package/src/composables/useRsTabsNav.ts +4 -0
  180. package/src/composables/useTableEdit.ts +1 -1
  181. package/src/index.ts +378 -10
  182. package/src/locale/interpolate.ts +32 -0
  183. package/src/locale/messages.ts +40 -0
  184. package/src/styles.css +365 -56
  185. package/src/theme/brand.example.css +24 -2
  186. package/src/theme/css-token.ts +107 -0
  187. package/src/theme/presets.ts +4 -0
  188. package/src/theme/types.ts +68 -0
  189. package/src/components/RsTableColgroup.vue +0 -17
  190. package/src/components/RsTableHeaderRow.vue +0 -30
@@ -69,6 +69,23 @@ describe('RsDialog', () => {
69
69
  wrapper.unmount()
70
70
  })
71
71
 
72
+ it('centers window layout when width is a viewport percentage', async () => {
73
+ const wrapper = mount(RsDialog, {
74
+ props: { open: true, title: '百分比宽度', width: '90%' },
75
+ attachTo: document.body,
76
+ })
77
+ await flushPromises()
78
+ const content = document.body.querySelector('.rs-dialog__content') as HTMLElement
79
+ const width = Number.parseFloat(content.style.width)
80
+ const left = Number.parseFloat(content.style.left)
81
+ const available = window.innerWidth - 32
82
+ const expectedWidth = Math.min(available, Math.max(320, Math.round(available * 0.9)))
83
+ expect(width).toBe(expectedWidth)
84
+ expect(left).toBeCloseTo(16 + (available - expectedWidth) / 2, 1)
85
+ expect(content.style.maxWidth).toBe('')
86
+ wrapper.unmount()
87
+ })
88
+
72
89
  it('applies window layout with resize handles by default', async () => {
73
90
  const wrapper = mount(RsDialog, {
74
91
  props: { open: true, title: '测试', width: 'sm' },
@@ -275,9 +292,14 @@ describe('RsDialog', () => {
275
292
  header.dispatchEvent(
276
293
  new PointerEvent('pointerdown', { clientX: 120, clientY: 80, bubbles: true, cancelable: true }),
277
294
  )
295
+ expect(document.body.style.cursor).toBe('move')
278
296
  window.dispatchEvent(
279
297
  new PointerEvent('pointermove', { clientX: 180, clientY: 120, bubbles: true, cancelable: true }),
280
298
  )
299
+ // 拖拽过程中应即时位移,不能等到 pointerup
300
+ expect(content.style.left).not.toBe(startLeft)
301
+ expect(content.style.top).not.toBe(startTop)
302
+
281
303
  window.dispatchEvent(new PointerEvent('pointerup', { bubbles: true, cancelable: true }))
282
304
  await flushPromises()
283
305
 
@@ -361,6 +383,50 @@ describe('RsDialog', () => {
361
383
  await flushPromises()
362
384
  expect(target.querySelector('.rs-dialog__content')).not.toBeNull()
363
385
  wrapper.unmount()
386
+ target.remove()
387
+ })
388
+
389
+ it('re-teleports to body while fullscreen, then restores custom target', async () => {
390
+ const target = document.createElement('div')
391
+ target.id = 'rs-dialog-fullscreen-target'
392
+ document.body.appendChild(target)
393
+
394
+ const wrapper = mount(RsDialog, {
395
+ props: {
396
+ open: true,
397
+ title: '全屏挂载',
398
+ layout: 'window',
399
+ fullscreenable: true,
400
+ teleportTo: '#rs-dialog-fullscreen-target',
401
+ },
402
+ attachTo: document.body,
403
+ })
404
+
405
+ await flushPromises()
406
+ expect(target.querySelector('.rs-dialog__content')).not.toBeNull()
407
+
408
+ const enterFullscreenBtn = target.querySelectorAll('.rs-dialog__actions button')[0] as HTMLElement
409
+ await enterFullscreenBtn.click()
410
+ await flushPromises()
411
+
412
+ expect(target.querySelector('.rs-dialog__content')).toBeNull()
413
+ const bodyContent = document.body.querySelector('.rs-dialog__content') as HTMLElement
414
+ expect(bodyContent).not.toBeNull()
415
+ expect(bodyContent.classList.contains('rs-dialog__content--fullscreen')).toBe(true)
416
+ expect(target.contains(bodyContent)).toBe(false)
417
+
418
+ const exitFullscreenBtn = document.body.querySelectorAll(
419
+ '.rs-dialog__actions button',
420
+ )[0] as HTMLElement
421
+ await exitFullscreenBtn.click()
422
+ await flushPromises()
423
+ expect(target.querySelector('.rs-dialog__content')).not.toBeNull()
424
+ expect(
425
+ target.querySelector('.rs-dialog__content')?.classList.contains('rs-dialog__content--fullscreen'),
426
+ ).toBe(false)
427
+
428
+ wrapper.unmount()
429
+ target.remove()
364
430
  })
365
431
 
366
432
  it('passes modal=false to DialogRoot for non-modal usage', async () => {
@@ -401,4 +467,116 @@ describe('RsDialog', () => {
401
467
  expect(wrapper.props('open')).toBe(true)
402
468
  wrapper.unmount()
403
469
  })
470
+
471
+ it('beforeClose returning false keeps dialog open', async () => {
472
+ const Host = defineComponent({
473
+ components: { RsDialog },
474
+ setup() {
475
+ const open = ref(true)
476
+ const beforeClose = () => false
477
+ return { open, beforeClose }
478
+ },
479
+ template:
480
+ '<RsDialog v-model:open="open" title="拦截关闭" :fullscreenable="false" :before-close="beforeClose" />',
481
+ })
482
+ const wrapper = mount(Host, { attachTo: document.body })
483
+ await flushPromises()
484
+ const closeBtn = document.body.querySelector('.rs-dialog__actions button') as HTMLElement
485
+ await closeBtn.click()
486
+ await flushPromises()
487
+ expect(wrapper.findComponent(RsDialog).props('open')).toBe(true)
488
+ wrapper.unmount()
489
+ })
490
+
491
+ it('renders builtin footer when showFooter is true and no footer slot', async () => {
492
+ const wrapper = mount(RsDialog, {
493
+ props: {
494
+ open: true,
495
+ title: '内置页脚',
496
+ showFooter: true,
497
+ layout: 'confirm',
498
+ resizable: false,
499
+ fullscreenable: false,
500
+ },
501
+ attachTo: document.body,
502
+ })
503
+ await flushPromises()
504
+ const footer = document.body.querySelector('.rs-dialog__footer')
505
+ expect(footer).not.toBeNull()
506
+ expect(footer?.querySelectorAll('button').length).toBe(2)
507
+ wrapper.unmount()
508
+ })
509
+
510
+ it('does not render builtin footer by default', async () => {
511
+ const wrapper = mount(RsDialog, {
512
+ props: {
513
+ open: true,
514
+ title: '无页脚',
515
+ layout: 'confirm',
516
+ resizable: false,
517
+ fullscreenable: false,
518
+ },
519
+ attachTo: document.body,
520
+ })
521
+ await flushPromises()
522
+ expect(document.body.querySelector('.rs-dialog__footer')).toBeNull()
523
+ wrapper.unmount()
524
+ })
525
+
526
+ it('applies custom numeric width on confirm layout', async () => {
527
+ const wrapper = mount(RsDialog, {
528
+ props: {
529
+ open: true,
530
+ title: '自定义宽度',
531
+ layout: 'confirm',
532
+ width: 520,
533
+ resizable: false,
534
+ fullscreenable: false,
535
+ },
536
+ attachTo: document.body,
537
+ })
538
+ await flushPromises()
539
+ const content = document.body.querySelector('.rs-dialog__content') as HTMLElement
540
+ expect(content.classList.contains('rs-dialog__content--custom-width')).toBe(true)
541
+ expect(content.style.maxWidth).toBe('520px')
542
+ wrapper.unmount()
543
+ })
544
+
545
+ it('title slot overrides title prop text', async () => {
546
+ const wrapper = mount(RsDialog, {
547
+ props: { open: true, title: '属性标题', fullscreenable: false },
548
+ slots: { title: '<span class="slot-title">插槽标题</span>' },
549
+ attachTo: document.body,
550
+ })
551
+ await flushPromises()
552
+ expect(document.body.querySelector('.slot-title')?.textContent).toBe('插槽标题')
553
+ wrapper.unmount()
554
+ })
555
+
556
+ it('emits afterClose when closed via close button', async () => {
557
+ const Host = defineComponent({
558
+ components: { RsDialog },
559
+ setup() {
560
+ const open = ref(true)
561
+ const reason = ref('')
562
+ return { open, reason }
563
+ },
564
+ template: `
565
+ <RsDialog
566
+ v-model:open="open"
567
+ title="关闭事件"
568
+ :fullscreenable="false"
569
+ @after-close="reason = $event"
570
+ />
571
+ `,
572
+ })
573
+ const wrapper = mount(Host, { attachTo: document.body })
574
+ await flushPromises()
575
+ const closeBtn = document.body.querySelector('.rs-dialog__actions button') as HTMLElement
576
+ await closeBtn.click()
577
+ await flushPromises()
578
+ expect(wrapper.vm.reason).toBe('close')
579
+ expect(wrapper.findComponent(RsDialog).props('open')).toBe(false)
580
+ wrapper.unmount()
581
+ })
404
582
  })
@@ -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 },
@@ -43,7 +125,7 @@ describe('RsForm', () => {
43
125
  },
44
126
  template: `
45
127
  <RsForm ref="formRef">
46
- <RsInput v-model="email" rule="email" required />
128
+ <RsInput v-model="email" name="email" rule="email" required />
47
129
  </RsForm>
48
130
  `,
49
131
  })
@@ -64,7 +146,7 @@ describe('RsForm', () => {
64
146
  },
65
147
  template: `
66
148
  <RsForm ref="formRef">
67
- <RsInput v-model="email" rule="email" required />
149
+ <RsInput v-model="email" name="email" rule="email" required />
68
150
  </RsForm>
69
151
  `,
70
152
  })
@@ -85,7 +167,7 @@ describe('RsForm', () => {
85
167
  },
86
168
  template: `
87
169
  <RsForm ref="formRef">
88
- <RsInput v-model="name" />
170
+ <RsInput v-model="name" name="name" />
89
171
  </RsForm>
90
172
  `,
91
173
  })
@@ -109,7 +191,7 @@ describe('RsForm', () => {
109
191
  },
110
192
  template: `
111
193
  <RsForm ref="formRef">
112
- <RsInput v-model="email" rule="email" required />
194
+ <RsInput v-model="email" name="email" rule="email" required />
113
195
  </RsForm>
114
196
  `,
115
197
  })
@@ -156,7 +238,7 @@ describe('RsForm', () => {
156
238
  },
157
239
  template: `
158
240
  <RsForm ref="formRef">
159
- <RsSelect v-model="role" :options="roleOptions" required />
241
+ <RsSelect v-model="role" name="role" :options="roleOptions" required />
160
242
  </RsForm>
161
243
  `,
162
244
  })
@@ -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
  })