diff --git a/devui/form/index.ts b/devui/form/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..199838e71262e54edc73c1a0f5e3bfa9f78a69aa --- /dev/null +++ b/devui/form/index.ts @@ -0,0 +1,42 @@ +import type { App } from 'vue' +import Form from './src/form' +import FormLabel from './src/form-label/form-label'; +import FormItem from './src/form-item/form-item'; +import FormControl from './src/form-control/form-control'; +import FormOperation from './src/form-operation/form-operation'; +import dValidateRules from './src/directive/d-validate-rules'; + +Form.install = function(app: App) { + app.component(Form.name, Form); + app.directive('d-validate-rules', dValidateRules); +} + +FormLabel.install = function(app: App) { + app.component(FormLabel.name, FormLabel) +} + +FormItem.install = function(app: App) { + app.component(FormItem.name, FormItem) +} + +FormControl.install = function(app: App) { + app.component(FormControl.name, FormControl) +} + +FormOperation.install = function(app: App) { + app.component(FormOperation.name, FormOperation) +} + +export { Form, FormLabel, FormItem, FormControl, FormOperation } + +export default { + title: 'Form 表单', + category: '数据录入', + install(app: App): void { + app.use(Form as any); + app.use(FormLabel as any); + app.use(FormItem as any); + app.use(FormControl as any); + app.use(FormOperation as any); + } +} diff --git a/devui/form/src/directive/d-validate-rules.ts b/devui/form/src/directive/d-validate-rules.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd7b7e8cf23c6fa43ccb443f718304f810d2dbee --- /dev/null +++ b/devui/form/src/directive/d-validate-rules.ts @@ -0,0 +1,305 @@ +import AsyncValidator from 'async-validator'; +import { VNode } from 'vue'; +import './style.scss'; +import { debounce } from 'lodash'; +import EventBus from '../util/event-bus'; + +// 获取async-validator可用的规则名 +function getAvaliableRuleObj(ruleName: string, value) { + if(!ruleName) { + console.error("[v-d-validate] validator's key is invalid"); + return null; + } + switch(ruleName) { + case 'maxlength': + return { + type: 'string', + max: value, + asyncValidator: (rule, val) => { + return new Promise((resolve, reject) => { + if(val.length > value) { + reject('最大长度为' + value); + }else { + resolve('校验通过'); + } + }) + } + }; + case 'minlength': + return { + type: 'string', + min: value, + asyncValidator: (rule, val) => { + return new Promise((resolve, reject) => { + if(val.length < value) { + reject('最小长度为' + value); + }else { + resolve('校验通过'); + } + }) + } + }; + case 'min': + return { + type: 'number', + asyncValidator: (rule, val) => { + return new Promise((resolve, reject) => { + if(val < value) { + reject('最小值为' + value); + }else { + resolve('校验通过'); + } + }) + } + }; + case 'max': + return { + type: 'number', + asyncValidator: (rule, val) => { + return new Promise((resolve, reject) => { + if(val > value) { + reject('最大值为' + value); + }else { + resolve('校验通过'); + } + }) + } + }; + case 'required': + return { + reqiured: true, + asyncValidator: (rule, val) => { + return new Promise((resolve, reject) => { + if(!val) { + reject('必填项'); + }else { + resolve('校验通过'); + } + }) + } + }; + case 'requiredTrue': + return { + asyncValidator: (rule, val) => { + return new Promise((resolve, reject) => { + if(!val) { + reject('必须为true值'); + }else { + resolve('校验通过'); + } + }) + } + }; + case 'email': + return { + type: 'email', + message: '邮箱格式不正确' + }; + case 'pattern': + return { + type: 'regexp', + pattern: value, + message: '只能包含数字与大小写字符', + validator: (rule, val) => value.test(val), + }; + case 'whitespace': + return { + message: '输入不能全部为空格或空字符', + validator: (rule, val) => !!val.trim() + }; + default: + return { + [ruleName]: value, + }; + } +} + +function getKeyValueOfObjectList(obj): {key: string; value: any;}[] { + const kvArr = []; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + kvArr.push({ + key, + value: obj[key] + }) + } + } + return kvArr; +} + + +function isObject(obj): boolean { + return Object.prototype.toString.call(obj).slice(8, -1) === 'Object'; +} + + +function hasKey(obj, key): boolean { + if (!isObject(obj)) return false; + return Object.prototype.hasOwnProperty.call(obj, key); +} + +function handleErrorStrategy(el: HTMLElement): void { + const classList: Array = [...el.classList]; + classList.push('d-validate-rules-error-pristine'); + el.setAttribute('class', classList.join(' ')); +} + +function handleErrorStrategyPass(el: HTMLElement): void { + const classList: Array = [...el.classList]; + const index = classList.indexOf('d-validate-rules-error-pristine'); + index !== -1 && classList.splice(index, 1); + el.setAttribute('class', classList.join(' ')); +} + +function handleValidateError(el: HTMLElement, tipEl: HTMLElement, message: string, isFormTag: boolean, messageShowType: string): void { + // 如果该指令用在form标签上,这里做特殊处理 + if(isFormTag && messageShowType === 'toast') { + // todo:待替换为toast + alert(message); + return; + } + + tipEl.innerText = '' + message; + tipEl.style.display = 'inline-flex'; + tipEl.setAttribute('class', 'd-validate-tip'); + handleErrorStrategy(el); +} + +function handleValidatePass(el: HTMLElement, tipEl: HTMLElement): void { + tipEl.style.display = 'none'; + handleErrorStrategyPass(el); +} + +// 获取表单name +function getFormName(binding): string { + const _refs = binding.instance.$refs; + const key = Object.keys(_refs)[0]; + return _refs[key]['name']; +} + +// 校验处理函数 +function validateFn({validator, modelValue, el, tipEl, isFormTag, messageShowType}) { + validator.validate({modelName: modelValue}).then(() => { + handleValidatePass(el, tipEl); + }).catch((err) => { + const { errors } = err; + if(!errors || errors.length === 0) return; + let msg = ''; + + // todo: 待支持国际化 + if(typeof errors[0].message === 'object') { + msg = errors[0].message.default; + }else { + msg = errors[0].message; + } + + handleValidateError(el, tipEl, msg, isFormTag, messageShowType); + }) +} + +export default { + mounted(el: HTMLElement, binding: any, vnode: VNode): void { + const isFormTag = el.tagName === 'FORM'; + + const hasOptions = isObject(binding.value) && hasKey(binding.value, 'options'); + const {rules: bindingRules, options = {}, messageShowType = 'popover'} = binding.value; + isFormTag && console.log('messageShowType', messageShowType); + + let {errorStrategy} = binding.value; + // errorStrategy可配置在options对象中 + const { updateOn = 'change', errorStrategy: optionsErrorStrategy = 'dirty', asyncDebounceTime = 300} = options; + + if(!errorStrategy) { + errorStrategy = optionsErrorStrategy; + } + + // 判断是否有options,有就取binding.value对象中的rules对象,再判断有没有rules对象,没有就取binding.value + const bindRules = hasOptions ? bindingRules : (bindingRules ? bindingRules : binding.value); + + const isCustomValidator = bindRules && isObject(bindRules) && (hasKey(bindRules, 'validators') || hasKey(bindRules, 'asyncValidators')); + + const rules = Array.isArray(bindRules) ? bindRules : [bindRules]; + const tipEl = document.createElement('span'); + + // messageShowType控制是否显示文字提示 + if(messageShowType !== 'none') { + el.parentNode.append(tipEl); + } + + const descriptor = { + modelName: [] + }; + + rules.forEach((rule) => { + const kvObjList = !Array.isArray(rule) && getKeyValueOfObjectList(rule); + let ruleObj = {}; + let avaliableRuleObj = {}; + kvObjList.forEach(item => { + avaliableRuleObj = getAvaliableRuleObj(item.key, item.value); + ruleObj = {...ruleObj, ...avaliableRuleObj}; + }); + descriptor.modelName.push(ruleObj); + }); + + // 使用自定义的验证器 + if(isCustomValidator) { + // descriptor.modelName = []; + const {validators, asyncValidators} = bindRules; + + // 校验器 + validators && validators.forEach(item => { + const ruleObj = { + message: item?.message || '', + validator: (rule, value) => item.validator(rule, value), + } + descriptor.modelName.push(ruleObj); + }); + + // 异步校验器 + asyncValidators && asyncValidators.forEach(item => { + const ruleObj = { + message: item?.message || '', + asyncValidator: (rule, value, callback) => { + return new Promise(debounce((resolve, reject) => { + const res = item.asyncValidator(rule, value); + if(res) { + resolve(''); + }else { + reject(rule.message); + } + }, asyncDebounceTime)) + }, + } + descriptor.modelName.push(ruleObj); + }); + } + + // 校验器对象 + const validator = new AsyncValidator(descriptor); + + const htmlEventValidateHandler = (e) => { + const modelValue = e.target.value; + validateFn({validator, modelValue, el, tipEl, isFormTag: false, messageShowType}); + } + + // 监听事件验证 + vnode.children[0].el.addEventListener(updateOn, htmlEventValidateHandler); + + // 设置errorStrategy + if(errorStrategy === 'pristine') { + handleErrorStrategy(el); + // pristine为初始化验证,初始化时需改变下原始值才能出发验证 + vnode.children[0].props.value = '' + vnode.children[0].props.value; + } + + const formName = getFormName(binding); + // 处理表单提交验证 + formName && EventBus.on(`formSubmit:${formName}`, () => { + const modelValue = isFormTag ? '' : vnode.children[0].el.value; + + // 进行提交验证 + validateFn({validator, modelValue, el, tipEl, isFormTag, messageShowType}); + }); + + } +} diff --git a/devui/form/src/directive/style.scss b/devui/form/src/directive/style.scss new file mode 100644 index 0000000000000000000000000000000000000000..56345b9d66fca17a2d3e17934fd1bf5023e44937 --- /dev/null +++ b/devui/form/src/directive/style.scss @@ -0,0 +1,24 @@ + +.d-validate-rules-error-pristine { + // background-color: #ffeeed; + input { + background-color: #ffeeed; + border: 1px solid #f66f6a; + + &:focus { + border: 1px solid #f66f6a !important; + } + + &:hover { + border: 1px solid #f66f6a !important; + } + } +} + +.d-validate-tip { + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; + color: #f66f6a; +} diff --git a/devui/form/src/form-control/form-control.scss b/devui/form/src/form-control/form-control.scss new file mode 100644 index 0000000000000000000000000000000000000000..993bae7c6654f75d29a904b6da6a99ffa5a4eaac --- /dev/null +++ b/devui/form/src/form-control/form-control.scss @@ -0,0 +1,101 @@ +.form-control { + position: relative; + + .star { + color: red; + } + + .devui-form-control-container { + position: relative; + + .feedback-status { + position: absolute; + top: 50%; + right: 0; + z-index: 1; + width: 32px; + height: 16px; + margin-top: -7px; + line-height: 16px; + text-align: center; + visibility: visible; + pointer-events: none; + } + + .devui-radio { + &:not(:last-child) { + margin-bottom: 20px; + } + } + } + + .devui-form-control-container-horizontal { + display: flex; + + .devui-radio { + &:not(:last-child) { + margin-bottom: 0; + margin-right: 20px; + } + } + + .devui-checkbox-group > div:first-child { + display: flex; + align-items: center; + } + + .devui-checkbox-column-margin { + &:not(:last-child) { + margin-right: 20px; + } + } + + input, + .devui-tags-host { + width: 200px; + } + + .d-validate-tip { + margin: 0 10px; + } + } + + .has-feedback { + display: flex; + align-items: center; + + input { + padding-right: 28px; + } + } + + .feedback-error { + border: 1px solid #f66f6a; + border-radius: 2px; + + input { + background-color: #ffeeed; + border-color: transparent; + + &:hover { + border-color: transparent !important; + } + + &:focus { + border-color: transparent !important; + } + } + + .devui-select-arrow { + right: 24px !important; + } + } + + .devui-form-control-extra-info { + font-size: 12px; + color: #8a8e99; + min-height: 20px; + line-height: 1.5; + text-align: justify; + } +} diff --git a/devui/form/src/form-control/form-control.tsx b/devui/form/src/form-control/form-control.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2b5cb779c05722e8a86589d7247b87b8a131953c --- /dev/null +++ b/devui/form/src/form-control/form-control.tsx @@ -0,0 +1,47 @@ +import { defineComponent, inject, ref, computed, reactive } from 'vue'; +import './form-control.scss'; +import Icon from '../../../icon/src/icon'; +import {IForm, formControlProps, formInjectionKey} from '../form-types'; + +export default defineComponent({ + name: 'DFormControl', + props: formControlProps, + setup(props, ctx) { + const formControl = ref(); + const dForm = reactive(inject(formInjectionKey, {} as IForm)); + const labelData = reactive(dForm.labelData); + const isHorizontal = labelData.layout === 'horizontal'; + + const iconData = computed(() => { + switch(props.feedbackStatus) { + case 'pending': + return {name: 'priority', color: '#e9edfa'}; + case 'success': + return {name: 'right-o', color: 'rgb(61, 204, 166)'}; + case 'error': + return {name: 'error-o', color: 'rgb(249, 95, 91)'}; + default: + return {name: '', color: ''}; + } + }) + + return () => { + const { + feedbackStatus, + extraInfo, + } = props; + return
+
+ {ctx.slots.default?.()} + { + (feedbackStatus || ctx.slots.suffixTemplate?.()) && + + } +
+ {extraInfo &&
{extraInfo}
} +
+ } + } +}) \ No newline at end of file diff --git a/devui/form/src/form-item/form-item.scss b/devui/form/src/form-item/form-item.scss new file mode 100644 index 0000000000000000000000000000000000000000..41c1a739690d7b84acb7107524392f956b7c97d8 --- /dev/null +++ b/devui/form/src/form-item/form-item.scss @@ -0,0 +1,36 @@ +.form-item { + display: flex; + // align-items: center; + margin-bottom: 20px; +} + +.form-item-vertical { + flex-direction: column; +} + +.form-item-columns { + flex-direction: column; + display: inline-block !important; +} +// .u-1-3 { +// width: 33.3%; +// } +.column-item { + margin-bottom: 20px; +} + +.column-item .form-control { + width: 60% !important; +} + +.d-validate-tip { + display: flex; + justify-content: center; + align-items: center; + font-size: 12px; + color: #f66f6a; +} + +.d-validate-tip-horizontal { + margin-left: 10px; +} diff --git a/devui/form/src/form-item/form-item.tsx b/devui/form/src/form-item/form-item.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5d677c631949e1c9a6dfc5cd958e883c9db3be9d --- /dev/null +++ b/devui/form/src/form-item/form-item.tsx @@ -0,0 +1,105 @@ +import { defineComponent, reactive, inject, onMounted, onBeforeUnmount, provide, ref} from 'vue'; +import { dFormEvents, dFormItemEvents, IForm, formItemProps, formInjectionKey, formItemInjectionKey } from '../form-types'; +import './form-item.scss'; +import AsyncValidator, { Rules } from 'async-validator'; +import mitt from 'mitt'; + +export default defineComponent({ + name: 'DFormItem', + props: formItemProps, + setup(props, ctx) { + const formItemMitt = mitt(); + const dForm = reactive(inject(formInjectionKey, {} as IForm)); + const formData = reactive(dForm.formData); + const columnsClass = ref(dForm.columnsClass); + const initFormItemData = formData[props.prop]; + const labelData = reactive(dForm.labelData); + const rules = reactive(dForm.rules); + + const resetField = () => { + formData[props.prop] = initFormItemData; + } + + const formItem = reactive({ + dHasFeedback: props.dHasFeedback, + prop: props.prop, + formItemMitt, + resetField + }) + provide(formItemInjectionKey, formItem); + + const isHorizontal = labelData.layout === 'horizontal'; + const isVertical = labelData.layout === 'vertical'; + const isColumns = labelData.layout === 'columns'; + + const showMessage = ref(false); + const tipMessage = ref(''); + + const validate = (trigger: string) => { + console.log('trigger', trigger); + + const ruleKey = props.prop; + const ruleItem = rules[ruleKey]; + const descriptor: Rules = {}; + descriptor[ruleKey] = ruleItem; + + const validator = new AsyncValidator(descriptor); + + validator.validate({[ruleKey]: formData[ruleKey]}).then(() => { + showMessage.value = false; + tipMessage.value = ''; + }).catch(({ errors }) => { + console.log('validator errors', errors); + showMessage.value = true; + tipMessage.value = errors[0].message; + }); + } + const validateEvents = []; + + const addValidateEvents = () => { + if(rules && rules[props.prop]) { + const ruleItem = rules[props.prop]; + let eventName = ruleItem['trigger']; + + if(Array.isArray(ruleItem)) { + ruleItem.forEach((item) => { + eventName = item['trigger']; + const cb = () => validate(eventName); + validateEvents.push({eventName: cb}); + formItem.formItemMitt.on(dFormItemEvents[eventName], cb); + }); + }else { + const cb = () => validate(eventName); + validateEvents.push({eventName: cb}); + ruleItem && formItem.formItemMitt.on(dFormItemEvents[eventName], cb); + } + } + } + + const removeValidateEvents = () => { + if(rules && rules[props.prop] && validateEvents.length > 0) { + validateEvents.forEach(item => { + formItem.formItemMitt.off(item.eventName, item.cb); + }); + } + } + + onMounted(() => { + dForm.formMitt.emit(dFormEvents.addField, formItem); + addValidateEvents(); + }); + + onBeforeUnmount(() => { + dForm.formMitt.emit(dFormEvents.removeField, formItem); + removeValidateEvents(); + }); + return () => { + return ( +
+ {ctx.slots.default?.()} +
{showMessage.value && tipMessage.value}
+
+ ) + } + }, +}) \ No newline at end of file diff --git a/devui/form/src/form-label/form-label.scss b/devui/form/src/form-label/form-label.scss new file mode 100644 index 0000000000000000000000000000000000000000..d761f33beb49af6eb153d323f614f5271770ad34 --- /dev/null +++ b/devui/form/src/form-label/form-label.scss @@ -0,0 +1,54 @@ +.form-label { + // flex: 1 1 auto; + -moz-box-flex: 1; + text-align: left; + padding-bottom: 8px; + justify-content: flex-start; + align-self: flex-start; + margin-right: 16px; + + .devui-required { + display: inline-flex; + align-items: center; + + &::before { + content: '*'; + color: red; + display: inline-block; + margin-right: 8px; + margin-left: -12px; + } + } +} + +.form-label_sm { + width: 80px; + min-width: 80px; +} + +.form-label_sd { + width: 100px; + min-width: 100px; +} + +.form-label_lg { + width: 150px; + min-width: 150px; +} + +.form-label_center { + text-align: center; +} + +.form-label_end { + text-align: end; +} + +.form-label-help { + border-radius: 50%; + display: inline-flex; + justify-content: center; + align-items: center; + position: relative; + margin-left: 10px; +} diff --git a/devui/form/src/form-label/form-label.tsx b/devui/form/src/form-label/form-label.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b3a2fe9fc63a1fd79f03cbb42501785717d6bca6 --- /dev/null +++ b/devui/form/src/form-label/form-label.tsx @@ -0,0 +1,44 @@ +import { defineComponent, inject, reactive, computed } from 'vue'; +import { IForm, formLabelProps, FormLabelProps, formInjectionKey } from '../form-types'; +import './form-label.scss'; +import Icon from '../../../icon/src/icon'; +import Popover from '../../../popover/src/popover'; + +export default defineComponent({ + name: 'DFormLabel', + props: formLabelProps, + setup(props: FormLabelProps, ctx) { + const dForm = reactive(inject(formInjectionKey, {} as IForm)); + const labelData = reactive(dForm.labelData); + + const isHorizontal = computed(() => labelData.layout === 'horizontal').value; + const isLg = computed(() => labelData.labelSize === 'lg').value; + const isSm = computed(() => labelData.labelSize === 'sm').value; + const isCenter = computed(() => labelData.labelAlign === 'center').value; + const isEnd = computed(() => labelData.labelAlign === 'end').value; + + const wrapperCls = `form-label${isHorizontal ? (isSm ? ' form-label_sm' : (isLg ? ' form-label_lg' : ' form-label_sd')) : ''}${isCenter ? ' form-label_center' : (isEnd ? ' form-label_end' : '')}`; + const className = `${props.required ? ' devui-required' : ''}`; + const style = {display: isHorizontal ? 'inline' : 'inline-block'}; + + return () => { + return + + {ctx.slots.default?.()} + { + props.hasHelp && props.helpTips && ( + ( + + + + ) + }}> + + ) + } + + + } + } +}) \ No newline at end of file diff --git a/devui/form/src/form-operation/form-operation.scss b/devui/form/src/form-operation/form-operation.scss new file mode 100644 index 0000000000000000000000000000000000000000..3e4154caac02e9483e8f765baac5d4bf9c43f8ad --- /dev/null +++ b/devui/form/src/form-operation/form-operation.scss @@ -0,0 +1,5 @@ +.form-operation { + .star { + color: red; + } +} diff --git a/devui/form/src/form-operation/form-operation.tsx b/devui/form/src/form-operation/form-operation.tsx new file mode 100644 index 0000000000000000000000000000000000000000..643591cdf7d978285b78c1fe6a85693c747811b5 --- /dev/null +++ b/devui/form/src/form-operation/form-operation.tsx @@ -0,0 +1,16 @@ +import { defineComponent } from 'vue'; +import './form-operation.scss'; + +export default defineComponent({ + name: 'DFormOperation', + props: { + + }, + setup(props, ctx) { + return () => { + return
+ {ctx.slots.default?.()} +
+ } + } +}) \ No newline at end of file diff --git a/devui/form/src/form-types.ts b/devui/form/src/form-types.ts new file mode 100644 index 0000000000000000000000000000000000000000..662bfab3009698d01c20e0b42a3146a056eb41fc --- /dev/null +++ b/devui/form/src/form-types.ts @@ -0,0 +1,161 @@ +import { Emitter } from 'mitt' +import type { PropType, ExtractPropTypes, InjectionKey, Ref } from 'vue' + +export const formProps = { + formData: { + type: Object, + default: {} + }, + layout: { + type: String as PropType<'horizontal' | 'vertical' | 'columns'>, + default: 'horizontal', + }, + labelSize: { + type: String as PropType<'sm' | '' | 'lg'>, + default: '', + }, + labelAlign: { + type: String as PropType<'start' | 'center' | 'end'>, + default: 'start', + }, + rules: { + type: Object, + default: {}, + }, + columnsClass: { + type: String as PropType<'u-1-3'>, + default: '', + }, + name: { + type: String, + default: '', + }, +} as const + +export const formItemProps = { + dHasFeedback: { + type: Boolean, + default: false + }, + prop: { + type: String, + default: '' + } +} as const + +export const formLabelProps = { + required: { + type: Boolean, + default: false + }, + hasHelp: { + type: Boolean, + default: false + }, + helpTips: { + type: String, + default: '' + } +} as const + +export const formControlProps = { + feedbackStatus: { + type: String as PropType<'success' | 'error' | 'pending' | ''>, + default: '' + }, + extraInfo: { + type: String, + default: '' + } +} as const + +export const dFormEvents = { + addField: 'd.form.addField', + removeField: 'd.form.removeField', +} as const + +type LabelData = { + layout: string + labelSize: string + labelAlign: string +} + +export const formInjectionKey: InjectionKey = Symbol('dForm'); +export const formItemInjectionKey: InjectionKey = Symbol('dFormItem'); + +export const dFormItemEvents = { + blur: 'd.form.blur', + change: 'd.form.change', + input: 'd.form.input', +} as const + + +export interface IForm { + formData: any + labelData: IFormLabel + formMitt: Emitter + rules: any + columnsClass: string +} + +export interface IFormLabel { + layout: string + labelSize: string + labelAlign: string +} + +export interface IFormItem { + dHasFeedback: boolean + prop: string + formItemMitt: Emitter + resetField(): void +} + +export interface IFormControl { + feedbackStatus: string + extraInfo: string + formItemMitt: Emitter + resetField(): void +} + +export type FormProps = ExtractPropTypes +export type FormItemProps = ExtractPropTypes +export type FormLabelProps = ExtractPropTypes +export type FormControlProps = ExtractPropTypes + + +export interface IValidators { + required: boolean + minlength: number + maxlength: number + min: number + max: number + requiredTrue: boolean + email: boolean + pattern: RegExp + whiteSpace: boolean +} + +const Validators: IValidators = { + required: false, + minlength: 0, + maxlength: 0, + min: 0, + max: 0, + requiredTrue: false, + email: false, + pattern: undefined, + whiteSpace: false +} + +export const dDefaultValidators = { + 'required': Validators.required, // 配置不能为空限制,rule中使用:{ required: true } + 'minlength': Validators.minlength, // 配置最小长度限制,rule中使用:{ minlength: 5 } + 'maxlength': Validators.maxlength, // 配置最大长度限制,rule中使用:{ maxlength: 128 } + 'min': Validators.min, // 配置最小值限制,rule中使用:{ min: 0 } + 'max': Validators.max, // 配置最大值限制,rule中使用:{ max: 100 } + 'requiredTrue': Validators.requiredTrue, // 配置需要为true,rule中使用:{ requiredTrue: true } + 'email': Validators.email, // 配置邮箱校验,rule中使用:{ email: true } + 'pattern': Validators.pattern, // 配置正则校验,rule中使用:{ pattern: RegExp } + 'whitespace': Validators.whiteSpace, // 配置输入不能全为空格限制,rule中使用:{ whitespace: true } +}; diff --git a/devui/form/src/form.scss b/devui/form/src/form.scss new file mode 100644 index 0000000000000000000000000000000000000000..f2170c54cc9fd44cb2b29adc8e2436f7ee5539ef --- /dev/null +++ b/devui/form/src/form.scss @@ -0,0 +1,3 @@ +.d-form { + position: relative; +} diff --git a/devui/form/src/form.tsx b/devui/form/src/form.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8aa16ca21913030ede125bb7fa0dbcc09f039bcf --- /dev/null +++ b/devui/form/src/form.tsx @@ -0,0 +1,66 @@ +import './form.scss' + +import { defineComponent, provide } from 'vue' +import { formProps, FormProps, IFormItem, dFormEvents, formInjectionKey, IForm } from './form-types' +import mitt from 'mitt' +import EventBus from './util/event-bus' + +export default defineComponent({ + name: 'DForm', + props: formProps, + emits: ['submit'], + setup(props: FormProps, ctx) { + const formMitt = mitt(); + const fields: IFormItem[] = []; + const resetFormFields = () => { + fields.forEach((field: IFormItem) => { + field.resetField(); + }) + } + + formMitt.on(dFormEvents.addField, (field: any) => { + if(field) { + fields.push(field); + } + }) + + formMitt.on(dFormEvents.removeField, (field: any) => { + if(field.prop) { + fields.splice(fields.indexOf(field), 1); + } + }) + + provide(formInjectionKey, { + formData: props.formData, + formMitt, + labelData: { + layout: props.layout, + labelSize: props.labelSize, + labelAlign: props.labelAlign, + }, + rules: props.rules, + columnsClass: props.columnsClass + }); + + const onSubmit = (e) => { + e.preventDefault(); + ctx.emit('submit', e); + EventBus.emit(`formSubmit:${props.name}`); + } + + return { + fields, + formMitt, + onSubmit, + resetFormFields + } + }, + render() { + const {onSubmit} = this; + return ( +
+ {this.$slots.default?.()} +
+ ); + } +}) diff --git a/devui/form/src/util/event-bus.ts b/devui/form/src/util/event-bus.ts new file mode 100644 index 0000000000000000000000000000000000000000..3411bb73f8e37cfbd90a7f874fce1a24f652ae37 --- /dev/null +++ b/devui/form/src/util/event-bus.ts @@ -0,0 +1,3 @@ +import mitt from 'mitt'; + +export default mitt(); \ No newline at end of file diff --git a/devui/input/src/input.tsx b/devui/input/src/input.tsx index 0c3763e56f597e0a04a348ddb5b6efc07513510f..01331774e11c08a4fa12c5b692040e40878fa40f 100644 --- a/devui/input/src/input.tsx +++ b/devui/input/src/input.tsx @@ -1,6 +1,7 @@ -import { defineComponent, computed, ref, watch, nextTick, onMounted, toRefs } from 'vue'; +import { defineComponent, computed, ref, watch, nextTick, onMounted, toRefs, inject } from 'vue'; import { inputProps, InputType } from './use-input'; import './input.scss' +import { dFormItemEvents, IFormItem, formItemInjectionKey } from '../../form/src/form-types'; export default defineComponent({ name: 'DInput', @@ -16,6 +17,7 @@ export default defineComponent({ props: inputProps, emits: ['update:value', 'focus', 'blur', 'change', 'keydown'], setup(props, ctx) { + const formItem = inject(formItemInjectionKey, {} as IFormItem); const sizeCls = computed(() => `devui-input-${props.size}`); const showPwdIcon = ref(false) const inputType = ref('text') @@ -37,15 +39,18 @@ export default defineComponent({ const onInput = ($event: Event) => { ctx.emit('update:value', ($event.target as HTMLInputElement).value); + formItem.formItemMitt.emit(dFormItemEvents.input); }, onFocus = () => { ctx.emit('focus'); }, onBlur = () => { ctx.emit('blur'); + formItem.formItemMitt.emit(dFormItemEvents.blur); }, onChange = ($event: Event) => { ctx.emit('change', ($event.target as HTMLInputElement).value); + formItem.formItemMitt.emit(dFormItemEvents.change); }, onKeydown = ($event: KeyboardEvent) => { ctx.emit('keydown', $event); diff --git a/devui/popover/src/popover.scss b/devui/popover/src/popover.scss index 175657b0f5ce800af2978981ebc1bd8aa07c3cfa..4923d1df120a3b5b2bd847f84480266e35746997 100644 --- a/devui/popover/src/popover.scss +++ b/devui/popover/src/popover.scss @@ -52,7 +52,6 @@ $devui-popover-offset: 8px; } } - @keyframes some-animation { 0% { opacity: 0; @@ -77,7 +76,6 @@ $devui-popover-offset: 8px; } } - //left等方向 .devui-popover { @mixin left-postion--content { @@ -95,6 +93,7 @@ $devui-popover-offset: 8px; &.left { .devui-popover-content { @include left-postion--content; + top: 50%; transform: translate(-100%, -50%); } @@ -142,7 +141,7 @@ $devui-popover-offset: 8px; transform: translate(0, -100%); } @mixin top-postion--after { - bottom: $devui-popover-width; + bottom: $devui-popover-width; margin-left: $devui-popover-width; } @@ -190,7 +189,7 @@ $devui-popover-offset: 8px; } } - //right等方向 +//right等方向 .devui-popover { @mixin right-postion--content { right: $devui-popover-margin; @@ -240,6 +239,7 @@ $devui-popover-offset: 8px; .after { @include right-postion--after; + top: auto; bottom: $devui-popover-offset; } @@ -256,7 +256,7 @@ $devui-popover-offset: 8px; } @mixin bottom-postion--after { left: $devui-popover-offset; - top: $devui-popover-width; + top: $devui-popover-width; margin-bottom: $devui-popover-width; margin-right: $devui-popover-width; } diff --git a/docs/components/form/index.md b/docs/components/form/index.md new file mode 100644 index 0000000000000000000000000000000000000000..2c784e44c4468d17496859a9c3d978edf20e7213 --- /dev/null +++ b/docs/components/form/index.md @@ -0,0 +1,1650 @@ +# Form 表单 + +表单用于收集数据 + +### 何时使用 + +需要进行数据收集、数据校验、数据提交功能时。 + + + +### 基础用法 + +> done + +基本用法当中,Label是在数据框的上面。 + + +:::demo + +```vue + + + + + + + +``` + +::: + + +### 横向排列 + +> done + +Label左右布局方式。 + + +:::demo + +```vue + + + + + + + +``` + +::: + + +### 弹框表单 + +> todo
+> 待替换为Modal组件 + +弹框表单,弹框建议是400px,550px,700px,900px,建议宽高比是16: 9、3: 2。 + + +:::demo + +```vue + + + + + + + +``` + +::: + + +### 多列表单 + +> done + +多列表单。layout的属性为`columns`,同时搭配columnsClass属性,值为"u-[row]-[col]",例如`u-1-3`为1行3列。 + + +:::demo + +```vue + + + + + + +``` + +::: + + + +### 模板驱动表单验证 + +> doing + +在`d-form`、`d-input`等表单类组件上使用`v-d-validate-rules`指令,配置校验规则。 + + +#### 验证单个元素,使用内置校验器,配置error message + +> done +> +> 待支持国际化词条配置 + +当前DevUI支持的内置校验器有:`required`、`minlength`、`maxlength`、`min`、`max`、`requiredTrue`、`email`、`pattern`、`whitespace`。
+ +- 若需限制用户输入不能全为空格,可使用`whitespace`内置校验器
+- 若需限制用户输入长度,将最大限制设置为实际校验值`+1`是一个好的办法。
+- 除`pattern`外,其他内置校验器我们也提供了内置的错误提示信息,在你未自定义提示消息时,我们将使用默认的提示信息。
+- message配置支持string与object两种形式(支持国际化词条配置,如`'zh-cn'`,默认将取`'default'`)。 + +:::demo + +```vue + + + + + + + +``` + +::: + +#### 验证单个元素,自定义校验器 + +> done + +自定义校验器,可传入`validators`字段配置校验规则,你可以简单返回`true | false `来标识当前校验是否通过,来标识当前是否错误并返回错误消息,适用于动态错误提示。如果是异步校验器,可传入`asyncValidators`字段配置校验规则。 + +:::demo + +```vue + + + + + + + +``` + +::: + + +#### 验证单个元素,配置错误更新策略errorStrategy、校验时机updateOn + +> done + +设置`errorStrategy`属性初始化时是否进行校验, 默认配置为`dirty`,校验不通过进行错误提示;若需要在初始化时将错误抛出,可配置为`pristine`。 + +设置`updateOn`,指定校验的时机。 校验器`updateOn`基于你绑定的模型的`updateOn`设置, 你可以通过`options`来指定, 默认为`change`,可选值还有`blur` 、`input`、`submit`、 设置为`submit`,则当元素所在表单进行提交时将触发校验。(待实现submit) + +:::demo + +```vue + + + + + + + +``` + +::: + + +#### 验证单个元素,自定义管理消息提示 + +> doing +> +> 待引入popover组件 + +配置`messageShowType`可选择消息自动提示的方式,默认为`popover`。 + +- 设置为`popover`错误信息将在元素聚焦时以`popover`形式呈现。 +- 设置为`text`错误信息将自动以文本方式显示在元素下方(需要与表单控件容器配合使用)。 +- 设置为`none`错误信息将不会自动呈现到视图, 可在模板中获取`message`或通过监听`messageChange`事件获取错误`message`, 或在模板中直接通过引用获取。 +- 配置`popPosition`可在消息提示方式为`popover`时,自定义`popover`内容弹出方向。 默认为`['right', 'bottom']`。 + +:::demo + +```vue + + + + + + + +``` + +::: + + + +#### 验证单个元素,自定义asyncDebounceTime + +> done + + +对于异步校验器,提供默认300ms debounce time。在options中设置`asyncDebounceTime`显示设置(单位ms)。 + + +:::demo + +```vue + + + + + + + +``` + +::: + + + +#### Form验证与提交 + +> done + +点击提交按钮时进行验证,需指定name属性,并同时绑定d-form标签的submit事件才能生效。 + +:::demo + +```vue + + + + + + + +``` + +::: + + +#### Form验证与提交,用户注册场景 + +> doing + +待实现在dForm层统一设置messageShowType。 + +:::demo + +```vue + + + + + + + +``` + +::: + +### 响应式表单验证 + +> done + +在`d-form`标签中指定校验规则rules,同时在`d-form-item`中指定`prop`的值为校验字段名。 + + +:::demo + +```vue + + + + + + + +``` + +::: + + +### 指定表单Feedback状态 + +> done + +你可通过对d-form-control设置feedbackStatus手动指定反馈状态。当前已支持状态:`success`、`error`、`pending`。 + + +:::demo + +```vue + + + + + + + +``` + +::: + + +可通过对具名插槽suffixTemplate在d-form-control中自定义反馈状态icon。 + + +:::demo + +```vue + + + + +``` + +::: + + +### 表单协同验证 + +> done + +在一些场景下,你的多个表单组件互相依赖,需共同校验(如注册场景中的密码输入与确认密码),通过自定义校验器实现校验规则(将密码输入与确认密码的值进行比较)。 + + +:::demo + +```vue + + + + + + +``` + +::: + +### 跨组件验证 + +> todo + + + + +:::demo + +```vue + + + + + + +``` +::: + + + +### Form Attributes + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| ------------ | ------------------------------------------------------------ | ------ | ----------------------------------- | ------------ | +| name | 可选,设置表单name属性,进行表单提交验证时必选。 | string | | | +| formData | 必选,表单数据 | object | | | +| layout | 可选,设置表单的排列方式 | string | `horizontal`、`vertical`、`columns` | `horizontal` | +| labelSize | 可选,设置 label 的占宽,未设置默认为 100px,'sm'对应 80px,'lg'对应 150px | string | `sm`、`lg` | -- | +| labelAlign | 可选,设置水平布局方式下,label 对齐方式 | string | `start`、`center`、`end` | `start` | +| columnsClass | 可选,设置多列表单样式 | string | | | +| rules | 可选,设置表单校验规则 | object | | | + + + +### Form Methods + +| 方法名 | 说明 | 参数 | +| ------ | ------------------ | ---- | +| submit | 可选,提交表单事件 | -- | + + + +### Form-Item Attributes + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| ------------ | ---------------------------------------------------- | ------- | --------------- | ------- | +| prop | 可选,指定验证表单需验证的字段,验证表单时必选该属性 | | | | +| dHasFeedback | 可选,设置当前 formControl 是否显示反馈图标 | boolean | `true`、`false` | `false` | + + + +### Form-Lable Attributes + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| -------- | ------------------------------------------------------------ | ------- | --------------- | ------- | +| required | 可选,表单选项是否必填 | boolean | `true`、`false` | `false` | +| hasHelp | 可选,表单项是否需要帮助指引 | boolean | `true`、`false` | `false` | +| helpTips | 可选,表单项帮助指引提示内容,需配合 `hasHelp`使用,且`helpTips`的值不能为空字符串才会生效。 | string | | -- | + + + +### Form-Control Attributes + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| -------------- | ---------------------------------------------------------- | ------- | --------------- | ------- | +| extraInfo | 可选,附件信息,一般用于补充表单选项的说明 | string | | -- | +| feedbackStatus | 可选,手动指定当前 control 状态反馈 | boolean | `true`、`false` | `false` | +| suffixTemplate | 可选,可传入图标模板作为输入框后缀(通过插槽传入icon组件) | | | -- | + + + +### Directives + +#### v-d-validate-rules + +| 参数 | 说明 | 类型 | 可选值 | 默认值 | +| ------- | ------------------ | ------ | --------------------------- | ------ | +| rules | 必选,表单校验规则 | object | | -- | +| options | 可选,配置选项 | object | `errorStrategy`、`updateOn` | | + +> 该指令仅在`d-form`标签或`d-input`等表单类组件上使用有效。 + + + +- rules格式如下 + +```js +{[validatorKey]: validatorValue, message: 'some tip messages.'} +``` + +当前DevUI支持的内置校验器validatorKey有:`required`、`minlength`、`maxlength`、`min`、`max`、`requiredTrue`、`email`、`pattern`、`whitespace`。
+ + + +
+ +- options支持以下字段 + - errorStrategy,错误更新策略:`dirty`(默认)、`prestine` + + - updateOn,校验时机,可选值有:`change`(默认)、 `blur`、 `input` + + + diff --git a/package.json b/package.json index d3765b4b29690988270f3b1023ce7037147aa2b1..e23fe6d232d815b682c9555d17a36b460e9f4b85 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,9 @@ "dependencies": { "@devui-design/icons": "^1.3.0", "@types/lodash-es": "^4.17.4", + "async-validator": "^4.0.2", "lodash-es": "^4.17.20", + "mitt": "^3.0.0", "vue": "^3.1.1", "vue-router": "^4.0.3" },