Repository: IduxFE/components (ts)

Issue:
[cdk: forms]: reactive forms system
- [x] I have searched the [issues](https://github.com/IduxFE/components/issues) of this repository and believe that this is not a duplicate.

### What problem does this feature solve?
<!-- Explain your use case, context, and rationale behind this feature request. More importantly, what is the end user experience you are trying to build that led to the need for this feature? -->


### What does the proposed API look like?
<!-- Describe how you propose to solve the problem and provide code samples of how the API would work once implemented. Note that you can use Markdown to format your code blocks. -->

参考自 [@angular/forms](https://angular.cn/guide/forms-overview)

## 特性

- 显式创建表单模型
- 函数式的表单验证：内置常用的，提供扩展接口
- 表单状态管理：valid，invalid, validing，untouched，touched

## API

### 实现一个表单组件

- useFormAccessor： 注册表单控制器

  - ```ts
    onChange(fn: any): void // 注册 onChange 事件。
    onTouched(fn: any): void // 注册 onTouched 事件
    ```

- useValidators: 注册同步验证器 (自定义业务表单组件需要)

  - ```ts
    validate(value: any): ValidationErrors | null // 自定义验证函数
    onValidatorChange?(fn: () => void): void // 注册验证状态改变的事件 可选实现
    ```

- useAsyncValidators：注册异步验证器 (自定义业务表单组件需要)

  - ```ts
    validate(value: any): Promise<ValidationErrors | null> // 自定义验证函数
    onValidatorChange?(fn: () => void): void // 注册验证状态改变的事件 可选实现
    ```

### 实现示例

```html
<template>
  <input :value="modelValue" @input="onInput($event.target.value)" @blur="onTouched" />
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue'
import { useFormAccessor } from '@idux/cdk/forms'

export default defineComponent({
  props: {
    modelValue: String,
  },
  emits: ['update:modelValue'],
  setup(,{emits}) {
    const { onChange, onTouched } = useFormAccessor()
    const onInput = value => {
      onChange(value)
      emits('update:modelValue', value)
    }
    return { onInput, onTouched }
  },
})
</script>
```



### 使用一个表单组件

- useFormGroup: 使用表单组
- useFormArray:  使用表单数组
- useFormControl:  使用单个表单控件

### 使用示例

```html
<template>
  <form>
    <ix-input v-model="modelRef.name" />
    <ix-input v-model="modelRef.age" />
    <ix-button @click="submit"> Submit </ix-button>
    <ix-button type="button" @click="reset"> Reset </ix-button>
    validMessages: {{ validMessages }}
  </form>
</template>
<script lang="ts">
import { defineComponent, toRaw } from 'vue'
import { uesFormGroup, Validators } from '@idux/cdk/forms'

export default defineComponent({
  setup() {
    const { required, min, max, minLength } = Validators
    const { modelRef, validate, reset, validMessages } = uesFormGroup({
      name: ['Tom', [required, minLength(2)]],
      age: [18, [required, min(18), max(99)]],
    })

    const submit = () => {
      if (validate()) {
        console.log(toRaw(modelRef))
      }
    }

    return { modelRef, submit, reset, validMessages  }
  },
})
</script>
```


Pull request description:
feat(cdk:forms): add use form control and vaalidators
* feat(cdk:forms): add use form control

fix #115

## PR Checklist

Please check if your PR fulfills the following requirements:

- [x] The commit message follows [our guidelines](https://github.com/IduxFE/components/blob/main/docs/contributing.zh.md#commit)
- [x] Tests for the changes have been added/updated or not needed
- [x] Docs and demo have been added/updated or not needed

## PR Type

What kind of change does this PR introduce?

- [ ] Bugfix
- [x] Feature
- [ ] Component style update
- [ ] Code style update (formatting, local variables)
- [ ] Refactoring (no functional changes, no api changes)
- [ ] Build related changes
- [ ] CI related changes
- [ ] Documentation content changes
- [ ] Application (the showcase website) / infrastructure changes
- [ ] Other... Please describe:

## What is the current behavior?
<!-- Please describe the current behavior that you are modifying, or link to a relevant issue. -->

## What is the new behavior?

## Other information


Patch:
diff --git a/.eslintrc.js b/.eslintrc.js
index 11d53324..da8d49bb 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -36,6 +36,7 @@ module.exports = {
     'no-trailing-spaces': 'error',
     'object-curly-spacing': ['error', 'always'],
     quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
+    'prefer-const': ['error', { destructuring: 'all' }],
     semi: ['error', 'never'],
 
     // ts
diff --git a/jest.config.js b/jest.config.js
index f35ff5c7..69b23647 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,7 +2,7 @@ module.exports = {
   // u can change this option to a more specific folder for test single component or util when dev
   // for example, ['<rootDir>/packages/components/button']
   roots: ['<rootDir>'],
-  
+
   testEnvironment: 'jsdom',
   transform: {
     '^.+\\.vue$': 'vue-jest',
diff --git a/packages/cdk/breakpoint/src/breakpoints.ts b/packages/cdk/breakpoint/src/breakpoints.ts
index aae2a1ec..c244c934 100644
--- a/packages/cdk/breakpoint/src/breakpoints.ts
+++ b/packages/cdk/breakpoint/src/breakpoints.ts
@@ -1,3 +1,4 @@
+// PascalCase is being used as Breakpoints is used like an enum.
 export const Breakpoints = {
   XSmall: '(max-width: 767.99px)',
   Small: '(min-width: 768px) and (max-width: 1023.99px)',
diff --git a/packages/cdk/forms/index.ts b/packages/cdk/forms/index.ts
new file mode 100644
index 00000000..c38dc75e
--- /dev/null
+++ b/packages/cdk/forms/index.ts
@@ -0,0 +1,4 @@
+export * from './src/typeof'
+export * from './src/types'
+export * from './src/useFormControl'
+export * from './src/validators'
diff --git a/packages/cdk/forms/src/constant.ts b/packages/cdk/forms/src/constant.ts
new file mode 100644
index 00000000..9eb9c44a
--- /dev/null
+++ b/packages/cdk/forms/src/constant.ts
@@ -0,0 +1,4 @@
+// PascalCase is being used as Breakpoints is used like an enum.
+export const ModelType = {
+  Control: '__control',
+}
diff --git a/packages/cdk/forms/src/typeof.ts b/packages/cdk/forms/src/typeof.ts
new file mode 100644
index 00000000..f808a763
--- /dev/null
+++ b/packages/cdk/forms/src/typeof.ts
@@ -0,0 +1,7 @@
+import { isNonNil } from '@idux/cdk/utils'
+import { ModelType } from './constant'
+import { FormControl } from './useFormControl'
+
+export const isFormControl = (val: unknown): val is FormControl => {
+  return isNonNil(val) && (val as { __type: string }).__type === ModelType.Control
+}
diff --git a/packages/cdk/forms/src/types.ts b/packages/cdk/forms/src/types.ts
new file mode 100644
index 00000000..9706193a
--- /dev/null
+++ b/packages/cdk/forms/src/types.ts
@@ -0,0 +1,42 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+export interface ValidationError {
+  message: string
+  actual?: any
+  [key: string]: any
+}
+
+type ErrorMessage = string | ((info: Omit<ValidationError, 'message'>) => 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 Errors = { [key in keyof ErrorMessages]: ValidationError }
+
+export interface ValidatorFn {
+  (value: any): Errors | null
+}
+
+export interface AsyncValidatorFn {
+  (value: any): Promise<Errors | null>
+}
+
+export type TriggerType = 'change' | 'blur' | 'submit'
+
+export interface ValidatorOptions {
+  validators?: ValidatorFn | ValidatorFn[] | null
+  asyncValidators?: AsyncValidatorFn | AsyncValidatorFn[] | null
+  trigger?: TriggerType
+}
+
+export type ValidStatus = 'valid' | 'invalid' | 'validating'
diff --git a/packages/cdk/forms/src/useFormControl.ts b/packages/cdk/forms/src/useFormControl.ts
new file mode 100644
index 00000000..a3086290
--- /dev/null
+++ b/packages/cdk/forms/src/useFormControl.ts
@@ -0,0 +1,255 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+
+import type { ComputedRef, DeepReadonly, Ref, UnwrapRef } from 'vue'
+import type {
+  AsyncValidatorFn,
+  ValidatorFn,
+  ValidatorOptions,
+  TriggerType,
+  ValidationError,
+  ErrorMessages,
+  ValidStatus,
+  Errors,
+} from './types'
+
+import { computed, readonly, ref, watchEffect } from 'vue'
+import { isArray, isNil, isObject } from '@idux/cdk/utils'
+import { Validators } from './validators'
+import { ModelType } from './constant'
+
+export interface FormControl<T = any> {
+  /**
+   * The ref value for the control.
+   */
+  modelRef: Ref<UnwrapRef<T> | null>
+
+  /**
+   * The validation status of the control, there are three possible validation status values:
+   * * **valid**: This control has passed all validation checks.
+   * * **invalid**: This control has failed at least one validation check.
+   * * **validating**: This control is in the midst of conducting a validation check.
+   */
+  status: DeepReadonly<Ref<ValidStatus>>
+
+  /**
+   * An object containing any errors generated by failing validation, or null if there are no errors.
+   */
+  errors: DeepReadonly<Ref<Errors | null>>
+
+  /**
+   * Running validations manually, rather than automatically.
+   */
+  validate: () => Promise<Errors | null>
+
+  /**
+   * Resets the form control, marking it `unblurred`, and setting the value to initialization value.
+   */
+  reset: () => void
+
+  /**
+   * Sets a new value for the form control.
+   *
+   * @param value The new value.
+   */
+  setValue: (value: T | null) => void
+
+  /**
+   * Sets the new sync validator for the form control, it overwrites existing sync validators.
+   * If you want to clear all sync validators, you can pass in a null.
+   */
+  setValidator: (newValidator: ValidatorFn | ValidatorFn[] | null) => void
+
+  /**
+   * Sets the new async validator for the form control, it overwrites existing async validators.
+   * If you want to clear all async validators, you can pass in a null.
+   */
+  setAsyncValidator: (newAsyncValidator: AsyncValidatorFn | AsyncValidatorFn[] | null) => void
+
+  /**
+   * Sets errors on a form control when running validations manually, rather than automatically.
+   */
+  setErrors: (newErrors: Errors | null) => void
+
+  /**
+   * Reports error data for the control.
+   *
+   * @param errorCode The code of the error to check
+   */
+  getError: (errorCode: keyof ErrorMessages) => ValidationError | null
+
+  /**
+   * Reports whether the control has the error specified.
+   *
+   * @param errorCode The code of the error to check
+   */
+  hasError: (errorCode: keyof ErrorMessages) => boolean
+
+  /**
+   * Marks the control as `blurred`.
+   */
+  markAsBlurred: () => void
+
+  /**
+   * Marks the control as `unblurred`.
+   */
+  markAsUnblurred: () => void
+
+  /**
+   * A control is valid when its `status` is `valid`.
+   */
+  valid: ComputedRef<boolean>
+
+  /**
+   * A control is invalid when its `status` is `invalid`.
+   */
+  invalid: ComputedRef<boolean>
+
+  /**
+   * A control is validating when its `status` is `validating`.
+   */
+  validating: ComputedRef<boolean>
+
+  /**
+   * A control is marked `blurred` once the user has triggered a `blur` event on it.
+   */
+  blurred: ComputedRef<boolean>
+
+  /**
+   * A control is `unblurred` if the user has not yet triggered a `blur` event on it.
+   */
+  unblurred: ComputedRef<boolean>
+}
+
+export function useFormControl<T = any>(
+  initValue?: T | null,
+  validator?: ValidatorFn | ValidatorFn[] | null,
+  asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null,
+): FormControl<T>
+export function useFormControl<T = any>(initValue?: T | null, options?: ValidatorOptions | null): FormControl<T>
+export function useFormControl<T = any>(
+  initValue: T | null = null,
+  validatorOrOptions?: ValidatorFn | ValidatorFn[] | ValidatorOptions | null,
+  asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null,
+): FormControl<T> {
+  const modelRef = ref(initValue)
+  const status = ref<ValidStatus>('valid')
+  const blurred = ref(false)
+  const errors = ref<Errors | null>(null)
+
+  let { _validator, _asyncValidator, _trigger } = convertOptions(validatorOrOptions, asyncValidator)
+
+  const reset = () => {
+    modelRef.value = initValue as any
+    markAsUnblurred()
+  }
+
+  const setValue = (value: T | null) => {
+    modelRef.value = value as any
+  }
+
+  const setValidator = (newValidator: ValidatorFn | ValidatorFn[] | null) => {
+    _validator = toValidator(newValidator)
+  }
+
+  const setAsyncValidator = (newAsyncValidator: AsyncValidatorFn | AsyncValidatorFn[] | null) => {
+    _asyncValidator = toAsyncValidator(newAsyncValidator)
+  }
+
+  const setErrors = (newErrors: Errors | null) => {
+    errors.value = newErrors
+    status.value = newErrors ? 'invalid' : 'valid'
+  }
+
+  const getError = (errorCode: keyof ErrorMessages) => {
+    return errors.value ? errors.value[errorCode] ?? null : null
+  }
+
+  const hasError = (errorCode: keyof ErrorMessages) => !!getError(errorCode)
+
+  const markAsBlurred = () => {
+    blurred.value = true
+  }
+
+  const markAsUnblurred = () => {
+    blurred.value = false
+  }
+
+  const _runValidator = () => {
+    return _validator ? _validator(modelRef.value) : null
+  }
+  const _runAsyncValidator = () => {
+    if (!_asyncValidator) {
+      return null
+    }
+    status.value = 'validating'
+    return _asyncValidator(modelRef.value)
+  }
+
+  const validate = async () => {
+    let newErrors = _runValidator()
+    if (isNil(newErrors)) {
+      newErrors = await _runAsyncValidator()
+    }
+    setErrors(newErrors)
+    return newErrors
+  }
+
+  watchEffect(() => {
+    if (_trigger === 'change' || (_trigger === 'blur' && blurred.value)) {
+      validate()
+    }
+  })
+
+  return {
+    __type: ModelType.Control,
+    modelRef,
+    status: readonly(status),
+    errors: readonly(errors),
+    validate,
+    reset,
+    setValue,
+    setValidator,
+    setAsyncValidator,
+    setErrors,
+    getError,
+    hasError,
+    markAsBlurred,
+    markAsUnblurred,
+    valid: computed(() => status.value === 'valid'),
+    invalid: computed(() => status.value === 'invalid'),
+    validating: computed(() => status.value === 'validating'),
+    blurred: computed(() => blurred.value),
+    unblurred: computed(() => !blurred.value),
+  } as FormControl<T>
+}
+
+function toValidator(validator?: ValidatorFn | ValidatorFn[] | null): ValidatorFn | null {
+  return isArray(validator) ? Validators.compose(validator) : validator || null
+}
+
+function toAsyncValidator(asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): AsyncValidatorFn | null {
+  return isArray(asyncValidator) ? Validators.composeAsync(asyncValidator) : asyncValidator || null
+}
+
+function isOptions(val?: ValidatorFn | ValidatorFn[] | ValidatorOptions | null): val is ValidatorOptions {
+  return isObject(val) && !isArray(val)
+}
+
+function convertOptions(
+  validatorOrOptions: ValidatorFn | ValidatorFn[] | ValidatorOptions | null | undefined,
+  asyncValidator: AsyncValidatorFn | AsyncValidatorFn[] | null | undefined,
+) {
+  let _trigger: TriggerType = 'change'
+  let _validator: ValidatorFn | null
+  let _asyncValidator: AsyncValidatorFn | null
+  if (isOptions(validatorOrOptions)) {
+    _trigger = validatorOrOptions.trigger ?? _trigger
+    _validator = toValidator(validatorOrOptions.validators)
+    _asyncValidator = toAsyncValidator(validatorOrOptions.asyncValidators)
+  } else {
+    _validator = toValidator(validatorOrOptions)
+    _asyncValidator = toAsyncValidator(asyncValidator)
+  }
+  return { _validator, _asyncValidator, _trigger }
+}
diff --git a/packages/cdk/forms/src/validators.ts b/packages/cdk/forms/src/validators.ts
new file mode 100644
index 00000000..3bfc2fc2
--- /dev/null
+++ b/packages/cdk/forms/src/validators.ts
@@ -0,0 +1,172 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
+
+import type { AsyncValidatorFn, ValidationError, ValidatorFn, Errors, ErrorMessages } from './types'
+
+import { isArray, isFunction, isNil, isNonNil, isNumber, isNumeric, isString } from '@idux/cdk/utils'
+
+/** See [this commit](https://github.com/angular/angular.js/commit/f3f5cf72e) for more details. */
+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 getMessage(key: keyof ErrorMessages, errors: Omit<ValidationError, 'message'>): 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: ErrorMessages): void {
+    Validators.messages = { ...Validators.messages, ...messages }
+  }
+
+  static required(value: any): { required: ValidationError } | null {
+    if (isEmpty(value)) {
+      return { required: Validators.getMessage('required', {}) }
+    }
+    return null
+  }
+
+  static requiredTrue(value: any): { requiredTrue: ValidationError } | null {
+    if (value === true) {
+      return null
+    }
+    return { requiredTrue: Validators.getMessage('requiredTrue', { actual: value }) }
+  }
+
+  static email(value: any): { email: ValidationError } | null {
+    if (isEmpty(value) || emailRegexp.test(value)) {
+      return null
+    }
+    return { email: Validators.getMessage('email', { actual: value }) }
+  }
+
+  static min(min: number): ValidatorFn {
+    return (value: any): { min: ValidationError } | null => {
+      if (isEmpty(value) || !isNumeric(value) || Number(value) >= min) {
+        return null
+      }
+      return { min: Validators.getMessage('min', { min, actual: value }) }
+    }
+  }
+
+  static max(max: number): ValidatorFn {
+    return (value: any): { max: ValidationError } | null => {
+      if (isEmpty(value) || !isNumeric(value) || Number(value) <= max) {
+        return null
+      }
+      return { max: Validators.getMessage('max', { max, actual: value }) }
+    }
+  }
+
+  static minLength(minLength: number): ValidatorFn {
+    return (value: any): { minLength: ValidationError } | null => {
+      if (isEmpty(value) || !hasLength(value) || value.length >= minLength) {
+        return null
+      }
+      return { minLength: Validators.getMessage('minLength', { minLength, actual: value.length }) }
+    }
+  }
+
+  static maxLength(maxLength: number): ValidatorFn {
+    return (value: any): { maxLength: ValidationError } | null => {
+      if (isEmpty(value) || !hasLength(value) || value.length <= maxLength) {
+        return null
+      }
+      return { maxLength: Validators.getMessage('maxLength', { maxLength, actual: value.length }) }
+    }
+  }
+
+  static pattern(pattern: string | RegExp): ValidatorFn {
+    if (!pattern) {
+      return Validators.nullValidator
+    }
+    let regex: RegExp
+    let regexStr: string
+    if (typeof pattern === 'string') {
+      regexStr = ''
+      if (pattern.charAt(0) !== '^') {
+        regexStr += '^'
+      }
+      regexStr += pattern
+      if (pattern.charAt(pattern.length - 1) !== '$') {
+        regexStr += '$'
+      }
+      regex = new RegExp(regexStr)
+    } else {
+      regexStr = pattern.toString()
+      regex = pattern
+    }
+    return (value: any): { pattern: ValidationError } | null => {
+      if (isEmpty(value) || regex.test(value)) {
+        return null
+      }
+      return { pattern: Validators.getMessage('pattern', { pattern: regexStr, actual: value }) }
+    }
+  }
+
+  static nullValidator(_: any): null {
+    return null
+  }
+
+  static compose(validators: (ValidatorFn | null | undefined)[] | null): ValidatorFn | null {
+    if (!validators) {
+      return null
+    }
+    const presentValidators: ValidatorFn[] = validators.filter(isNonNil)
+    if (presentValidators.length == 0) {
+      return null
+    }
+
+    return (value: any) => mergeMessages(executeValidators<ValidatorFn>(value, presentValidators))
+  }
+
+  static composeAsync(validators: (AsyncValidatorFn | null)[] | null): AsyncValidatorFn | null {
+    if (!validators) {
+      return null
+    }
+    const presentValidators: AsyncValidatorFn[] = validators.filter(isNonNil)
+    if (presentValidators.length == 0) {
+      return null
+    }
+
+    return (value: any) => {
+      const validationErrors = executeValidators<AsyncValidatorFn>(value, presentValidators)
+      return Promise.all(validationErrors).then(mergeMessages)
+    }
+  }
+}
+
+/** checks whether string or array is empty */
+function isEmpty(val: any): boolean {
+  return isNil(val) || (val.length === 0 && (isString(val) || isArray(val)))
+}
+
+/** checks whether variable has length props */
+function hasLength(val: any): boolean {
+  return isNonNil(val) && isNumber(val.length)
+}
+
+type GenericValidatorFn = (value: any) => any
+
+function executeValidators<V extends GenericValidatorFn>(value: any, validators: V[]): ReturnType<V>[] {
+  return validators.map(validator => validator(value))
+}
+
+function mergeMessages(validationErrors: (Errors | null)[]): Errors | 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: Errors | null) => {
+    res = isNonNil(errors) ? { ...res, ...errors } : res
+  })
+
+  return Object.keys(res).length === 0 ? null : res
+}
diff --git a/packages/cdk/utils/convert.ts b/packages/cdk/utils/convert.ts
index a27c0c9d..d1836d0e 100644
--- a/packages/cdk/utils/convert.ts
+++ b/packages/cdk/utils/convert.ts
@@ -3,6 +3,9 @@ import { isNil, isNumeric } from './typeof'
 export function toArray<T>(value: T | T[]): T[]
 export function toArray<T>(value: T | readonly T[]): readonly T[]
 export function toArray<T>(value: T | T[]): T[] {
+  if (isNil(value)) {
+    return []
+  }
   return Array.isArray(value) ? value : [value]
 }
 
diff --git a/packages/components/spin/demo/Tip.vue b/packages/components/spin/demo/Tip.vue
index 5d1110b5..9725c2dd 100644
--- a/packages/components/spin/demo/Tip.vue
+++ b/packages/components/spin/demo/Tip.vue
@@ -14,7 +14,7 @@ import { ref, defineComponent, computed } from 'vue'
 
 export default defineComponent({
   setup() {
-    let tipAlignIsHorizontal = ref(true)
+    const tipAlignIsHorizontal = ref(true)
 
     const tip = ref('数据加载中，请等待')
     const tipAlign = computed(() => (tipAlignIsHorizontal.value ? 'horizontal' : 'vertical'))


Test patch:
diff --git a/packages/cdk/forms/__tests__/typeof.spec.ts b/packages/cdk/forms/__tests__/typeof.spec.ts
new file mode 100644
index 00000000..99402f0d
--- /dev/null
+++ b/packages/cdk/forms/__tests__/typeof.spec.ts
@@ -0,0 +1,12 @@
+import { isFormControl } from '../src/typeof'
+import { ModelType } from '../src/constant'
+import { useFormControl } from '../src/useFormControl'
+
+describe('typeof.ts', () => {
+  test('isFormControl work', () => {
+    expect(isFormControl(null)).toEqual(false)
+    expect(isFormControl({})).toEqual(false)
+    expect(isFormControl({ __type: ModelType.Control })).toEqual(true)
+    expect(isFormControl(useFormControl())).toEqual(true)
+  })
+})
diff --git a/packages/cdk/forms/__tests__/useFormControl.spec.ts b/packages/cdk/forms/__tests__/useFormControl.spec.ts
new file mode 100644
index 00000000..8b6db473
--- /dev/null
+++ b/packages/cdk/forms/__tests__/useFormControl.spec.ts
@@ -0,0 +1,149 @@
+import { flushPromises } from '@vue/test-utils'
+
+import { AsyncValidatorFn, Errors } from '../src/types'
+import { FormControl, useFormControl } from '../src/useFormControl'
+import { Validators } from '../src/validators'
+
+describe('useFormControl.ts', () => {
+  describe('basic work', () => {
+    let control: FormControl
+
+    beforeEach(() => {
+      control = useFormControl()
+    })
+
+    test('init modelRef work', () => {
+      expect(control.modelRef.value).toBeNull()
+    })
+
+    test('init status work', () => {
+      expect(control.status.value).toEqual('valid')
+      expect(control.valid.value).toEqual(true)
+      expect(control.invalid.value).toEqual(false)
+      expect(control.validating.value).toEqual(false)
+    })
+
+    test('init errors work', () => {
+      expect(control.errors.value).toBeNull()
+    })
+
+    test('init blurred work', () => {
+      expect(control.blurred.value).toEqual(false)
+      expect(control.unblurred.value).toEqual(true)
+    })
+
+    test('validate work', async () => {
+      expect(await control.validate()).toBeNull()
+    })
+
+    test('reset work', async () => {
+      control.setValue('test')
+      control.markAsBlurred()
+
+      expect(control.modelRef.value).toEqual('test')
+      expect(control.blurred.value).toEqual(true)
+
+      control.reset()
+
+      expect(control.modelRef.value).toBeNull()
+      expect(control.blurred.value).toEqual(false)
+    })
+
+    test('setValue work', async () => {
+      expect(control.modelRef.value).toBeNull()
+
+      control.setValue('test')
+
+      expect(control.modelRef.value).toEqual('test')
+
+      control.setValue('')
+
+      expect(control.modelRef.value).toEqual('')
+    })
+
+    test('setValidator work', async () => {
+      const { required, minLength, email } = Validators
+      control.setValidator(required)
+
+      expect(await control.validate()).toEqual({ required: { message: '' } })
+
+      control.setValidator([email, minLength(5)])
+      control.setValue('test')
+
+      expect(await control.validate()).toEqual({
+        email: { message: '', actual: 'test' },
+        minLength: { message: '', minLength: 5, actual: 4 },
+      })
+    })
+
+    test('setAsyncValidator work', async () => {
+      const _asyncValidator = (key: string, error: unknown): AsyncValidatorFn => {
+        return (_: unknown) => {
+          return Promise.resolve({ [key]: error } as Errors)
+        }
+      }
+      const message1 = { message: 1 }
+      const message2 = { message: 2 }
+
+      control.setAsyncValidator(_asyncValidator('a', message1))
+
+      expect(await control.validate()).toEqual({ a: message1 })
+
+      control.setAsyncValidator([_asyncValidator('a', message1), _asyncValidator('b', message2)])
+
+      expect(await control.validate()).toEqual({ a: message1, b: message2 })
+    })
+
+    test('setErrors work', async () => {
+      expect(control.errors.value).toBeNull()
+      expect(control.getError('required')).toBeNull()
+
+      const errors = { required: { message: '' } }
+      control.setErrors(errors)
+
+      expect(control.errors.value).toEqual(errors)
+
+      expect(control.getError('required')).toEqual(errors.required)
+      expect(control.getError('max')).toBeNull()
+
+      expect(control.hasError('required')).toEqual(true)
+      expect(control.hasError('max')).toEqual(false)
+    })
+  })
+
+  describe('trigger work', () => {
+    let control: FormControl
+
+    test('default change work', async () => {
+      control = useFormControl(null, { validators: Validators.required })
+
+      control.setValue('')
+      await flushPromises()
+
+      expect(control.errors.value).toEqual({ required: { message: '' } })
+    })
+
+    test('blur trigger validate work', async () => {
+      control = useFormControl(null, { trigger: 'blur', validators: Validators.required })
+      control.markAsBlurred()
+      await flushPromises()
+      expect(control.errors.value).toEqual({ required: { message: '' } })
+    })
+  })
+
+  describe('validator work', () => {
+    let control: FormControl
+
+    test('validate work', async () => {
+      control = useFormControl(null, [Validators.required])
+
+      control.setValue('test')
+      await flushPromises()
+      expect(control.errors.value).toBeNull()
+
+      control.setValue('')
+      await flushPromises()
+      expect(control.errors.value).toEqual({ required: { message: '' } })
+    })
+  })
+})
diff --git a/packages/cdk/forms/__tests__/validators.spec.ts b/packages/cdk/forms/__tests__/validators.spec.ts
new file mode 100644
index 00000000..bb7b2d87
--- /dev/null
+++ b/packages/cdk/forms/__tests__/validators.spec.ts
@@ -0,0 +1,206 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import { AsyncValidatorFn, ErrorMessages, Errors, ValidatorFn } from '../src/types'
+import { Validators } from '../src/validators'
+
+describe('validators.ts', () => {
+  test('required work', () => {
+    const required = Validators.required
+
+    expect(required(0)).toBeNull()
+    expect(required('value')).toBeNull()
+    expect(required([1, 2])).toBeNull()
+
+    const errorMessage = { required: { message: '' } }
+    expect(required(null)).toEqual(errorMessage)
+    expect(required(undefined)).toEqual(errorMessage)
+    expect(required('')).toEqual(errorMessage)
+    expect(required([])).toEqual(errorMessage)
+  })
+
+  test('requiredTrue work', () => {
+    const requiredTrue = Validators.requiredTrue
+
+    expect(requiredTrue(true)).toBeNull()
+
+    const errorMessage = (actual: unknown) => ({ requiredTrue: { message: '', actual } })
+    expect(requiredTrue(null)).toEqual(errorMessage(null))
+    expect(requiredTrue(undefined)).toEqual(errorMessage(undefined))
+    expect(requiredTrue('')).toEqual(errorMessage(''))
+    expect(requiredTrue([])).toEqual(errorMessage([]))
+    expect(requiredTrue({})).toEqual(errorMessage({}))
+    expect(requiredTrue(false)).toEqual(errorMessage(false))
+  })
+
+  test('email work', () => {
+    const email = Validators.email
+
+    expect(email('')).toBeNull()
+    expect(email(null)).toBeNull()
+    expect(email('test@gmail.com')).toBeNull()
+
+    const errorMessage = (actual: unknown) => ({ email: { message: '', actual } })
+    expect(email({})).toEqual(errorMessage({}))
+    expect(email('test')).toEqual(errorMessage('test'))
+  })
+
+  test('min work', () => {
+    const minOne = Validators.min(1)
+
+    expect(minOne('')).toBeNull()
+    expect(minOne(null)).toBeNull()
+    expect(minOne('test')).toBeNull()
+    expect(minOne('1')).toBeNull()
+    expect(minOne(1)).toBeNull()
+    expect(minOne(2)).toBeNull()
+
+    const errorMessage = (actual: unknown) => ({ min: { message: '', min: 1, actual } })
+    expect(minOne(0)).toEqual(errorMessage(0))
+    expect(minOne('0')).toEqual(errorMessage('0'))
+  })
+
+  test('max work', () => {
+    const maxOne = Validators.max(1)
+
+    expect(maxOne('')).toBeNull()
+    expect(maxOne(null)).toBeNull()
+    expect(maxOne('test')).toBeNull()
+    expect(maxOne('1')).toBeNull()
+    expect(maxOne(1)).toBeNull()
+    expect(maxOne(0)).toBeNull()
+
+    const errorMessage = (actual: unknown) => ({ max: { message: '', max: 1, actual } })
+    expect(maxOne(2)).toEqual(errorMessage(2))
+    expect(maxOne('2')).toEqual(errorMessage('2'))
+  })
+
+  test('minLength work', () => {
+    const minLengthTwo = Validators.minLength(2)
+
+    expect(minLengthTwo('')).toBeNull()
+    expect(minLengthTwo(null)).toBeNull()
+    expect(minLengthTwo(1)).toBeNull()
+    expect(minLengthTwo('te')).toBeNull()
+    expect(minLengthTwo('test')).toBeNull()
+    expect(minLengthTwo([])).toBeNull()
+    expect(minLengthTwo([1, 2])).toBeNull()
+    expect(minLengthTwo([1, 2, 3])).toBeNull()
+
+    const errorMessage = (actual: unknown) => ({ minLength: { message: '', minLength: 2, actual } })
+    expect(minLengthTwo('t')).toEqual(errorMessage(1))
+    expect(minLengthTwo([1])).toEqual(errorMessage(1))
+  })
+
+  test('maxLength work', () => {
+    const maxLengthTwo = Validators.maxLength(2)
+
+    expect(maxLengthTwo('')).toBeNull()
+    expect(maxLengthTwo(1)).toBeNull()
+    expect(maxLengthTwo(null)).toBeNull()
+    expect(maxLengthTwo('te')).toBeNull()
+    expect(maxLengthTwo('t')).toBeNull()
+    expect(maxLengthTwo([])).toBeNull()
+    expect(maxLengthTwo([1, 2])).toBeNull()
+    expect(maxLengthTwo([1])).toBeNull()
+
+    const errorMessage = (actual: unknown) => ({ maxLength: { message: '', maxLength: 2, actual } })
+    expect(maxLengthTwo('test')).toEqual(errorMessage(4))
+    expect(maxLengthTwo([1, 2, 3])).toEqual(errorMessage(3))
+  })
+
+  test('pattern work', () => {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    expect(Validators.pattern(null!)('test')).toBeNull()
+
+    let stringPattern = Validators.pattern('[a-zA-Z]+')
+
+    expect(stringPattern('')).toBeNull()
+    expect(stringPattern(null)).toBeNull()
+    expect(stringPattern('test')).toBeNull()
+
+    let errorMessage = (actual: unknown) => ({ pattern: { message: '', pattern: '^[a-zA-Z]+$', actual } })
+    expect(stringPattern('test1')).toEqual(errorMessage('test1'))
+    expect(stringPattern(1)).toEqual(errorMessage(1))
+
+    stringPattern = Validators.pattern('^[a-zA-Z]+$')
+    expect(stringPattern('test1')).toEqual(errorMessage('test1'))
+    expect(stringPattern(1)).toEqual(errorMessage(1))
+
+    const regExpPattern = Validators.pattern(new RegExp('[a-zA-Z]+'))
+
+    expect(regExpPattern('')).toBeNull()
+    expect(regExpPattern(null)).toBeNull()
+    expect(regExpPattern('test')).toBeNull()
+    expect(regExpPattern('test1')).toBeNull()
+
+    errorMessage = (actual: unknown) => ({ pattern: { message: '', pattern: '/[a-zA-Z]+/', actual } })
+    expect(regExpPattern(1)).toEqual(errorMessage(1))
+
+    const regExpPattern2 = Validators.pattern(new RegExp('^[a-zA-Z]+$'))
+
+    expect(regExpPattern2('test')).toBeNull()
+
+    errorMessage = (actual: unknown) => ({ pattern: { message: '', pattern: '/^[a-zA-Z]+$/', actual } })
+    expect(regExpPattern2('test1')).toEqual(errorMessage('test1'))
+    expect(regExpPattern2(1)).toEqual(errorMessage(1))
+  })
+
+  test('compose work', () => {
+    const _validator = (key: string, error: unknown): ValidatorFn => {
+      return (_: unknown) => {
+        return { [key]: error } as Errors
+      }
+    }
+    const message1 = { message: 1 }
+    const message2 = { message: 2 }
+    const { compose, nullValidator, required } = Validators
+
+    expect(compose(null)).toBeNull()
+    expect(compose([])).toBe(null)
+    expect(compose([nullValidator, nullValidator])!('test')).toBeNull()
+
+    expect(compose([_validator('a', message1), _validator('b', message2)])!('test')).toEqual({
+      a: message1,
+      b: message2,
+    })
+    expect(compose([_validator('a', message1), _validator('a', message2)])!('test')).toEqual({ a: message2 })
+    expect(compose([null, nullValidator, required])!('')).toEqual({ required: { message: '' } })
+  })
+
+  test('composeAsync work', async () => {
+    const _asyncValidator = (key: string, error: unknown): AsyncValidatorFn => {
+      return (_: unknown) => {
+        return Promise.resolve({ [key]: error } as Errors)
+      }
+    }
+    const message1 = { message: 1 }
+    const message2 = { message: 2 }
+    const composeAsync = Validators.composeAsync
+
+    expect(composeAsync(null)).toBe(null)
+    expect(composeAsync([])).toBe(null)
+
+    let errors = await composeAsync([_asyncValidator('a', message1), _asyncValidator('b', message2)])!('test')
+
+    expect(errors).toEqual({ a: message1, b: message2 })
+
+    errors = await composeAsync([_asyncValidator('a', message1), _asyncValidator('a', message2)])!('test')
+    expect(errors).toEqual({ a: message2 })
+
+    errors = await composeAsync([null, _asyncValidator('a', message1)])!('test')
+    expect(errors).toEqual({ a: message1 })
+  })
+
+  test('setMessages work', () => {
+    const { setMessages, required, requiredTrue } = Validators
+
+    const messages: ErrorMessages = { default: 'invalid input', required: 'please input' }
+    setMessages(messages)
+
+    expect(required('')).toEqual({ required: { message: messages.required } })
+    expect(requiredTrue(false)).toEqual({ requiredTrue: { message: messages.default, actual: false } })
+
+    setMessages({ requiredTrue: () => 'please input true' })
+
+    expect(requiredTrue(false)).toEqual({ requiredTrue: { message: 'please input true', actual: false } })
+  })
+})