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
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, useId, useSlots, watch } from 'vue'
|
|
3
|
+
import { useRsI18n } from '../composables/useRsI18n'
|
|
4
|
+
import RsVNodeHost from './RsVNodeHost.vue'
|
|
5
|
+
import RsFormItemControl from './RsFormItemControl.vue'
|
|
6
|
+
import {
|
|
7
|
+
provideRsFormItemContext,
|
|
8
|
+
useRsFormContext,
|
|
9
|
+
useRsFormField,
|
|
10
|
+
useRsFormListContext,
|
|
11
|
+
type RsFormLabelAlign,
|
|
12
|
+
type RsFormLabelPosition,
|
|
13
|
+
type RsFormValidateStatus,
|
|
14
|
+
} from './form-utils'
|
|
15
|
+
import {
|
|
16
|
+
buildLocalInputRules,
|
|
17
|
+
normalizeFormRules,
|
|
18
|
+
runFormFieldRules,
|
|
19
|
+
type RsFormRuleItem,
|
|
20
|
+
type RsFormRuleTrigger,
|
|
21
|
+
} from './form-rules'
|
|
22
|
+
import {
|
|
23
|
+
concatNamePath,
|
|
24
|
+
isIndexSegment,
|
|
25
|
+
namePathKey,
|
|
26
|
+
type RsFormNamePath,
|
|
27
|
+
} from './form-path'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 表单项容器:包住任意自定义控件,负责 name / rules / 错误文案。
|
|
31
|
+
* 对标 Ant Design Form.Item、Element Plus ElFormItem。
|
|
32
|
+
* 内置 RsInput / RsSelect 已自行注册;放在已声明 name 的 Item 内时不再重复注册。
|
|
33
|
+
*/
|
|
34
|
+
defineOptions({ name: 'RsFormItem' })
|
|
35
|
+
|
|
36
|
+
const model = defineModel<unknown>()
|
|
37
|
+
|
|
38
|
+
const props = withDefaults(
|
|
39
|
+
defineProps<{
|
|
40
|
+
name?: RsFormNamePath
|
|
41
|
+
label?: string
|
|
42
|
+
required?: boolean
|
|
43
|
+
rules?: RsFormRuleItem | RsFormRuleItem[]
|
|
44
|
+
invalid?: boolean
|
|
45
|
+
errorMessage?: string
|
|
46
|
+
showMessage?: boolean
|
|
47
|
+
hint?: string
|
|
48
|
+
/** 始终展示的辅助说明(错误出现时仍保留) */
|
|
49
|
+
extra?: string
|
|
50
|
+
/** 无错误时的说明;出错时被错误文案替换 */
|
|
51
|
+
help?: string
|
|
52
|
+
validateStatus?: RsFormValidateStatus
|
|
53
|
+
/** 不渲染标签与外壳,只保留控件与错误(schema 表单外置 label) */
|
|
54
|
+
noStyle?: boolean
|
|
55
|
+
/** 依赖字段变化时重新校验本字段(对齐 Ant Design dependencies) */
|
|
56
|
+
dependencies?: RsFormNamePath[]
|
|
57
|
+
labelPosition?: RsFormLabelPosition
|
|
58
|
+
}>(),
|
|
59
|
+
{
|
|
60
|
+
showMessage: true,
|
|
61
|
+
noStyle: false,
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
const emit = defineEmits<{
|
|
66
|
+
validate: [result: { valid: boolean; message?: string }]
|
|
67
|
+
}>()
|
|
68
|
+
|
|
69
|
+
const { t } = useRsI18n()
|
|
70
|
+
const slots = useSlots()
|
|
71
|
+
const formContext = useRsFormContext()
|
|
72
|
+
const listContext = useRsFormListContext()
|
|
73
|
+
const fieldId = useId()
|
|
74
|
+
const errorId = computed(() => `${fieldId}-error`)
|
|
75
|
+
const extraId = computed(() => `${fieldId}-extra`)
|
|
76
|
+
|
|
77
|
+
const touched = ref(false)
|
|
78
|
+
const autoInvalid = ref(false)
|
|
79
|
+
const autoMessage = ref('')
|
|
80
|
+
|
|
81
|
+
const resolvedNamePath = computed(() => {
|
|
82
|
+
if (props.name == null) return undefined
|
|
83
|
+
const own = concatNamePath(props.name)
|
|
84
|
+
if (!listContext) return own
|
|
85
|
+
return concatNamePath(listContext.prefix.value, own)
|
|
86
|
+
})
|
|
87
|
+
const nameKey = computed(() =>
|
|
88
|
+
resolvedNamePath.value?.length ? namePathKey(resolvedNamePath.value) : undefined,
|
|
89
|
+
)
|
|
90
|
+
const ownsField = computed(() => Boolean(nameKey.value))
|
|
91
|
+
|
|
92
|
+
const resolvedLabelPosition = computed(
|
|
93
|
+
() => props.labelPosition ?? formContext?.labelPosition.value ?? 'top',
|
|
94
|
+
)
|
|
95
|
+
const resolvedLabelAlign = computed<RsFormLabelAlign>(
|
|
96
|
+
() => formContext?.labelAlign.value ?? 'start',
|
|
97
|
+
)
|
|
98
|
+
const resolvedSize = computed(() => formContext?.size.value ?? 'md')
|
|
99
|
+
|
|
100
|
+
function readValue(): unknown {
|
|
101
|
+
const path = resolvedNamePath.value
|
|
102
|
+
if (formContext && path?.length && formContext.model.value) {
|
|
103
|
+
return formContext.getFieldValue(path)
|
|
104
|
+
}
|
|
105
|
+
return model.value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function writeValue(value: unknown): void {
|
|
109
|
+
model.value = value
|
|
110
|
+
const path = resolvedNamePath.value
|
|
111
|
+
if (formContext?.model.value && path?.length) {
|
|
112
|
+
formContext.setFieldValue(path, value)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 解析 dependencies / getFieldValue 路径。
|
|
118
|
+
* List 内以数字下标开头的视为相对路径([0, 'password'] → users.0.password);
|
|
119
|
+
* 其它视为表单根路径,便于 List 内依赖根字段。
|
|
120
|
+
*/
|
|
121
|
+
function resolveWatchName(name: RsFormNamePath): Array<string | number> {
|
|
122
|
+
const segs = concatNamePath(name)
|
|
123
|
+
const prefix = listContext?.prefix.value
|
|
124
|
+
if (!prefix?.length || !segs.length) return segs
|
|
125
|
+
const first = segs[0]
|
|
126
|
+
if (first !== undefined && isIndexSegment(first)) {
|
|
127
|
+
return concatNamePath(prefix, segs)
|
|
128
|
+
}
|
|
129
|
+
return segs
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const showRequiredMark = computed(() => {
|
|
133
|
+
if (props.required) return true
|
|
134
|
+
const formRules = formContext?.getFieldRules(nameKey.value) ?? []
|
|
135
|
+
const localRules = [
|
|
136
|
+
...buildLocalInputRules({ required: props.required }),
|
|
137
|
+
...normalizeFormRules(props.rules),
|
|
138
|
+
]
|
|
139
|
+
return [...formRules, ...localRules].some((rule) => rule.required)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const displayMessage = computed(() => {
|
|
143
|
+
if (props.errorMessage) return props.errorMessage
|
|
144
|
+
if (props.showMessage && autoInvalid.value) return autoMessage.value
|
|
145
|
+
return ''
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const isInvalid = computed(() => {
|
|
149
|
+
if (props.validateStatus === 'error') return true
|
|
150
|
+
if (props.validateStatus) return false
|
|
151
|
+
return props.invalid || autoInvalid.value
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
const mergedStatus = computed<RsFormValidateStatus>(() => {
|
|
155
|
+
if (props.validateStatus) return props.validateStatus
|
|
156
|
+
if (isInvalid.value) return 'error'
|
|
157
|
+
return ''
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const describedBy = computed(() => {
|
|
161
|
+
const ids: string[] = []
|
|
162
|
+
if (displayMessage.value || props.help) ids.push(errorId.value)
|
|
163
|
+
if (props.extra) ids.push(extraId.value)
|
|
164
|
+
return ids.length ? ids.join(' ') : undefined
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const errorSlotProps = computed(() => ({
|
|
168
|
+
name: nameKey.value,
|
|
169
|
+
message: displayMessage.value,
|
|
170
|
+
value: readValue(),
|
|
171
|
+
}))
|
|
172
|
+
|
|
173
|
+
const formErrorContent = computed(() => {
|
|
174
|
+
if (!displayMessage.value || !formContext?.renderError) return null
|
|
175
|
+
return formContext.renderError(errorSlotProps.value)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const fieldStyle = computed(() => {
|
|
179
|
+
if (props.noStyle || resolvedLabelPosition.value !== 'left') return undefined
|
|
180
|
+
const labelWidth = formContext?.labelWidth.value
|
|
181
|
+
if (!labelWidth) return undefined
|
|
182
|
+
return { '--rs-field-label-width': labelWidth } as Record<string, string>
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const showLabel = computed(() => Boolean(props.label || slots.label) && !props.noStyle)
|
|
186
|
+
|
|
187
|
+
const controlInject = computed(() => ({
|
|
188
|
+
id: fieldId,
|
|
189
|
+
invalid: isInvalid.value,
|
|
190
|
+
showValidateMessage: false,
|
|
191
|
+
'aria-invalid': isInvalid.value || undefined,
|
|
192
|
+
'aria-describedby': describedBy.value,
|
|
193
|
+
modelValue: readValue(),
|
|
194
|
+
'onUpdate:modelValue': writeValue,
|
|
195
|
+
}))
|
|
196
|
+
|
|
197
|
+
async function runValidate(trigger: RsFormRuleTrigger = 'submit'): Promise<boolean> {
|
|
198
|
+
touched.value = true
|
|
199
|
+
const formRules = formContext?.getFieldRules(nameKey.value) ?? []
|
|
200
|
+
const localRules = [
|
|
201
|
+
...buildLocalInputRules({ required: props.required }),
|
|
202
|
+
...normalizeFormRules(props.rules),
|
|
203
|
+
]
|
|
204
|
+
const rules = [...formRules, ...localRules]
|
|
205
|
+
|
|
206
|
+
if (!rules.length) {
|
|
207
|
+
autoInvalid.value = false
|
|
208
|
+
autoMessage.value = ''
|
|
209
|
+
emit('validate', { valid: true })
|
|
210
|
+
return true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const result = await runFormFieldRules(readValue(), rules, {
|
|
214
|
+
trigger,
|
|
215
|
+
t,
|
|
216
|
+
name: nameKey.value,
|
|
217
|
+
label: typeof props.label === 'string' ? props.label : undefined,
|
|
218
|
+
validateMessages: formContext?.validateMessages.value,
|
|
219
|
+
getFieldValue: (name) => formContext?.getFieldValue(resolveWatchName(name)),
|
|
220
|
+
getFieldsValue: () => formContext?.getFieldsValue() ?? {},
|
|
221
|
+
})
|
|
222
|
+
autoInvalid.value = !result.valid
|
|
223
|
+
autoMessage.value = result.message ?? ''
|
|
224
|
+
emit('validate', result)
|
|
225
|
+
return result.valid
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function setError(message: string): void {
|
|
229
|
+
autoInvalid.value = true
|
|
230
|
+
autoMessage.value = message
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function setValue(value: unknown): void {
|
|
234
|
+
writeValue(value)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function clearValidation(): void {
|
|
238
|
+
touched.value = false
|
|
239
|
+
autoInvalid.value = false
|
|
240
|
+
autoMessage.value = ''
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
useRsFormField(() => {
|
|
244
|
+
if (!nameKey.value) return undefined
|
|
245
|
+
return {
|
|
246
|
+
name: nameKey.value,
|
|
247
|
+
getValue: () => readValue(),
|
|
248
|
+
setValue,
|
|
249
|
+
validate: async (trigger) => {
|
|
250
|
+
const valid = await runValidate(trigger ?? 'submit')
|
|
251
|
+
return { valid, message: autoMessage.value || undefined, name: nameKey.value }
|
|
252
|
+
},
|
|
253
|
+
clearValidation,
|
|
254
|
+
setError,
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
provideRsFormItemContext({
|
|
259
|
+
name: nameKey,
|
|
260
|
+
ownsField,
|
|
261
|
+
invalid: isInvalid,
|
|
262
|
+
message: displayMessage,
|
|
263
|
+
status: mergedStatus,
|
|
264
|
+
fieldId: computed(() => fieldId),
|
|
265
|
+
errorId,
|
|
266
|
+
describedBy,
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
watch(
|
|
270
|
+
() => readValue(),
|
|
271
|
+
() => {
|
|
272
|
+
if (touched.value) void runValidate('change')
|
|
273
|
+
},
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
watch(
|
|
277
|
+
() =>
|
|
278
|
+
JSON.stringify(
|
|
279
|
+
(props.dependencies ?? []).map((name) =>
|
|
280
|
+
formContext?.getFieldValue(resolveWatchName(name)),
|
|
281
|
+
),
|
|
282
|
+
),
|
|
283
|
+
(_next, prev) => {
|
|
284
|
+
if (prev === undefined) return
|
|
285
|
+
const current = readValue()
|
|
286
|
+
const hasValue =
|
|
287
|
+
current != null && !(typeof current === 'string' && current.trim() === '')
|
|
288
|
+
if (touched.value || autoInvalid.value || hasValue) {
|
|
289
|
+
void runValidate('change')
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
defineExpose({
|
|
295
|
+
validate: runValidate,
|
|
296
|
+
clearValidation,
|
|
297
|
+
setValue,
|
|
298
|
+
setError,
|
|
299
|
+
})
|
|
300
|
+
</script>
|
|
301
|
+
|
|
302
|
+
<template>
|
|
303
|
+
<div
|
|
304
|
+
class="rs-form-item"
|
|
305
|
+
:class="[
|
|
306
|
+
!noStyle ? 'rs-field' : undefined,
|
|
307
|
+
!noStyle ? `rs-field--label-${resolvedLabelPosition}` : undefined,
|
|
308
|
+
!noStyle && resolvedLabelPosition === 'left' && resolvedLabelAlign === 'end'
|
|
309
|
+
? 'rs-field--label-align-end'
|
|
310
|
+
: undefined,
|
|
311
|
+
mergedStatus ? `rs-form-item--${mergedStatus}` : undefined,
|
|
312
|
+
`rs-form-item--${resolvedSize}`,
|
|
313
|
+
]"
|
|
314
|
+
:style="fieldStyle"
|
|
315
|
+
:data-rs-form-item="nameKey"
|
|
316
|
+
>
|
|
317
|
+
<span v-if="showLabel" class="rs-field__label">
|
|
318
|
+
<slot name="label">
|
|
319
|
+
<label :for="fieldId">{{ label }}</label>
|
|
320
|
+
</slot>
|
|
321
|
+
<span v-if="showRequiredMark" class="rs-field__required" aria-hidden="true">*</span>
|
|
322
|
+
</span>
|
|
323
|
+
|
|
324
|
+
<div class="rs-form-item__control">
|
|
325
|
+
<RsFormItemControl :inject="controlInject">
|
|
326
|
+
<slot
|
|
327
|
+
:id="fieldId"
|
|
328
|
+
:invalid="isInvalid"
|
|
329
|
+
:message="displayMessage"
|
|
330
|
+
:field="nameKey"
|
|
331
|
+
:status="mergedStatus"
|
|
332
|
+
/>
|
|
333
|
+
</RsFormItemControl>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
<p
|
|
337
|
+
v-if="displayMessage"
|
|
338
|
+
:id="errorId"
|
|
339
|
+
class="rs-form-item__error"
|
|
340
|
+
role="alert"
|
|
341
|
+
>
|
|
342
|
+
<slot name="error" v-bind="errorSlotProps">
|
|
343
|
+
<RsVNodeHost
|
|
344
|
+
v-if="formErrorContent !== null && formErrorContent !== undefined"
|
|
345
|
+
:content="formErrorContent"
|
|
346
|
+
/>
|
|
347
|
+
<template v-else>{{ displayMessage }}</template>
|
|
348
|
+
</slot>
|
|
349
|
+
</p>
|
|
350
|
+
<p
|
|
351
|
+
v-else-if="help"
|
|
352
|
+
:id="errorId"
|
|
353
|
+
class="rs-form-item__help"
|
|
354
|
+
>
|
|
355
|
+
{{ help }}
|
|
356
|
+
</p>
|
|
357
|
+
|
|
358
|
+
<span v-if="hint && !noStyle" class="rs-field__hint">{{ hint }}</span>
|
|
359
|
+
<span v-if="extra" :id="extraId" class="rs-form-item__extra">{{ extra }}</span>
|
|
360
|
+
</div>
|
|
361
|
+
</template>
|
|
362
|
+
|
|
363
|
+
<style scoped>
|
|
364
|
+
.rs-form-item__control {
|
|
365
|
+
width: 100%;
|
|
366
|
+
min-height: var(--rs-control-height-md);
|
|
367
|
+
min-width: 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/*
|
|
371
|
+
* 左侧标签与控件行等高、文字垂直居中。
|
|
372
|
+
* 网格仍是 align-items:start,错误文案在第 2 列,不会把 label 顶歪。
|
|
373
|
+
*/
|
|
374
|
+
.rs-field--label-left > .rs-field__label {
|
|
375
|
+
display: flex;
|
|
376
|
+
align-items: center;
|
|
377
|
+
box-sizing: border-box;
|
|
378
|
+
min-height: var(--rs-control-height-md);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.rs-form-item--ssm.rs-field--label-left > .rs-field__label,
|
|
382
|
+
.rs-form-item--ssm .rs-form-item__control {
|
|
383
|
+
min-height: var(--rs-control-height-ssm);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.rs-form-item--sm.rs-field--label-left > .rs-field__label,
|
|
387
|
+
.rs-form-item--sm .rs-form-item__control {
|
|
388
|
+
min-height: var(--rs-control-height-sm);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.rs-form-item--lg.rs-field--label-left > .rs-field__label,
|
|
392
|
+
.rs-form-item--lg .rs-form-item__control {
|
|
393
|
+
min-height: var(--rs-control-height-lg);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
.rs-field--label-left .rs-form-item__control,
|
|
397
|
+
.rs-field--label-left .rs-form-item__error,
|
|
398
|
+
.rs-field--label-left .rs-form-item__help,
|
|
399
|
+
.rs-field--label-left .rs-form-item__extra {
|
|
400
|
+
grid-column: 2;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.rs-form-item__error,
|
|
404
|
+
.rs-form-item__help,
|
|
405
|
+
.rs-form-item__extra {
|
|
406
|
+
margin: 0.25rem 0 0;
|
|
407
|
+
font-size: var(--rs-font-size-xs);
|
|
408
|
+
line-height: var(--rs-line-height-tight);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.rs-form-item__error {
|
|
412
|
+
color: var(--rs-danger);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.rs-form-item__help,
|
|
416
|
+
.rs-form-item__extra {
|
|
417
|
+
color: var(--rs-muted);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
.rs-form-item--warning .rs-form-item__error {
|
|
421
|
+
color: var(--rs-warning, #d48806);
|
|
422
|
+
}
|
|
423
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { defineComponent, type PropType, type VNode } from 'vue'
|
|
3
|
+
import { bindFormItemControl } from './form-item-control'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 将 inject 合并到默认插槽第一个子节点,供 Form.Item 注入控件 props。
|
|
7
|
+
*/
|
|
8
|
+
export default defineComponent({
|
|
9
|
+
name: 'RsFormItemControl',
|
|
10
|
+
inheritAttrs: false,
|
|
11
|
+
props: {
|
|
12
|
+
inject: {
|
|
13
|
+
type: Object as PropType<Record<string, unknown>>,
|
|
14
|
+
default: () => ({}),
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
setup(props, { slots }) {
|
|
18
|
+
return () => bindFormItemControl(slots.default?.() as VNode[] | undefined, props.inject)
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
</script>
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import {
|
|
4
|
+
provideRsFormListContext,
|
|
5
|
+
useRsFormContext,
|
|
6
|
+
useRsFormListContext,
|
|
7
|
+
type RsFormListField,
|
|
8
|
+
type RsFormListOperations,
|
|
9
|
+
} from './form-utils'
|
|
10
|
+
import { concatNamePath, type RsFormNamePath } from './form-path'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 动态数组字段(对齐 Ant Design Form.List)。
|
|
14
|
+
* 需要 Form.model;子 Item 的 name 为相对路径,会拼上 List 前缀。
|
|
15
|
+
*/
|
|
16
|
+
defineOptions({ name: 'RsFormList' })
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
name: RsFormNamePath
|
|
20
|
+
}>()
|
|
21
|
+
|
|
22
|
+
const form = useRsFormContext()
|
|
23
|
+
const parentList = useRsFormListContext()
|
|
24
|
+
|
|
25
|
+
const prefix = computed(() => concatNamePath(parentList?.prefix.value, props.name))
|
|
26
|
+
|
|
27
|
+
provideRsFormListContext({ prefix })
|
|
28
|
+
|
|
29
|
+
const rowKeys = ref<number[]>([])
|
|
30
|
+
let keySeed = 0
|
|
31
|
+
|
|
32
|
+
function readList(): unknown[] {
|
|
33
|
+
const raw = form?.getFieldValue(prefix.value)
|
|
34
|
+
return Array.isArray(raw) ? raw : []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function writeList(next: unknown[]): void {
|
|
38
|
+
form?.setFieldValue(prefix.value, next)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function syncKeys(length: number): void {
|
|
42
|
+
const keys = rowKeys.value
|
|
43
|
+
if (keys.length === length) return
|
|
44
|
+
if (keys.length < length) {
|
|
45
|
+
const next = keys.slice()
|
|
46
|
+
while (next.length < length) {
|
|
47
|
+
keySeed += 1
|
|
48
|
+
next.push(keySeed)
|
|
49
|
+
}
|
|
50
|
+
rowKeys.value = next
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
rowKeys.value = keys.slice(0, length)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
watch(
|
|
57
|
+
() => readList().length,
|
|
58
|
+
(length) => {
|
|
59
|
+
syncKeys(length)
|
|
60
|
+
},
|
|
61
|
+
{ immediate: true },
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
const fields = computed<RsFormListField[]>(() =>
|
|
65
|
+
readList().map((_, index) => ({
|
|
66
|
+
key: rowKeys.value[index] ?? index,
|
|
67
|
+
name: index,
|
|
68
|
+
index,
|
|
69
|
+
})),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const add: RsFormListOperations['add'] = (defaultValue = {}) => {
|
|
73
|
+
const current = readList()
|
|
74
|
+
keySeed += 1
|
|
75
|
+
rowKeys.value = [...rowKeys.value, keySeed]
|
|
76
|
+
writeList([...current, defaultValue])
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const remove: RsFormListOperations['remove'] = (index) => {
|
|
80
|
+
const current = readList()
|
|
81
|
+
if (index < 0 || index >= current.length) return
|
|
82
|
+
rowKeys.value = rowKeys.value.filter((_, i) => i !== index)
|
|
83
|
+
writeList(current.filter((_, i) => i !== index))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const move: RsFormListOperations['move'] = (from, to) => {
|
|
87
|
+
const current = readList()
|
|
88
|
+
if (
|
|
89
|
+
from === to ||
|
|
90
|
+
from < 0 ||
|
|
91
|
+
to < 0 ||
|
|
92
|
+
from >= current.length ||
|
|
93
|
+
to >= current.length
|
|
94
|
+
) {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const next = current.slice()
|
|
98
|
+
const [row] = next.splice(from, 1)
|
|
99
|
+
next.splice(to, 0, row)
|
|
100
|
+
const keys = rowKeys.value.slice()
|
|
101
|
+
const [key] = keys.splice(from, 1)
|
|
102
|
+
keys.splice(to, 0, key!)
|
|
103
|
+
rowKeys.value = keys
|
|
104
|
+
writeList(next)
|
|
105
|
+
}
|
|
106
|
+
</script>
|
|
107
|
+
|
|
108
|
+
<template>
|
|
109
|
+
<div class="rs-form-list">
|
|
110
|
+
<slot
|
|
111
|
+
:fields="fields"
|
|
112
|
+
:add="add"
|
|
113
|
+
:remove="remove"
|
|
114
|
+
:move="move"
|
|
115
|
+
/>
|
|
116
|
+
</div>
|
|
117
|
+
</template>
|