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