niuma-ui 1.0.2 → 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.
- package/CHANGELOG.md +28 -1
- package/package.json +1 -1
- package/src/__tests__/RsDatePicker.spec.ts +21 -0
- package/src/__tests__/RsDialog.spec.ts +61 -0
- package/src/__tests__/RsForm.spec.ts +5 -5
- package/src/__tests__/RsFormItem.spec.ts +478 -0
- package/src/__tests__/RsFormList.spec.ts +109 -0
- package/src/__tests__/RsInput.spec.ts +93 -0
- package/src/__tests__/RsInputNumber.spec.ts +33 -0
- package/src/__tests__/RsSelect.spec.ts +423 -1
- package/src/__tests__/RsSwitch.spec.ts +30 -0
- package/src/__tests__/RsTimePicker.spec.ts +9 -0
- package/src/__tests__/RsTooltip.spec.ts +43 -0
- package/src/__tests__/css-token.spec.ts +51 -0
- package/src/__tests__/date-picker-utils.spec.ts +49 -0
- package/src/__tests__/dialog-utils.spec.ts +43 -0
- package/src/__tests__/form-path.spec.ts +46 -0
- package/src/__tests__/form-rules.spec.ts +63 -0
- package/src/__tests__/theme.spec.ts +2 -0
- package/src/components/RsAlert.vue +1 -1
- package/src/components/RsAvatar.vue +1 -1
- package/src/components/RsBadge.vue +1 -1
- package/src/components/RsBreadcrumb.vue +1 -1
- package/src/components/RsButton.vue +4 -4
- package/src/components/RsCalendarGrid.vue +3 -3
- package/src/components/RsCard.vue +4 -4
- package/src/components/RsCodeBlock.vue +4 -4
- package/src/components/RsCodeEditor.vue +8 -8
- package/src/components/RsConfirmDialog.vue +2 -2
- package/src/components/RsContextMenu.vue +6 -4
- package/src/components/RsDatePicker.vue +75 -31
- package/src/components/RsDateTimePicker.vue +9 -1
- package/src/components/RsDescriptions.vue +2 -2
- package/src/components/RsDialog.vue +26 -16
- package/src/components/RsDrawer.vue +1 -1
- package/src/components/RsDropdown.vue +1 -1
- package/src/components/RsDynamicTags.vue +18 -4
- package/src/components/RsEmpty.vue +1 -1
- package/src/components/RsForm.vue +68 -1
- package/src/components/RsFormItem.vue +423 -0
- package/src/components/RsFormItemControl.vue +21 -0
- package/src/components/RsFormList.vue +117 -0
- package/src/components/RsInput.vue +411 -18
- package/src/components/RsInputNumber.vue +13 -4
- package/src/components/RsLink.vue +1 -1
- package/src/components/RsMarkdown.vue +5 -5
- package/src/components/RsMenu.vue +2 -2
- package/src/components/RsMonacoEditor.vue +9 -3
- package/src/components/RsPagination.vue +1 -1
- package/src/components/RsProseEditor.vue +3 -3
- package/src/components/RsSelect.vue +332 -611
- package/src/components/RsSidebarGroup.vue +1 -1
- package/src/components/RsSplitPane.vue +22 -1
- package/src/components/RsStatCard.vue +2 -2
- package/src/components/RsSteps.vue +2 -2
- package/src/components/RsSwitch.vue +20 -7
- package/src/components/RsTabs.vue +9 -9
- package/src/components/RsTag.vue +1 -1
- package/src/components/RsTerminal.vue +58 -19
- package/src/components/RsTimePicker.vue +28 -4
- package/src/components/RsTimePickerColumns.vue +4 -4
- package/src/components/RsToaster.vue +7 -7
- package/src/components/RsTooltip.vue +10 -1
- package/src/components/RsTooltipProvider.vue +11 -1
- package/src/components/RsTree.vue +6 -6
- package/src/components/RsUpload.vue +2 -2
- package/src/components/date-picker-utils.ts +113 -2
- package/src/components/dialog-utils.ts +37 -2
- package/src/components/form-item-control.ts +55 -0
- package/src/components/form-path.ts +92 -0
- package/src/components/form-rules.ts +64 -10
- package/src/components/form-utils.ts +94 -2
- package/src/components/select-utils.ts +272 -12
- package/src/components/table/RsTableHeaderFilter.vue +1 -1
- package/src/components/table/rs-table.css +1 -1
- package/src/components/use-rs-select.ts +436 -0
- package/src/composables/useRsConfig.ts +10 -3
- package/src/composables/useRsI18n.ts +16 -5
- package/src/index.ts +69 -1
- package/src/locale/interpolate.ts +32 -0
- package/src/locale/messages.ts +10 -0
- package/src/styles.css +68 -12
- package/src/theme/brand.example.css +4 -1
- package/src/theme/css-token.ts +107 -0
- package/src/theme/types.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,32 @@
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [1.1.0] - 2026-08-14
|
|
10
|
+
|
|
11
|
+
### 新增
|
|
12
|
+
|
|
13
|
+
- `RsFormItem`:对标 Ant Design Form.Item / Element `ElFormItem`。声明 `name` 后成为字段唯一注册点;内置 `RsInput` 等在 Item 内不再重复注册。支持 `help` / `extra` / `validateStatus` / `#label` / `noStyle`。
|
|
14
|
+
- `RsForm.model`、NamePath(`user.email`)、`getFieldsValue` / `setFieldsValue` / `scrollToField`、`validateMessages`。
|
|
15
|
+
- `RsFormList`:动态数组字段(`add` / `remove` / `move`),子 Item 相对 name 自动拼前缀。
|
|
16
|
+
- `RsFormItem.dependencies`:依赖字段变化时重校验;`validator(value, { getFieldValue, getFieldsValue })` 可读整表。
|
|
17
|
+
- `RsSwitch.checkedValue` / `uncheckedValue`:自定义打开/关闭写入 v-model 的值(默认 `true` / `false`,既有 boolean 用法不变)。
|
|
18
|
+
- `RsDatePicker` / `RsDateTimePicker`:`valueFormat` 对齐 Element Plus / Ant Design Vue——展示仍为墙钟,绑定可选用 `string`(默认)、`timestamp`、`iso`(本地偏移 RFC3339),或任意 dayjs 模板。`iso` 空值为 `null`。
|
|
19
|
+
- `RsSelect`:`option.value` / `v-model` 支持 `string | number`(数字 `0` 可回显);`filterOption` / `optionFilterProp` / `filterSort`;多选 `maxTagCount` / `maxTagTextLength` / `multipleLimit` / `tokenSeparators`;`labelInValue`;`fieldNames` / `optionLabelProp`;`v-model:searchValue`;`listHeight` / `placement` / `popupClassName` / `status` / `showArrow` / `getPopupContainer`;事件 `select` / `deselect` / `clear`;插槽 `#prefix` `#option` `#tag` `#header` `#footer` `#dropdownRender` `#suffixIcon` `#clearIcon` `#empty` `#loading`。选择逻辑抽到 `use-rs-select.ts`。
|
|
20
|
+
- `RsInput`:框内 affix 顺序固定为字数 / 清除 / 密码显隐 / 自定义 `suffix`;新增框外连体 `addonBefore` / `addonAfter`(及同名插槽);IME `compositionstart/end` 期间不触发校验与 `pressEnter`。
|
|
21
|
+
- `RsInput.addonAfterIcon` / `addonAfterIconLabel` / `addonAfterClick`:选择器等后置图标按钮走连体 `addonAfter`,由组件自绘;连体外壳统一外边框,避免 input/addon 拼缝。
|
|
22
|
+
- `hasByNamePath`:判断 NamePath 是否已存在于对象树(含叶子为 null / undefined)。
|
|
23
|
+
- 排版子系统 token:`--rs-font-serif`、`--rs-code-font-*`、`--rs-prose-font-*`、`--rs-terminal-font-*`。JS 导出 `readCssVar` / `readCssLengthPx` / `readCodeFontFamily` / `readTerminalFontFamily` 等,供 Monaco / xterm 读取。
|
|
24
|
+
|
|
25
|
+
### 变更
|
|
26
|
+
|
|
27
|
+
- Form 校验 / `resetFields` 只收集带 `name` 的字段(无 name 不进字段表,避免弹层搜索框误校验)。请给控件加 `name` 或包 `RsFormItem`。
|
|
28
|
+
- 封装开源组件排版对齐 token:CodeMirror / Monaco / xterm / marked / vue-sonner 不再硬编码字体栈或 px。`RsTerminal` 未传 `fontFamily` / `fontSize` / `fontWeight` 时改为读 CSS token(默认 `--rs-font-size-sm` / regular,不再写死 13px / 300)。组件字重改为 `--rs-font-weight-*`。
|
|
29
|
+
- `.rs-field--label-left`:有 `--rs-field-label-width` 时固定标签列宽(不再 `max-content` 撑开),栅格内控件起点对齐;过长标签省略。
|
|
30
|
+
- `RsFormItem`:子控件已绑定 v-model 时不再覆盖(避免 switch 的 Y/N 被注入成非 boolean)。
|
|
31
|
+
- `RsDatePicker` / `RsTimePicker` / `RsSelect`:校验失败时红框画在 trigger 上(与 `RsInput` 一致)。未声明的 attrs(`class` / `style` / `aria-*`)同样落到 trigger;`v-model` 与事件签名不变。
|
|
32
|
+
- `RsTooltip` / `RsTooltipProvider`:默认开启 `ignoreNonKeyboardFocus`(仅 `:focus-visible` 因焦点打开 tip),避免 Dialog/Popover 关闭回焦后误开提示;悬停与键盘聚焦行为不变。
|
|
33
|
+
- `RsInputNumber` / `RsTabs`:用 `v-on` 条件对象绑定 `wheel`(避免 `@wheel="cond ? fn : undefined"` 被编译成常驻包装函数仍挂非 passive 监听);Tabs 导航 `@scroll` 改为 passive。
|
|
34
|
+
|
|
9
35
|
## [1.0.2] - 2026-08-13
|
|
10
36
|
|
|
11
37
|
### 新增
|
|
@@ -72,7 +98,8 @@
|
|
|
72
98
|
|
|
73
99
|
- 1.0 之前的私有 tag(如 `v0.1.0`)仅作历史记录;新接入请依赖 `v1.0.0` 及之后版本。
|
|
74
100
|
|
|
75
|
-
[Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.0
|
|
101
|
+
[Unreleased]: https://github.com/Blair-Shang/niuma-ui/compare/v1.1.0...HEAD
|
|
102
|
+
[1.1.0]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.1.0
|
|
76
103
|
[1.0.2]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.0.2
|
|
77
104
|
[1.0.1]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.0.1
|
|
78
105
|
[1.0.0]: https://github.com/Blair-Shang/niuma-ui/releases/tag/v1.0.0
|
package/package.json
CHANGED
|
@@ -10,6 +10,18 @@ describe('RsDatePicker', () => {
|
|
|
10
10
|
expect(wrapper.classes()).toContain('rs-field')
|
|
11
11
|
})
|
|
12
12
|
|
|
13
|
+
it('displays iso v-model as wall-clock text', () => {
|
|
14
|
+
const wrapper = mount(RsDatePicker, {
|
|
15
|
+
props: {
|
|
16
|
+
modelValue: '2025-06-16T14:30:00+08:00',
|
|
17
|
+
withTime: true,
|
|
18
|
+
valueFormat: 'iso',
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
expect(wrapper.find('.rs-date-picker__trigger--placeholder').exists()).toBe(false)
|
|
22
|
+
expect(wrapper.find('.rs-date-picker__value').text()).toBe('2025-06-16 14:30:00')
|
|
23
|
+
})
|
|
24
|
+
|
|
13
25
|
it('shows formatted value on trigger when model is set', () => {
|
|
14
26
|
const wrapper = mount(RsDatePicker, {
|
|
15
27
|
props: { modelValue: '2025-06-16' },
|
|
@@ -85,4 +97,13 @@ describe('RsDatePicker', () => {
|
|
|
85
97
|
})
|
|
86
98
|
expect(wrapper.find('.rs-date-picker__trigger').attributes('disabled')).toBeDefined()
|
|
87
99
|
})
|
|
100
|
+
|
|
101
|
+
it('marks invalid on the trigger instead of the field wrapper', () => {
|
|
102
|
+
const wrapper = mount(RsDatePicker, { props: { modelValue: '', invalid: true } })
|
|
103
|
+
expect(wrapper.find('.rs-date-picker__trigger').classes()).toContain(
|
|
104
|
+
'rs-date-picker__trigger--invalid',
|
|
105
|
+
)
|
|
106
|
+
expect(wrapper.find('.rs-date-picker__trigger').attributes('aria-invalid')).toBe('true')
|
|
107
|
+
expect(wrapper.attributes('aria-invalid')).toBeUndefined()
|
|
108
|
+
})
|
|
88
109
|
})
|
|
@@ -69,6 +69,23 @@ describe('RsDialog', () => {
|
|
|
69
69
|
wrapper.unmount()
|
|
70
70
|
})
|
|
71
71
|
|
|
72
|
+
it('centers window layout when width is a viewport percentage', async () => {
|
|
73
|
+
const wrapper = mount(RsDialog, {
|
|
74
|
+
props: { open: true, title: '百分比宽度', width: '90%' },
|
|
75
|
+
attachTo: document.body,
|
|
76
|
+
})
|
|
77
|
+
await flushPromises()
|
|
78
|
+
const content = document.body.querySelector('.rs-dialog__content') as HTMLElement
|
|
79
|
+
const width = Number.parseFloat(content.style.width)
|
|
80
|
+
const left = Number.parseFloat(content.style.left)
|
|
81
|
+
const available = window.innerWidth - 32
|
|
82
|
+
const expectedWidth = Math.min(available, Math.max(320, Math.round(available * 0.9)))
|
|
83
|
+
expect(width).toBe(expectedWidth)
|
|
84
|
+
expect(left).toBeCloseTo(16 + (available - expectedWidth) / 2, 1)
|
|
85
|
+
expect(content.style.maxWidth).toBe('')
|
|
86
|
+
wrapper.unmount()
|
|
87
|
+
})
|
|
88
|
+
|
|
72
89
|
it('applies window layout with resize handles by default', async () => {
|
|
73
90
|
const wrapper = mount(RsDialog, {
|
|
74
91
|
props: { open: true, title: '测试', width: 'sm' },
|
|
@@ -366,6 +383,50 @@ describe('RsDialog', () => {
|
|
|
366
383
|
await flushPromises()
|
|
367
384
|
expect(target.querySelector('.rs-dialog__content')).not.toBeNull()
|
|
368
385
|
wrapper.unmount()
|
|
386
|
+
target.remove()
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it('re-teleports to body while fullscreen, then restores custom target', async () => {
|
|
390
|
+
const target = document.createElement('div')
|
|
391
|
+
target.id = 'rs-dialog-fullscreen-target'
|
|
392
|
+
document.body.appendChild(target)
|
|
393
|
+
|
|
394
|
+
const wrapper = mount(RsDialog, {
|
|
395
|
+
props: {
|
|
396
|
+
open: true,
|
|
397
|
+
title: '全屏挂载',
|
|
398
|
+
layout: 'window',
|
|
399
|
+
fullscreenable: true,
|
|
400
|
+
teleportTo: '#rs-dialog-fullscreen-target',
|
|
401
|
+
},
|
|
402
|
+
attachTo: document.body,
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
await flushPromises()
|
|
406
|
+
expect(target.querySelector('.rs-dialog__content')).not.toBeNull()
|
|
407
|
+
|
|
408
|
+
const enterFullscreenBtn = target.querySelectorAll('.rs-dialog__actions button')[0] as HTMLElement
|
|
409
|
+
await enterFullscreenBtn.click()
|
|
410
|
+
await flushPromises()
|
|
411
|
+
|
|
412
|
+
expect(target.querySelector('.rs-dialog__content')).toBeNull()
|
|
413
|
+
const bodyContent = document.body.querySelector('.rs-dialog__content') as HTMLElement
|
|
414
|
+
expect(bodyContent).not.toBeNull()
|
|
415
|
+
expect(bodyContent.classList.contains('rs-dialog__content--fullscreen')).toBe(true)
|
|
416
|
+
expect(target.contains(bodyContent)).toBe(false)
|
|
417
|
+
|
|
418
|
+
const exitFullscreenBtn = document.body.querySelectorAll(
|
|
419
|
+
'.rs-dialog__actions button',
|
|
420
|
+
)[0] as HTMLElement
|
|
421
|
+
await exitFullscreenBtn.click()
|
|
422
|
+
await flushPromises()
|
|
423
|
+
expect(target.querySelector('.rs-dialog__content')).not.toBeNull()
|
|
424
|
+
expect(
|
|
425
|
+
target.querySelector('.rs-dialog__content')?.classList.contains('rs-dialog__content--fullscreen'),
|
|
426
|
+
).toBe(false)
|
|
427
|
+
|
|
428
|
+
wrapper.unmount()
|
|
429
|
+
target.remove()
|
|
369
430
|
})
|
|
370
431
|
|
|
371
432
|
it('passes modal=false to DialogRoot for non-modal usage', async () => {
|
|
@@ -125,7 +125,7 @@ describe('RsForm', () => {
|
|
|
125
125
|
},
|
|
126
126
|
template: `
|
|
127
127
|
<RsForm ref="formRef">
|
|
128
|
-
<RsInput v-model="email" rule="email" required />
|
|
128
|
+
<RsInput v-model="email" name="email" rule="email" required />
|
|
129
129
|
</RsForm>
|
|
130
130
|
`,
|
|
131
131
|
})
|
|
@@ -146,7 +146,7 @@ describe('RsForm', () => {
|
|
|
146
146
|
},
|
|
147
147
|
template: `
|
|
148
148
|
<RsForm ref="formRef">
|
|
149
|
-
<RsInput v-model="email" rule="email" required />
|
|
149
|
+
<RsInput v-model="email" name="email" rule="email" required />
|
|
150
150
|
</RsForm>
|
|
151
151
|
`,
|
|
152
152
|
})
|
|
@@ -167,7 +167,7 @@ describe('RsForm', () => {
|
|
|
167
167
|
},
|
|
168
168
|
template: `
|
|
169
169
|
<RsForm ref="formRef">
|
|
170
|
-
<RsInput v-model="name" />
|
|
170
|
+
<RsInput v-model="name" name="name" />
|
|
171
171
|
</RsForm>
|
|
172
172
|
`,
|
|
173
173
|
})
|
|
@@ -191,7 +191,7 @@ describe('RsForm', () => {
|
|
|
191
191
|
},
|
|
192
192
|
template: `
|
|
193
193
|
<RsForm ref="formRef">
|
|
194
|
-
<RsInput v-model="email" rule="email" required />
|
|
194
|
+
<RsInput v-model="email" name="email" rule="email" required />
|
|
195
195
|
</RsForm>
|
|
196
196
|
`,
|
|
197
197
|
})
|
|
@@ -238,7 +238,7 @@ describe('RsForm', () => {
|
|
|
238
238
|
},
|
|
239
239
|
template: `
|
|
240
240
|
<RsForm ref="formRef">
|
|
241
|
-
<RsSelect v-model="role" :options="roleOptions" required />
|
|
241
|
+
<RsSelect v-model="role" name="role" :options="roleOptions" required />
|
|
242
242
|
</RsForm>
|
|
243
243
|
`,
|
|
244
244
|
})
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
import { defineComponent, ref } from 'vue'
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { flushPromises, mount } from '@vue/test-utils'
|
|
4
|
+
import RsConfigProvider from '../components/RsConfigProvider.vue'
|
|
5
|
+
import RsForm from '../components/RsForm.vue'
|
|
6
|
+
import RsFormItem from '../components/RsFormItem.vue'
|
|
7
|
+
import RsDatePicker from '../components/RsDatePicker.vue'
|
|
8
|
+
import RsInput from '../components/RsInput.vue'
|
|
9
|
+
import RsSelect from '../components/RsSelect.vue'
|
|
10
|
+
import RsSwitch from '../components/RsSwitch.vue'
|
|
11
|
+
import RsTimePicker from '../components/RsTimePicker.vue'
|
|
12
|
+
|
|
13
|
+
describe('RsFormItem', () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
document.body.innerHTML = ''
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('validates custom slot content without rendering an input', async () => {
|
|
19
|
+
const Host = defineComponent({
|
|
20
|
+
components: { RsForm, RsFormItem },
|
|
21
|
+
setup() {
|
|
22
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
23
|
+
const service = ref('')
|
|
24
|
+
return { formRef, service }
|
|
25
|
+
},
|
|
26
|
+
template: `
|
|
27
|
+
<RsForm ref="formRef">
|
|
28
|
+
<RsFormItem v-model="service" name="service" required>
|
|
29
|
+
<button type="button" class="picker">pick</button>
|
|
30
|
+
</RsFormItem>
|
|
31
|
+
</RsForm>
|
|
32
|
+
`,
|
|
33
|
+
})
|
|
34
|
+
const wrapper = mount(Host)
|
|
35
|
+
await flushPromises()
|
|
36
|
+
|
|
37
|
+
const result = await wrapper.vm.formRef!.validate()
|
|
38
|
+
expect(result.valid).toBe(false)
|
|
39
|
+
expect(result.errors.service).toBe('此项为必填')
|
|
40
|
+
expect(wrapper.find('input').exists()).toBe(false)
|
|
41
|
+
expect(wrapper.find('.rs-form-item__error').text()).toBe('此项为必填')
|
|
42
|
+
expect(wrapper.find('.picker').exists()).toBe(true)
|
|
43
|
+
wrapper.unmount()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('uses en-US required copy inside RsConfigProvider', async () => {
|
|
47
|
+
const Host = defineComponent({
|
|
48
|
+
components: { RsConfigProvider, RsForm, RsFormItem },
|
|
49
|
+
setup() {
|
|
50
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
51
|
+
const service = ref('')
|
|
52
|
+
return { formRef, service }
|
|
53
|
+
},
|
|
54
|
+
template: `
|
|
55
|
+
<RsConfigProvider locale="en-US">
|
|
56
|
+
<RsForm ref="formRef">
|
|
57
|
+
<RsFormItem v-model="service" name="service" required>
|
|
58
|
+
<button type="button">pick</button>
|
|
59
|
+
</RsFormItem>
|
|
60
|
+
</RsForm>
|
|
61
|
+
</RsConfigProvider>
|
|
62
|
+
`,
|
|
63
|
+
})
|
|
64
|
+
const wrapper = mount(Host)
|
|
65
|
+
await flushPromises()
|
|
66
|
+
const result = await wrapper.vm.formRef!.validate()
|
|
67
|
+
expect(result.valid).toBe(false)
|
|
68
|
+
expect(result.errors.service).toBe('This field is required')
|
|
69
|
+
wrapper.unmount()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('clears error when custom value changes after submit', async () => {
|
|
73
|
+
const Host = defineComponent({
|
|
74
|
+
components: { RsForm, RsFormItem },
|
|
75
|
+
setup() {
|
|
76
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
77
|
+
const service = ref('')
|
|
78
|
+
return { formRef, service }
|
|
79
|
+
},
|
|
80
|
+
template: `
|
|
81
|
+
<RsForm ref="formRef">
|
|
82
|
+
<RsFormItem v-model="service" name="service" required>
|
|
83
|
+
<button type="button" class="picker">pick</button>
|
|
84
|
+
</RsFormItem>
|
|
85
|
+
</RsForm>
|
|
86
|
+
`,
|
|
87
|
+
})
|
|
88
|
+
const wrapper = mount(Host)
|
|
89
|
+
await flushPromises()
|
|
90
|
+
await wrapper.vm.formRef!.validate()
|
|
91
|
+
await flushPromises()
|
|
92
|
+
expect(wrapper.find('.rs-form-item__error').exists()).toBe(true)
|
|
93
|
+
|
|
94
|
+
wrapper.vm.service = 'svc-1'
|
|
95
|
+
await flushPromises()
|
|
96
|
+
expect(wrapper.find('.rs-form-item__error').exists()).toBe(false)
|
|
97
|
+
|
|
98
|
+
wrapper.vm.service = ''
|
|
99
|
+
await flushPromises()
|
|
100
|
+
expect(wrapper.find('.rs-form-item__error').text()).toBe('此项为必填')
|
|
101
|
+
wrapper.unmount()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('resetFields restores the item model', async () => {
|
|
105
|
+
const Host = defineComponent({
|
|
106
|
+
components: { RsForm, RsFormItem },
|
|
107
|
+
setup() {
|
|
108
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
109
|
+
const service = ref('initial')
|
|
110
|
+
return { formRef, service }
|
|
111
|
+
},
|
|
112
|
+
template: `
|
|
113
|
+
<RsForm ref="formRef">
|
|
114
|
+
<RsFormItem v-model="service" name="service">
|
|
115
|
+
<span class="value">{{ service }}</span>
|
|
116
|
+
</RsFormItem>
|
|
117
|
+
</RsForm>
|
|
118
|
+
`,
|
|
119
|
+
})
|
|
120
|
+
const wrapper = mount(Host)
|
|
121
|
+
await flushPromises()
|
|
122
|
+
wrapper.vm.service = 'changed'
|
|
123
|
+
await flushPromises()
|
|
124
|
+
wrapper.vm.formRef!.resetFields()
|
|
125
|
+
await flushPromises()
|
|
126
|
+
expect(wrapper.vm.service).toBe('initial')
|
|
127
|
+
wrapper.unmount()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('exposes invalid via default slot props', async () => {
|
|
131
|
+
const Host = defineComponent({
|
|
132
|
+
components: { RsForm, RsFormItem },
|
|
133
|
+
setup() {
|
|
134
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
135
|
+
const service = ref('')
|
|
136
|
+
return { formRef, service }
|
|
137
|
+
},
|
|
138
|
+
template: `
|
|
139
|
+
<RsForm ref="formRef">
|
|
140
|
+
<RsFormItem v-model="service" name="service" required>
|
|
141
|
+
<template #default="{ invalid }">
|
|
142
|
+
<button type="button" class="picker" :data-invalid="invalid">pick</button>
|
|
143
|
+
</template>
|
|
144
|
+
</RsFormItem>
|
|
145
|
+
</RsForm>
|
|
146
|
+
`,
|
|
147
|
+
})
|
|
148
|
+
const wrapper = mount(Host)
|
|
149
|
+
await flushPromises()
|
|
150
|
+
expect(wrapper.find('.picker').attributes('data-invalid')).toBe('false')
|
|
151
|
+
await wrapper.vm.formRef!.validate()
|
|
152
|
+
await flushPromises()
|
|
153
|
+
expect(wrapper.find('.picker').attributes('data-invalid')).toBe('true')
|
|
154
|
+
wrapper.unmount()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('renders Form #error slot for item messages', async () => {
|
|
158
|
+
const Host = defineComponent({
|
|
159
|
+
components: { RsForm, RsFormItem },
|
|
160
|
+
setup() {
|
|
161
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
162
|
+
const service = ref('')
|
|
163
|
+
const rules = {
|
|
164
|
+
service: [{ required: true, message: 'pick a service' }],
|
|
165
|
+
}
|
|
166
|
+
return { formRef, service, rules }
|
|
167
|
+
},
|
|
168
|
+
template: `
|
|
169
|
+
<RsForm ref="formRef" :rules="rules">
|
|
170
|
+
<template #error="{ message }">
|
|
171
|
+
<span class="custom-form-error">CUSTOM:{{ message }}</span>
|
|
172
|
+
</template>
|
|
173
|
+
<RsFormItem v-model="service" name="service">
|
|
174
|
+
<button type="button">pick</button>
|
|
175
|
+
</RsFormItem>
|
|
176
|
+
</RsForm>
|
|
177
|
+
`,
|
|
178
|
+
})
|
|
179
|
+
const wrapper = mount(Host)
|
|
180
|
+
await flushPromises()
|
|
181
|
+
await wrapper.vm.formRef!.validate()
|
|
182
|
+
await flushPromises()
|
|
183
|
+
expect(wrapper.find('.custom-form-error').text()).toBe('CUSTOM:pick a service')
|
|
184
|
+
wrapper.unmount()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('does not register when name is omitted', async () => {
|
|
188
|
+
const Host = defineComponent({
|
|
189
|
+
components: { RsForm, RsFormItem },
|
|
190
|
+
setup() {
|
|
191
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
192
|
+
return { formRef }
|
|
193
|
+
},
|
|
194
|
+
template: `
|
|
195
|
+
<RsForm ref="formRef">
|
|
196
|
+
<RsFormItem required>
|
|
197
|
+
<button type="button">layout only</button>
|
|
198
|
+
</RsFormItem>
|
|
199
|
+
</RsForm>
|
|
200
|
+
`,
|
|
201
|
+
})
|
|
202
|
+
const wrapper = mount(Host)
|
|
203
|
+
await flushPromises()
|
|
204
|
+
const result = await wrapper.vm.formRef!.validate()
|
|
205
|
+
expect(result.valid).toBe(true)
|
|
206
|
+
expect(result.errors).toEqual({})
|
|
207
|
+
wrapper.unmount()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('interpolates required message with label', async () => {
|
|
211
|
+
const Host = defineComponent({
|
|
212
|
+
components: { RsForm, RsFormItem },
|
|
213
|
+
setup() {
|
|
214
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
215
|
+
const service = ref('')
|
|
216
|
+
return { formRef, service }
|
|
217
|
+
},
|
|
218
|
+
template: `
|
|
219
|
+
<RsForm ref="formRef">
|
|
220
|
+
<RsFormItem v-model="service" name="service" label="关联服务" required>
|
|
221
|
+
<button type="button">pick</button>
|
|
222
|
+
</RsFormItem>
|
|
223
|
+
</RsForm>
|
|
224
|
+
`,
|
|
225
|
+
})
|
|
226
|
+
const wrapper = mount(Host)
|
|
227
|
+
await flushPromises()
|
|
228
|
+
const result = await wrapper.vm.formRef!.validate()
|
|
229
|
+
expect(result.errors.service).toBe('请填写关联服务')
|
|
230
|
+
wrapper.unmount()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('lets Form.Item own the field so nested RsInput does not show a second error', async () => {
|
|
234
|
+
const Host = defineComponent({
|
|
235
|
+
components: { RsForm, RsFormItem, RsInput },
|
|
236
|
+
setup() {
|
|
237
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
238
|
+
const email = ref('')
|
|
239
|
+
return { formRef, email }
|
|
240
|
+
},
|
|
241
|
+
template: `
|
|
242
|
+
<RsForm ref="formRef">
|
|
243
|
+
<RsFormItem v-model="email" name="email" required>
|
|
244
|
+
<RsInput />
|
|
245
|
+
</RsFormItem>
|
|
246
|
+
</RsForm>
|
|
247
|
+
`,
|
|
248
|
+
})
|
|
249
|
+
const wrapper = mount(Host)
|
|
250
|
+
await flushPromises()
|
|
251
|
+
await wrapper.vm.formRef!.validate()
|
|
252
|
+
await flushPromises()
|
|
253
|
+
expect(wrapper.findAll('.rs-form-item__error')).toHaveLength(1)
|
|
254
|
+
expect(wrapper.find('.rs-input-field__error').exists()).toBe(false)
|
|
255
|
+
expect(wrapper.find('.rs-input-group--invalid').exists()).toBe(true)
|
|
256
|
+
wrapper.unmount()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('binds Form.model into nested input without child v-model', async () => {
|
|
260
|
+
const Host = defineComponent({
|
|
261
|
+
components: { RsForm, RsFormItem, RsInput },
|
|
262
|
+
setup() {
|
|
263
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
264
|
+
const model = ref({ email: '' })
|
|
265
|
+
return { formRef, model }
|
|
266
|
+
},
|
|
267
|
+
template: `
|
|
268
|
+
<RsForm ref="formRef" :model="model">
|
|
269
|
+
<RsFormItem name="email" required>
|
|
270
|
+
<RsInput />
|
|
271
|
+
</RsFormItem>
|
|
272
|
+
</RsForm>
|
|
273
|
+
`,
|
|
274
|
+
})
|
|
275
|
+
const wrapper = mount(Host)
|
|
276
|
+
await flushPromises()
|
|
277
|
+
wrapper.find('input').setValue('a@b.com')
|
|
278
|
+
await flushPromises()
|
|
279
|
+
expect(wrapper.vm.model.email).toBe('a@b.com')
|
|
280
|
+
wrapper.vm.formRef!.setFieldsValue({ email: 'c@d.com' })
|
|
281
|
+
await flushPromises()
|
|
282
|
+
expect(wrapper.vm.model.email).toBe('c@d.com')
|
|
283
|
+
expect((wrapper.find('input').element as HTMLInputElement).value).toBe('c@d.com')
|
|
284
|
+
wrapper.unmount()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('renders help and extra', () => {
|
|
288
|
+
const wrapper = mount(RsFormItem, {
|
|
289
|
+
props: { help: '辅助说明', extra: '额外提示' },
|
|
290
|
+
slots: { default: '<span class="ctrl">x</span>' },
|
|
291
|
+
})
|
|
292
|
+
expect(wrapper.find('.rs-form-item__help').text()).toBe('辅助说明')
|
|
293
|
+
expect(wrapper.find('.rs-form-item__extra').text()).toBe('额外提示')
|
|
294
|
+
wrapper.unmount()
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
it('revalidates when a dependency field changes', async () => {
|
|
298
|
+
const Host = defineComponent({
|
|
299
|
+
components: { RsForm, RsFormItem, RsInput },
|
|
300
|
+
setup() {
|
|
301
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
302
|
+
const model = ref({ password: 'aa', confirm: 'bb' })
|
|
303
|
+
return { formRef, model }
|
|
304
|
+
},
|
|
305
|
+
template: `
|
|
306
|
+
<RsForm ref="formRef" :model="model">
|
|
307
|
+
<RsFormItem name="password" label="密码" required>
|
|
308
|
+
<RsInput />
|
|
309
|
+
</RsFormItem>
|
|
310
|
+
<RsFormItem
|
|
311
|
+
name="confirm"
|
|
312
|
+
label="确认密码"
|
|
313
|
+
:dependencies="['password']"
|
|
314
|
+
:rules="[{
|
|
315
|
+
validator(value, ctx) {
|
|
316
|
+
if (String(value) !== String(ctx.getFieldValue('password'))) return '两次密码不一致'
|
|
317
|
+
return true
|
|
318
|
+
}
|
|
319
|
+
}]"
|
|
320
|
+
>
|
|
321
|
+
<RsInput />
|
|
322
|
+
</RsFormItem>
|
|
323
|
+
</RsForm>
|
|
324
|
+
`,
|
|
325
|
+
})
|
|
326
|
+
const wrapper = mount(Host)
|
|
327
|
+
await flushPromises()
|
|
328
|
+
const result = await wrapper.vm.formRef!.validate()
|
|
329
|
+
expect(result.valid).toBe(false)
|
|
330
|
+
expect(result.errors.confirm).toBe('两次密码不一致')
|
|
331
|
+
|
|
332
|
+
wrapper.vm.model.password = 'bb'
|
|
333
|
+
await flushPromises()
|
|
334
|
+
expect(wrapper.findAll('.rs-form-item__error').length).toBe(0)
|
|
335
|
+
wrapper.unmount()
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it('does not overwrite a child that already binds v-model', async () => {
|
|
339
|
+
const Host = defineComponent({
|
|
340
|
+
components: { RsForm, RsFormItem, RsSwitch },
|
|
341
|
+
setup() {
|
|
342
|
+
const model = ref({ deptAdminFlag: 'N' })
|
|
343
|
+
const toBool = (v: string) => v === 'Y'
|
|
344
|
+
const fromBool = (v: boolean) => (v ? 'Y' : 'N')
|
|
345
|
+
return { model, toBool, fromBool }
|
|
346
|
+
},
|
|
347
|
+
template: `
|
|
348
|
+
<RsForm :model="model">
|
|
349
|
+
<RsFormItem name="deptAdminFlag">
|
|
350
|
+
<RsSwitch
|
|
351
|
+
:model-value="toBool(model.deptAdminFlag)"
|
|
352
|
+
@update:model-value="(v) => (model.deptAdminFlag = fromBool(v))"
|
|
353
|
+
/>
|
|
354
|
+
</RsFormItem>
|
|
355
|
+
</RsForm>
|
|
356
|
+
`,
|
|
357
|
+
})
|
|
358
|
+
const wrapper = mount(Host)
|
|
359
|
+
await flushPromises()
|
|
360
|
+
expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
|
|
361
|
+
await wrapper.find('.rs-switch__root').trigger('click')
|
|
362
|
+
await flushPromises()
|
|
363
|
+
expect(wrapper.vm.model.deptAdminFlag).toBe('Y')
|
|
364
|
+
expect(wrapper.find('.rs-switch--checked').exists()).toBe(true)
|
|
365
|
+
wrapper.unmount()
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
it('lets Form.Item inject Y/N into Switch via checkedValue', async () => {
|
|
369
|
+
const Host = defineComponent({
|
|
370
|
+
components: { RsForm, RsFormItem, RsSwitch },
|
|
371
|
+
setup() {
|
|
372
|
+
const model = ref({ deptAdminFlag: 'N' })
|
|
373
|
+
return { model }
|
|
374
|
+
},
|
|
375
|
+
template: `
|
|
376
|
+
<RsForm :model="model">
|
|
377
|
+
<RsFormItem name="deptAdminFlag">
|
|
378
|
+
<RsSwitch checked-value="Y" unchecked-value="N" />
|
|
379
|
+
</RsFormItem>
|
|
380
|
+
</RsForm>
|
|
381
|
+
`,
|
|
382
|
+
})
|
|
383
|
+
const wrapper = mount(Host)
|
|
384
|
+
await flushPromises()
|
|
385
|
+
expect(wrapper.find('.rs-switch--checked').exists()).toBe(false)
|
|
386
|
+
await wrapper.find('.rs-switch__root').trigger('click')
|
|
387
|
+
await flushPromises()
|
|
388
|
+
expect(wrapper.vm.model.deptAdminFlag).toBe('Y')
|
|
389
|
+
expect(wrapper.find('.rs-switch--checked').exists()).toBe(true)
|
|
390
|
+
wrapper.unmount()
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it('paints DatePicker invalid on the trigger, not the field wrapper', async () => {
|
|
394
|
+
const Host = defineComponent({
|
|
395
|
+
components: { RsForm, RsFormItem, RsDatePicker },
|
|
396
|
+
setup() {
|
|
397
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
398
|
+
const expire = ref('')
|
|
399
|
+
return { formRef, expire }
|
|
400
|
+
},
|
|
401
|
+
template: `
|
|
402
|
+
<RsForm ref="formRef">
|
|
403
|
+
<RsFormItem v-model="expire" name="expire" required>
|
|
404
|
+
<RsDatePicker />
|
|
405
|
+
</RsFormItem>
|
|
406
|
+
</RsForm>
|
|
407
|
+
`,
|
|
408
|
+
})
|
|
409
|
+
const wrapper = mount(Host)
|
|
410
|
+
await flushPromises()
|
|
411
|
+
await wrapper.vm.formRef!.validate()
|
|
412
|
+
await flushPromises()
|
|
413
|
+
|
|
414
|
+
const trigger = wrapper.find('.rs-date-picker__trigger')
|
|
415
|
+
expect(trigger.classes()).toContain('rs-date-picker__trigger--invalid')
|
|
416
|
+
expect(trigger.attributes('aria-invalid')).toBe('true')
|
|
417
|
+
expect(wrapper.find('.rs-date-picker').attributes('aria-invalid')).toBeUndefined()
|
|
418
|
+
expect(wrapper.find('.rs-field').attributes('aria-invalid')).toBeUndefined()
|
|
419
|
+
wrapper.unmount()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
it('paints TimePicker invalid on the trigger, not the field wrapper', async () => {
|
|
423
|
+
const Host = defineComponent({
|
|
424
|
+
components: { RsForm, RsFormItem, RsTimePicker },
|
|
425
|
+
setup() {
|
|
426
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
427
|
+
const time = ref('')
|
|
428
|
+
return { formRef, time }
|
|
429
|
+
},
|
|
430
|
+
template: `
|
|
431
|
+
<RsForm ref="formRef">
|
|
432
|
+
<RsFormItem v-model="time" name="time" required>
|
|
433
|
+
<RsTimePicker />
|
|
434
|
+
</RsFormItem>
|
|
435
|
+
</RsForm>
|
|
436
|
+
`,
|
|
437
|
+
})
|
|
438
|
+
const wrapper = mount(Host)
|
|
439
|
+
await flushPromises()
|
|
440
|
+
await wrapper.vm.formRef!.validate()
|
|
441
|
+
await flushPromises()
|
|
442
|
+
|
|
443
|
+
const trigger = wrapper.find('.rs-time-picker__trigger')
|
|
444
|
+
expect(trigger.classes()).toContain('rs-time-picker__trigger--invalid')
|
|
445
|
+
expect(trigger.attributes('aria-invalid')).toBe('true')
|
|
446
|
+
expect(wrapper.find('.rs-field').attributes('aria-invalid')).toBeUndefined()
|
|
447
|
+
wrapper.unmount()
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('paints Select invalid on the trigger, not the combobox root', async () => {
|
|
451
|
+
const Host = defineComponent({
|
|
452
|
+
components: { RsForm, RsFormItem, RsSelect },
|
|
453
|
+
setup() {
|
|
454
|
+
const formRef = ref<InstanceType<typeof RsForm> | null>(null)
|
|
455
|
+
const role = ref('')
|
|
456
|
+
const options = [{ label: '管理员', value: 'admin' }]
|
|
457
|
+
return { formRef, role, options }
|
|
458
|
+
},
|
|
459
|
+
template: `
|
|
460
|
+
<RsForm ref="formRef">
|
|
461
|
+
<RsFormItem v-model="role" name="role" required>
|
|
462
|
+
<RsSelect :options="options" />
|
|
463
|
+
</RsFormItem>
|
|
464
|
+
</RsForm>
|
|
465
|
+
`,
|
|
466
|
+
})
|
|
467
|
+
const wrapper = mount(Host)
|
|
468
|
+
await flushPromises()
|
|
469
|
+
await wrapper.vm.formRef!.validate()
|
|
470
|
+
await flushPromises()
|
|
471
|
+
|
|
472
|
+
const trigger = wrapper.find('.rs-select__trigger')
|
|
473
|
+
expect(trigger.classes()).toContain('rs-select__trigger--invalid')
|
|
474
|
+
expect(trigger.attributes('aria-invalid')).toBe('true')
|
|
475
|
+
expect(wrapper.find('.rs-select').attributes('aria-invalid')).toBeUndefined()
|
|
476
|
+
wrapper.unmount()
|
|
477
|
+
})
|
|
478
|
+
})
|