diff --git a/packages/cdk/forms/__tests__/abstractControl.spec.ts b/packages/cdk/forms/__tests__/abstractControl.spec.ts index 06a688ef4..e4e8b7f95 100644 --- a/packages/cdk/forms/__tests__/abstractControl.spec.ts +++ b/packages/cdk/forms/__tests__/abstractControl.spec.ts @@ -1,7 +1,7 @@ import { flushPromises } from '@vue/test-utils' import { Ref, ref, watch } from 'vue' import { AbstractControl } from '../src/controls/abstractControl' -import { AsyncValidatorFn, ValidationErrors, ValidatorFn, ValidatorOptions } from '../src/types' +import { AsyncValidatorFn, ValidateErrors, ValidatorFn, ValidatorOptions } from '../src/types' import { Validators } from '../src/validators' class Control extends AbstractControl { @@ -27,7 +27,7 @@ class Control extends AbstractControl { markAsUnblurred(): void {} markAsDirty(): void {} markAsPristine(): void {} - async validate(): Promise { + async validate(): Promise { return this._validate() } private _watchEffect() { @@ -71,21 +71,21 @@ describe('abstractControl.ts', () => { const { required, minLength, email } = Validators control.setValidator(required) - expect(await control.validate()).toEqual({ required: { message: '' } }) + expect(await control.validate()).toEqual({ required: { message: null } }) control.setValidator([email, minLength(5)]) control.setValue('test') expect(await control.validate()).toEqual({ - email: { message: '', actual: 'test' }, - minLength: { message: '', minLength: 5, actual: 4 }, + email: { message: null, actual: 'test' }, + minLength: { message: null, minLength: 5, actual: 4 }, }) }) test('setAsyncValidator work', async () => { const _asyncValidator = (key: string, error: unknown): AsyncValidatorFn => { return (_: unknown) => { - return Promise.resolve({ [key]: error } as ValidationErrors) + return Promise.resolve({ [key]: error } as ValidateErrors) } } const message1 = { message: 1 } @@ -105,7 +105,7 @@ describe('abstractControl.ts', () => { expect(control.getError('required')).toBeNull() expect(control.hasError('required')).toEqual(false) - const errors = { required: { message: '' } } + const errors = { required: { message: null } } control.setErrors(errors) expect(control.errors.value).toEqual(errors) @@ -200,7 +200,7 @@ describe('abstractControl.ts', () => { test('options work', async () => { control = new Control({ validators: Validators.required }) - expect(await control.validate()).toEqual({ required: { message: '' } }) + expect(await control.validate()).toEqual({ required: { message: null } }) expect(control.trigger).toEqual('change') @@ -210,10 +210,10 @@ describe('abstractControl.ts', () => { }) test('validators work', async () => { - const _asyncValidator = (_: unknown) => Promise.resolve({ async: { message: 'async' } } as ValidationErrors) + const _asyncValidator = (_: unknown) => Promise.resolve({ async: { message: 'async' } } as ValidateErrors) control = new Control(Validators.required, _asyncValidator) - expect(await control.validate()).toEqual({ required: { message: '' } }) + expect(await control.validate()).toEqual({ required: { message: null } }) control.setValue('test') control.validate() diff --git a/packages/cdk/forms/__tests__/formArray.spec.ts b/packages/cdk/forms/__tests__/formArray.spec.ts index e563ea556..73a3028f6 100644 --- a/packages/cdk/forms/__tests__/formArray.spec.ts +++ b/packages/cdk/forms/__tests__/formArray.spec.ts @@ -2,7 +2,7 @@ import { flushPromises } from '@vue/test-utils' import { FormArray } from '../src/controls/formArray' import { FormControl } from '../src/controls/formControl' import { FormGroup } from '../src/controls/formGroup' -import { ValidationErrors } from '../src/types' +import { ValidateErrors } from '../src/types' import { Validators } from '../src/validators' interface BasicGroup { @@ -35,12 +35,12 @@ describe('formArray.ts', () => { test('push work', async () => { const group = newFormGroup() - expect(array.length).toEqual(1) + expect(array.length.value).toEqual(1) expect(array.getValue()).toEqual([basicValue]) array.push(group) - expect(array.length).toEqual(2) + expect(array.length.value).toEqual(2) expect(array.at(0)).not.toEqual(group) expect(array.at(1)).toEqual(group) expect(array.getValue()).toEqual([basicValue, basicValue]) @@ -55,18 +55,18 @@ describe('formArray.ts', () => { const group1 = newFormGroup() const group2 = newFormGroup() - expect(array.length).toEqual(1) + expect(array.length.value).toEqual(1) expect(array.getValue()).toEqual([basicValue]) array.insert(0, group1) - expect(array.length).toEqual(2) + expect(array.length.value).toEqual(2) expect(array.at(0)).toEqual(group1) expect(array.getValue()).toEqual([basicValue, basicValue]) array.insert(0, group2) - expect(array.length).toEqual(3) + expect(array.length.value).toEqual(3) expect(array.at(0)).toEqual(group2) expect(array.getValue()).toEqual([basicValue, basicValue, basicValue]) @@ -76,17 +76,17 @@ describe('formArray.ts', () => { }) test('removeAt work', async () => { - expect(array.length).toEqual(1) + expect(array.length.value).toEqual(1) expect(array.getValue()).toEqual([basicValue]) array.removeAt(1) - expect(array.length).toEqual(1) + expect(array.length.value).toEqual(1) expect(array.getValue()).toEqual([basicValue]) const group = array.at(0) array.removeAt(0) - expect(array.length).toEqual(0) + expect(array.length.value).toEqual(0) expect(array.getValue()).toEqual([]) group.markAsBlurred() @@ -168,15 +168,15 @@ describe('formArray.ts', () => { test('validate work', async () => { expect(await array.validate()).toBeNull() - const _validator = (_: unknown) => ({ test: { message: '' } } as ValidationErrors) + const _validator = (_: unknown) => ({ test: { message: null } } as ValidateErrors) array.setValidator(_validator) - expect(await array.validate()).toEqual({ test: { message: '' } }) + expect(await array.validate()).toEqual({ test: { message: null } }) }) test('get work', async () => { - const [group] = array.controls + const [group] = array.controls.value expect(array.get('0')).toEqual(group) expect(array.get([0])).toEqual(group) @@ -191,7 +191,7 @@ describe('formArray.ts', () => { let array: FormArray // eslint-disable-next-line @typescript-eslint/no-explicit-any const _validator = (value: any) => { - return value[0].control === 'test' ? null : ({ test: { message: '' } } as ValidationErrors) + return value[0].control === 'test' ? null : ({ test: { message: null } } as ValidateErrors) } test('default change work', async () => { @@ -239,7 +239,7 @@ describe('formArray.ts', () => { }) test('submit trigger validate work', async () => { - const _asyncValidator = (_: unknown) => Promise.resolve({ async: { message: 'async' } } as ValidationErrors) + const _asyncValidator = (_: unknown) => Promise.resolve({ async: { message: 'async' } } as ValidateErrors) array = new FormArray( [ new FormGroup({ diff --git a/packages/cdk/forms/__tests__/formControl.spec.ts b/packages/cdk/forms/__tests__/formControl.spec.ts index 160d9912d..476938b4d 100644 --- a/packages/cdk/forms/__tests__/formControl.spec.ts +++ b/packages/cdk/forms/__tests__/formControl.spec.ts @@ -1,6 +1,6 @@ import { flushPromises } from '@vue/test-utils' import { FormControl } from '../src/controls/formControl' -import { ValidationErrors } from '../src/types' +import { ValidateErrors } from '../src/types' import { Validators } from '../src/validators' describe('formControl.ts', () => { @@ -66,7 +66,7 @@ describe('formControl.ts', () => { control.setValidator(Validators.required) - expect(await control.validate()).toEqual({ required: { message: '' } }) + expect(await control.validate()).toEqual({ required: { message: null } }) }) }) @@ -75,7 +75,7 @@ describe('formControl.ts', () => { test('default change work', async () => { const _asyncValidator = (value: unknown) => - Promise.resolve(value === 'test' ? null : ({ async: { message: 'async' } } as ValidationErrors)) + Promise.resolve(value === 'test' ? null : ({ async: { message: 'async' } } as ValidateErrors)) control = new FormControl('test', { validators: Validators.required, asyncValidators: _asyncValidator }) diff --git a/packages/cdk/forms/__tests__/formGroup.spec.ts b/packages/cdk/forms/__tests__/formGroup.spec.ts index 1108ca1d8..d49b21e42 100644 --- a/packages/cdk/forms/__tests__/formGroup.spec.ts +++ b/packages/cdk/forms/__tests__/formGroup.spec.ts @@ -2,7 +2,7 @@ import { flushPromises } from '@vue/test-utils' import { FormArray } from '../src/controls/formArray' import { FormControl } from '../src/controls/formControl' import { FormGroup } from '../src/controls/formGroup' -import { ValidationErrors } from '../src/types' +import { ValidateErrors } from '../src/types' import { Validators } from '../src/validators' interface BasicGroup { @@ -128,20 +128,21 @@ describe('formGroup.ts', () => { test('validate work', async () => { expect(await group.validate()).toBeNull() - const _validator = (_: unknown) => ({ test: { message: '' } } as ValidationErrors) + const _validator = (_: unknown) => ({ test: { message: null } } as ValidateErrors) group.setValidator(_validator) - expect(await group.validate()).toEqual({ test: { message: '' } }) + expect(await group.validate()).toEqual({ test: { message: null } }) }) test('get work', async () => { - const { control, array, group: groupChild } = group.controls + const { control, array, group: groupChild } = group.controls.value expect(group.get('control')).toEqual(control) expect(group.get('array')).toEqual(array) expect(group.get('group')).toEqual(groupChild) - expect(group.get('group.control')).toEqual((groupChild as FormGroup<{ control: string }>).controls.control) - expect(group.get(['array', 0])).toEqual((array as FormArray).controls[0]) + expect(group.get('group.control')).toEqual((groupChild as FormGroup<{ control: string }>).controls.value.control) + expect(group.get(['array', 0])).toEqual((array as FormArray).controls.value[0]) + expect(group.get('array')!.get(0)).toEqual((array as FormArray).controls.value[0]) expect(group.get(undefined as never)).toBeNull() expect(group.get('')).toBeNull() @@ -156,7 +157,7 @@ describe('formGroup.ts', () => { let group: FormGroup // eslint-disable-next-line @typescript-eslint/no-explicit-any const _validator = (value: any) => { - return value.control === 'test' ? null : ({ test: { message: '' } } as ValidationErrors) + return value.control === 'test' ? null : ({ test: { message: null } } as ValidateErrors) } test('default change work', async () => { @@ -238,7 +239,7 @@ describe('formGroup.ts', () => { }) test('submit trigger validate work', async () => { - const _asyncValidator = (_: unknown) => Promise.resolve({ async: { message: 'async' } } as ValidationErrors) + const _asyncValidator = (_: unknown) => Promise.resolve({ async: { message: 'async' } } as ValidateErrors) group = new FormGroup( { diff --git a/packages/cdk/forms/__tests__/useForms.spec.ts b/packages/cdk/forms/__tests__/useForms.spec.ts index ea54e1bad..067d325ae 100644 --- a/packages/cdk/forms/__tests__/useForms.spec.ts +++ b/packages/cdk/forms/__tests__/useForms.spec.ts @@ -18,7 +18,7 @@ describe('useForms.ts', () => { const group = useFormGroup({ control1: ['', Validators.required], control2: [undefined, { trigger: 'blur', validators: Validators.required }], - array: useFormArray<(string | number)[]>(['', 1]), + array: useFormArray(['', 1]), group: useFormGroup({ control: '' }), }) diff --git a/packages/cdk/forms/__tests__/useValueAccessor.spec.ts b/packages/cdk/forms/__tests__/useValueAccessor.spec.ts index dbe23d344..d8aa61212 100644 --- a/packages/cdk/forms/__tests__/useValueAccessor.spec.ts +++ b/packages/cdk/forms/__tests__/useValueAccessor.spec.ts @@ -33,7 +33,7 @@ const getApp = ( options: { group: AbstractControl valueRef?: Ref - control?: string | AbstractControl + control?: string | number | AbstractControl }, ) => { return mount({ @@ -131,13 +131,13 @@ describe('useValueAccessor.ts', () => { const input = wrapper.find('input') - expect(input.element.value).toEqual('') + expect(input.element.value).toEqual('valueRef') await input.setValue('input change') await input.trigger('blur') expect(group.getValue()).toEqual({ control: 'control' }) expect(group.blurred.value).toEqual(false) - expect(valueRef.value).toEqual('valueRef') + expect(valueRef.value).toEqual('input change') }) }) diff --git a/packages/cdk/forms/__tests__/validators.spec.ts b/packages/cdk/forms/__tests__/validators.spec.ts index a6ebcef9d..0f7c14a54 100644 --- a/packages/cdk/forms/__tests__/validators.spec.ts +++ b/packages/cdk/forms/__tests__/validators.spec.ts @@ -1,5 +1,5 @@ import { AbstractControl } from '../src/controls/abstractControl' -import { AsyncValidatorFn, ErrorMessages, ValidationErrors, ValidatorFn } from '../src/types' +import { AsyncValidatorFn, ValidateErrors, ValidateMessages, ValidatorFn } from '../src/types' import { Validators } from '../src/validators' describe('validators.ts', () => { @@ -12,7 +12,7 @@ describe('validators.ts', () => { expect(required('value', control)).toBeNull() expect(required([1, 2], control)).toBeNull() - const errorMessage = { required: { message: '' } } + const errorMessage = { required: { message: null } } expect(required(null, control)).toEqual(errorMessage) expect(required(undefined, control)).toEqual(errorMessage) expect(required('', control)).toEqual(errorMessage) @@ -24,7 +24,7 @@ describe('validators.ts', () => { expect(requiredTrue(true, control)).toBeNull() - const errorMessage = (actual: unknown) => ({ requiredTrue: { message: '', actual } }) + const errorMessage = (actual: unknown) => ({ requiredTrue: { message: null, actual } }) expect(requiredTrue(null, control)).toEqual(errorMessage(null)) expect(requiredTrue(undefined, control)).toEqual(errorMessage(undefined)) expect(requiredTrue('', control)).toEqual(errorMessage('')) @@ -40,7 +40,7 @@ describe('validators.ts', () => { expect(email(null, control)).toBeNull() expect(email('test@gmail.com', control)).toBeNull() - const errorMessage = (actual: unknown) => ({ email: { message: '', actual } }) + const errorMessage = (actual: unknown) => ({ email: { message: null, actual } }) expect(email({}, control)).toEqual(errorMessage({})) expect(email('test', control)).toEqual(errorMessage('test')) }) @@ -55,7 +55,7 @@ describe('validators.ts', () => { expect(minOne(1, control)).toBeNull() expect(minOne(2, control)).toBeNull() - const errorMessage = (actual: unknown) => ({ min: { message: '', min: 1, actual } }) + const errorMessage = (actual: unknown) => ({ min: { message: null, min: 1, actual } }) expect(minOne(0, control)).toEqual(errorMessage(0)) expect(minOne('0', control)).toEqual(errorMessage('0')) }) @@ -70,7 +70,7 @@ describe('validators.ts', () => { expect(maxOne(1, control)).toBeNull() expect(maxOne(0, control)).toBeNull() - const errorMessage = (actual: unknown) => ({ max: { message: '', max: 1, actual } }) + const errorMessage = (actual: unknown) => ({ max: { message: null, max: 1, actual } }) expect(maxOne(2, control)).toEqual(errorMessage(2)) expect(maxOne('2', control)).toEqual(errorMessage('2')) }) @@ -87,7 +87,7 @@ describe('validators.ts', () => { expect(minLengthTwo([1, 2], control)).toBeNull() expect(minLengthTwo([1, 2, 3], control)).toBeNull() - const errorMessage = (actual: unknown) => ({ minLength: { message: '', minLength: 2, actual } }) + const errorMessage = (actual: unknown) => ({ minLength: { message: null, minLength: 2, actual } }) expect(minLengthTwo('t', control)).toEqual(errorMessage(1)) expect(minLengthTwo([1], control)).toEqual(errorMessage(1)) }) @@ -104,7 +104,7 @@ describe('validators.ts', () => { expect(maxLengthTwo([1, 2], control)).toBeNull() expect(maxLengthTwo([1], control)).toBeNull() - const errorMessage = (actual: unknown) => ({ maxLength: { message: '', maxLength: 2, actual } }) + const errorMessage = (actual: unknown) => ({ maxLength: { message: null, maxLength: 2, actual } }) expect(maxLengthTwo('test', control)).toEqual(errorMessage(4)) expect(maxLengthTwo([1, 2, 3], control)).toEqual(errorMessage(3)) }) @@ -118,7 +118,7 @@ describe('validators.ts', () => { expect(stringPattern(null, control)).toBeNull() expect(stringPattern('test', control)).toBeNull() - let errorMessage = (actual: unknown) => ({ pattern: { message: '', pattern: '^[a-zA-Z]+$', actual } }) + let errorMessage = (actual: unknown) => ({ pattern: { message: null, pattern: '^[a-zA-Z]+$', actual } }) expect(stringPattern('test1', control)).toEqual(errorMessage('test1')) expect(stringPattern(1, control)).toEqual(errorMessage(1)) @@ -133,14 +133,14 @@ describe('validators.ts', () => { expect(regExpPattern('test', control)).toBeNull() expect(regExpPattern('test1', control)).toBeNull() - errorMessage = (actual: unknown) => ({ pattern: { message: '', pattern: '/[a-zA-Z]+/', actual } }) + errorMessage = (actual: unknown) => ({ pattern: { message: null, pattern: '/[a-zA-Z]+/', actual } }) expect(regExpPattern(1, control)).toEqual(errorMessage(1)) const regExpPattern2 = Validators.pattern(new RegExp('^[a-zA-Z]+$')) expect(regExpPattern2('test', control)).toBeNull() - errorMessage = (actual: unknown) => ({ pattern: { message: '', pattern: '/^[a-zA-Z]+$/', actual } }) + errorMessage = (actual: unknown) => ({ pattern: { message: null, pattern: '/^[a-zA-Z]+$/', actual } }) expect(regExpPattern2('test1', control)).toEqual(errorMessage('test1')) expect(regExpPattern2(1, control)).toEqual(errorMessage(1)) }) @@ -148,7 +148,7 @@ describe('validators.ts', () => { test('compose work', () => { const _validator = (key: string, error: unknown): ValidatorFn => { return (_: unknown) => { - return { [key]: error } as ValidationErrors + return { [key]: error } as ValidateErrors } } const message1 = { message: 1 } @@ -164,13 +164,13 @@ describe('validators.ts', () => { b: message2, }) expect(compose([_validator('a', message1), _validator('a', message2)])!('test', control)).toEqual({ a: message2 }) - expect(compose([null, nullValidator, required])!('', control)).toEqual({ required: { message: '' } }) + expect(compose([null, nullValidator, required])!('', control)).toEqual({ required: { message: null } }) }) test('composeAsync work', async () => { const _asyncValidator = (key: string, error: unknown): AsyncValidatorFn => { return (_: unknown) => { - return Promise.resolve({ [key]: error } as ValidationErrors) + return Promise.resolve({ [key]: error } as ValidateErrors) } } const message1 = { message: 1 } @@ -194,7 +194,7 @@ describe('validators.ts', () => { test('setMessages work', () => { const { setMessages, required, requiredTrue } = Validators - const messages: ErrorMessages = { default: 'invalid input', required: 'please input' } + const messages: ValidateMessages = { default: 'invalid input', required: 'please input' } setMessages(messages) expect(required('', control)).toEqual({ required: { message: messages.required } }) diff --git a/packages/cdk/forms/docs/Index.zh.md b/packages/cdk/forms/docs/Index.zh.md index 36e5c68fb..b608a0e9d 100644 --- a/packages/cdk/forms/docs/Index.zh.md +++ b/packages/cdk/forms/docs/Index.zh.md @@ -1,8 +1,8 @@ --- category: cdk type: -title: Reactive Forms -subtitle: 响应式表单 +title: Forms +subtitle: 表单 cover: --- @@ -115,7 +115,7 @@ export function useFormControl(initValue?: T | null, options?: Validato | `minLength()` | 验证表单控件的值的长度大于或等于指定的数字 | `number` | - | 验证失败返回 `{ minLength: { message: '', minLength, actual: value.length } }`| | `maxLength()` | 验证表单控件的值的长度小于或等于指定的数字 | `number` | - | 验证失败返回 `{ maxLength: { message: '', maxLength, actual: value.length } }`| | `pattern()` | 验证表单控件的值匹配一个正则表达式 | `string \| RegExp` | - | 验证失败返回 `{ pattern: { message: '', pattern, actual: value } }`| -| `setMessages()` | 设置验证失败的提示信息 | `ErrorMessages` | - | 每次设置的 `messages` 会跟之前的进行合并 | +| `setMessages()` | 设置验证失败的提示信息 | `ValidateMessages` | - | 每次设置的 `messages` 会跟之前的进行合并 | ### AbstractControl @@ -134,12 +134,12 @@ export abstract class AbstractControl { * * **invalid**: This control has failed at least one validation check. * * **validating**: This control is in the midst of conducting a validation check. */ - readonly status: DeepReadonly> + readonly status: DeepReadonly> /** * An object containing any errors generated by failing validation, or null if there are no errors. */ - readonly errors: DeepReadonly> + readonly errors: DeepReadonly> /** * A control is valid when its `status` is `valid`. @@ -236,7 +236,7 @@ export abstract class AbstractControl { /** * Running validations manually, rather than automatically. */ - abstract validate(): Promise + abstract validate(): Promise /** * Sets the new sync validator for the form control, it overwrites existing sync validators. @@ -261,7 +261,7 @@ export abstract class AbstractControl { /** * Sets errors on a form control when running validations manually, rather than automatically. */ - setErrors(errors: ValidationErrors | null): void + setErrors(errors: ValidateErrors | null): void /** * Reports error data for the control with the given path. @@ -270,7 +270,7 @@ export abstract class AbstractControl { * @param path A list of control names that designates how to move from the current control * to the control that should be queried for errors. */ - getError(errorCode: keyof ErrorMessages, path?: Array | string): ValidationError | null + getError(errorCode: string, path?: Array | string): ValidateError | null /** * Reports whether the control with the given path has the error specified. @@ -301,7 +301,7 @@ export abstract class AbstractControl { * @param cb The callback when the status changes * @param options Optional options of watch */ - watchStatus(cb: WatchCallback, options?: WatchOptions): WatchStopHandle + watchStatus(cb: WatchCallback, options?: WatchOptions): WatchStopHandle } ``` diff --git a/packages/cdk/forms/index.ts b/packages/cdk/forms/index.ts index a4f0d0a24..aeb69f33c 100644 --- a/packages/cdk/forms/index.ts +++ b/packages/cdk/forms/index.ts @@ -3,6 +3,7 @@ export * from './src/controls/formArray' export * from './src/controls/formControl' export * from './src/controls/formGroup' export * from './src/controls/types' +export * from './src/typeof' export * from './src/types' export * from './src/useForms' export * from './src/useValueAccessor' diff --git a/packages/cdk/forms/src/controls/abstractControl.ts b/packages/cdk/forms/src/controls/abstractControl.ts index 2b6991c37..ed2b9b286 100644 --- a/packages/cdk/forms/src/controls/abstractControl.ts +++ b/packages/cdk/forms/src/controls/abstractControl.ts @@ -2,13 +2,12 @@ import type { ComputedRef, Ref, WatchCallback, WatchOptions, WatchStopHandle } from 'vue' import type { AsyncValidatorFn, - ErrorMessages, - ValidationErrors, + ValidateErrors, TriggerType, - ValidationError, + ValidateError, ValidatorFn, ValidatorOptions, - ValidationStatus, + ValidateStatus, } from '../types' import type { ArrayElement, GroupControls } from './types' @@ -17,7 +16,14 @@ import { hasOwnProperty, isArray, isNil, isObject } from '@idux/cdk/utils' import { Validators } from '../validators' import { isFormArray, isFormGroup } from '../typeof' +let controlId = 0 export abstract class AbstractControl { + readonly id: number = controlId++ + /** + * A collection of child controls. + */ + readonly controls: Readonly | AbstractControl>[]>> | null = null + /** * The ref value for the control. */ @@ -29,12 +35,12 @@ export abstract class AbstractControl { * * **invalid**: This control has failed at least one validation check. * * **validating**: This control is in the midst of conducting a validation check. */ - readonly status!: Readonly> + readonly status!: Readonly> /** * An object containing any errors generated by failing validation, or null if there are no errors. */ - readonly errors!: Readonly> + readonly errors!: Readonly> /** * A control is valid when its `status` is `valid`. @@ -71,15 +77,10 @@ export abstract class AbstractControl { */ readonly pristine!: ComputedRef - /** - * A collection of child controls. - */ - readonly controls: GroupControls | AbstractControl>[] | null = null - /** * The parent control. */ - get parent(): AbstractControl | null { + get parent(): AbstractControl | null { return this._parent } @@ -106,8 +107,8 @@ export abstract class AbstractControl { } protected _valueRef!: Ref - protected _status!: Ref - protected _errors!: Ref + protected _status!: Ref + protected _errors!: Ref protected _blurred = ref(false) protected _dirty = ref(false) @@ -131,6 +132,7 @@ export abstract class AbstractControl { /** * Sets a new value for the control. */ + abstract setValue(value: T, options?: { dirty?: boolean }): void abstract setValue(value: Partial, options?: { dirty?: boolean }): void /** @@ -161,7 +163,7 @@ export abstract class AbstractControl { /** * Running validations manually, rather than automatically. */ - abstract validate(): Promise + abstract validate(): Promise /** * Sets the new sync validator for the form control, it overwrites existing sync validators. @@ -185,12 +187,12 @@ export abstract class AbstractControl { * @param path A dot-delimited string or array of string/number values that define the path to the * control. */ - get(path: Array | string): AbstractControl | null { + get(path: Array | string | number): AbstractControl | null { if (isNil(path)) { return null } if (!isArray(path)) { - path = path.split('.') + path = path.toString().split('.') } if (path.length === 0) { return null @@ -202,7 +204,8 @@ export abstract class AbstractControl { // https://bugs.chromium.org/p/chromium/issues/detail?id=1049982 path.forEach((name: string | number) => { if (isFormGroup(controlToFind)) { - controlToFind = hasOwnProperty(controlToFind.controls, name as string) ? controlToFind.controls[name]! : null + const controls = controlToFind.controls.value + controlToFind = hasOwnProperty(controls, name as string) ? controls[name]! : null } else if (isFormArray(controlToFind)) { controlToFind = controlToFind.at(name) || null } else { @@ -215,7 +218,7 @@ export abstract class AbstractControl { /** * Sets errors on a form control when running validations manually, rather than automatically. */ - setErrors(errors: ValidationErrors | null): void { + setErrors(errors: ValidateErrors | null): void { this._errors.value = errors } @@ -226,7 +229,7 @@ export abstract class AbstractControl { * @param path A list of control names that designates how to move from the current control * to the control that should be queried for errors. */ - getError(errorCode: keyof ErrorMessages, path?: Array | string): ValidationError | null { + getError(errorCode: string, path?: Array | string): ValidateError | null { const control = path ? this.get(path) : this return control?._errors?.value?.[errorCode] || null } @@ -246,7 +249,7 @@ export abstract class AbstractControl { /** * @param parent Sets the parent of the control */ - setParent(parent: AbstractControl): void { + setParent(parent: AbstractControl): void { this._parent = parent } @@ -257,7 +260,7 @@ export abstract class AbstractControl { * @param options Optional options of watch */ watchValue(cb: WatchCallback, options?: WatchOptions): WatchStopHandle { - return watch(this._valueRef, cb, { ...options }) + return watch(this._valueRef, cb, options) } /** @@ -266,14 +269,11 @@ export abstract class AbstractControl { * @param cb The callback when the status changes * @param options Optional options of watch */ - watchStatus( - cb: WatchCallback, - options?: WatchOptions, - ): WatchStopHandle { + watchStatus(cb: WatchCallback, options?: WatchOptions): WatchStopHandle { return watch(this._status, cb, options) } - protected async _validate(): Promise { + protected async _validate(): Promise { let newErrors = null let value = null if (this._validators) { @@ -311,10 +311,11 @@ export abstract class AbstractControl { errors = this._validators(value, this) } - let status: ValidationStatus = errors ? 'invalid' : 'valid' - if (status === 'valid' && this.controls) { - for (const key in this.controls) { - const controlStatus = (this.controls as any)[key].status.value + let status: ValidateStatus = errors ? 'invalid' : 'valid' + const controls = this.controls?.value + if (status === 'valid' && controls) { + for (const key in controls) { + const controlStatus = (controls as any)[key].status.value if (controlStatus === 'invalid') { status = 'invalid' break diff --git a/packages/cdk/forms/src/controls/formArray.ts b/packages/cdk/forms/src/controls/formArray.ts index 83e2f1d97..0f61f3ca6 100644 --- a/packages/cdk/forms/src/controls/formArray.ts +++ b/packages/cdk/forms/src/controls/formArray.ts @@ -1,34 +1,34 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { WatchStopHandle } from 'vue' -import type { AsyncValidatorFn, ValidatorFn, ValidatorOptions, ValidationErrors, ValidationStatus } from '../types' +import type { ComputedRef, Ref } from 'vue' +import type { AsyncValidatorFn, ValidatorFn, ValidatorOptions, ValidateErrors, ValidateStatus } from '../types' import type { ArrayElement } from './types' -import { shallowRef, watch, watchEffect } from 'vue' +import { computed, shallowReadonly, shallowRef, toRaw, watch, watchEffect } from 'vue' import { AbstractControl } from './abstractControl' export class FormArray extends AbstractControl { /** * Length of the control array. */ - get length(): number { - return this.controls.length - } + readonly length: ComputedRef + + readonly controls!: Readonly>[]>> - private _valueWatchStopHandle: WatchStopHandle | null = null - private _statusWatchStopHandle: WatchStopHandle | null = null - private _blurredWatchStopHandle: WatchStopHandle | null = null - private _dirtyWatchStopHandle: WatchStopHandle | null = null + private _controls: Ref>[]> constructor( /** * An array of child controls. Each child control is given an index where it is registered. */ - public readonly controls: AbstractControl>[], + controls: AbstractControl>[], validatorOrOptions?: ValidatorFn | ValidatorFn[] | ValidatorOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, ) { super(validatorOrOptions, asyncValidator) controls.forEach(control => control.setParent(this as AbstractControl)) + this._controls = shallowRef(controls) + ;(this as any).controls = shallowReadonly((this as any)._controls) + this.length = computed(() => this._controls.value.length) this._valueRef = shallowRef(this._calculateValue()) this._initAllStatus() @@ -46,7 +46,7 @@ export class FormArray extends AbstractControl { * @param index Index in the array to retrieve the control */ at(index: number): AbstractControl> { - return this.controls[index] + return this._controls.value[index] } /** @@ -55,9 +55,9 @@ export class FormArray extends AbstractControl { * @param control Form control to be inserted */ push(control: AbstractControl>): void { - this.controls.push(control) - this._registerControl(control) - this._refreshValueAndWatch() + control.setParent(this as AbstractControl) + const controls = toRaw(this._controls.value) + this._controls.value = [...controls, control] } /** @@ -67,9 +67,10 @@ export class FormArray extends AbstractControl { * @param control Form control to be inserted */ insert(index: number, control: AbstractControl>): void { - this.controls.splice(index, 0, control) - this._registerControl(control) - this._refreshValueAndWatch() + control.setParent(this as AbstractControl) + const controls = toRaw(this._controls.value) + controls.splice(index, 0, control) + this._controls.value = [...controls] } /** @@ -78,8 +79,9 @@ export class FormArray extends AbstractControl { * @param index Index in the array to remove the control */ removeAt(index: number): void { - this.controls.splice(index, 1) - this._refreshValueAndWatch() + const controls = toRaw(this._controls.value) + controls.splice(index, 1) + this._controls.value = [...controls] } /** @@ -89,16 +91,17 @@ export class FormArray extends AbstractControl { * @param control The `AbstractControl` control to replace the existing control */ setControl(index: number, control: AbstractControl>): void { - this.controls.splice(index, 1, control) - this._registerControl(control) - this._refreshValueAndWatch() + control.setParent(this as AbstractControl) + const controls = toRaw(this._controls.value) + controls.splice(index, 1, control) + this._controls.value = [...controls] } /** - * Resets all controls of the form array. + * Resets all _controls.value of the form array. */ reset(): void { - this.controls.forEach(control => control.reset()) + this._controls.value.forEach(control => control.reset()) } /** @@ -120,42 +123,42 @@ export class FormArray extends AbstractControl { * The aggregate value of the form array. */ getValue(): T { - return this.controls.map(control => control.getValue()) as T + return this._controls.value.map(control => control.getValue()) as T } /** * Marks all controls of the form array as `blurred`. */ markAsBlurred(): void { - this.controls.forEach(control => control.markAsBlurred()) + this._controls.value.forEach(control => control.markAsBlurred()) } /** * Marks all controls of the form array as `unblurred`. */ markAsUnblurred(): void { - this.controls.forEach(control => control.markAsUnblurred()) + this._controls.value.forEach(control => control.markAsUnblurred()) } /** * Marks all controls of the form array as `dirty`. */ markAsDirty(): void { - this.controls.forEach(control => control.markAsDirty()) + this._controls.value.forEach(control => control.markAsDirty()) } /** * Marks all controls of the form array as `pristine`. */ markAsPristine(): void { - this.controls.forEach(control => control.markAsPristine()) + this._controls.value.forEach(control => control.markAsPristine()) } /** * Running validations manually, rather than automatically. */ - async validate(): Promise { - this.controls.forEach(control => control.validate()) + async validate(): Promise { + this._controls.value.forEach(control => control.validate()) return this._validate() } @@ -168,22 +171,17 @@ export class FormArray extends AbstractControl { } private _watchValue() { - if (this._valueWatchStopHandle) { - this._valueWatchStopHandle() - } - this._valueWatchStopHandle = watchEffect(() => { + watchEffect(() => { this._valueRef.value = this._calculateValue() }) } private _watchStatus() { - if (this._statusWatchStopHandle) { - this._statusWatchStopHandle() - } - this._statusWatchStopHandle = watchEffect(() => { - let status: ValidationStatus = this._errors.value ? 'invalid' : 'valid' + watchEffect(() => { + let status: ValidateStatus = this._errors.value ? 'invalid' : 'valid' if (status === 'valid') { - for (const control of this.controls) { + const controls = this._controls.value + for (const control of controls) { const controlStatus = control.status.value if (controlStatus === 'invalid') { status = 'invalid' @@ -198,12 +196,10 @@ export class FormArray extends AbstractControl { } private _watchBlurred() { - if (this._blurredWatchStopHandle) { - this._blurredWatchStopHandle() - } - this._blurredWatchStopHandle = watchEffect(() => { + watchEffect(() => { let blurred = false - for (const control of this.controls) { + const controls = this._controls.value + for (const control of controls) { if (control.blurred.value) { blurred = true break @@ -214,12 +210,10 @@ export class FormArray extends AbstractControl { } private _watchDirty() { - if (this._dirtyWatchStopHandle) { - this._dirtyWatchStopHandle() - } - this._dirtyWatchStopHandle = watchEffect(() => { + watchEffect(() => { let dirty = false - for (const control of this.controls) { + const controls = this._controls.value + for (const control of controls) { if (control.dirty.value) { dirty = true break @@ -230,17 +224,6 @@ export class FormArray extends AbstractControl { } private _calculateValue() { - return this.controls.map(control => control.getValue()) as T - } - - private _refreshValueAndWatch() { - this._watchValue() - this._watchStatus() - this._watchBlurred() - this._watchDirty() - } - - private _registerControl(control: AbstractControl>) { - control.setParent(this as AbstractControl) + return this._controls.value.map(control => control.getValue()) as T } } diff --git a/packages/cdk/forms/src/controls/formControl.ts b/packages/cdk/forms/src/controls/formControl.ts index 90512def7..f86fb4446 100644 --- a/packages/cdk/forms/src/controls/formControl.ts +++ b/packages/cdk/forms/src/controls/formControl.ts @@ -1,17 +1,17 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { AsyncValidatorFn, ValidatorFn, ValidatorOptions, ValidationErrors } from '../types' +import type { AsyncValidatorFn, ValidatorFn, ValidatorOptions, ValidateErrors } from '../types' import { shallowRef, watch } from 'vue' import { AbstractControl } from './abstractControl' export class FormControl extends AbstractControl { constructor( - private _initValue: T, + private _initValue?: T, validatorOrOptions?: ValidatorFn | ValidatorFn[] | ValidatorOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, ) { super(validatorOrOptions, asyncValidator) - this._valueRef = shallowRef(this._initValue) + this._valueRef = shallowRef(this._initValue!) this._initAllStatus() @@ -24,7 +24,7 @@ export class FormControl extends AbstractControl { * and setting the value to initialization value. */ reset(): void { - this._valueRef.value = this._initValue + this._valueRef.value = this._initValue! this.markAsUnblurred() this.markAsPristine() } @@ -81,7 +81,7 @@ export class FormControl extends AbstractControl { /** * Running validations manually, rather than automatically. */ - async validate(): Promise { + async validate(): Promise { return this._validate() } diff --git a/packages/cdk/forms/src/controls/formGroup.ts b/packages/cdk/forms/src/controls/formGroup.ts index 491f29bd5..4a81ca673 100644 --- a/packages/cdk/forms/src/controls/formGroup.ts +++ b/packages/cdk/forms/src/controls/formGroup.ts @@ -1,27 +1,28 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import type { WatchStopHandle } from 'vue' -import type { AsyncValidatorFn, ValidatorFn, ValidatorOptions, ValidationErrors, ValidationStatus } from '../types' +import type { Ref } from 'vue' +import type { AsyncValidatorFn, ValidatorFn, ValidatorOptions, ValidateErrors, ValidateStatus } from '../types' import type { GroupControls } from './types' -import { shallowRef, watch, watchEffect } from 'vue' +import { shallowReadonly, shallowRef, toRaw, watch, watchEffect } from 'vue' import { hasOwnProperty } from '@idux/cdk/utils' import { AbstractControl } from './abstractControl' export class FormGroup = Record> extends AbstractControl { - private _valueWatchStopHandle: WatchStopHandle | null = null - private _statusWatchStopHandle: WatchStopHandle | null = null - private _blurredWatchStopHandle: WatchStopHandle | null = null - private _dirtyWatchStopHandle: WatchStopHandle | null = null + readonly controls!: Readonly>> + + private _controls: Ref> constructor( /** * A collection of child controls. The key for each child is the name under which it is registered. */ - public readonly controls: GroupControls, + controls: GroupControls, validatorOrOptions?: ValidatorFn | ValidatorFn[] | ValidatorOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, ) { super(validatorOrOptions, asyncValidator) + this._controls = shallowRef(controls) + ;(this as any).controls = shallowReadonly((this as any)._controls) this._forEachChild(control => control.setParent(this as AbstractControl)) this._valueRef = shallowRef(this._calculateValue()) @@ -41,8 +42,13 @@ export class FormGroup = Record> exte * @param control Provides the control for the given name */ addControl>(name: K, control: AbstractControl): void { - this._registerControl(name, control) - this._refreshValueAndWatch() + control.setParent(this as AbstractControl) + const controls = toRaw(this._controls.value) + if (hasOwnProperty(controls, name as string)) { + return + } + controls[name] = control + this._controls.value = { ...controls } } /** @@ -51,8 +57,9 @@ export class FormGroup = Record> exte * @param name The control name to remove from the collection */ removeControl>(name: K): void { - delete this.controls[name] - this._refreshValueAndWatch() + const controls = toRaw(this._controls.value) + delete controls[name] + this._controls.value = { ...controls } } /** @@ -62,9 +69,10 @@ export class FormGroup = Record> exte * @param control Provides the control for the given name */ setControl(name: K, control: AbstractControl): void { - delete this.controls[name] - this._registerControl(name, control) - this._refreshValueAndWatch() + control.setParent(this as AbstractControl) + const controls = toRaw(this._controls.value) + controls[name] = control + this._controls.value = { ...controls } } /** @@ -83,7 +91,7 @@ export class FormGroup = Record> exte */ setValue(value: Partial, options: { dirty?: boolean } = {}): void { Object.keys(value).forEach(key => { - this.controls[key as keyof T]!.setValue((value as any)[key], options) + this._controls.value[key as keyof T]!.setValue((value as any)[key], options) }) } @@ -129,7 +137,7 @@ export class FormGroup = Record> exte /** * Running validations manually, rather than automatically. */ - async validate(): Promise { + async validate(): Promise { this._forEachChild(control => control.validate()) return this._validate() } @@ -143,23 +151,18 @@ export class FormGroup = Record> exte } private _watchValue() { - if (this._valueWatchStopHandle) { - this._valueWatchStopHandle() - } - this._valueWatchStopHandle = watchEffect(() => { + watchEffect(() => { this._valueRef.value = this._calculateValue() }) } private _watchStatus() { - if (this._statusWatchStopHandle) { - this._statusWatchStopHandle() - } - this._statusWatchStopHandle = watchEffect(() => { - let status: ValidationStatus = this._errors.value ? 'invalid' : 'valid' + watchEffect(() => { + let status: ValidateStatus = this._errors.value ? 'invalid' : 'valid' if (status === 'valid') { - for (const key in this.controls) { - const controlStatus = this.controls[key]!.status.value + const controls = this._controls.value + for (const key in controls) { + const controlStatus = controls[key]!.status.value if (controlStatus === 'invalid') { status = 'invalid' break @@ -173,13 +176,11 @@ export class FormGroup = Record> exte } private _watchBlurred() { - if (this._blurredWatchStopHandle) { - this._blurredWatchStopHandle() - } - this._blurredWatchStopHandle = watchEffect(() => { + watchEffect(() => { let blurred = false - for (const key in this.controls) { - if (this.controls[key]!.blurred.value) { + const controls = this._controls.value + for (const key in controls) { + if (controls[key]!.blurred.value) { blurred = true break } @@ -189,13 +190,11 @@ export class FormGroup = Record> exte } private _watchDirty() { - if (this._dirtyWatchStopHandle) { - this._dirtyWatchStopHandle() - } - this._dirtyWatchStopHandle = watchEffect(() => { + watchEffect(() => { let dirty = false - for (const key in this.controls) { - if (this.controls[key]!.dirty.value) { + const controls = this._controls.value + for (const key in controls) { + if (controls[key]!.dirty.value) { dirty = true break } @@ -206,30 +205,16 @@ export class FormGroup = Record> exte private _calculateValue() { const value = {} as T - - Object.keys(this.controls).forEach(key => { - value[key as keyof T] = this.controls[key as keyof T]!.getValue() + const controls = this._controls.value + Object.keys(controls).forEach(key => { + value[key as keyof T] = controls[key as keyof T]!.getValue() }) return value } - private _refreshValueAndWatch() { - this._watchValue() - this._watchStatus() - this._watchBlurred() - this._watchDirty() - } - - private _registerControl(name: K, control: AbstractControl) { - if (hasOwnProperty(this.controls, name as string)) { - return - } - this.controls[name] = control - control.setParent(this as AbstractControl) - } - private _forEachChild(cb: (v: AbstractControl, k: keyof T) => void): void { - Object.keys(this.controls).forEach(key => cb(this.controls[key as keyof T]!, key as keyof T)) + const controls = this._controls.value + Object.keys(controls).forEach(key => cb(controls[key as keyof T]!, key as keyof T)) } } diff --git a/packages/cdk/forms/src/typeof.ts b/packages/cdk/forms/src/typeof.ts index 744ed86c4..ca4238e77 100644 --- a/packages/cdk/forms/src/typeof.ts +++ b/packages/cdk/forms/src/typeof.ts @@ -12,7 +12,7 @@ export const isAbstractControl = (val: unknown): val is AbstractControl => { // Since AbstractControl be dependent on the function, `val instanceof FormArray` cannot be used here. export const isFormArray = (val: unknown): val is FormArray => { - return isAbstractControl(val) && isArray((val as FormArray).controls) + return isAbstractControl(val) && !isFormControl(val) && isArray((val as FormArray).controls.value) } // Since AbstractControl be dependent on the function, `val instanceof FormGroup` cannot be used here. diff --git a/packages/cdk/forms/src/types.ts b/packages/cdk/forms/src/types.ts index 60d74dfde..c5e887d13 100644 --- a/packages/cdk/forms/src/types.ts +++ b/packages/cdk/forms/src/types.ts @@ -2,35 +2,29 @@ import { AbstractControl } from './controls/abstractControl' -export interface ValidationError { - message: string +export interface ValidateError { + /** + * There are two types of validation message: + * * **string**: a simple string. + * * **Record**: you can return an object whose key is locale id, when you need for i18n. + */ + message: string | ValidateMessageFn | Record | null actual?: any [key: string]: any } -type ErrorMessage = string | ((err: Omit) => string) - -export interface ErrorMessages { - default?: ErrorMessage - required?: ErrorMessage - requiredTrue?: ErrorMessage - email?: ErrorMessage - min?: ErrorMessage - max?: ErrorMessage - minLength?: ErrorMessage - maxLength?: ErrorMessage - pattern?: ErrorMessage - [key: string]: ErrorMessage | undefined -} +export type ValidateErrors = Record + +export type ValidateMessageFn = (err: Omit) => string -export type ValidationErrors = { [key in keyof ErrorMessages]: ValidationError } +export type ValidateMessages = Record> export interface ValidatorFn { - (value: any, control: AbstractControl): ValidationErrors | null + (value: any, control: AbstractControl): ValidateErrors | null } export interface AsyncValidatorFn { - (value: any, control: AbstractControl): Promise + (value: any, control: AbstractControl): Promise } export type TriggerType = 'change' | 'blur' | 'submit' @@ -41,4 +35,4 @@ export interface ValidatorOptions { trigger?: TriggerType } -export type ValidationStatus = 'valid' | 'invalid' | 'validating' +export type ValidateStatus = 'valid' | 'invalid' | 'validating' diff --git a/packages/cdk/forms/src/useForms.ts b/packages/cdk/forms/src/useForms.ts index 981fb2e37..2c9d483ca 100644 --- a/packages/cdk/forms/src/useForms.ts +++ b/packages/cdk/forms/src/useForms.ts @@ -59,7 +59,7 @@ export function useFormArray( } export function useFormControl( - initValue: T, + initValue?: T, validator?: ValidatorFn | ValidatorFn[] | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null, ): FormControl diff --git a/packages/cdk/forms/src/useValueAccessor.ts b/packages/cdk/forms/src/useValueAccessor.ts index 80c4e7a93..14a13ca5a 100644 --- a/packages/cdk/forms/src/useValueAccessor.ts +++ b/packages/cdk/forms/src/useValueAccessor.ts @@ -1,10 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { AbstractControl } from './controls/abstractControl' -import { reactive, getCurrentInstance, watchEffect } from 'vue' -import { Logger } from '@idux/cdk/utils' +import { reactive, getCurrentInstance, watchEffect, computed, ComputedRef } from 'vue' +import { isNil, Logger } from '@idux/cdk/utils' import { isAbstractControl } from './typeof' -import { injectControl } from './utils' +import { injectControl, injectControlOrPath } from './utils' interface AccessorOptions { controlKey?: string @@ -12,33 +12,44 @@ interface AccessorOptions { } export interface ValueAccessor { - value?: any - setValue?: (value: any) => void - markAsBlurred?: () => void + value: any + setValue: (value: any) => void + markAsBlurred: () => void } -export function useValueAccessor(options: AccessorOptions = {}): ValueAccessor { - const { controlKey = 'control', valueKey = 'value' } = options - const accessor = reactive({} as ValueAccessor) - const { props, emit } = getCurrentInstance()! - - watchEffect(() => { - const controlOrPath = props[controlKey] as string | AbstractControl - if (controlOrPath) { - let control: AbstractControl | null = null - if (isAbstractControl(controlOrPath)) { - control = controlOrPath +export function useValueControl(controlKey = 'control'): ComputedRef { + const { props } = getCurrentInstance()! + const controlOrPath = injectControlOrPath() + return computed(() => { + let control: AbstractControl | null = null + const _controlOrPath = (props[controlKey] ?? controlOrPath?.value) as AbstractControl | string | number | undefined + if (!isNil(_controlOrPath)) { + if (isAbstractControl(_controlOrPath)) { + control = _controlOrPath } else { - control = injectControl(controlOrPath) + control = injectControl(_controlOrPath) if (!control) { - Logger.error(`not find control by [${controlOrPath}]`) + Logger.error(`not find control by [${_controlOrPath}]`) } } - if (control) { - accessor.value = control.valueRef - accessor.setValue = (value: any) => control!.setValue(value, { dirty: true }) - accessor.markAsBlurred = () => control!.markAsBlurred() - } + } + return control + }) +} + +export function useValueAccessor(options: AccessorOptions = {}): ValueAccessor { + const { controlKey, valueKey = 'value' } = options + const accessor = reactive({} as ValueAccessor) + const { props, emit } = getCurrentInstance()! + + const control = useValueControl(controlKey) + + watchEffect(() => { + const currControl = control.value + if (currControl) { + accessor.value = currControl.valueRef + accessor.setValue = (value: any) => currControl.setValue(value, { dirty: true }) + accessor.markAsBlurred = () => currControl.markAsBlurred() } else { accessor.setValue = value => { accessor.value = value @@ -49,8 +60,7 @@ export function useValueAccessor(options: AccessorOptions = {}): ValueAccessor { }) watchEffect(() => { - const control = props[controlKey] - if (!control) { + if (!control.value) { accessor.value = props[valueKey] } }) diff --git a/packages/cdk/forms/src/utils.ts b/packages/cdk/forms/src/utils.ts index 8afb13ca9..347cda7c9 100644 --- a/packages/cdk/forms/src/utils.ts +++ b/packages/cdk/forms/src/utils.ts @@ -1,19 +1,35 @@ -import type { InjectionKey } from 'vue' +import type { ComputedRef, InjectionKey } from 'vue' import type { AbstractControl } from './controls/abstractControl' import { inject, provide } from 'vue' import { object } from 'vue-types' -import { PropTypes, withUndefined } from '@idux/cdk/utils' +import { isNil, PropTypes, withUndefined } from '@idux/cdk/utils' -export const ControlPropType = withUndefined(PropTypes.oneOfType([PropTypes.string, object()])) +export const controlPropTypeDef = withUndefined( + PropTypes.oneOfType([PropTypes.string, PropTypes.number, object()]), +) -const token: InjectionKey = Symbol() +const controlToken: InjectionKey = Symbol('controlToken') export function provideControl(control: AbstractControl): void { - provide(token, control) + provide(controlToken, control) } -export function injectControl(path: Array | string): AbstractControl | null { - const controlParent = inject(token, null) - return controlParent ? controlParent.get(path) : null +export function injectControl(path?: Array | string | number): AbstractControl | null { + const control = inject(controlToken, null) + if (!control) { + return null + } + + return isNil(path) ? control : control.get(path) +} + +const controlOrPathToken: InjectionKey> = Symbol('controlOrPathToken') + +export function provideControlOrPath(controlOrPath: ComputedRef): void { + provide(controlOrPathToken, controlOrPath) +} + +export function injectControlOrPath(): ComputedRef | null { + return inject(controlOrPathToken, null) } diff --git a/packages/cdk/forms/src/validators.ts b/packages/cdk/forms/src/validators.ts index ac177dbbb..c80479ccd 100644 --- a/packages/cdk/forms/src/validators.ts +++ b/packages/cdk/forms/src/validators.ts @@ -1,7 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ -import type { AsyncValidatorFn, ValidationError, ValidatorFn, ValidationErrors, ErrorMessages } from './types' +import type { + AsyncValidatorFn, + ValidateError, + ValidatorFn, + ValidateErrors, + ValidateMessages, + ValidateMessageFn, +} from './types' import type { AbstractControl } from './controls/abstractControl' import { isArray, isFunction, isNil, isNonNil, isNumber, isNumeric, isString } from '@idux/cdk/utils' @@ -11,77 +18,77 @@ const emailRegexp = /^(?=.{1,254}$)(?=.{1,64}@)[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/ export class Validators { - private static messages: ErrorMessages = {} + private static messages: ValidateMessages = {} - private static getMessage(key: keyof ErrorMessages, errors: Omit): ValidationError { - let message = '' - const validMessage = Validators.messages[key] || Validators.messages.default - if (isString(validMessage)) { - message = validMessage - } else if (isFunction(validMessage)) { - message = validMessage(errors) - } - return { ...errors, message } + static setMessages(messages: ValidateMessages): void { + Validators.messages = { ...Validators.messages, ...messages } } - static setMessages(messages: ErrorMessages): void { - Validators.messages = { ...Validators.messages, ...messages } + static getError(key: string, errorContext: Omit = {}): ValidateError { + let message: string | ValidateMessageFn | Record | null = null + const validMessage = Validators.messages[key] || Validators.messages.default || null + if (isFunction(validMessage)) { + message = validMessage(errorContext) + } else { + message = validMessage + } + return { ...errorContext, message } } - static required(value: any, _: AbstractControl): { required: ValidationError } | null { + static required(value: any, _: AbstractControl): { required: ValidateError } | null { if (isEmpty(value)) { - return { required: Validators.getMessage('required', {}) } + return { required: Validators.getError('required') } } return null } - static requiredTrue(value: any, _: AbstractControl): { requiredTrue: ValidationError } | null { + static requiredTrue(value: any, _: AbstractControl): { requiredTrue: ValidateError } | null { if (value === true) { return null } - return { requiredTrue: Validators.getMessage('requiredTrue', { actual: value }) } + return { requiredTrue: Validators.getError('requiredTrue', { actual: value }) } } - static email(value: any, _: AbstractControl): { email: ValidationError } | null { + static email(value: any, _: AbstractControl): { email: ValidateError } | null { if (isEmpty(value) || emailRegexp.test(value)) { return null } - return { email: Validators.getMessage('email', { actual: value }) } + return { email: Validators.getError('email', { actual: value }) } } static min(min: number): ValidatorFn { - return (value: any, _: AbstractControl): { min: ValidationError } | null => { + return (value: any, _: AbstractControl): { min: ValidateError } | null => { if (isEmpty(value) || !isNumeric(value) || Number(value) >= min) { return null } - return { min: Validators.getMessage('min', { min, actual: value }) } + return { min: Validators.getError('min', { min, actual: value }) } } } static max(max: number): ValidatorFn { - return (value: any, _: AbstractControl): { max: ValidationError } | null => { + return (value: any, _: AbstractControl): { max: ValidateError } | null => { if (isEmpty(value) || !isNumeric(value) || Number(value) <= max) { return null } - return { max: Validators.getMessage('max', { max, actual: value }) } + return { max: Validators.getError('max', { max, actual: value }) } } } static minLength(minLength: number): ValidatorFn { - return (value: any, _: AbstractControl): { minLength: ValidationError } | null => { + return (value: any, _: AbstractControl): { minLength: ValidateError } | null => { if (isEmpty(value) || !hasLength(value) || value.length >= minLength) { return null } - return { minLength: Validators.getMessage('minLength', { minLength, actual: value.length }) } + return { minLength: Validators.getError('minLength', { minLength, actual: value.length }) } } } static maxLength(maxLength: number): ValidatorFn { - return (value: any, _: AbstractControl): { maxLength: ValidationError } | null => { + return (value: any, _: AbstractControl): { maxLength: ValidateError } | null => { if (isEmpty(value) || !hasLength(value) || value.length <= maxLength) { return null } - return { maxLength: Validators.getMessage('maxLength', { maxLength, actual: value.length }) } + return { maxLength: Validators.getError('maxLength', { maxLength, actual: value.length }) } } } @@ -105,11 +112,11 @@ export class Validators { regexStr = pattern.toString() regex = pattern } - return (value: any, _: AbstractControl): { pattern: ValidationError } | null => { + return (value: any, _: AbstractControl): { pattern: ValidateError } | null => { if (isEmpty(value) || regex.test(value)) { return null } - return { pattern: Validators.getMessage('pattern', { pattern: regexStr, actual: value }) } + return { pattern: Validators.getError('pattern', { pattern: regexStr, actual: value }) } } } @@ -140,8 +147,8 @@ export class Validators { } return (value: any, control: AbstractControl) => { - const validationErrors = executeValidators(value, control, presentValidators) - return Promise.all(validationErrors).then(mergeMessages) + const ValidateErrors = executeValidators(value, control, presentValidators) + return Promise.all(ValidateErrors).then(mergeMessages) } } } @@ -166,12 +173,12 @@ function executeValidators( return validators.map(validator => validator(value, control)) } -function mergeMessages(validationErrors: (ValidationErrors | null)[]): ValidationErrors | null { +function mergeMessages(validateErrors: (ValidateErrors | null)[]): ValidateErrors | null { let res: { [key: string]: any } = {} // Not using Array.reduce here due to a Chrome 80 bug // https://bugs.chromium.org/p/chromium/issues/detail?id=1049982 - validationErrors.forEach((errors: ValidationErrors | null) => { + validateErrors.forEach((errors: ValidateErrors | null) => { res = isNonNil(errors) ? { ...res, ...errors } : res }) diff --git a/packages/components/components.less b/packages/components/components.less index 7d274c457..6c84ebd76 100644 --- a/packages/components/components.less +++ b/packages/components/components.less @@ -13,6 +13,7 @@ @import './dropdown/style/index.less'; @import './menu/style/index.less'; // import Data Entry +@import './form/style/index.less'; @import './checkbox/style/index.less'; @import './input/style/index.less'; @import './radio/style/index.less'; diff --git a/packages/components/config/src/defaultConfig.ts b/packages/components/config/src/defaultConfig.ts index 617b3ae6f..5675b5b89 100644 --- a/packages/components/config/src/defaultConfig.ts +++ b/packages/components/config/src/defaultConfig.ts @@ -29,6 +29,7 @@ import type { StepsConfig, DropdownConfig, ListConfig, + FormConfig, } from './types' import { shallowReactive } from 'vue' @@ -74,6 +75,13 @@ const subMenu = shallowReactive({ }) // --------------------- Data Entry --------------------- +const form = shallowReactive({ + colonless: false, + labelAlign: 'right', + layout: 'horizontal', + size: 'medium', +}) + const input = shallowReactive({ size: 'medium', clearable: false, @@ -206,6 +214,7 @@ export const defaultConfig: GlobalConfig = { menu, subMenu, // Data Entry + form, input, textarea, rate, diff --git a/packages/components/config/src/types.ts b/packages/components/config/src/types.ts index b356995d4..fa41c1808 100644 --- a/packages/components/config/src/types.ts +++ b/packages/components/config/src/types.ts @@ -54,7 +54,17 @@ export interface SubMenuConfig { } // Data Entry -type FormSize = 'small' | 'medium' | 'large' +export type FormLabelAlign = 'left' | 'right' +export type FormLayout = 'horizontal' | 'vertical' | `inline` +export type FormSize = 'small' | 'medium' | 'large' + +export interface FormConfig { + colonless: boolean + labelAlign: FormLabelAlign + layout: FormLayout + size: FormSize +} + export type InputSize = FormSize export interface InputConfig { size: InputSize @@ -224,6 +234,7 @@ export interface GlobalConfig { menu: MenuConfig subMenu: SubMenuConfig // Data Entry + form: FormConfig input: InputConfig textarea: TextareaConfig radioGroup: RadioGroupConfig diff --git a/packages/components/form/__tests__/form.spec.ts b/packages/components/form/__tests__/form.spec.ts new file mode 100644 index 000000000..33337c1b4 --- /dev/null +++ b/packages/components/form/__tests__/form.spec.ts @@ -0,0 +1,15 @@ +import { mount, MountingOptions, VueWrapper } from '@vue/test-utils' +import { renderWork } from '@tests' +import IxForm from '../src/Form.vue' +import { FormInstance, FormProps } from '../src/types' + +describe.skip('Form.vue', () => { + let FormMount: (options?: MountingOptions>) => VueWrapper + + beforeEach(() => { + FormMount = options => mount(IxForm, { ...options }) + console.log(FormMount) + }) + + renderWork(IxForm) +}) diff --git a/packages/components/form/demo/Basic.md b/packages/components/form/demo/Basic.md new file mode 100644 index 000000000..817a7ee7c --- /dev/null +++ b/packages/components/form/demo/Basic.md @@ -0,0 +1,14 @@ +--- +title: + zh: 基本使用 + en: Basic usage +order: 0 +--- + +## zh + +最简单的用法。 + +## en + +The simplest usage. diff --git a/packages/components/form/demo/Basic.vue b/packages/components/form/demo/Basic.vue new file mode 100644 index 000000000..7f84e18e7 --- /dev/null +++ b/packages/components/form/demo/Basic.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/packages/components/form/demo/Coordinated.md b/packages/components/form/demo/Coordinated.md new file mode 100644 index 000000000..29f9f2f76 --- /dev/null +++ b/packages/components/form/demo/Coordinated.md @@ -0,0 +1,18 @@ +--- +title: + zh: 表单联动 + en: Coordinated Controls +order: 3 +--- + +## zh + +使用 `setValue` 来动态设置其他控件的值。 + +使用 `setValidators` 来动态设置其他控件的校验规则。 + +## en + +Use `setValue` to set other control's value according to different situations. + +Use `setValidators` to set other control's validators according to different situations. diff --git a/packages/components/form/demo/Coordinated.vue b/packages/components/form/demo/Coordinated.vue new file mode 100644 index 000000000..898021690 --- /dev/null +++ b/packages/components/form/demo/Coordinated.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/packages/components/form/demo/CustomizedValidation.md b/packages/components/form/demo/CustomizedValidation.md new file mode 100644 index 000000000..fb4230e24 --- /dev/null +++ b/packages/components/form/demo/CustomizedValidation.md @@ -0,0 +1,22 @@ +--- +title: + zh: 自定义验证 + en: Customized validation +order: 7 +--- + +## zh + +我们提供了 `status`, `hasFeedback` 和 `message` 等属性,你可以不通过 `control` 来设置验证状态和提示信息。 + +- `status`: 校验状态, 共3种: `valid`, `validating`, `invalid`。 +- `hasFeedback`: 添加反馈图标。 +- `message`: 设置提示信息。 + +## en + +We provide properties like `status`, `hasFeedback` and `message` to customize validate status and message, without using `control`. + +- `status`: validate status of form components which could be `valid`, `validating` and `invalid`. +- `hasFeedback`: display feed icon of input control +- `message`: display validate message. diff --git a/packages/components/form/demo/CustomizedValidation.vue b/packages/components/form/demo/CustomizedValidation.vue new file mode 100644 index 000000000..b1c932c42 --- /dev/null +++ b/packages/components/form/demo/CustomizedValidation.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/packages/components/form/demo/DynamicItem.md b/packages/components/form/demo/DynamicItem.md new file mode 100644 index 000000000..37690b4ca --- /dev/null +++ b/packages/components/form/demo/DynamicItem.md @@ -0,0 +1,14 @@ +--- +title: + zh: 动态增减表单项 + en: Dynamic Form Item +order: 5 +--- + +## zh + +动态增加、减少表单项。 + +## en + +Add or remove form items dynamically. diff --git a/packages/components/form/demo/DynamicItem.vue b/packages/components/form/demo/DynamicItem.vue new file mode 100644 index 000000000..dc1fe8c67 --- /dev/null +++ b/packages/components/form/demo/DynamicItem.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/packages/components/form/demo/Layout.md b/packages/components/form/demo/Layout.md new file mode 100644 index 000000000..67180be16 --- /dev/null +++ b/packages/components/form/demo/Layout.md @@ -0,0 +1,14 @@ +--- +title: + zh: 表单布局 + en: Form layout +order: 12 +--- + +## zh + +表单有三种布局: `horizontal`, `vertical`, `inline`。 + +## en + +There are three layout for form: `horizontal`, `vertical`, `inline`. diff --git a/packages/components/form/demo/Layout.vue b/packages/components/form/demo/Layout.vue new file mode 100644 index 000000000..ef12242fb --- /dev/null +++ b/packages/components/form/demo/Layout.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/packages/components/form/demo/Message.md b/packages/components/form/demo/Message.md new file mode 100644 index 000000000..2020cdfb2 --- /dev/null +++ b/packages/components/form/demo/Message.md @@ -0,0 +1,18 @@ +--- +title: + zh: 默认错误提示 + en: Default error messages +order: 6 +--- + +## zh + +通过 `Validators.setMessages` 来设置默认的错误提示,可以减少 `template` 中的重复代码。 + +注意:只需要全局设置一次,通常在 `main.ts` 中设置它。 + +## en + +By `Validators.setMessages` to set the default error message, you can reduce the repetitive code in the `template`. + +Note: You only need to set it globally once, usually in `main.ts`. diff --git a/packages/components/form/demo/Message.vue b/packages/components/form/demo/Message.vue new file mode 100644 index 000000000..069b4027e --- /dev/null +++ b/packages/components/form/demo/Message.vue @@ -0,0 +1,136 @@ + + + + + diff --git a/packages/components/form/demo/Nest.md b/packages/components/form/demo/Nest.md new file mode 100644 index 000000000..db602adf4 --- /dev/null +++ b/packages/components/form/demo/Nest.md @@ -0,0 +1,14 @@ +--- +title: + zh: 嵌套表单 + en: Nest form +order: 4 +--- + +## zh + +支持嵌套形式的表单,可以使用 `ix-form-wrapper` 来简化 `control` 的路径。 + +## en + +Supports nest forms, you can use `ix-form-wrapper` to simplify the path of `control`. diff --git a/packages/components/form/demo/Nest.vue b/packages/components/form/demo/Nest.vue new file mode 100644 index 000000000..1787c3ee9 --- /dev/null +++ b/packages/components/form/demo/Nest.vue @@ -0,0 +1,89 @@ + + + diff --git a/packages/components/form/demo/Register.md b/packages/components/form/demo/Register.md new file mode 100644 index 000000000..d8cc264c7 --- /dev/null +++ b/packages/components/form/demo/Register.md @@ -0,0 +1,14 @@ +--- +title: + zh: 注册表单 + en: Registration form +order: 1 +--- + +## zh + +用户填写必须的信息以注册新用户。 + +## en + +Fill in this form to create a new account for you. diff --git a/packages/components/form/demo/Register.vue b/packages/components/form/demo/Register.vue new file mode 100644 index 000000000..07ced0aed --- /dev/null +++ b/packages/components/form/demo/Register.vue @@ -0,0 +1,129 @@ + + + + + diff --git a/packages/components/form/demo/Search.md b/packages/components/form/demo/Search.md new file mode 100644 index 000000000..64f115231 --- /dev/null +++ b/packages/components/form/demo/Search.md @@ -0,0 +1,18 @@ +--- +title: + zh: 高级搜索 + en: Advanced search +order: 2 +--- + +## zh + +三列栅格式的表单排列方式,常用于数据表格的高级搜索。 + +有部分定制的样式代码,由于输入标签长度不确定,需要根据具体情况自行调整。 + +## en + +Three columns layout is often used for advanced searching of data table. + +Because the width of label is not fixed, you may need to adjust it by customizing its style. diff --git a/packages/components/form/demo/Search.vue b/packages/components/form/demo/Search.vue new file mode 100644 index 000000000..e5ef2dd3c --- /dev/null +++ b/packages/components/form/demo/Search.vue @@ -0,0 +1,56 @@ + + + + + diff --git a/packages/components/form/docs/Index.en.md b/packages/components/form/docs/Index.en.md new file mode 100644 index 000000000..45895a43b --- /dev/null +++ b/packages/components/form/docs/Index.en.md @@ -0,0 +1,33 @@ +--- +category: components +type: Data Entry +title: Form +subtitle: +order: 0 +--- + + + +## When To Use + +## API + +### ix-form + +#### Props + +| Name | Description | Type | Default | Global Config | Remark | +| --- | --- | --- | --- | --- | --- | +| - | - | - | - | ✅ | - | + +#### Slots + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | + +#### Emits + +| Name | Description | Parameter Type | Remark | +| --- | --- | --- | --- | +| - | - | - | - | diff --git a/packages/components/form/docs/Index.zh.md b/packages/components/form/docs/Index.zh.md new file mode 100644 index 000000000..7f412406b --- /dev/null +++ b/packages/components/form/docs/Index.zh.md @@ -0,0 +1,69 @@ +--- +category: components +type: 数据录入 +title: Form +subtitle: 表单 +order: 0 +single: true +--- + +具有数据收集、校验和提交功能的表单,包含复选框、单选框、输入框、下拉选择框等元素。 + +该组件推荐与 `@idux/cdk/forms` 结合使用。 + +## 何时使用 + +- 用于创建一个实体或收集信息。 + +- 需要对输入的数据进行校验时。 + +## API + +### ix-form + +#### FormProps + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `colonless` | 配置 `ix-form-item` 的 `colon` 默认值 | `boolean` | `false` | ✅ | - | +| `control` | 表单的控制器 | `string \| number \| AbstractControl` | - | - | 通常是配合 `useFormGroup` 使用 | +| `controlCol` | 配置 `ix-form-item` 的 `controlCol` 默认值 | `string \| number \| ColProps` | - | - | - | +| `hasFeedback` | 配置 `ix-form-item` 的 `hasFeedback` 默认值 | `boolean` | `false` | - | - | +| `labelAlign` | 配置 `ix-form-item` 的 `labelAlign` 默认值 | `left \| right` | `right` | ✅ | - | +| `labelCol` | 配置 `ix-form-item` 的 `labelCol` 默认值 | `string \| number \| ColProps` | - | - | - | +| `layout` | 表单布局 | `horizontal \| vertical \| inline` | `horizontal` | ✅ | - | +| `size` | 表单大小 | `small \| medium \| large` | `medium` | ✅ | - | + +### ix-form-item + +表单项组件,用于控制器的绑定、校验、布局等。 + +#### FormItemProps + +> 除以下表格之外还支持 `ix-row` 组件的[所有属性](/components/grid/zh#ix-row) + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `colonless` | 是否不显示 `label` 后面的冒号 | `boolean` | - | - | - | +| `control` | 表单控件的控制器 | `string \| number \| AbstractControl` | - | - | - | +| `controlCol` | 配置表单控件的布局,同 `` 组件,设置 `span` `offset` 的值 | `string \| number \| ColProps` | - | - | 传入 `string` 或者 `number` 时,为 `ix-col` 的 `span` 配置 | +| `extra` | 额外的提示信息 | `string \| v-slot:extra` | - | - | 当需要错误信息和提示文案同时出现时使用 | +| `hasFeedback` | 是否展示校验状态图标 | `boolean` | `false` | - | - | +| `label` | `label` 标签的文本| `string \| v-slot:label` | - | - | - | +| `labelAlign` | `label` 标签文本对齐方式 | `left \| right` | - | - | - | +| `labelCol` | `label` 标签布局,同 `` 组件,设置 `span` `offset` 的值 | `string \| number \| ColProps` | - | - | 传入 `string` 或者 `number` 时,为 `ix-col` 的 `span` 配置 | +| `labelFor` | `label` 标签的 `for` 属性 | `string` | - | - | - | +| `labelTooltip` | 配置提示信息 | `sting \| v-slot:tooltip` | - | - | - | +| `required` | 必填样式设置 | `boolean` | `false` | - | 仅控制样式 | +| `message` | 手动指定表单项的校验提示 | `string \| FormMessageFn \| FormStatusMessage` | - | - | 传入 `string` 时,为 `invalid` 状态的提示 | +| `status` | 手动指定表单项的校验状态 | `valid \| invalid \| validating` | - | - | - | + +### ix-form-wrapper + +用于嵌套表单时, 简于子组件的 `control` 路径 + +#### FormWrapperProps + +| 名称 | 说明 | 类型 | 默认值 | 全局配置 | 备注 | +| --- | --- | --- | --- | --- | --- | +| `control` | 表单控件的控制器 | `string \| number \| AbstractControl` | - | - | - | diff --git a/packages/components/form/index.ts b/packages/components/form/index.ts new file mode 100644 index 000000000..5181d7731 --- /dev/null +++ b/packages/components/form/index.ts @@ -0,0 +1,21 @@ +import type { App } from 'vue' + +import IxForm from './src/Form.vue' +import IxFormItem from './src/FormItem.vue' +import IxFormWrapper from './src/FormWrapper.vue' + +IxForm.install = (app: App): void => { + app.component(IxForm.name, IxForm) +} + +IxFormItem.install = (app: App): void => { + app.component(IxFormItem.name, IxFormItem) +} + +IxFormWrapper.install = (app: App): void => { + app.component(IxFormWrapper.name, IxFormWrapper) +} + +export { IxForm, IxFormItem, IxFormWrapper } + +export type { FormInstance, FormProps, FormItemInstance, FormItemProps } from './src/types' diff --git a/packages/components/form/src/Form.vue b/packages/components/form/src/Form.vue new file mode 100644 index 000000000..e91a7d9e4 --- /dev/null +++ b/packages/components/form/src/Form.vue @@ -0,0 +1,59 @@ + + + diff --git a/packages/components/form/src/FormItem.vue b/packages/components/form/src/FormItem.vue new file mode 100644 index 000000000..64f056e00 --- /dev/null +++ b/packages/components/form/src/FormItem.vue @@ -0,0 +1,75 @@ + + + diff --git a/packages/components/form/src/FormWrapper.vue b/packages/components/form/src/FormWrapper.vue new file mode 100644 index 000000000..0231e063c --- /dev/null +++ b/packages/components/form/src/FormWrapper.vue @@ -0,0 +1,20 @@ + + + diff --git a/packages/components/form/src/token.ts b/packages/components/form/src/token.ts new file mode 100644 index 000000000..18c87f54f --- /dev/null +++ b/packages/components/form/src/token.ts @@ -0,0 +1,15 @@ +import type { ComputedRef, InjectionKey, Ref } from 'vue' +import type { FormLabelAlign } from '@idux/components/config' +import type { ColType, FormItemProps } from './types' + +export interface FormContext { + colonless: ComputedRef + controlCol: Ref + hasFeedback: Ref + labelAlign: ComputedRef + labelCol: Ref +} + +export const formToken: InjectionKey = Symbol('formToken') + +export const formItemToken: InjectionKey = Symbol('formItemToken') diff --git a/packages/components/form/src/types.ts b/packages/components/form/src/types.ts new file mode 100644 index 000000000..96fd88df0 --- /dev/null +++ b/packages/components/form/src/types.ts @@ -0,0 +1,71 @@ +import type { DefineComponent } from 'vue' +import type { AbstractControl, ValidateStatus } from '@idux/cdk/forms' +import type { FormLabelAlign, FormLayout, FormSize } from '@idux/components/config' +import type { ColProps } from '@idux/components/grid' + +import { controlPropTypeDef } from '@idux/cdk/forms' +import { PropTypes, withUndefined } from '@idux/cdk/utils' +import { object } from 'vue-types' + +export type ColType = string | number | ColProps +const colPropTypeDef = withUndefined(PropTypes.oneOfType([PropTypes.string, PropTypes.number, object()])) + +export interface FormProps { + colonless?: boolean + control?: string | number | AbstractControl + controlCol?: ColType + hasFeedback: boolean + labelAlign?: FormLabelAlign + labelCol?: ColType + layout?: FormLayout + size?: FormSize +} + +export const formPropsDef = { + colonless: PropTypes.bool, + control: controlPropTypeDef, + controlCol: colPropTypeDef, + hasFeedback: PropTypes.bool.def(false), + labelAlign: PropTypes.oneOf(['left', 'right'] as const), + labelCol: colPropTypeDef, + layout: PropTypes.oneOf(['horizontal', 'vertical', 'inline'] as const), + size: PropTypes.oneOf(['large', 'medium', 'small'] as const), +} + +export type FormInstance = InstanceType> +export type FormMessageFn = (control: AbstractControl | null) => string +export type FormStatusMessage = Partial> + +export interface FormItemProps { + colonless?: boolean + control?: string | number | AbstractControl + controlCol?: ColType + extra?: string + hasFeedback?: boolean + label?: string + labelAlign?: FormLabelAlign + labelCol?: ColType + labelFor?: string + labelTooltip?: string + required: boolean + message?: string | FormMessageFn | FormStatusMessage + status?: ValidateStatus +} + +export const formItemPropsDef = { + colonless: PropTypes.bool, + control: controlPropTypeDef, + controlCol: colPropTypeDef, + extra: PropTypes.string, + hasFeedback: PropTypes.bool, + label: PropTypes.string, + labelAlign: PropTypes.oneOf(['left', 'right'] as const), + labelCol: colPropTypeDef, + labelFor: PropTypes.string, + labelTooltip: PropTypes.string, + required: PropTypes.bool.def(false), + status: PropTypes.oneOf(['valid', 'invalid', 'validating'] as const), + message: withUndefined(PropTypes.oneOfType([PropTypes.string, PropTypes.func, object()])), +} + +export type FormItemInstance = InstanceType> diff --git a/packages/components/form/src/useFormItem.ts b/packages/components/form/src/useFormItem.ts new file mode 100644 index 000000000..789efb0fa --- /dev/null +++ b/packages/components/form/src/useFormItem.ts @@ -0,0 +1,163 @@ +import type { ComputedRef } from 'vue' +import type { AbstractControl, ValidateStatus, ValidateErrors } from '@idux/cdk/forms' +import type { ColProps } from '@idux/components/grid' +import type { FormItemProps, FormMessageFn } from './types' +import type { FormContext } from './token' + +import { computed } from 'vue' +import { useValueControl, provideControlOrPath } from '@idux/cdk/forms' +import { isFunction, isNumber, isString } from '@idux/cdk/utils' +import { getLocale, LocaleType } from '@idux/components/i18n' + +export const useFormItemClasses = ( + hasFeedback: ComputedRef, + status: ComputedRef, + message: ComputedRef, +): ComputedRef> => { + return computed(() => { + const statusValue = status.value + return { + 'ix-form-item': true, + [`ix-form-item-${statusValue}`]: !!statusValue, + 'ix-form-item-has-feedback': hasFeedback.value && !!statusValue, + 'ix-form-item-has-message': !!message.value, + } + }) +} + +export const useFormItemLabelClasses = ( + props: FormItemProps, + formContext: FormContext | undefined, +): ComputedRef> => { + return computed(() => { + const labelAlign = props.labelAlign ?? formContext?.labelAlign.value + return { + 'ix-form-item-label': true, + 'ix-form-item-label-required': props.required, + 'ix-form-item-label-colonless': !!(props.colonless ?? formContext?.colonless.value), + 'ix-form-item-label-left': labelAlign === 'left', + } + }) +} + +const normalizeColConfig = (col: string | number | ColProps | undefined) => { + return isNumber(col) || isString(col) ? { span: col } : col +} + +export interface UseFormItemColConfig { + labelColConfig: ComputedRef + controlColConfig: ComputedRef +} + +export const useFormItemColConfig = ( + props: FormItemProps, + formContext: FormContext | undefined, +): UseFormItemColConfig => { + return { + labelColConfig: computed(() => normalizeColConfig(props.labelCol ?? formContext?.labelCol.value)), + controlColConfig: computed(() => normalizeColConfig(props.controlCol ?? formContext?.controlCol.value)), + } +} + +const useStatus = (props: FormItemProps, control: ComputedRef) => { + return computed(() => { + if (props.status !== undefined) { + return props.status + } + const currControl = control.value + if (!currControl) { + return null + } + if (currControl.blurred.value || currControl.dirty.value) { + return currControl.status.value + } + return null + }) +} + +const getMessageByError = (error: ValidateErrors | null | undefined, localeType: LocaleType) => { + if (!error) { + return null + } + + for (const key in error) { + const { message, ...rest } = error[key] + if (message) { + if (isString(message)) { + return message + } + + if (isFunction(message)) { + return message(rest) + } + + const currMessage = message[localeType] + if (isString(currMessage)) { + return currMessage + } + return currMessage(rest) + } + } + + return null +} + +const useMessage = ( + control: ComputedRef, + status: ComputedRef, + messages: ComputedRef>>, + errors: ComputedRef, +) => { + const locale = getLocale() + return computed(() => { + const currStatus = status.value + if (!currStatus) { + return null + } + + const currMessage = messages.value[currStatus] + if (currMessage) { + return isString(currMessage) ? currMessage : currMessage(control.value) + } + + return getMessageByError(errors.value, locale.value.type) + }) +} + +export interface UseFormItemControl { + control: ComputedRef + errors: ComputedRef + status: ComputedRef + message: ComputedRef + statusIcon: ComputedRef +} + +const iconTypeMap = { + invalid: 'close-circle-filled', + validating: 'loading', + valid: 'check-circle-filled', +} as const + +export const useFormItemControl = (props: FormItemProps): UseFormItemControl => { + provideControlOrPath(computed(() => props.control)) + + const control = useValueControl() + + const errors = computed(() => control.value?.errors.value) + + const status = useStatus(props, control) + + const messages = computed(() => { + const message = props.message + return isString(message) || isFunction(message) ? { invalid: message } : message || {} + }) + + const message = useMessage(control, status, messages, errors) + + const statusIcon = computed(() => { + const currStatus = status.value + return currStatus ? iconTypeMap[currStatus] : null + }) + + return { control, errors, status, message, statusIcon } +} diff --git a/packages/components/form/style/index.less b/packages/components/form/style/index.less new file mode 100644 index 000000000..5c229e0c2 --- /dev/null +++ b/packages/components/form/style/index.less @@ -0,0 +1,123 @@ +@import '../../style/default.less'; +@import './mixin.less'; +@import './status.less'; +@import './layout.less'; + +@form-prefix: ~'@{idux-prefix}-form'; +@form-item-prefix: ~'@{idux-prefix}-form-item'; + +@form-item-valid-color: @success; +@form-item-invalid-color: @error; +@form-item-validating-color: @pending; +@form-item-margin-bottom: 24px; +@form-vertical-label-margin: 0; +@form-vertical-label-padding: 0 0 8px; +@form-font-height: ceil(@font-size-md * @line-height-base); +@label-required-color: @red-l10; +@label-color: @heading-color; +@form-warning-input-bg: @background-color-component; +@form-item-label-font-size: @font-size-md; +@form-item-label-height: @input-height-md; +@form-item-label-colon-margin-right: 8px; +@form-item-label-colon-margin-left: 2px; +@form-error-input-bg: @background-color-component; + +@form-invalid-box-shadow: 0 0 0 2px fade(@form-item-invalid-color, 20%); + +.@{form-prefix} { + &-small { + .form-size(@input-height-sm); + } + &-medium { + .form-size(@input-height-md); + } + &-large { + .form-size(@input-height-lg); + } +} + +.@{form-item-prefix} { + margin-bottom: @form-item-margin-bottom; + vertical-align: top; + + &-has-message { + margin-bottom: 0; + } + + &-label { + display: inline-block; + flex-grow: 0; + overflow: hidden; + white-space: nowrap; + text-align: right; + vertical-align: middle; + + &-left { + text-align: left; + } + + > label { + position: relative; + display: inline-flex; + align-items: center; + color: @label-color; + font-size: @form-item-label-font-size; + + &::after { + position: relative; + top: -0.5px; + margin: 0 @form-item-label-colon-margin-right 0 @form-item-label-colon-margin-left; + content: ':'; + } + } + + &-colonless > label::after { + content: ' '; + } + + &-required > label::before { + display: inline-block; + margin-right: 4px; + color: @label-required-color; + font-size: @form-item-label-font-size; + font-family: SimSun, sans-serif; + line-height: 1; + content: '*'; + } + + &-tooltip { + color: @text-color-secondary; + cursor: help; + writing-mode: horizontal-tb; + margin-inline-start: @margin-xs; + } + } + + &-control { + display: flex; + flex-direction: column; + flex-grow: 1; + + &-input { + position: relative; + display: flex; + align-items: center; + + &-content { + flex: auto; + max-width: 100%; + } + } + } + + &-message, + &-extra { + clear: both; + min-height: @form-item-margin-bottom; + color: @text-color-secondary; + font-size: @font-size-base; + line-height: @line-height-base; + transition: color 0.3s @ease-out; // sync input color transition + .form-message-extra-distance((@form-item-margin-bottom - @form-font-height) / 2); + } +} diff --git a/packages/components/form/style/layout.less b/packages/components/form/style/layout.less new file mode 100644 index 000000000..2909e5d55 --- /dev/null +++ b/packages/components/form/style/layout.less @@ -0,0 +1,76 @@ +.@{form-prefix}-horizontal { + .@{form-item-prefix}-label { + flex-grow: 0; + } + .@{form-item-prefix}-control { + flex: 1 1 0; + } +} + +.@{form-prefix}-inline { + display: flex; + flex-wrap: wrap; + + .@{form-prefix}-item { + flex: none; + flex-wrap: nowrap; + margin-right: 16px; + margin-bottom: 0; + + &-has-message { + margin-bottom: @form-item-margin-bottom; + } + + > .@{form-item-prefix}-label, + > .@{form-item-prefix}-control { + display: inline-block; + vertical-align: top; + } + + > .@{form-item-prefix}-label { + flex: none; + } + } +} + +.@{form-prefix}-vertical { + .@{form-item-prefix} { + flex-direction: column; + + &-label > label { + height: auto; + } + } +} + +.@{form-prefix}-vertical .@{form-item-prefix}-label, + // when labelCol is 24, it is a vertical form + .@{idux-prefix}-col-24.@{form-item-prefix}-label, + .@{idux-prefix}-col-xl-24.@{form-item-prefix}-label { + .form-vertical-layout-label(); +} + +@media (max-width: @screen-xs-max) { + .form-vertical-layout(); + .@{idux-prefix}-col-xs-24.@{form-item-prefix}-label { + .form-vertical-layout-label(); + } +} + +@media (max-width: @screen-sm-max) { + .@{idux-prefix}-col-sm-24.@{form-item-prefix}-label { + .form-vertical-layout-label(); + } +} + +@media (max-width: @screen-md-max) { + .@{idux-prefix}-col-md-24.@{form-item-prefix}-label { + .form-vertical-layout-label(); + } +} + +@media (max-width: @screen-lg-max) { + .@{idux-prefix}-col-lg-24.@{form-item-prefix}-label { + .form-vertical-layout-label(); + } +} diff --git a/packages/components/form/style/mixin.less b/packages/components/form/style/mixin.less new file mode 100644 index 000000000..e55f25f29 --- /dev/null +++ b/packages/components/form/style/mixin.less @@ -0,0 +1,52 @@ +.form-vertical-layout-label() { + & when (@form-vertical-label-margin > 0) { + margin: @form-vertical-label-margin; + } + padding: @form-vertical-label-padding; + line-height: @line-height-base; + white-space: initial; + text-align: left; + + > label { + margin: 0; + + &::after { + display: none; + } + } +} + +.form-vertical-layout() { + .@{form-prefix}-item .@{form-prefix}-item-label { + .form-vertical-layout-label(); + } + .@{form-prefix} { + .@{form-prefix}-item { + flex-wrap: wrap; + .@{form-prefix}-item-label, + .@{form-prefix}-item-control { + flex: 0 0 100%; + max-width: 100%; + } + } + } +} + +.form-size(@form-height) { + .@{form-item-prefix}-label > label { + height: @form-height; + } + + .@{form-item-prefix}-control-input { + min-height: @form-height; + } +} + +.form-message-extra-distance(@num) when (@num >= 0) { + padding-top: floor(@num); +} + +.form-message-extra-distance(@num) when (@num < 0) { + margin-top: ceil(@num); + margin-bottom: ceil(@num); +} diff --git a/packages/components/form/style/status.less b/packages/components/form/style/status.less new file mode 100644 index 000000000..c18de3ee5 --- /dev/null +++ b/packages/components/form/style/status.less @@ -0,0 +1,117 @@ +.@{form-item-prefix} { + // === invalid === + &-invalid { + .@{form-item-prefix}-message { + color: @form-item-invalid-color; + } + + &.@{form-item-prefix}-has-feedback .@{form-item-prefix}-status-icon { + color: @form-item-invalid-color; + } + + // === input === + .@{idux-prefix}-input:not(.@{idux-prefix}-input-disabled) { + .@{idux-prefix}-input-wrapper { + border-color: @form-item-invalid-color; + + &:hover { + border-color: @form-item-invalid-color; + } + + .@{idux-prefix}-input-suffix, + .@{idux-prefix}-input-prefix { + color: @form-item-invalid-color; + &:hover { + color: @form-item-invalid-color; + } + } + } + + &.@{idux-prefix}-input-focused .@{idux-prefix}-input-wrapper { + border-color: @form-item-invalid-color; + box-shadow: @form-invalid-box-shadow; + } + + .@{idux-prefix}-input-addon { + color: @form-item-invalid-color; + border-color: @form-item-invalid-color; + + .@{idux-prefix}-select { + &:hover .@{idux-prefix}-select-selector, + .@{idux-prefix}-select-selector { + border-color: transparent; + } + + &.@{idux-prefix}-select-active .@{idux-prefix}-select-selector { + border-color: transparent; + // todo remove + box-shadow: none; + } + } + } + } + + // select + .@{idux-prefix}-select:not(.@{idux-prefix}-select-disabled) { + &:hover .@{idux-prefix}-select-selector, + .@{idux-prefix}-select-selector { + border-color: @form-item-invalid-color; + } + + &.@{idux-prefix}-select-active .@{idux-prefix}-select-selector { + border-color: @form-item-invalid-color; + box-shadow: @form-invalid-box-shadow; + } + } + } + + // === Validating === + &-validating { + .@{form-item-prefix}-message { + color: @form-item-validating-color; + } + + &.@{form-item-prefix}-has-feedback .@{form-item-prefix}-status-icon { + display: inline-block; + color: @form-item-validating-color; + } + } + + // === valid === + &-valid { + .@{form-item-prefix}-message { + color: @form-item-valid-color; + } + + &.@{form-item-prefix}-has-feedback .@{form-item-prefix}-status-icon { + color: @form-item-valid-color; + } + } + + &-has-feedback { + .@{form-item-prefix}-status-icon { + position: absolute; + top: 50%; + right: 0; + z-index: 1; + width: @input-height-md; + height: 20px; + margin-top: -10px; + font-size: @input-font-size-md; + line-height: 20px; + text-align: center; + visibility: visible; + animation: zoomIn 0.3s @ease-out-back; + pointer-events: none; + } + + // input + .@{idux-prefix}-input-wrapper { + padding-right: 24px; + + .@{idux-prefix}-input-suffix { + padding-right: 18px; + } + } + } +} diff --git a/packages/components/icon/__tests__/icon.spec.ts b/packages/components/icon/__tests__/icon.spec.ts index 5c79a624b..087507e9d 100644 --- a/packages/components/icon/__tests__/icon.spec.ts +++ b/packages/components/icon/__tests__/icon.spec.ts @@ -100,28 +100,6 @@ describe('Icon.vue', () => { expect(wrapper.find('svg').attributes()['style']).toEqual('transform: rotate(180deg)') }) - test('tag work', async () => { - const wrapper = mount({ - components: { IxIcon }, - template: ``, - props: { - onClick: { type: Function, default: () => void 0 }, - }, - }) - expect(wrapper.find('i').exists()).toBeFalsy() - expect(wrapper.find('button').exists()).toBeTruthy() - - await wrapper.setProps({ onClick: null }) - - expect(wrapper.find('i').exists()).toBeTruthy() - expect(wrapper.find('button').exists()).toBeFalsy() - - await wrapper.setProps({ onClick: () => void 0 }) - - expect(wrapper.find('i').exists()).toBeFalsy() - expect(wrapper.find('button').exists()).toBeTruthy() - }) - test('slot work', async () => { const wrapper = IconMount({ slots: { diff --git a/packages/components/icon/src/Icon.vue b/packages/components/icon/src/Icon.vue index 5f5c2b254..4a2f8c88a 100644 --- a/packages/components/icon/src/Icon.vue +++ b/packages/components/icon/src/Icon.vue @@ -1,14 +1,14 @@ diff --git a/packages/components/icon/src/staticIcons.ts b/packages/components/icon/src/staticIcons.ts index 4de520ae5..dea1f96e3 100644 --- a/packages/components/icon/src/staticIcons.ts +++ b/packages/components/icon/src/staticIcons.ts @@ -24,6 +24,7 @@ import { Star, VerticalAlignTop, Ellipsis, + QuestionCircle, } from './definitions' export const staticIcons: IconDefinition[] = [ @@ -37,12 +38,12 @@ export const staticIcons: IconDefinition[] = [ RotateRight, ZoomIn, ZoomOut, - Close, Check, CheckCircle, + CheckCircleFilled, + Close, CloseCircle, CloseCircleFilled, - CheckCircleFilled, InfoCircle, InfoCircleFilled, ExclamationCircleFilled, @@ -50,4 +51,5 @@ export const staticIcons: IconDefinition[] = [ Star, VerticalAlignTop, Ellipsis, + QuestionCircle, ] diff --git a/packages/components/icon/src/types.ts b/packages/components/icon/src/types.ts index 902a9d6d6..ef9e2539c 100644 --- a/packages/components/icon/src/types.ts +++ b/packages/components/icon/src/types.ts @@ -1,13 +1,11 @@ import type { DefineComponent } from 'vue' -interface IconOriginalProps { +export interface IconProps { + iconfont: boolean name?: string rotate?: boolean | number | string - iconfont?: boolean } -export type IconProps = Readonly - export type IconInstance = InstanceType> export interface IconDefinition { diff --git a/packages/components/image/src/Image.vue b/packages/components/image/src/Image.vue index 7df026108..8167c0a1d 100644 --- a/packages/components/image/src/Image.vue +++ b/packages/components/image/src/Image.vue @@ -45,11 +45,7 @@ export default defineComponent({ alt: PropTypes.string.def(''), objectFit: PropTypes.string.def('fill'), }, - emits: { - statusChange: (status: string) => { - return !!status - }, - }, + emits: ['statusChange'], setup(props: ImageProps, { emit }) { const isShowPreview = ref(false) const imageConfig = useGlobalConfig('image') diff --git a/packages/components/index.ts b/packages/components/index.ts index 7808d1144..c7a5e0070 100644 --- a/packages/components/index.ts +++ b/packages/components/index.ts @@ -17,6 +17,7 @@ import { IxAffix } from './affix' import { IxDropdown, IxDropdownButton } from './dropdown' import { IxMenu, IxMenuItem, IxMenuItemGroup, IxMenuDivider, IxSubMenu } from './menu' // import Data Entry +import { IxForm, IxFormItem, IxFormWrapper } from './form' import { IxCheckbox, IxCheckboxGroup } from './checkbox' import { IxInput, IxTextarea } from './input' import { IxRadio, IxRadioButton, IxRadioGroup } from './radio' @@ -66,6 +67,9 @@ const components = [ IxMenuDivider, IxSubMenu, // components Data Entry + IxForm, + IxFormItem, + IxFormWrapper, IxCheckbox, IxCheckboxGroup, IxInput, diff --git a/packages/components/input/demo/Addon.vue b/packages/components/input/demo/Addon.vue index 2ce37f019..8efa0e7d3 100644 --- a/packages/components/input/demo/Addon.vue +++ b/packages/components/input/demo/Addon.vue @@ -1,7 +1,22 @@