niuma-ui 1.0.1 → 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 (153) hide show
  1. package/CHANGELOG.md +38 -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__/RsDateTimePicker.spec.ts +11 -0
  8. package/src/__tests__/RsDescriptions.spec.ts +50 -0
  9. package/src/__tests__/RsDialog.spec.ts +117 -0
  10. package/src/__tests__/RsDrawer.spec.ts +96 -3
  11. package/src/__tests__/RsDynamicTags.spec.ts +52 -0
  12. package/src/__tests__/RsForm.spec.ts +245 -1
  13. package/src/__tests__/RsInput.spec.ts +15 -1
  14. package/src/__tests__/RsMenu.spec.ts +406 -14
  15. package/src/__tests__/RsPagination.spec.ts +67 -2
  16. package/src/__tests__/RsSelect.spec.ts +42 -1
  17. package/src/__tests__/RsSwitch.spec.ts +44 -0
  18. package/src/__tests__/RsTable.a11y.spec.ts +83 -0
  19. package/src/__tests__/RsTable.dom-baseline.spec.ts +87 -0
  20. package/src/__tests__/RsTable.perf.spec.ts +23 -4
  21. package/src/__tests__/RsTable.spec.ts +63 -8
  22. package/src/__tests__/RsTable.ssr.spec.ts +71 -0
  23. package/src/__tests__/RsTable.summary.spec.ts +45 -0
  24. package/src/__tests__/RsTabs.spec.ts +102 -4
  25. package/src/__tests__/RsTimePicker.spec.ts +11 -0
  26. package/src/__tests__/RsUpload.spec.ts +62 -6
  27. package/src/__tests__/createRsDialog.spec.ts +145 -0
  28. package/src/__tests__/form-rules.spec.ts +76 -0
  29. package/src/__tests__/perf-budgets.ts +32 -0
  30. package/src/__tests__/rs-table-api.spec.ts +206 -0
  31. package/src/__tests__/rs-table-compat-matrix.spec.ts +108 -0
  32. package/src/__tests__/rs-table-echarts-adapter.spec.ts +43 -0
  33. package/src/__tests__/rs-table-grid-nav.spec.ts +63 -0
  34. package/src/__tests__/rs-table-isolation.spec.ts +158 -0
  35. package/src/__tests__/split-pane-utils.spec.ts +21 -0
  36. package/src/__tests__/table-chart-utils.spec.ts +88 -0
  37. package/src/__tests__/table-summary-utils.spec.ts +62 -0
  38. package/src/__tests__/table-utils.spec.ts +124 -0
  39. package/src/__tests__/theme.spec.ts +25 -0
  40. package/src/__tests__/useRsTableColumnResize.spec.ts +25 -0
  41. package/src/__tests__/useRsTableCore.perf.spec.ts +170 -0
  42. package/src/components/RsAlert.vue +212 -0
  43. package/src/components/RsButton.vue +145 -21
  44. package/src/components/RsCard.vue +200 -16
  45. package/src/components/RsConfirmDialog.vue +215 -28
  46. package/src/components/RsContextMenu.vue +51 -13
  47. package/src/components/RsDatePicker.vue +267 -34
  48. package/src/components/RsDateTimePicker.vue +19 -2
  49. package/src/components/RsDescriptions.vue +186 -0
  50. package/src/components/RsDescriptionsItem.vue +32 -0
  51. package/src/components/RsDialog.vue +363 -51
  52. package/src/components/RsDivider.vue +89 -0
  53. package/src/components/RsDrawer.vue +558 -33
  54. package/src/components/RsDropdown.vue +33 -2
  55. package/src/components/RsDynamicTags.vue +517 -0
  56. package/src/components/RsForm.vue +97 -8
  57. package/src/components/RsInput.vue +80 -82
  58. package/src/components/RsInputNumber.vue +64 -23
  59. package/src/components/RsLabel.vue +20 -5
  60. package/src/components/RsLoadingBar.vue +151 -0
  61. package/src/components/RsMenu.vue +73 -15
  62. package/src/components/RsMenuItems.vue +147 -9
  63. package/src/components/RsPagination.vue +93 -14
  64. package/src/components/RsRadio.vue +81 -0
  65. package/src/components/RsRadioItem.vue +156 -0
  66. package/src/components/RsSelect.vue +67 -58
  67. package/src/components/RsSplitPane.vue +69 -1
  68. package/src/components/RsSwitch.vue +179 -0
  69. package/src/components/RsTable.vue +653 -2555
  70. package/src/components/RsTabs.vue +415 -83
  71. package/src/components/RsTag.vue +191 -0
  72. package/src/components/RsTimePicker.vue +44 -5
  73. package/src/components/RsTimePickerColumns.vue +136 -77
  74. package/src/components/RsToaster.vue +58 -18
  75. package/src/components/RsTree.vue +39 -21
  76. package/src/components/RsUpload.vue +145 -12
  77. package/src/components/RsVNodeHost.vue +20 -0
  78. package/src/components/button-utils.ts +39 -0
  79. package/src/components/context-menu-utils.ts +6 -0
  80. package/src/components/date-picker-utils.ts +84 -4
  81. package/src/components/descriptions-utils.ts +24 -0
  82. package/src/components/dialog-utils.ts +147 -0
  83. package/src/components/dialog-window.ts +74 -9
  84. package/src/components/drawer-utils.ts +73 -0
  85. package/src/components/form-rules.ts +257 -0
  86. package/src/components/form-utils.ts +52 -2
  87. package/src/components/menu-utils.ts +11 -0
  88. package/src/components/overlay-utils.ts +3 -0
  89. package/src/components/radio-utils.ts +12 -0
  90. package/src/components/reka.ts +5 -0
  91. package/src/components/select-utils.ts +21 -2
  92. package/src/components/split-pane-utils.ts +65 -12
  93. package/src/components/table/RsTableBody.vue +217 -0
  94. package/src/components/table/RsTableBodyRow.vue +90 -1
  95. package/src/components/table/RsTableColGroup.vue +41 -0
  96. package/src/components/table/RsTableHeader.vue +158 -0
  97. package/src/components/table/RsTableSummaryRow.vue +52 -0
  98. package/src/components/table/rs-table-api.ts +99 -0
  99. package/src/components/table/rs-table-compat-matrix.ts +71 -0
  100. package/src/components/table/rs-table-echarts-adapter.ts +81 -0
  101. package/src/components/table/rs-table-feature-host.ts +178 -0
  102. package/src/components/table/rs-table-grid-nav.ts +122 -0
  103. package/src/components/table/rs-table-header-types.ts +47 -0
  104. package/src/components/table/rs-table-module-registry.ts +72 -0
  105. package/src/components/table/rs-table-props.ts +274 -0
  106. package/src/components/table/rs-table-view-context.ts +258 -0
  107. package/src/components/table/rs-table.css +84 -6
  108. package/src/components/table/table-chart-utils.ts +165 -0
  109. package/src/components/table/table-features.ts +284 -0
  110. package/src/components/table/table-summary-utils.ts +194 -0
  111. package/src/components/table-utils.ts +452 -2
  112. package/src/components/tabs-utils.ts +142 -2
  113. package/src/components/time-picker-utils.ts +76 -3
  114. package/src/components/upload-utils.ts +122 -0
  115. package/src/components/virtual-list-utils.ts +15 -2
  116. package/src/composables/assembleRsTableApi.ts +79 -0
  117. package/src/composables/bindRsTableViewContext.ts +269 -0
  118. package/src/composables/createRsDialog.ts +357 -0
  119. package/src/composables/createRsTableEditEmitBridge.ts +38 -0
  120. package/src/composables/useRsLoadingBar.ts +36 -0
  121. package/src/composables/useRsTable.ts +134 -0
  122. package/src/composables/useRsTableA11y.ts +52 -0
  123. package/src/composables/useRsTableChartBridge.ts +79 -0
  124. package/src/composables/useRsTableColumnLayout.ts +320 -0
  125. package/src/composables/useRsTableColumnResize.ts +244 -0
  126. package/src/composables/useRsTableColumnVirtual.ts +180 -0
  127. package/src/composables/useRsTableColumns.ts +200 -0
  128. package/src/composables/useRsTableContextMenu.ts +257 -0
  129. package/src/composables/useRsTableCore.ts +256 -0
  130. package/src/composables/useRsTableEditActions.ts +606 -0
  131. package/src/composables/useRsTableEditLayer.ts +251 -0
  132. package/src/composables/useRsTableEngine.ts +357 -0
  133. package/src/composables/useRsTableGridKeyboard.ts +123 -0
  134. package/src/composables/useRsTableHeadless.ts +137 -0
  135. package/src/composables/useRsTableInteraction.ts +433 -0
  136. package/src/composables/useRsTableScrollHost.ts +75 -0
  137. package/src/composables/useRsTableScrollLayout.ts +255 -0
  138. package/src/composables/useRsTableSelectionSource.ts +89 -0
  139. package/src/composables/useRsTableShell.ts +416 -0
  140. package/src/composables/useRsTableShellChrome.ts +170 -0
  141. package/src/composables/useRsTableSummary.ts +113 -0
  142. package/src/composables/useRsTableViewProvide.ts +263 -0
  143. package/src/composables/useRsTableVirtual.ts +154 -0
  144. package/src/composables/useRsTabsNav.ts +4 -0
  145. package/src/composables/useTableEdit.ts +1 -1
  146. package/src/index.ts +309 -9
  147. package/src/locale/messages.ts +30 -0
  148. package/src/styles.css +305 -52
  149. package/src/theme/brand.example.css +21 -2
  150. package/src/theme/presets.ts +4 -0
  151. package/src/theme/types.ts +67 -0
  152. package/src/components/RsTableColgroup.vue +0 -17
  153. package/src/components/RsTableHeaderRow.vue +0 -30
package/CHANGELOG.md CHANGED
@@ -6,6 +6,42 @@
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.0.2] - 2026-08-13
10
+
11
+ ### 新增
12
+
13
+ - `RsTable` 架构分层:`RsTableApi` / ModuleRegistry / FeatureHost、`useRsTable` 表面、`useRsTableCore` / Shell / EditLayer、ViewContext 子件与 `useRsTableHeadless`。
14
+ - `RsTable` 无障碍基线:`region` + `role=grid`、键盘漫游;`tabindex` 落在 grid 表上。
15
+ - 图表适配:`createChartSeriesTableFeature`、`mapRsTableSeriesToEChartsOption`(无 echarts 依赖)。
16
+ - CI 门禁:`test:perf` / `test:compat` / `test:ssr` / `test:dom-baseline`;Playwright 视觉回归 `test:visual`。
17
+ - 架构文档:`rs-table-architecture` / `ssr` / `chart-adapter` / `visual`。
18
+ - `RsMenu` 菜单项 token:在 `styles.css` 主题块维护默认值(`--rs-menu-item-active-fg|bg` 等),组件只消费变量;父级覆盖即可,无需 `:deep`。
19
+ - 排版 token:字号阶梯扩展至 `xl` / `2xl` / `3xl`;新增字重、字族、行高 `relaxed`。
20
+ - 文字语义 token:`--rs-text-primary|secondary|tertiary|disabled|inverse|link|link-hover`(兼容原 `--rs-text` / `--rs-muted`)。
21
+ - JS 导出:`RsFontSize` / `RsFontWeight` 与 `RS_FONT_SIZE_CSS` / `RS_FONT_WEIGHT_CSS`。
22
+ - `RsSwitch`:开关控件(Reka Switch)。
23
+ - `RsRadio` / `RsRadioItem`:单选分组。
24
+ - `RsTag` / `RsDynamicTags`:标签与可编辑标签组。
25
+ - `RsAlert`:反馈提示条。
26
+ - `RsDivider`:分隔线。
27
+ - `RsDescriptions` / `RsDescriptionsItem`:描述列表。
28
+ - `RsLoadingBar` + `useRsLoadingBar`:顶栏加载进度。
29
+
30
+ ### 变更
31
+
32
+ - `RsPagination`:尺寸扩展为 `sm|md|lg`(对齐 Ant small/medium/large);页码按钮圆角 `sm`;当前页改为文字主色高亮、无填充底;跳转输入与每页条数同档高度;`showJumpConfirm` 默认 `false`(回车/失焦跳转)。
33
+ - `RsTabs`:非激活 `TabsContent` 强制 `display: none`,避免多页内容叠层。
34
+ - 表单标签 token:`--rs-label-font-size|font-weight|line-height|color` 写入 `styles.css`(默认对齐 Ant 14px / medium / text);`RsLabel` 与 `.rs-field__label` 共用,父级覆盖即可,无需 `:deep`。
35
+ - 表单控件高度对齐:`RsInput` / `RsSelect` / `RsInputNumber` / `RsDatePicker` 统一用 `--rs-control-height-*` 锁定 `height`,Select/DatePicker 行高改为 `tight`(与 Input 一致)。
36
+ - `RsDatePicker` 新增 `size` prop(ssm/sm/md/lg),可跟随 Form / ConfigProvider。
37
+ - `RsSwitch`:轨道高度用 `control-height * 2/3` 等 calc 推导(对齐 Ant 比例),随主题 token 缩放,不写死档位 px。
38
+ - `RsInputNumber`:恢复常规 `border` 描边(inset shadow 在部分主题下不可见);内层仍填满外壳高度。
39
+ - `RsSelect`:默认去掉 `max-width: 20rem` 上限(改为 `max-width: 100%`),避免表单栅格里 Select 比同列 Input 窄;`block` 仍负责拉满宽度。
40
+
41
+ ### 移除
42
+
43
+ - 公开组件 `RsTableHeaderRow` / `RsTableColgroup`(轻量辅助;正式路径为 `RsTableHeader` / `RsTableColGroup`)。
44
+
9
45
  ## [1.0.1] - 2026-08-11
10
46
 
11
47
  ### 变更
@@ -36,6 +72,7 @@
36
72
 
37
73
  - 1.0 之前的私有 tag(如 `v0.1.0`)仅作历史记录;新接入请依赖 `v1.0.0` 及之后版本。
38
74
 
39
- [Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.0.1...HEAD
75
+ [Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.0.2...HEAD
76
+ [1.0.2]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.0.2
40
77
  [1.0.1]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.0.1
41
78
  [1.0.0]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.0.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niuma-ui",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "Vue 3 设计系统与 Rs* 组件库(--rs-* token)",
@@ -80,9 +80,11 @@
80
80
  "vue-sonner": "^2.0.9"
81
81
  },
82
82
  "devDependencies": {
83
+ "@playwright/test": "^1.62.1",
83
84
  "@tailwindcss/vite": "^4.3.2",
84
85
  "@types/node": "^26.1.1",
85
86
  "@vitejs/plugin-vue": "^6.0.7",
87
+ "@vue/server-renderer": "^3.5.39",
86
88
  "@vue/test-utils": "^2.4.11",
87
89
  "jsdom": "^29.1.1",
88
90
  "tailwindcss": "^4.3.2",
@@ -101,6 +103,12 @@
101
103
  "build:playground": "vite build --config playground/vite.config.ts",
102
104
  "preview:playground": "vite preview --config playground/vite.config.ts",
103
105
  "test": "vitest run",
104
- "test:watch": "vitest"
106
+ "test:watch": "vitest",
107
+ "test:perf": "vitest run src/__tests__/RsTable.perf.spec.ts src/__tests__/useRsTableCore.perf.spec.ts",
108
+ "test:compat": "vitest run src/__tests__/rs-table-compat-matrix.spec.ts src/__tests__/rs-table-api.spec.ts src/__tests__/rs-table-isolation.spec.ts",
109
+ "test:ssr": "vitest run src/__tests__/RsTable.ssr.spec.ts",
110
+ "test:dom-baseline": "vitest run src/__tests__/RsTable.dom-baseline.spec.ts src/__tests__/rs-table-echarts-adapter.spec.ts",
111
+ "test:visual": "playwright test",
112
+ "test:visual:update": "playwright test --update-snapshots"
105
113
  }
106
114
  }
@@ -31,6 +31,53 @@ describe('RsButton', () => {
31
31
  expect(wrapper.classes()).toContain('rs-btn--ghost')
32
32
  })
33
33
 
34
+ it('text variant defaults to borderless + neutral tone', () => {
35
+ const wrapper = mount(RsButton, {
36
+ props: { variant: 'text' },
37
+ slots: { default: '编辑' },
38
+ })
39
+ expect(wrapper.classes()).toContain('rs-btn--text')
40
+ expect(wrapper.classes()).toContain('rs-btn--borderless')
41
+ expect(wrapper.classes()).toContain('rs-btn--tone-neutral')
42
+ })
43
+
44
+ it('text + tone applies semantic tone class', () => {
45
+ const wrapper = mount(RsButton, {
46
+ props: { variant: 'text', tone: 'primary' },
47
+ slots: { default: '新增' },
48
+ })
49
+ expect(wrapper.classes()).toContain('rs-btn--text')
50
+ expect(wrapper.classes()).toContain('rs-btn--tone-primary')
51
+ })
52
+
53
+ it('ghost supports tone without becoming filled', () => {
54
+ const wrapper = mount(RsButton, {
55
+ props: { variant: 'ghost', tone: 'danger' },
56
+ slots: { default: '删除' },
57
+ })
58
+ expect(wrapper.classes()).toContain('rs-btn--ghost')
59
+ expect(wrapper.classes()).toContain('rs-btn--tone-danger')
60
+ expect(wrapper.classes()).not.toContain('rs-btn--danger')
61
+ })
62
+
63
+ it('filled primary ignores tone class', () => {
64
+ const wrapper = mount(RsButton, {
65
+ props: { variant: 'primary', tone: 'danger' },
66
+ slots: { default: '保存' },
67
+ })
68
+ expect(wrapper.classes()).toContain('rs-btn--primary')
69
+ expect(wrapper.classes()).not.toContain('rs-btn--tone-danger')
70
+ })
71
+
72
+ it('link defaults to primary tone', () => {
73
+ const wrapper = mount(RsButton, {
74
+ props: { variant: 'link' },
75
+ slots: { default: '查看' },
76
+ })
77
+ expect(wrapper.classes()).toContain('rs-btn--link')
78
+ expect(wrapper.classes()).toContain('rs-btn--tone-primary')
79
+ })
80
+
34
81
  it('renders prefix icon', () => {
35
82
  const wrapper = mount(RsButton, {
36
83
  props: { icon: 'plus' },
@@ -34,11 +34,22 @@ describe('RsCard', () => {
34
34
  expect(wrapper.find('header.rs-card__header').exists()).toBe(true)
35
35
  })
36
36
 
37
- it('omits header when no title, description, or header slot', () => {
37
+ it('omits header when no title, description, header, or actions slot', () => {
38
38
  const wrapper = mount(RsCard, { slots: { default: '仅正文' } })
39
39
  expect(wrapper.find('header').exists()).toBe(false)
40
40
  })
41
41
 
42
+ it('renders header when only actions slot is provided', () => {
43
+ const wrapper = mount(RsCard, {
44
+ slots: {
45
+ actions: '<button type="button">操作</button>',
46
+ default: '正文',
47
+ },
48
+ })
49
+ expect(wrapper.find('header.rs-card__header').exists()).toBe(true)
50
+ expect(wrapper.find('.rs-card__actions button').text()).toBe('操作')
51
+ })
52
+
42
53
  it('renders header slot instead of default heading', () => {
43
54
  const wrapper = mount(RsCard, {
44
55
  slots: {
@@ -61,6 +72,40 @@ describe('RsCard', () => {
61
72
  expect(wrapper.find('.rs-card__actions button').text()).toBe('操作')
62
73
  })
63
74
 
75
+ it('renders cover slot before header', () => {
76
+ const wrapper = mount(RsCard, {
77
+ props: { title: '封面卡' },
78
+ slots: {
79
+ cover: '<div class="cover-el">封面</div>',
80
+ default: '正文',
81
+ },
82
+ })
83
+ const cover = wrapper.find('.rs-card__cover')
84
+ expect(cover.exists()).toBe(true)
85
+ expect(cover.text()).toBe('封面')
86
+ const rootHtml = wrapper.html()
87
+ expect(rootHtml.indexOf('rs-card__cover')).toBeLessThan(rootHtml.indexOf('rs-card__header'))
88
+ })
89
+
90
+ it('renders footer slot', () => {
91
+ const wrapper = mount(RsCard, {
92
+ props: { title: '带底部' },
93
+ slots: {
94
+ default: '正文',
95
+ footer: '<button type="button">保存</button>',
96
+ },
97
+ })
98
+ expect(wrapper.find('footer.rs-card__footer button').text()).toBe('保存')
99
+ })
100
+
101
+ it('omits footer when footer slot is absent', () => {
102
+ const wrapper = mount(RsCard, {
103
+ props: { title: '无底部' },
104
+ slots: { default: '正文' },
105
+ })
106
+ expect(wrapper.find('footer').exists()).toBe(false)
107
+ })
108
+
64
109
  it('applies padded body class by default', () => {
65
110
  const wrapper = mount(RsCard, { slots: { default: '内容' } })
66
111
  expect(wrapper.find('.rs-card__body').classes()).toContain('rs-card__body--padded')
@@ -82,6 +127,30 @@ describe('RsCard', () => {
82
127
  expect(wrapper.classes()).toContain('rs-card--elevated')
83
128
  })
84
129
 
130
+ it('applies hoverable class', () => {
131
+ const wrapper = mount(RsCard, {
132
+ props: { hoverable: true },
133
+ slots: { default: '内容' },
134
+ })
135
+ expect(wrapper.classes()).toContain('rs-card--hoverable')
136
+ })
137
+
138
+ it('applies clip class for overflow hidden', () => {
139
+ const wrapper = mount(RsCard, {
140
+ props: { clip: true },
141
+ slots: { default: '内容' },
142
+ })
143
+ expect(wrapper.classes()).toContain('rs-card--clip')
144
+ })
145
+
146
+ it('applies fill class for pane layouts', () => {
147
+ const wrapper = mount(RsCard, {
148
+ props: { fill: true },
149
+ slots: { default: '内容' },
150
+ })
151
+ expect(wrapper.classes()).toContain('rs-card--fill')
152
+ })
153
+
85
154
  it('applies variant class', () => {
86
155
  const wrapper = mount(RsCard, {
87
156
  props: { variant: 'plain' },
@@ -94,4 +163,94 @@ describe('RsCard', () => {
94
163
  const wrapper = mount(RsCard, { slots: { default: '内容' } })
95
164
  expect(wrapper.classes()).toContain('rs-card--grouped')
96
165
  })
166
+
167
+ it('defaults to md size', () => {
168
+ const wrapper = mount(RsCard, { slots: { default: '内容' } })
169
+ expect(wrapper.classes()).toContain('rs-card--md')
170
+ })
171
+
172
+ it.each(['sm', 'md', 'lg'] as const)('applies size=%s class', (size) => {
173
+ const wrapper = mount(RsCard, {
174
+ props: { size },
175
+ slots: { default: '内容' },
176
+ })
177
+ expect(wrapper.classes()).toContain(`rs-card--${size}`)
178
+ })
179
+
180
+ it('defaults radius css var to md token', () => {
181
+ const wrapper = mount(RsCard, { slots: { default: '内容' } })
182
+ const style = wrapper.attributes('style') ?? ''
183
+ expect(style).toContain('--rs-card-radius')
184
+ expect(style).toMatch(/var\(--rs-radius\)|--rs-radius[^-]/)
185
+ })
186
+
187
+ it('applies explicit radius css var', () => {
188
+ const wrapper = mount(RsCard, {
189
+ props: { radius: 'lg' },
190
+ slots: { default: '内容' },
191
+ })
192
+ expect(wrapper.attributes('style')).toContain('var(--rs-radius-lg)')
193
+ })
194
+
195
+ it('applies none radius as 0', () => {
196
+ const wrapper = mount(RsCard, {
197
+ props: { radius: 'none' },
198
+ slots: { default: '内容' },
199
+ })
200
+ expect(wrapper.attributes('style')).toMatch(/--rs-card-radius:\s*0/)
201
+ })
202
+
203
+ it('applies borderless class when borderless is true', () => {
204
+ const wrapper = mount(RsCard, {
205
+ props: { borderless: true },
206
+ slots: { default: '内容' },
207
+ })
208
+ expect(wrapper.classes()).toContain('rs-card--borderless')
209
+ })
210
+
211
+ it('supports admin outlined preset combination', () => {
212
+ const wrapper = mount(RsCard, {
213
+ props: {
214
+ variant: 'outlined',
215
+ size: 'md',
216
+ radius: 'md',
217
+ title: '节点列表',
218
+ },
219
+ slots: { default: '表格' },
220
+ })
221
+ expect(wrapper.classes()).toEqual(
222
+ expect.arrayContaining(['rs-card--outlined', 'rs-card--md']),
223
+ )
224
+ expect(wrapper.find('.rs-card__title').text()).toBe('节点列表')
225
+ })
226
+
227
+ it('supports frontend marketing preset combination', () => {
228
+ const wrapper = mount(RsCard, {
229
+ props: {
230
+ variant: 'filled',
231
+ size: 'lg',
232
+ radius: 'lg',
233
+ elevated: true,
234
+ hoverable: true,
235
+ clip: true,
236
+ title: '产品亮点',
237
+ },
238
+ slots: {
239
+ cover: '<img alt="" src="about:blank" />',
240
+ default: '介绍',
241
+ footer: '<button type="button">了解更多</button>',
242
+ },
243
+ })
244
+ expect(wrapper.classes()).toEqual(
245
+ expect.arrayContaining([
246
+ 'rs-card--filled',
247
+ 'rs-card--lg',
248
+ 'rs-card--elevated',
249
+ 'rs-card--hoverable',
250
+ 'rs-card--clip',
251
+ ]),
252
+ )
253
+ expect(wrapper.find('.rs-card__cover img').exists()).toBe(true)
254
+ expect(wrapper.find('.rs-card__footer').exists()).toBe(true)
255
+ })
97
256
  })
@@ -62,6 +62,76 @@ describe('RsConfirmDialog', () => {
62
62
  wrapper.unmount()
63
63
  })
64
64
 
65
+ it('renders subtitle and description separately', async () => {
66
+ const wrapper = mount(RsConfirmDialog, {
67
+ props: {
68
+ open: true,
69
+ title: '确认删除',
70
+ subtitle: '此操作不可恢复',
71
+ description: '确定删除该用户吗?',
72
+ },
73
+ attachTo: document.body,
74
+ })
75
+ await flushPromises()
76
+ const content = document.body.querySelector('.rs-confirm-dialog__content')
77
+ expect(content?.querySelector('.rs-confirm-dialog__subtitle')?.textContent).toContain(
78
+ '此操作不可恢复',
79
+ )
80
+ expect(content?.querySelector('.rs-confirm-dialog__description')?.textContent).toContain(
81
+ '确定删除该用户吗?',
82
+ )
83
+ wrapper.unmount()
84
+ })
85
+
86
+ it('applies custom width as max-width', async () => {
87
+ const wrapper = mount(RsConfirmDialog, {
88
+ props: { open: true, title: '宽', width: 500 },
89
+ attachTo: document.body,
90
+ })
91
+ await flushPromises()
92
+ const content = document.body.querySelector('.rs-confirm-dialog__content') as HTMLElement
93
+ expect(content.style.maxWidth).toBe('500px')
94
+ wrapper.unmount()
95
+ })
96
+
97
+ it('applies preset width class', async () => {
98
+ const wrapper = mount(RsConfirmDialog, {
99
+ props: { open: true, title: 'md', width: 'md' },
100
+ attachTo: document.body,
101
+ })
102
+ await flushPromises()
103
+ const content = document.body.querySelector('.rs-confirm-dialog__content')
104
+ expect(content?.classList.contains('rs-confirm-dialog__content--md')).toBe(true)
105
+ wrapper.unmount()
106
+ })
107
+
108
+ it('keeps open when autoCloseOnConfirm is false', async () => {
109
+ const Host = defineComponent({
110
+ components: { RsConfirmDialog },
111
+ setup() {
112
+ const open = ref(true)
113
+ return { open }
114
+ },
115
+ template: `
116
+ <RsConfirmDialog
117
+ v-model:open="open"
118
+ title="异步确认"
119
+ :auto-close-on-confirm="false"
120
+ @confirm="$emit('confirmed')"
121
+ />
122
+ `,
123
+ })
124
+ const wrapper = mount(Host, { attachTo: document.body })
125
+ await flushPromises()
126
+ const buttons = document.body.querySelectorAll('.rs-confirm-dialog__footer button')
127
+ const confirmBtn = buttons[buttons.length - 1] as HTMLElement
128
+ await confirmBtn.click()
129
+ await flushPromises()
130
+ expect(wrapper.emitted('confirmed')).toHaveLength(1)
131
+ expect(wrapper.findComponent(RsConfirmDialog).props('open')).toBe(true)
132
+ wrapper.unmount()
133
+ })
134
+
65
135
  it('hides overlay by default', async () => {
66
136
  const wrapper = mount(RsConfirmDialog, {
67
137
  props: { open: true, title: '无遮罩' },
@@ -155,4 +225,61 @@ describe('RsConfirmDialog', () => {
155
225
  expect(target.querySelector('.rs-confirm-dialog__content')).not.toBeNull()
156
226
  wrapper.unmount()
157
227
  })
228
+
229
+ it('hides cancel button when showCancel is false', async () => {
230
+ const wrapper = mount(RsConfirmDialog, {
231
+ props: {
232
+ open: true,
233
+ title: '单按钮提示',
234
+ showCancel: false,
235
+ confirmText: '知道了',
236
+ },
237
+ attachTo: document.body,
238
+ })
239
+ await flushPromises()
240
+ const buttons = document.body.querySelectorAll('.rs-confirm-dialog__footer button')
241
+ expect(buttons).toHaveLength(1)
242
+ expect(buttons[0]?.textContent).toContain('知道了')
243
+ wrapper.unmount()
244
+ })
245
+
246
+ it('renders #extra slot between description and footer', async () => {
247
+ const wrapper = mount(RsConfirmDialog, {
248
+ props: { open: true, title: '额外内容', description: '正文' },
249
+ slots: {
250
+ extra: () => h('div', { class: 'extra-probe' }, 'extra-body'),
251
+ },
252
+ attachTo: document.body,
253
+ })
254
+ await flushPromises()
255
+ const extra = document.body.querySelector('.rs-confirm-dialog__extra .extra-probe')
256
+ expect(extra?.textContent).toContain('extra-body')
257
+ wrapper.unmount()
258
+ })
259
+
260
+ it('blocks close when beforeClose returns false', async () => {
261
+ const Host = defineComponent({
262
+ components: { RsConfirmDialog },
263
+ setup() {
264
+ const open = ref(true)
265
+ return { open }
266
+ },
267
+ template: `
268
+ <RsConfirmDialog
269
+ v-model:open="open"
270
+ title="拦截"
271
+ :before-close="() => false"
272
+ />
273
+ `,
274
+ })
275
+ const wrapper = mount(Host, { attachTo: document.body })
276
+ await flushPromises()
277
+ const cancelBtn = document.body.querySelector(
278
+ '.rs-confirm-dialog__footer button',
279
+ ) as HTMLElement
280
+ await cancelBtn.click()
281
+ await flushPromises()
282
+ expect(wrapper.findComponent(RsConfirmDialog).props('open')).toBe(true)
283
+ wrapper.unmount()
284
+ })
158
285
  })
@@ -115,4 +115,16 @@ describe('RsContextMenu', () => {
115
115
  expect(document.body.textContent).toContain('项 19')
116
116
  wrapper.unmount()
117
117
  })
118
+
119
+ it('does not open empty menu bubble when items are empty', async () => {
120
+ const wrapper = mount(RsContextMenu, {
121
+ props: { items: [] },
122
+ slots: { default: '<div class="trigger">Right click</div>' },
123
+ attachTo: document.body,
124
+ })
125
+ await wrapper.find('.trigger').trigger('contextmenu')
126
+ await flushPromises()
127
+ expect(document.body.querySelector('.rs-context-menu__content')).toBeNull()
128
+ wrapper.unmount()
129
+ })
118
130
  })
@@ -60,6 +60,17 @@ describe('RsDateTimePicker', () => {
60
60
  wrapper.unmount()
61
61
  })
62
62
 
63
+ it('propagates size to embedded time picker', async () => {
64
+ const wrapper = mount(RsDateTimePicker, {
65
+ props: { modelValue: '', size: 'lg', open: true },
66
+ attachTo: document.body,
67
+ })
68
+ await flushPromises()
69
+ expect(wrapper.find('.rs-date-picker--lg').exists()).toBe(true)
70
+ expect(document.body.querySelector('.rs-time-picker--lg')).not.toBeNull()
71
+ wrapper.unmount()
72
+ })
73
+
63
74
  it('disables trigger when disabled', () => {
64
75
  const wrapper = mount(RsDateTimePicker, {
65
76
  props: { modelValue: '2025-06-16 14:30:00', disabled: true },
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { mount } from '@vue/test-utils'
3
+ import { h } from 'vue'
4
+ import RsDescriptions from '../components/RsDescriptions.vue'
5
+ import RsConfigProvider from '../components/RsConfigProvider.vue'
6
+
7
+ const sampleItems = [
8
+ { key: 'name', label: '名称', value: '弱水' },
9
+ { key: 'owner', label: '负责人', value: 'Ada' },
10
+ ]
11
+
12
+ describe('RsDescriptions', () => {
13
+ it('defaults to md size without config override', () => {
14
+ const wrapper = mount(RsDescriptions, {
15
+ props: { items: sampleItems },
16
+ })
17
+ expect(wrapper.classes()).toContain('rs-descriptions--md')
18
+ })
19
+
20
+ it('inherits controlSize from RsConfigProvider when size omitted', () => {
21
+ const wrapper = mount(RsConfigProvider, {
22
+ props: { controlSize: 'sm' },
23
+ slots: {
24
+ default: () => h(RsDescriptions, { items: sampleItems }),
25
+ },
26
+ })
27
+ expect(wrapper.find('.rs-descriptions').classes()).toContain('rs-descriptions--sm')
28
+ })
29
+
30
+ it('props size overrides RsConfigProvider controlSize', () => {
31
+ const wrapper = mount(RsConfigProvider, {
32
+ props: { controlSize: 'sm' },
33
+ slots: {
34
+ default: () => h(RsDescriptions, { items: sampleItems, size: 'lg' }),
35
+ },
36
+ })
37
+ expect(wrapper.find('.rs-descriptions').classes()).toContain('rs-descriptions--lg')
38
+ expect(wrapper.find('.rs-descriptions').classes()).not.toContain('rs-descriptions--sm')
39
+ })
40
+
41
+ it('supports ssm from config', () => {
42
+ const wrapper = mount(RsConfigProvider, {
43
+ props: { controlSize: 'ssm' },
44
+ slots: {
45
+ default: () => h(RsDescriptions, { items: sampleItems }),
46
+ },
47
+ })
48
+ expect(wrapper.find('.rs-descriptions').classes()).toContain('rs-descriptions--ssm')
49
+ })
50
+ })
@@ -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
  })