diff --git a/src/form/__test__/__snapshots__/demo.test.jsx.snap b/src/form/__test__/__snapshots__/demo.test.jsx.snap index 657b1a978..0f154be90 100644 --- a/src/form/__test__/__snapshots__/demo.test.jsx.snap +++ b/src/form/__test__/__snapshots__/demo.test.jsx.snap @@ -718,10 +718,9 @@ exports[`Form > Form horizontalVue demo works fine 1`] = ` @@ -971,10 +970,9 @@ exports[`Form > Form horizontalVue demo works fine 1`] = ` @@ -2407,10 +2405,9 @@ exports[`Form > Form mobileVue demo works fine 1`] = ` @@ -2660,10 +2657,9 @@ exports[`Form > Form mobileVue demo works fine 1`] = ` @@ -3959,10 +3955,9 @@ exports[`Form > Form verticalVue demo works fine 1`] = ` @@ -4210,10 +4205,9 @@ exports[`Form > Form verticalVue demo works fine 1`] = ` diff --git a/src/form/demos/horizontal.vue b/src/form/demos/horizontal.vue index 13bf1b8aa..a9da04206 100644 --- a/src/form/demos/horizontal.vue +++ b/src/form/demos/horizontal.vue @@ -7,6 +7,7 @@ show-error-message label-align="left" :disabled="disabled" + scroll-to-first-error="auto" @reset="onReset" @submit="onSubmit" > diff --git a/src/form/demos/vertical.vue b/src/form/demos/vertical.vue index 69cad00d2..e3cbb8b27 100644 --- a/src/form/demos/vertical.vue +++ b/src/form/demos/vertical.vue @@ -7,6 +7,7 @@ show-error-message label-align="top" :disabled="disabled" + scroll-to-first-error="auto" @reset="onReset" @submit="onSubmit" > diff --git a/src/form/form-item.tsx b/src/form/form-item.tsx index 83ecafd40..8bb926257 100644 --- a/src/form/form-item.tsx +++ b/src/form/form-item.tsx @@ -9,8 +9,6 @@ import { ref, toRefs, watch, - getCurrentInstance, - h, } from 'vue'; import isArray from 'lodash/isArray'; import isNumber from 'lodash/isNumber'; @@ -22,7 +20,6 @@ import lodashSet from 'lodash/set'; import isNil from 'lodash/isNil'; import lodashTemplate from 'lodash/template'; import { ChevronRightIcon } from 'tdesign-icons-vue-next'; -import { renderTNode, TNode } from '../shared'; import { validate } from './form-model'; import { @@ -40,25 +37,24 @@ import { ErrorListType, FormInjectionKey, FormItemContext, - FormItemInjectionKey, SuccessListType, ValidateStatus, } from './const'; import config from '../config'; import { useTNodeJSX } from '../hooks/tnode'; +import { usePrefixClass } from '../hooks/useClass'; const { prefix } = config; -const name = `${prefix}-form-item`; -const classPrefix = `${prefix}-form__item`; export type FormItemValidateResult = { [key in keyof T]: boolean | AllValidateResult[] }; export default defineComponent({ - name, - components: { TNode }, + name: `${prefix}-form-item`, props, setup(props, { slots }) { const renderTNodeJSX = useTNodeJSX(); + const formClass = usePrefixClass('form'); + const formItemClass = usePrefixClass('form__item'); const { name } = toRefs(props); const form = inject(FormInjectionKey, undefined); @@ -74,11 +70,11 @@ export default defineComponent({ return null; }); - const formItemClass = computed(() => [ - `${prefix}-form__item`, - `${prefix}-form__item--bordered`, - `${prefix}-form--${labelAlign.value}`, - `${prefix}-form-item__${props.name}`, + const formItemClasses = computed(() => [ + formItemClass.value, + `${formItemClass.value}--bordered`, + `${formClass.value}--${labelAlign.value}`, + `${formClass.value}-item__${props.name}`, ]); const needRequiredMark = computed(() => { @@ -89,19 +85,19 @@ export default defineComponent({ const hasLabel = computed(() => slots.label || props.label); const hasColon = computed(() => !!(form?.colon && hasLabel.value)); - const FROM_LABEL = `${prefix}-form__label`; + const labelClass = `${formClass.value}__label`; const labelAlign = computed(() => (isNil(props.labelAlign) ? form?.labelAlign : props.labelAlign)); const labelWidth = computed(() => (isNil(props.labelWidth) ? form?.labelWidth : props.labelWidth)); const contentAlign = computed(() => (isNil(props.contentAlign) ? form?.contentAlign : props.contentAlign)); const labelClasses = computed(() => [ - `${prefix}-form__label`, + labelClass, { - [`${FROM_LABEL}--required`]: needRequiredMark.value, - [`${FROM_LABEL}--colon`]: hasColon.value, - [`${FROM_LABEL}--top`]: hasLabel.value && (labelAlign.value === 'top' || !labelWidth.value), - [`${FROM_LABEL}--left`]: labelAlign.value === 'left' && labelWidth.value, - [`${FROM_LABEL}--right`]: labelAlign.value === 'right' && labelWidth.value, + [`${labelClass}--required`]: needRequiredMark.value, + [`${labelClass}--colon`]: hasColon.value, + [`${labelClass}--top`]: hasLabel.value && (labelAlign.value === 'top' || !labelWidth.value), + [`${labelClass}--left`]: labelAlign.value === 'left' && labelWidth.value, + [`${labelClass}--right`]: labelAlign.value === 'right' && labelWidth.value, }, ]); @@ -123,13 +119,13 @@ export default defineComponent({ if (!showErrorMessage.value) return ''; if (!errorList.value.length) return ''; const type = errorList.value[0].type || 'error'; - return type === 'error' ? `${classPrefix}--error` : `${classPrefix}--warning`; + return type === 'error' ? `${formItemClass.value}--error` : `${formItemClass.value}--warning`; }); - const contentClasses = computed(() => [`${prefix}-form__controls`, errorClasses.value]); + const contentClasses = computed(() => [`${formClass.value}__controls`, errorClasses.value]); const contentSlotClasses = computed(() => [ - `${prefix}-form__controls-content`, - `${prefix}-form__controls--${contentAlign.value}`, + `${formClass.value}__controls-content`, + `${formClass.value}__controls--${contentAlign.value}`, ]); const contentStyle = computed(() => { @@ -321,7 +317,7 @@ export default defineComponent({ if (!props.arrow) { return null; } - return h(ChevronRightIcon, { size: '24px', color: 'rgba(0, 0, 0, .4)' }); + return ; }; const renderLabelContent = () => { if (Number(labelWidth.value) === 0) { @@ -334,22 +330,26 @@ export default defineComponent({ if (!helpNode) { return null; } - return
{helpNode}
; + return ( +
+ {helpNode} +
+ ); }; const renderExtraNode = () => { if (!extraNode.value) { return null; } return ( -
+
{extraNode.value}
); }; return ( -
-
+
+
diff --git a/src/form/form.en-US.md b/src/form/form.en-US.md index 08f635f49..1b0d45e1d 100644 --- a/src/form/form.en-US.md +++ b/src/form/form.en-US.md @@ -7,20 +7,18 @@ name | type | default | description | required -- | -- | -- | -- | -- colon | Boolean | false | \- | N +contentAlign | String | left | options: left/right | N data | Object | {} | Typescript:`FormData` | N disabled | Boolean | undefined | \- | N errorMessage | Object | - | Typescript:`FormErrorMessage` | N formControlledComponents | Array | - | Typescript:`Array` | N -labelAlign | String | right | options:left/right/top | N +labelAlign | String | right | options: left/right/top | N labelWidth | String / Number | '81px' | \- | N -layout | String | vertical | options:vertical/inline | N -preventSubmitDefault | Boolean | true | \- | N requiredMark | Boolean | undefined | \- | N -resetType | String | empty | options:empty/initial | N +resetType | String | empty | options: empty/initial | N rules | Object | - | Typescript:`FormRules` `type FormRules = { [field in keyof T]?: Array }`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/form/type.ts) | N -scrollToFirstError | String | - | options:smooth/auto | N +scrollToFirstError | String | - | options: ''/smooth/auto | N showErrorMessage | Boolean | true | \- | N -statusIcon | Boolean / Slot / Function | undefined | Typescript:`boolean \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N submitWithWarningMessage | Boolean | false | \- | N onReset | Function | | Typescript:`(context: { e?: FormResetEvent }) => void`
| N onSubmit | Function | | Typescript:`(context: SubmitContext) => void`
[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/form/type.ts)。
`interface SubmitContext { e?: FormSubmitEvent; validateResult: FormValidateResult; firstError?: string; fields?: any }`

`type FormValidateResult = boolean \| ValidateResultObj`

`type ValidateResultObj = { [key in keyof T]: boolean \| ValidateResultList }`

`type ValidateResultList = Array`

`type AllValidateResult = CustomValidateObj \| ValidateResultType`

`interface ValidateResultType extends FormRule { result: boolean }`

`type ValidateResult = { [key in keyof T]: boolean \| ErrorList }`

`type ErrorList = Array`
| N @@ -45,23 +43,21 @@ submit | `(params?: { showErrorMessage?: boolean })` | \- | required validate | `(params?: FormValidateParams)` | `Promise>` | required。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/form/type.ts)。
`interface FormValidateParams { fields?: Array; showErrorMessage?: boolean; trigger?: ValidateTriggerType }`

`type ValidateTriggerType = 'blur' \| 'change' \| 'all'`
validateOnly | `(params?: Pick)` | `Promise>` | required + ### FormItem Props name | type | default | description | required -- | -- | -- | -- | -- +contentAlign | String | left | options: left/right | N for | String | - | \- | N help | String / Slot / Function | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N label | String / Slot / Function | '' | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N -labelAlign | String | - | options:left/right/top | N +labelAlign | String | - | options: left/right/top | N labelWidth | String / Number | - | \- | N name | String / Number | - | Typescript:`string \| number` | N requiredMark | Boolean | undefined | \- | N rules | Array | - | Typescript:`Array` | N showErrorMessage | Boolean | undefined | \- | N -status | String | - | Typescript:`'error' \| 'warning' \| 'success' \| 'validating'` | N -statusIcon | Boolean / Slot / Function | undefined | Typescript:`boolean \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N -successBorder | Boolean | false | \- | N -tips | String / Slot / Function | - | Typescript:`string \| TNode`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N ### FormRule @@ -80,8 +76,8 @@ number | Boolean | - | \- | N pattern | Object | - | Typescript:`RegExp` | N required | Boolean | - | \- | N telnumber | Boolean | - | \- | N -trigger | String | change | options:change/blur | N -type | String | error | options:error/warning | N +trigger | String | change | options: change/blur | N +type | String | error | options: error/warning | N url | Boolean / Object | - | Typescript:`boolean \| IsURLOptions` `import { IsURLOptions } from 'validator/es/lib/isURL'`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/form/type.ts) | N validator | Function | - | Typescript:`CustomValidator` `type CustomValidator = (val: ValueType) => CustomValidateResolveType \| Promise` `type CustomValidateResolveType = boolean \| CustomValidateObj` `interface CustomValidateObj { result: boolean; message: string; type?: 'error' \| 'warning' \| 'success' }` `type ValueType = any`。[see more ts definition](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/form/type.ts) | N whitespace | Boolean | - | \- | N diff --git a/src/form/form.md b/src/form/form.md index 9604dee96..de0e24d9c 100644 --- a/src/form/form.md +++ b/src/form/form.md @@ -4,20 +4,20 @@ ### Form Props -名称 | 类型 | 默认值 | 说明 | 必传 +名称 | 类型 | 默认值 | 描述 | 必传 -- | -- | -- | -- | -- colon | Boolean | false | 是否在表单标签字段右侧显示冒号 | N +contentAlign | String | left | 表单内容对齐方式:左对齐、右对齐。可选项:left/right | N data | Object | {} | 表单数据。TS 类型:`FormData` | N disabled | Boolean | undefined | 是否禁用整个表单 | N errorMessage | Object | - | 表单错误信息配置,示例:`{ idcard: '请输入正确的身份证号码', max: '字符长度不能超过 ${max}' }`。TS 类型:`FormErrorMessage` | N formControlledComponents | Array | - | 【开发中】允许表单统一控制禁用状态的自定义组件名称列表。默认会有组件库的全部输入类组件:TInput、TSwitch、TRadio、TRadioGroup、TUpload、TSlider。对于自定义组件,组件内部需要包含可以控制表单禁用状态的变量 `formDisabled`。示例:`['CustomUpload', 'CustomInput']`。TS 类型:`Array` | N labelAlign | String | right | 表单字段标签对齐方式:左对齐、右对齐、顶部对齐。可选项:left/right/top | N labelWidth | String / Number | '81px' | 可以整体设置label标签宽度,默认为81px | N -contentAlign | String | left | 表单内容对齐方式:左对齐、右对齐。可选项:left/right | N requiredMark | Boolean | undefined | 是否显示必填符号(*),默认显示 | N resetType | String | empty | 重置表单的方式,值为 empty 表示重置表单为空,值为 initial 表示重置表单数据为初始值。可选项:empty/initial | N rules | Object | - | 表单字段校验规则。TS 类型:`FormRules` `type FormRules = { [field in keyof T]?: Array }`。[详细类型定义](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/form/type.ts) | N -scrollToFirstError | String | - | 【开发中】表单校验不通过时,是否自动滚动到第一个校验不通过的字段,平滑滚动或是瞬间直达。值为空则表示不滚动。可选项:smooth/auto | N +scrollToFirstError | String | - | 表单校验不通过时,是否自动滚动到第一个校验不通过的字段,平滑滚动或是瞬间直达。值为空则表示不滚动。可选项:''/smooth/auto | N showErrorMessage | Boolean | true | 校验不通过时,是否显示错误提示信息,统一控制全部表单项。如果希望控制单个表单项,请给 FormItem 设置该属性 | N submitWithWarningMessage | Boolean | false | 【讨论中】当校验结果只有告警信息时,是否触发 `submit` 提交事件 | N onReset | Function | | TS 类型:`(context: { e?: FormResetEvent }) => void`
表单重置时触发 | N @@ -43,23 +43,25 @@ submit | `(params?: { showErrorMessage?: boolean })` | \- | 必需。提交表 validate | `(params?: FormValidateParams)` | `Promise>` | 必需。校验函数,包含错误文本提示等功能。泛型 `FormData` 表示表单数据 TS 类型。
【关于参数】`params.fields` 表示校验字段,如果设置了 `fields`,本次校验将仅对这些字段进行校验。`params.trigger` 表示本次触发校验的范围,'params.trigger = blur' 表示只触发校验规则设定为 trigger='blur' 的字段,'params.trigger = change' 表示只触发校验规则设定为 trigger='change' 的字段,默认触发全范围校验。`params.showErrorMessage` 表示校验结束后是否显示错误文本提示,默认显示。
【关于返回值】返回值为 true 表示校验通过;如果校验不通过,返回值为校验结果列表。[详细类型定义](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/form/type.ts)。
`interface FormValidateParams { fields?: Array; showErrorMessage?: boolean; trigger?: ValidateTriggerType }`

`type ValidateTriggerType = 'blur' \| 'change' \| 'all'`
validateOnly | `(params?: Pick)` | `Promise>` | 必需。纯净的校验函数,仅返回校验结果,不对组件进行任何操作。泛型 `FormData` 表示表单数据 TS 类型。参数和返回值含义同 `validate` 方法 + ### FormItem Props -名称 | 类型 | 默认值 | 说明 | 必传 +名称 | 类型 | 默认值 | 描述 | 必传 -- | -- | -- | -- | -- +contentAlign | String | left | 表单内容对齐方式:左对齐、右对齐。可选项:left/right | N for | String | - | label 原生属性 | N help | String / Slot / Function | - | 表单项说明内容。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N label | String / Slot / Function | '' | 字段标签名称。TS 类型:`string \| TNode`。[通用类型定义](https://github.com/Tencent/tdesign-mobile-vue/blob/develop/src/common.ts) | N labelAlign | String | - | 表单字段标签对齐方式:左对齐、右对齐、顶部对齐。默认使用 Form 的对齐方式,优先级高于 Form.labelAlign。可选项:left/right/top | N labelWidth | String / Number | - | 可以整体设置标签宽度,优先级高于 Form.labelWidth | N -contentAlign | String | left | 表单内容对齐方式:左对齐、右对齐。可选项:left/right | N name | String / Number | - | 表单字段名称。TS 类型:`string \| number` | N requiredMark | Boolean | undefined | 是否显示必填符号(*),优先级高于 Form.requiredMark | N rules | Array | - | 表单字段校验规则。TS 类型:`Array` | N showErrorMessage | Boolean | undefined | 校验不通过时,是否显示错误提示信息,优先级高于 `Form.showErrorMessage` | N + ### FormRule -名称 | 类型 | 默认值 | 说明 | 必传 +名称 | 类型 | 默认值 | 描述 | 必传 -- | -- | -- | -- | -- boolean | Boolean | - | 内置校验方法,校验值类型是否为布尔类型,示例:`{ boolean: true, message: '数据类型必须是布尔类型' }` | N date | Boolean / Object | - | 内置校验方法,校验值是否为日期格式,[参数文档](https://github.com/validatorjs/validator.js),示例:`{ date: { delimiters: '-' }, message: '日期分隔线必须是短横线(-)' }`。TS 类型:`boolean \| IsDateOptions` `interface IsDateOptions { format: string; strictMode: boolean; delimiters: string[] }`。[详细类型定义](https://github.com/Tencent/tdesign-mobile-vue/tree/develop/src/form/type.ts) | N @@ -82,7 +84,7 @@ whitespace | Boolean | - | 内置校验方法,校验值是否为空格。示 ### FormErrorMessage -名称 | 类型 | 默认值 | 说明 | 必传 +名称 | 类型 | 默认值 | 描述 | 必传 -- | -- | -- | -- | -- boolean | String | - | 布尔类型校验不通过时的表单项显示文案,全局配置默认是:`'${name}数据类型必须是布尔类型'` | N date | String | - | 日期校验规则不通过时的表单项显示文案,全局配置默认是:`'请输入正确的${name}'` | N diff --git a/src/form/form.tsx b/src/form/form.tsx index 21dd0ac62..5c28855c4 100644 --- a/src/form/form.tsx +++ b/src/form/form.tsx @@ -1,4 +1,4 @@ -import { computed, defineComponent, provide, reactive, ref, toRefs } from 'vue'; +import { defineComponent, provide, reactive, ref, toRefs } from 'vue'; import isEmpty from 'lodash/isEmpty'; import isArray from 'lodash/isArray'; import isBoolean from 'lodash/isBoolean'; @@ -10,6 +10,7 @@ import { FormValidateParams, FormValidateResult, TdFormProps, + ValidateResultList, } from './type'; import props from './props'; import { FormInjectionKey, FormItemContext } from './const'; @@ -19,9 +20,9 @@ import { renderContent } from '../shared'; import { preventDefault } from '../shared/dom'; import { FormItemValidateResult } from './form-item'; import { useTNodeJSX } from '../hooks/tnode'; +import { usePrefixClass } from '../hooks/useClass'; const { prefix } = config; -const name = `${prefix}-form`; type FormResetEvent = Event; // export type FormSubmitEvent = SubmitEvent; (for higher typescript version) @@ -41,7 +42,7 @@ export const requestSubmit = (target: HTMLFormElement) => { }; export default defineComponent({ - name, + name: `${prefix}-form`, props, setup(props, { expose }) { const renderTNodeJSX = useTNodeJSX(); @@ -59,8 +60,7 @@ export default defineComponent({ resetType, } = toRefs(props); - // @ts-ignore - const formRef = ref(null); + const formRef = ref(); const children = ref([]); provide('formDisabled', { @@ -85,7 +85,7 @@ export default defineComponent({ }), ); - const formClass = computed(() => [name]); + const formClass = usePrefixClass('form'); const needValidate = (name: string | number, fields: string[] | undefined) => { if (!fields || !isArray(fields)) return true; @@ -113,9 +113,26 @@ export default defineComponent({ return result; }; - const getFirstError = (r: Result) => { - if (isBoolean(r)) return ''; - return r?.[Object.keys(r)?.[0]]?.[0]?.message || ''; + const getFirstError = (result: Result) => { + if (isBoolean(result)) return ''; + + const [firstKey] = Object.keys(result); + if (props.scrollToFirstError) { + const tmpClassName = `${formClass.value}-item__${firstKey}`; + scrollTo(tmpClassName); + } + const resArr = result[firstKey] as ValidateResultList; + if (!isArray(resArr)) return ''; + + return result?.[Object.keys(result)?.[0]]?.[0]?.message || ''; + }; + // 校验不通过时,滚动到第一个错误表单 + const scrollTo = (selector: string) => { + const [dom] = formRef.value.getElementsByClassName(selector); + const behavior = props.scrollToFirstError; + if (behavior) { + dom && dom.scrollIntoView({ behavior }); + } }; const submitParams = ref>(); const onSubmit = (e?: FormSubmitEvent) => {