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
@@ -54,6 +54,49 @@ describe('RsTooltip', () => {
54
54
  wrapper.unmount()
55
55
  })
56
56
 
57
+ it('does not open on non-keyboard focus when ignoreNonKeyboardFocus is on', async () => {
58
+ const wrapper = mountTooltip(
59
+ '<button type="button" class="trigger">Hover</button>',
60
+ { content: 'Focus tip' },
61
+ )
62
+ try {
63
+ const trigger = wrapper.find('.trigger').element as HTMLButtonElement
64
+ // 模拟 Dialog 关闭回焦:有 focus 但无 :focus-visible(jsdom 对 :focus-visible 不稳定)
65
+ const matchesSpy = vi
66
+ .spyOn(trigger, 'matches')
67
+ .mockImplementation((selectors: string) => !selectors.includes(':focus-visible'))
68
+ trigger.dispatchEvent(new FocusEvent('focus', { bubbles: true }))
69
+ await vi.runAllTimersAsync()
70
+ await flushPromises()
71
+ expect(document.body.querySelector('.rs-tooltip__content')).toBeNull()
72
+ matchesSpy.mockRestore()
73
+ } finally {
74
+ wrapper.unmount()
75
+ }
76
+ })
77
+
78
+ it('opens on keyboard-style focus-visible', async () => {
79
+ const wrapper = mountTooltip(
80
+ '<button type="button" class="trigger">Hover</button>',
81
+ { content: 'Keyboard tip' },
82
+ )
83
+ try {
84
+ const trigger = wrapper.find('.trigger').element as HTMLButtonElement
85
+ const matchesSpy = vi
86
+ .spyOn(trigger, 'matches')
87
+ .mockImplementation((selectors: string) => selectors.includes(':focus-visible'))
88
+ trigger.dispatchEvent(new FocusEvent('focus', { bubbles: true }))
89
+ await vi.runAllTimersAsync()
90
+ await flushPromises()
91
+ expect(document.body.querySelector('.rs-tooltip__content')).not.toBeNull()
92
+ expect(document.body.textContent).toContain('Keyboard tip')
93
+ matchesSpy.mockRestore()
94
+ } finally {
95
+ wrapper.unmount()
96
+ }
97
+ })
98
+
99
+
57
100
  it('renders custom content slot', async () => {
58
101
  const wrapper = mount(
59
102
  {
@@ -1,4 +1,4 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { describe, expect, it, vi } from 'vitest'
2
2
  import { h } from 'vue'
3
3
  import { mount } from '@vue/test-utils'
4
4
  import RsConfigProvider from '../components/RsConfigProvider.vue'
@@ -8,6 +8,7 @@ import {
8
8
  isFileAccepted,
9
9
  mergeUploadFiles,
10
10
  removeUploadFileAt,
11
+ resolveUploadFileIcon,
11
12
  validateUploadFiles,
12
13
  } from '../components/upload-utils'
13
14
 
@@ -63,6 +64,17 @@ describe('upload-utils', () => {
63
64
  const files = [createFile('a.txt', 'a'), createFile('b.txt', 'b')]
64
65
  expect(removeUploadFileAt(files, 0).map((file) => file.name)).toEqual(['b.txt'])
65
66
  })
67
+
68
+ it('resolves list icons by extension and mime', () => {
69
+ expect(resolveUploadFileIcon('localhost.pem')).toBe('file-key')
70
+ expect(resolveUploadFileIcon('localhost.key')).toBe('key-round')
71
+ expect(resolveUploadFileIcon('photo.png')).toBe('file-image')
72
+ expect(resolveUploadFileIcon('app.ts')).toBe('file-code')
73
+ expect(resolveUploadFileIcon('data.xlsx')).toBe('file-spreadsheet')
74
+ expect(resolveUploadFileIcon('pack.zip')).toBe('file-archive')
75
+ expect(resolveUploadFileIcon(createFile('blob.bin', 'x', 'image/png'))).toBe('file-image')
76
+ expect(resolveUploadFileIcon('unknown.xyz')).toBe('file')
77
+ })
66
78
  })
67
79
 
68
80
  describe('RsUpload', () => {
@@ -149,12 +161,56 @@ describe('RsUpload', () => {
149
161
  expect(wrapper.find('.rs-upload__dropzone').classes()).toContain('rs-upload__dropzone--disabled')
150
162
  })
151
163
 
152
- it('binds accept and multiple attributes', () => {
164
+ it('hides file list when showFileList is false', () => {
153
165
  const wrapper = mount(RsUpload, {
154
- props: { accept: 'image/*', multiple: true },
166
+ props: {
167
+ modelValue: [createFile('a.txt', 'a')],
168
+ showFileList: false,
169
+ },
170
+ })
171
+ expect(wrapper.find('.rs-upload__list').exists()).toBe(false)
172
+ })
173
+
174
+ it('shows download action and emits download', async () => {
175
+ const createObjectURL = vi.fn(() => 'blob:mock')
176
+ const revokeObjectURL = vi.fn()
177
+ vi.stubGlobal('URL', { createObjectURL, revokeObjectURL })
178
+
179
+ const file = createFile('a.txt', 'hello')
180
+ const wrapper = mount(RsUpload, {
181
+ props: {
182
+ modelValue: [file],
183
+ showDownload: true,
184
+ },
185
+ })
186
+ expect(wrapper.find('.rs-upload__file-action').exists()).toBe(true)
187
+ await wrapper.find('.rs-upload__file-action').trigger('click')
188
+ expect(wrapper.emitted('download')?.[0]?.[0]).toBe(file)
189
+ expect(createObjectURL).toHaveBeenCalled()
190
+
191
+ vi.unstubAllGlobals()
192
+ })
193
+
194
+ it('accepts dropped files', async () => {
195
+ const wrapper = mount(RsUpload, {
196
+ props: { modelValue: [] },
197
+ })
198
+ const file = createFile('drop.txt', 'x')
199
+ await wrapper.find('.rs-upload__dropzone').trigger('drop', {
200
+ dataTransfer: { files: [file] },
201
+ })
202
+ expect(wrapper.emitted('update:modelValue')?.[0]?.[0]).toEqual([file])
203
+ })
204
+
205
+ it('hides dropzone when full and hideDropzoneWhenFull', () => {
206
+ const wrapper = mount(RsUpload, {
207
+ props: {
208
+ modelValue: [createFile('a.txt', 'a')],
209
+ maxCount: 1,
210
+ hideDropzoneWhenFull: true,
211
+ },
155
212
  })
156
- const input = wrapper.find('input')
157
- expect(input.attributes('accept')).toBe('image/*')
158
- expect(input.attributes('multiple')).toBeDefined()
213
+ expect(wrapper.find('.rs-upload__dropzone').exists()).toBe(false)
214
+ expect(wrapper.find('.rs-upload__file-name').text()).toBe('a.txt')
159
215
  })
160
216
  })
@@ -0,0 +1,145 @@
1
+ import { h } from 'vue'
2
+ import { afterEach, describe, expect, it } from 'vitest'
3
+ import { flushPromises } from '@vue/test-utils'
4
+ import { openRsDialog, rsConfirm } from '../composables/createRsDialog'
5
+
6
+ describe('createRsDialog', () => {
7
+ afterEach(() => {
8
+ document.body.querySelectorAll('.rs-dialog__content, .rs-confirm-dialog__content').forEach((el) => {
9
+ el.closest('body > div')?.remove()
10
+ })
11
+ })
12
+
13
+ it('rsConfirm resolves true when confirm clicked', async () => {
14
+ const promise = rsConfirm({
15
+ title: '删除?',
16
+ description: '不可撤销',
17
+ showOverlay: false,
18
+ })
19
+ await flushPromises()
20
+ const confirmBtn = Array.from(document.body.querySelectorAll('.rs-confirm-dialog__footer button')).at(
21
+ -1,
22
+ ) as HTMLButtonElement
23
+ expect(confirmBtn).toBeTruthy()
24
+ confirmBtn.click()
25
+ await flushPromises()
26
+ await expect(promise).resolves.toBe(true)
27
+ })
28
+
29
+ it('rsConfirm resolves false when cancel clicked', async () => {
30
+ const promise = rsConfirm({
31
+ title: '取消测试',
32
+ showOverlay: false,
33
+ })
34
+ await flushPromises()
35
+ const cancelBtn = document.body.querySelector(
36
+ '.rs-confirm-dialog__footer button',
37
+ ) as HTMLButtonElement
38
+ cancelBtn.click()
39
+ await flushPromises()
40
+ await expect(promise).resolves.toBe(false)
41
+ })
42
+
43
+ it('rsConfirm passes subtitle and width', async () => {
44
+ const promise = rsConfirm({
45
+ title: '删除?',
46
+ subtitle: '不可恢复',
47
+ description: '确定删除吗?',
48
+ width: 500,
49
+ showOverlay: false,
50
+ })
51
+ await flushPromises()
52
+ const content = document.body.querySelector('.rs-confirm-dialog__content') as HTMLElement
53
+ expect(content?.textContent).toContain('不可恢复')
54
+ expect(content?.textContent).toContain('确定删除吗?')
55
+ expect(content.style.maxWidth).toBe('500px')
56
+ const cancelBtn = document.body.querySelector(
57
+ '.rs-confirm-dialog__footer button',
58
+ ) as HTMLButtonElement
59
+ cancelBtn.click()
60
+ await flushPromises()
61
+ await expect(promise).resolves.toBe(false)
62
+ })
63
+
64
+ it('rsConfirm waits for async onConfirm before resolving', async () => {
65
+ let release!: () => void
66
+ const gate = new Promise<void>((resolve) => {
67
+ release = resolve
68
+ })
69
+ const promise = rsConfirm({
70
+ title: '异步',
71
+ showOverlay: false,
72
+ onConfirm: () => gate,
73
+ })
74
+ await flushPromises()
75
+ const confirmBtn = Array.from(document.body.querySelectorAll('.rs-confirm-dialog__footer button')).at(
76
+ -1,
77
+ ) as HTMLButtonElement
78
+ confirmBtn.click()
79
+ await flushPromises()
80
+ expect(confirmBtn.getAttribute('disabled')).not.toBeNull()
81
+ release()
82
+ await flushPromises()
83
+ await expect(promise).resolves.toBe(true)
84
+ })
85
+
86
+ it('openRsDialog mounts builtin footer and can destroy', async () => {
87
+ const handle = openRsDialog({
88
+ title: '命令式',
89
+ showOverlay: false,
90
+ body: () => '临时内容',
91
+ })
92
+ await flushPromises()
93
+ const content = document.body.querySelector('.rs-dialog__content')
94
+ expect(content?.textContent).toContain('命令式')
95
+ expect(content?.classList.contains('rs-dialog__content--window')).toBe(true)
96
+ expect(document.body.querySelector('.rs-dialog__footer')).not.toBeNull()
97
+ handle.destroy()
98
+ await flushPromises()
99
+ expect(document.body.querySelector('.rs-dialog__content')).toBeNull()
100
+ })
101
+
102
+ it('rsConfirm.info uses single confirm button', async () => {
103
+ const promise = rsConfirm.info({
104
+ title: '提示',
105
+ description: '仅确认',
106
+ showOverlay: false,
107
+ })
108
+ await flushPromises()
109
+ const buttons = document.body.querySelectorAll('.rs-confirm-dialog__footer button')
110
+ expect(buttons).toHaveLength(1)
111
+ ;(buttons[0] as HTMLButtonElement).click()
112
+ await flushPromises()
113
+ await expect(promise).resolves.toBe(true)
114
+ })
115
+
116
+ it('rsConfirm.warning accepts string shorthand', async () => {
117
+ const promise = rsConfirm.warning('确定删除吗?')
118
+ await flushPromises()
119
+ const content = document.body.querySelector('.rs-confirm-dialog__content')
120
+ expect(content?.textContent).toContain('确定删除吗?')
121
+ const cancelBtn = document.body.querySelector(
122
+ '.rs-confirm-dialog__footer button',
123
+ ) as HTMLButtonElement
124
+ cancelBtn.click()
125
+ await flushPromises()
126
+ await expect(promise).resolves.toBe(false)
127
+ })
128
+
129
+ it('rsConfirm supports extra render content', async () => {
130
+ const promise = rsConfirm({
131
+ title: '带额外',
132
+ description: '正文',
133
+ showOverlay: false,
134
+ extra: () => h('div', { class: 'extra-probe' }, 'list-item'),
135
+ })
136
+ await flushPromises()
137
+ expect(document.body.querySelector('.extra-probe')?.textContent).toContain('list-item')
138
+ const cancelBtn = document.body.querySelector(
139
+ '.rs-confirm-dialog__footer button',
140
+ ) as HTMLButtonElement
141
+ cancelBtn.click()
142
+ await flushPromises()
143
+ await expect(promise).resolves.toBe(false)
144
+ })
145
+ })
@@ -0,0 +1,51 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import {
3
+ parseCssLengthToPx,
4
+ readCodeFontFamily,
5
+ readCodeFontSizePx,
6
+ readCssLengthPx,
7
+ readCssVar,
8
+ readTerminalFontFamily,
9
+ readTerminalFontSizePx,
10
+ readTerminalFontWeight,
11
+ readTerminalFontWeightBold,
12
+ } from '../theme/css-token'
13
+
14
+ describe('css-token', () => {
15
+ afterEach(() => {
16
+ document.documentElement.style.cssText = ''
17
+ })
18
+
19
+ it('parses px / rem / unitless lengths', () => {
20
+ expect(parseCssLengthToPx('12px', 16)).toBe(12)
21
+ expect(parseCssLengthToPx('0.875rem', 16)).toBe(14)
22
+ expect(parseCssLengthToPx('1.5em', 16)).toBe(24)
23
+ expect(parseCssLengthToPx('14', 16)).toBe(14)
24
+ expect(parseCssLengthToPx('var(--x)', 16)).toBeNull()
25
+ })
26
+
27
+ it('reads and unwraps nested custom properties', () => {
28
+ const root = document.documentElement
29
+ root.style.setProperty('--rs-font-size-sm', '0.875rem')
30
+ root.style.setProperty('--rs-code-font-size', 'var(--rs-font-size-sm)')
31
+ root.style.setProperty('--rs-font-mono', 'JetBrains Mono, monospace')
32
+ root.style.setProperty('--rs-code-font-family', 'var(--rs-font-mono)')
33
+ root.style.setProperty('--rs-font-weight-regular', '400')
34
+ root.style.setProperty('--rs-terminal-font-weight', 'var(--rs-font-weight-regular)')
35
+
36
+ expect(readCssVar('--rs-code-font-size')).toBe('0.875rem')
37
+ expect(readCssLengthPx('--rs-code-font-size', 99)).toBe(14)
38
+ expect(readCodeFontSizePx()).toBe(14)
39
+ expect(readCodeFontFamily()).toBe('JetBrains Mono, monospace')
40
+ expect(readTerminalFontWeight()).toBe('400')
41
+ })
42
+
43
+ it('falls back when tokens are missing', () => {
44
+ expect(readCodeFontSizePx()).toBe(14)
45
+ expect(readTerminalFontSizePx()).toBe(14)
46
+ expect(readCodeFontFamily()).toContain('monospace')
47
+ expect(readTerminalFontFamily()).toContain('monospace')
48
+ expect(readTerminalFontWeight()).toBe('400')
49
+ expect(readTerminalFontWeightBold()).toBe('500')
50
+ })
51
+ })
@@ -4,8 +4,10 @@ import {
4
4
  formatDateTimeDisplay,
5
5
  formatDateTimeValue,
6
6
  formatDateValue,
7
+ fromInternalPickerValue,
7
8
  parseDateTimeValue,
8
9
  parseDateValue,
10
+ toInternalPickerValue,
9
11
  } from '../components/date-picker-utils'
10
12
  import { RS_DATE_FORMAT, RS_DATETIME_FORMAT } from '../lib/rs-dayjs'
11
13
 
@@ -68,3 +70,50 @@ describe('date-picker-utils (dayjs)', () => {
68
70
  expect(formatDateTimeValue('not-a-date')).toBe('not-a-date')
69
71
  })
70
72
  })
73
+
74
+ describe('date-picker valueFormat conversion', () => {
75
+ const datetimeOpts = { valueFormat: 'iso', withTime: true }
76
+ const dateOpts = { valueFormat: 'iso', withTime: false }
77
+
78
+ it('converts internal datetime to local-offset RFC3339', () => {
79
+ const iso = fromInternalPickerValue('2025-06-16 14:30:00', datetimeOpts)
80
+ expect(iso).toMatch(/^2025-06-16T14:30:00[+-]\d{2}:\d{2}$/)
81
+ })
82
+
83
+ it('converts internal date to RFC3339 at midnight local offset', () => {
84
+ const iso = fromInternalPickerValue('2025-06-16', dateOpts)
85
+ expect(iso).toMatch(/^2025-06-16T00:00:00[+-]\d{2}:\d{2}$/)
86
+ })
87
+
88
+ it('round-trips iso datetime as wall clock', () => {
89
+ const iso = fromInternalPickerValue('2025-06-16 14:30:00', datetimeOpts)
90
+ expect(toInternalPickerValue(iso, datetimeOpts)).toBe('2025-06-16 14:30:00')
91
+ expect(toInternalPickerValue('2026-07-01T14:49:43Z', datetimeOpts)).toBe('2026-07-01 14:49:43')
92
+ expect(toInternalPickerValue('2026-08-14T14:38:42+08:00', datetimeOpts)).toBe('2026-08-14 14:38:42')
93
+ })
94
+
95
+ it('keeps string format as internal wall clock', () => {
96
+ expect(fromInternalPickerValue('2025-06-16 14:30:00', { valueFormat: 'string', withTime: true }))
97
+ .toBe('2025-06-16 14:30:00')
98
+ expect(toInternalPickerValue('2025-06-16 14:30:00', { valueFormat: 'string', withTime: true }))
99
+ .toBe('2025-06-16 14:30:00')
100
+ })
101
+
102
+ it('converts timestamp format', () => {
103
+ const ms = fromInternalPickerValue('2025-06-16 14:30:00', { valueFormat: 'timestamp', withTime: true })
104
+ expect(typeof ms).toBe('number')
105
+ expect(toInternalPickerValue(ms, { valueFormat: 'timestamp', withTime: true })).toBe('2025-06-16 14:30:00')
106
+ })
107
+
108
+ it('supports custom dayjs value-format templates', () => {
109
+ const custom = { valueFormat: 'YYYY-MM-DDTHH:mm:ss', withTime: true }
110
+ expect(fromInternalPickerValue('2025-06-16 14:30:00', custom)).toBe('2025-06-16T14:30:00')
111
+ expect(toInternalPickerValue('2025-06-16T14:30:00', custom)).toBe('2025-06-16 14:30:00')
112
+ })
113
+
114
+ it('emits null for empty iso and timestamp', () => {
115
+ expect(fromInternalPickerValue('', datetimeOpts)).toBeNull()
116
+ expect(fromInternalPickerValue('', { valueFormat: 'timestamp', withTime: true })).toBeNull()
117
+ expect(fromInternalPickerValue('', { valueFormat: 'string', withTime: true })).toBe('')
118
+ })
119
+ })
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, it } from 'vitest'
2
+ import { resolveRsDialogCssWidth, resolveRsDialogWidthPx } from '../components/dialog-utils'
3
+
4
+ describe('resolveRsDialogWidthPx', () => {
5
+ afterEach(() => {
6
+ document.documentElement.style.removeProperty('font-size')
7
+ })
8
+
9
+ it('returns undefined for presets', () => {
10
+ expect(resolveRsDialogWidthPx('sm')).toBeUndefined()
11
+ expect(resolveRsDialogWidthPx('md')).toBeUndefined()
12
+ expect(resolveRsDialogWidthPx('lg')).toBeUndefined()
13
+ })
14
+
15
+ it('keeps numeric and px widths', () => {
16
+ expect(resolveRsDialogWidthPx(1200)).toBe(1200)
17
+ expect(resolveRsDialogWidthPx('960px')).toBe(960)
18
+ })
19
+
20
+ it('converts rem using root font size', () => {
21
+ document.documentElement.style.fontSize = '16px'
22
+ expect(resolveRsDialogWidthPx('40rem')).toBe(640)
23
+ })
24
+
25
+ it('converts percent against the given viewport width', () => {
26
+ expect(resolveRsDialogWidthPx('90%', 1000)).toBe(900)
27
+ expect(resolveRsDialogWidthPx('80%', 800)).toBe(640)
28
+ })
29
+
30
+ it('returns undefined for empty or unknown units', () => {
31
+ expect(resolveRsDialogWidthPx('')).toBeUndefined()
32
+ expect(resolveRsDialogWidthPx('min(90vw, 1200px)')).toBeUndefined()
33
+ expect(resolveRsDialogWidthPx(0)).toBeUndefined()
34
+ })
35
+ })
36
+
37
+ describe('resolveRsDialogCssWidth', () => {
38
+ it('maps number to px and keeps custom CSS strings', () => {
39
+ expect(resolveRsDialogCssWidth('md')).toBeUndefined()
40
+ expect(resolveRsDialogCssWidth(520)).toBe('520px')
41
+ expect(resolveRsDialogCssWidth('90%')).toBe('90%')
42
+ })
43
+ })
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ concatNamePath,
4
+ getByNamePath,
5
+ hasByNamePath,
6
+ namePathKey,
7
+ normalizeNamePath,
8
+ setByNamePath,
9
+ } from '../components/form-path'
10
+
11
+ describe('form-path', () => {
12
+ it('normalizes string and array name paths', () => {
13
+ expect(normalizeNamePath('user.email')).toEqual(['user', 'email'])
14
+ expect(normalizeNamePath(['user', 'email'])).toEqual(['user', 'email'])
15
+ expect(namePathKey(['a', 0, 'b'])).toBe('a.0.b')
16
+ })
17
+
18
+ it('gets and sets nested values', () => {
19
+ const model: Record<string, unknown> = {}
20
+ setByNamePath(model, 'user.email', 'a@b.com')
21
+ expect(model).toEqual({ user: { email: 'a@b.com' } })
22
+ expect(getByNamePath(model, ['user', 'email'])).toBe('a@b.com')
23
+ expect(hasByNamePath(model, 'user.email')).toBe(true)
24
+ })
25
+
26
+ it('walks multi-level JSON paths', () => {
27
+ const model: Record<string, unknown> = {}
28
+ setByNamePath(model, 'config.headerConfig.isRequestHeader', true)
29
+ expect(model).toEqual({
30
+ config: { headerConfig: { isRequestHeader: true } },
31
+ })
32
+ expect(getByNamePath(model, 'config.headerConfig.isRequestHeader')).toBe(true)
33
+ expect(hasByNamePath(model, 'config.headerConfig.headerName')).toBe(false)
34
+ })
35
+
36
+ it('preserves arrays when setting nested list fields', () => {
37
+ const model: Record<string, unknown> = { users: [{ name: 'A' }] }
38
+ setByNamePath(model, ['users', 0, 'name'], 'B')
39
+ expect(model.users).toEqual([{ name: 'B' }])
40
+ expect(Array.isArray(model.users)).toBe(true)
41
+ })
42
+
43
+ it('concatenates list prefix and item name', () => {
44
+ expect(concatNamePath('users', [0, 'name'])).toEqual(['users', 0, 'name'])
45
+ })
46
+ })
@@ -0,0 +1,139 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ buildLocalInputRules,
4
+ matchFormRuleTrigger,
5
+ normalizeFormRules,
6
+ runFormFieldRules,
7
+ } from '../components/form-rules'
8
+
9
+ describe('form-rules', () => {
10
+ it('normalizes single rule and arrays', () => {
11
+ expect(normalizeFormRules({ required: true })).toHaveLength(1)
12
+ expect(normalizeFormRules([{ required: true }, { min: 2 }])).toHaveLength(2)
13
+ expect(normalizeFormRules()).toEqual([])
14
+ })
15
+
16
+ it('matches trigger filters', () => {
17
+ expect(matchFormRuleTrigger({ required: true }, 'blur')).toBe(true)
18
+ expect(matchFormRuleTrigger({ required: true, trigger: 'blur' }, 'change')).toBe(false)
19
+ expect(matchFormRuleTrigger({ required: true, trigger: ['blur', 'change'] }, 'change')).toBe(
20
+ true,
21
+ )
22
+ })
23
+
24
+ it('validates required / min / pattern / email type', async () => {
25
+ const required = await runFormFieldRules('', [{ required: true, message: '必填' }])
26
+ expect(required).toEqual({ valid: false, message: '必填' })
27
+
28
+ const min = await runFormFieldRules('ab', [{ min: 3, message: '短' }])
29
+ expect(min.valid).toBe(false)
30
+
31
+ const email = await runFormFieldRules('bad', [{ type: 'email', message: '邮箱错' }])
32
+ expect(email.message).toBe('邮箱错')
33
+
34
+ const ok = await runFormFieldRules('a@b.com', [{ type: 'email' }])
35
+ expect(ok.valid).toBe(true)
36
+
37
+ const pattern = await runFormFieldRules('x', [
38
+ { pattern: /^\d+$/, message: '数字' },
39
+ ])
40
+ expect(pattern.message).toBe('数字')
41
+ })
42
+
43
+ it('supports async validator', async () => {
44
+ const result = await runFormFieldRules('admin', [
45
+ {
46
+ async validator(value) {
47
+ if (String(value) === 'admin') return '占用'
48
+ return true
49
+ },
50
+ },
51
+ ])
52
+ expect(result).toEqual({ valid: false, message: '占用' })
53
+ })
54
+
55
+ it('builds local input rules', () => {
56
+ const rules = buildLocalInputRules({
57
+ required: true,
58
+ rule: 'email',
59
+ validator: () => true,
60
+ })
61
+ expect(rules).toHaveLength(3)
62
+ expect(rules[0]?.required).toBe(true)
63
+ expect(rules[1]?.type).toBe('email')
64
+ })
65
+
66
+ it('interpolates required message with label', async () => {
67
+ const labeled = await runFormFieldRules('', [{ required: true }], { label: '邮箱' })
68
+ expect(labeled.message).toBe('请填写邮箱')
69
+ })
70
+
71
+ it('supports message as function', async () => {
72
+ const result = await runFormFieldRules('x', [
73
+ {
74
+ min: 3,
75
+ message: ({ value }) => `当前「${String(value)}」太短`,
76
+ },
77
+ ])
78
+ expect(result.valid).toBe(false)
79
+ expect(result.message).toBe('当前「x」太短')
80
+ })
81
+
82
+ it('validator can read other fields via context', async () => {
83
+ const result = await runFormFieldRules('two', [
84
+ {
85
+ validator(value, ctx) {
86
+ if (String(value) !== String(ctx.getFieldValue('a'))) return '不一致'
87
+ return true
88
+ },
89
+ },
90
+ ], {
91
+ getFieldValue: (name) => (String(name) === 'a' ? 'one' : undefined),
92
+ })
93
+ expect(result).toEqual({ valid: false, message: '不一致' })
94
+
95
+ const ok = await runFormFieldRules('one', [
96
+ {
97
+ validator(value, ctx) {
98
+ if (String(value) !== String(ctx.getFieldValue('a'))) return '不一致'
99
+ return true
100
+ },
101
+ },
102
+ ], {
103
+ getFieldValue: (name) => (String(name) === 'a' ? 'one' : undefined),
104
+ })
105
+ expect(ok.valid).toBe(true)
106
+ })
107
+
108
+ it('runs custom validator on empty value for cross-field required', async () => {
109
+ const result = await runFormFieldRules(
110
+ '',
111
+ [
112
+ {
113
+ validator(_value, ctx) {
114
+ if (ctx.getFieldValue('kind') === 'email') return '邮箱必填'
115
+ return true
116
+ },
117
+ },
118
+ ],
119
+ { getFieldValue: (name) => (String(name) === 'kind' ? 'email' : undefined) },
120
+ )
121
+ expect(result).toEqual({ valid: false, message: '邮箱必填' })
122
+ })
123
+
124
+ it('treats Error return and thrown errors as failure', async () => {
125
+ const returned = await runFormFieldRules('x', [
126
+ { validator: () => new Error('返回错误') as unknown as string },
127
+ ])
128
+ expect(returned).toEqual({ valid: false, message: '返回错误' })
129
+
130
+ const thrown = await runFormFieldRules('x', [
131
+ {
132
+ validator() {
133
+ throw new Error('抛出错误')
134
+ },
135
+ },
136
+ ])
137
+ expect(thrown).toEqual({ valid: false, message: '抛出错误' })
138
+ })
139
+ })
@@ -0,0 +1,32 @@
1
+ /**
2
+ * RsTable / Core 性能门槛(CI 与本地共用)。
3
+ *
4
+ * CI(process.env.CI)用更紧预算,防止 jsdom 抖动掩盖回归;
5
+ * 本地默认略松,避免开发机噪音误杀。
6
+ */
7
+
8
+ const isCi = Boolean(process.env.CI)
9
+
10
+ function budget(localMs: number, ciMs: number): number {
11
+ return isCi ? ciMs : localMs
12
+ }
13
+
14
+ /** mount 类门槛(ms) */
15
+ export const RS_TABLE_PERF_BUDGETS = {
16
+ /** 3000 行只读虚拟表 mount */
17
+ mountReadonly3k: budget(8000, 5000),
18
+ /** 双表各 1500 行 mount */
19
+ mountDual1k5: budget(12000, 8000),
20
+ /**
21
+ * 可编辑相对只读的倍率上限(另加 slackMs)。
22
+ * editableElapsed < readonlyElapsed * ratio + slackMs
23
+ */
24
+ editableVsReadonlyRatio: 3,
25
+ editableVsReadonlySlackMs: budget(2000, 1500),
26
+ } as const
27
+
28
+ export function assertWithinBudget(elapsedMs: number, limitMs: number, label: string): void {
29
+ if (elapsedMs >= limitMs) {
30
+ throw new Error(`[perf] ${label}: ${elapsedMs.toFixed(1)}ms >= budget ${limitMs}ms`)
31
+ }
32
+ }