mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-02-01 20:57:57 +01:00
feat(Form): form validation properties (#3137)
This commit is contained in:
@@ -195,9 +195,13 @@ This will give you access to the following:
|
|||||||
| Name | Type |
|
| Name | Type |
|
||||||
| ---- | ---- |
|
| ---- | ---- |
|
||||||
| `submit()`{lang="ts-type"} | `Promise<void>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Triggers form submission.</p> |
|
| `submit()`{lang="ts-type"} | `Promise<void>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Triggers form submission.</p> |
|
||||||
| `validate(opts: { name?: string \| string[], silent?: boolean, nested?: boolean, transform?: boolean })`{lang="ts-type"} | `Promise<T>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Triggers form validation. Will raise any errors unless `opts.silent` is set to true.</p> |
|
| `validate(opts: { name?: keyof T \| (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean })`{lang="ts-type"} | `Promise<T>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Triggers form validation. Will raise any errors unless `opts.silent` is set to true.</p> |
|
||||||
| `clear(path?: string)`{lang="ts-type"} | `void` <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Clears form errors associated with a specific path. If no path is provided, clears all form errors.</p> |
|
| `clear(path?: keyof T)`{lang="ts-type"} | `void` <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Clears form errors associated with a specific path. If no path is provided, clears all form errors.</p> |
|
||||||
| `getErrors(path?: string)`{lang="ts-type"} | `FormError[]`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.</p></div> |
|
| `getErrors(path?: keyof T)`{lang="ts-type"} | `FormError[]`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.</p></div> |
|
||||||
| `setErrors(errors: FormError[], path?: string)`{lang="ts-type"} | `void` <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Sets form errors for a given path. If no path is provided, overrides all errors.</p> |
|
| `setErrors(errors: FormError[], path?: keyof T)`{lang="ts-type"} | `void` <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>Sets form errors for a given path. If no path is provided, overrides all errors.</p> |
|
||||||
| `errors`{lang="ts-type"} | `Ref<FormError[]>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>A reference to the array containing validation errors. Use this to access or manipulate the error information.</p> |
|
| `errors`{lang="ts-type"} | `Ref<FormError[]>`{lang="ts-type"} <br> <div class="text-[var(--ui-text-toned)] mt-1"><p>A reference to the array containing validation errors. Use this to access or manipulate the error information.</p> |
|
||||||
| `disabled`{lang="ts-type"} | `Ref<boolean>`{lang="ts-type"} |
|
| `disabled`{lang="ts-type"} | `Ref<boolean>`{lang="ts-type"} |
|
||||||
|
| `dirty`{lang="ts-type"} | `Ref<boolean>`{lang="ts-type"} `true` if at least one form field has been updated by the user.|
|
||||||
|
| `dirtyFields`{lang="ts-type"} | `DeepReadonly<Set<keyof T>>`{lang="ts-type"} Tracks fields that have been modified by the user. |
|
||||||
|
| `touchedFields`{lang="ts-type"} | `DeepReadonly<Set<keyof T>>`{lang="ts-type"} Tracks fields that the user interacted with. |
|
||||||
|
| `blurredFields`{lang="ts-type"} | `DeepReadonly<Set<keyof T>>`{lang="ts-type"} Tracks fields blurred by the user. |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import theme from '#build/ui/form'
|
|||||||
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
|
import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta'
|
||||||
import { tv } from '../utils/tv'
|
import { tv } from '../utils/tv'
|
||||||
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId } from '../types/form'
|
import type { FormSchema, FormError, FormInputEvents, FormErrorEvent, FormSubmitEvent, FormEvent, Form, FormErrorWithId } from '../types/form'
|
||||||
|
import type { DeepReadonly } from 'vue'
|
||||||
|
|
||||||
const appConfig = _appConfig as AppConfig & { ui: { form: Partial<typeof theme> } }
|
const appConfig = _appConfig as AppConfig & { ui: { form: Partial<typeof theme> } }
|
||||||
|
|
||||||
@@ -52,7 +53,7 @@ defineSlots<FormSlots>()
|
|||||||
|
|
||||||
const formId = props.id ?? useId() as string
|
const formId = props.id ?? useId() as string
|
||||||
|
|
||||||
const bus = useEventBus<FormEvent>(`form-${formId}`)
|
const bus = useEventBus<FormEvent<T>>(`form-${formId}`)
|
||||||
const parentBus = inject(
|
const parentBus = inject(
|
||||||
formBusInjectionKey,
|
formBusInjectionKey,
|
||||||
undefined
|
undefined
|
||||||
@@ -68,8 +69,24 @@ onMounted(async () => {
|
|||||||
nestedForms.value.set(event.formId, { validate: event.validate })
|
nestedForms.value.set(event.formId, { validate: event.validate })
|
||||||
} else if (event.type === 'detach') {
|
} else if (event.type === 'detach') {
|
||||||
nestedForms.value.delete(event.formId)
|
nestedForms.value.delete(event.formId)
|
||||||
} else if (props.validateOn?.includes(event.type as FormInputEvents)) {
|
} else if (props.validateOn?.includes(event.type)) {
|
||||||
await _validate({ name: event.name, silent: true, nested: false })
|
if (event.type !== 'input') {
|
||||||
|
await _validate({ name: event.name, silent: true, nested: false })
|
||||||
|
} else if (event.eager || blurredFields.has(event.name)) {
|
||||||
|
await _validate({ name: event.name, silent: true, nested: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'blur') {
|
||||||
|
blurredFields.add(event.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'change' || event.type === 'input' || event.type === 'blur' || event.type === 'focus') {
|
||||||
|
touchedFields.add(event.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'change' || event.type === 'input') {
|
||||||
|
dirtyFields.add(event.name)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -94,8 +111,12 @@ onUnmounted(() => {
|
|||||||
const errors = ref<FormErrorWithId[]>([])
|
const errors = ref<FormErrorWithId[]>([])
|
||||||
provide('form-errors', errors)
|
provide('form-errors', errors)
|
||||||
|
|
||||||
const inputs = ref<Record<string, { id?: string, pattern?: RegExp }>>({})
|
const inputs = ref<{ [P in keyof T]?: { id?: string, pattern?: RegExp } }>({})
|
||||||
provide(formInputsInjectionKey, inputs)
|
provide(formInputsInjectionKey, inputs as any)
|
||||||
|
|
||||||
|
const dirtyFields = new Set<keyof T>()
|
||||||
|
const touchedFields = new Set<keyof T>()
|
||||||
|
const blurredFields = new Set<keyof T>()
|
||||||
|
|
||||||
function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
|
function resolveErrorIds(errs: FormError[]): FormErrorWithId[] {
|
||||||
return errs.map(err => ({
|
return errs.map(err => ({
|
||||||
@@ -121,8 +142,8 @@ async function getErrors(): Promise<FormErrorWithId[]> {
|
|||||||
return resolveErrorIds(errs)
|
return resolveErrorIds(errs)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _validate(opts: { name?: string | string[], silent?: boolean, nested?: boolean, transform?: boolean } = { silent: false, nested: true, transform: false }): Promise<T | false> {
|
async function _validate(opts: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean } = { silent: false, nested: true, transform: false }): Promise<T | false> {
|
||||||
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as string[]
|
const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof T)[]
|
||||||
|
|
||||||
const nestedValidatePromises = !names && opts.nested
|
const nestedValidatePromises = !names && opts.nested
|
||||||
? Array.from(nestedForms.value.values()).map(
|
? Array.from(nestedForms.value.values()).map(
|
||||||
@@ -203,7 +224,7 @@ defineExpose<Form<T>>({
|
|||||||
validate: _validate,
|
validate: _validate,
|
||||||
errors,
|
errors,
|
||||||
|
|
||||||
setErrors(errs: FormError[], name?: string) {
|
setErrors(errs: FormError[], name?: keyof T) {
|
||||||
if (name) {
|
if (name) {
|
||||||
errors.value = errors.value
|
errors.value = errors.value
|
||||||
.filter(error => error.name !== name)
|
.filter(error => error.name !== name)
|
||||||
@@ -217,7 +238,7 @@ defineExpose<Form<T>>({
|
|||||||
await onSubmitWrapper(new Event('submit'))
|
await onSubmitWrapper(new Event('submit'))
|
||||||
},
|
},
|
||||||
|
|
||||||
getErrors(name?: string) {
|
getErrors(name?: keyof T) {
|
||||||
if (name) {
|
if (name) {
|
||||||
return errors.value.filter(err => err.name === name)
|
return errors.value.filter(err => err.name === name)
|
||||||
}
|
}
|
||||||
@@ -232,7 +253,12 @@ defineExpose<Form<T>>({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
disabled
|
disabled,
|
||||||
|
dirty: computed(() => !!dirtyFields.size),
|
||||||
|
|
||||||
|
dirtyFields: readonly(dirtyFields) as DeepReadonly<Set<keyof T>>,
|
||||||
|
blurredFields: readonly(blurredFields) as DeepReadonly<Set<keyof T>>,
|
||||||
|
touchedFields: readonly(touchedFields) as DeepReadonly<Set<keyof T>>
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ const slots = defineSlots<InputSlots>()
|
|||||||
|
|
||||||
const [modelValue, modelModifiers] = defineModel<string | number>()
|
const [modelValue, modelModifiers] = defineModel<string | number>()
|
||||||
|
|
||||||
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props, { deferInputValidation: true })
|
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField<InputProps>(props, { deferInputValidation: true })
|
||||||
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
|
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
|
||||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props)
|
||||||
|
|
||||||
@@ -170,6 +170,7 @@ onMounted(() => {
|
|||||||
@input="onInput"
|
@input="onInput"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
|
@focus="emitFormFocus"
|
||||||
>
|
>
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', '
|
|||||||
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as ComboboxContentProps)
|
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as ComboboxContentProps)
|
||||||
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
|
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
|
||||||
|
|
||||||
const { emitFormBlur, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
|
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
|
||||||
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
|
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
|
||||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
|
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
|
||||||
|
|
||||||
@@ -279,6 +279,7 @@ function onBlur(event: FocusEvent) {
|
|||||||
|
|
||||||
function onFocus(event: FocusEvent) {
|
function onFocus(event: FocusEvent) {
|
||||||
emits('focus', event)
|
emits('focus', event)
|
||||||
|
emitFormFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
function onUpdateOpen(value: boolean) {
|
function onUpdateOpen(value: boolean) {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ defineSlots<InputNumberSlots>()
|
|||||||
|
|
||||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'formatOptions'), emits)
|
const rootProps = useForwardPropsEmits(reactivePick(props, 'as', 'modelValue', 'defaultValue', 'min', 'max', 'step', 'formatOptions'), emits)
|
||||||
|
|
||||||
const { emitFormBlur, emitFormChange, emitFormInput, id, color, size, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)
|
const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size, name, highlight, disabled, ariaAttrs } = useFormField<InputNumberProps>(props)
|
||||||
|
|
||||||
const { t, code: codeLocale } = useLocale()
|
const { t, code: codeLocale } = useLocale()
|
||||||
const locale = computed(() => props.locale || codeLocale.value)
|
const locale = computed(() => props.locale || codeLocale.value)
|
||||||
@@ -158,6 +158,7 @@ defineExpose({
|
|||||||
:required="required"
|
:required="required"
|
||||||
:class="ui.base({ class: props.ui?.base })"
|
:class="ui.base({ class: props.ui?.base })"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
|
@focus="emitFormFocus"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div :class="ui.increment({ class: props.ui?.increment })">
|
<div :class="ui.increment({ class: props.ui?.increment })">
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ const props = withDefaults(defineProps<PinInputProps>(), {
|
|||||||
const emits = defineEmits<PinInputEmits>()
|
const emits = defineEmits<PinInputEmits>()
|
||||||
|
|
||||||
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultValue', 'disabled', 'id', 'mask', 'modelValue', 'name', 'otp', 'placeholder', 'required', 'type'), emits)
|
const rootProps = useForwardPropsEmits(reactivePick(props, 'defaultValue', 'disabled', 'id', 'mask', 'modelValue', 'name', 'otp', 'placeholder', 'required', 'type'), emits)
|
||||||
const { emitFormInput, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<PinInputProps>(props)
|
const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<PinInputProps>(props)
|
||||||
|
|
||||||
const ui = computed(() => pinInput({
|
const ui = computed(() => pinInput({
|
||||||
color: color.value,
|
color: color.value,
|
||||||
@@ -92,6 +92,7 @@ function onBlur(event: FocusEvent) {
|
|||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
|
@focus="emitFormFocus"
|
||||||
/>
|
/>
|
||||||
</PinInputRoot>
|
</PinInputRoot>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ const rootProps = useForwardPropsEmits(reactivePick(props, 'open', 'defaultOpen'
|
|||||||
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as SelectContentProps)
|
const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffset: 8, collisionPadding: 8, position: 'popper' }) as SelectContentProps)
|
||||||
const arrowProps = toRef(() => props.arrow as SelectArrowProps)
|
const arrowProps = toRef(() => props.arrow as SelectArrowProps)
|
||||||
|
|
||||||
const { emitFormChange, emitFormInput, emitFormBlur, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
|
const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
|
||||||
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
|
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
|
||||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
|
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
|
||||||
|
|
||||||
@@ -179,6 +179,7 @@ function onUpdateOpen(value: boolean) {
|
|||||||
} else {
|
} else {
|
||||||
const event = new FocusEvent('focus')
|
const event = new FocusEvent('focus')
|
||||||
emits('focus', event)
|
emits('focus', event)
|
||||||
|
emitFormFocus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffse
|
|||||||
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
|
const arrowProps = toRef(() => props.arrow as ComboboxArrowProps)
|
||||||
const searchInputProps = toRef(() => defu(props.searchInput, { placeholder: t('selectMenu.search'), variant: 'none' }) as InputProps)
|
const searchInputProps = toRef(() => defu(props.searchInput, { placeholder: t('selectMenu.search'), variant: 'none' }) as InputProps)
|
||||||
|
|
||||||
const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
|
const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField<InputProps>(props)
|
||||||
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
|
const { orientation, size: buttonGroupSize } = useButtonGroup<InputProps>(props)
|
||||||
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
|
const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown })))
|
||||||
|
|
||||||
@@ -272,6 +272,7 @@ function onUpdateOpen(value: boolean) {
|
|||||||
} else {
|
} else {
|
||||||
const event = new FocusEvent('focus')
|
const event = new FocusEvent('focus')
|
||||||
emits('focus', event)
|
emits('focus', event)
|
||||||
|
emitFormFocus()
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ const emits = defineEmits<TextareaEmits>()
|
|||||||
|
|
||||||
const [modelValue, modelModifiers] = defineModel<string | number>()
|
const [modelValue, modelModifiers] = defineModel<string | number>()
|
||||||
|
|
||||||
const { emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<TextareaProps>(props, { deferInputValidation: true })
|
const { emitFormFocus, emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField<TextareaProps>(props, { deferInputValidation: true })
|
||||||
|
|
||||||
const ui = computed(() => textarea({
|
const ui = computed(() => textarea({
|
||||||
color: color.value,
|
color: color.value,
|
||||||
@@ -189,6 +189,7 @@ onMounted(() => {
|
|||||||
@input="onInput"
|
@input="onInput"
|
||||||
@blur="onBlur"
|
@blur="onBlur"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
|
@focus="emitFormFocus"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { inject, ref, computed, type InjectionKey, type Ref, type ComputedRef } from 'vue'
|
import { inject, computed, type InjectionKey, type Ref, type ComputedRef } from 'vue'
|
||||||
import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core'
|
import { type UseEventBusReturn, useDebounceFn } from '@vueuse/core'
|
||||||
import type { FormFieldProps } from '../types'
|
import type { FormFieldProps } from '../types'
|
||||||
import type { FormEvent, FormInputEvents, FormFieldInjectedOptions, FormInjectedOptions } from '../types/form'
|
import type { FormEvent, FormInputEvents, FormFieldInjectedOptions, FormInjectedOptions } from '../types/form'
|
||||||
@@ -14,7 +14,7 @@ type Props<T> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const formOptionsInjectionKey: InjectionKey<ComputedRef<FormInjectedOptions>> = Symbol('nuxt-ui.form-options')
|
export const formOptionsInjectionKey: InjectionKey<ComputedRef<FormInjectedOptions>> = Symbol('nuxt-ui.form-options')
|
||||||
export const formBusInjectionKey: InjectionKey<UseEventBusReturn<FormEvent, string>> = Symbol('nuxt-ui.form-events')
|
export const formBusInjectionKey: InjectionKey<UseEventBusReturn<FormEvent<any>, string>> = Symbol('nuxt-ui.form-events')
|
||||||
export const formFieldInjectionKey: InjectionKey<ComputedRef<FormFieldInjectedOptions<FormFieldProps>>> = Symbol('nuxt-ui.form-field')
|
export const formFieldInjectionKey: InjectionKey<ComputedRef<FormFieldInjectedOptions<FormFieldProps>>> = Symbol('nuxt-ui.form-field')
|
||||||
export const inputIdInjectionKey: InjectionKey<Ref<string | undefined>> = Symbol('nuxt-ui.input-id')
|
export const inputIdInjectionKey: InjectionKey<Ref<string | undefined>> = Symbol('nuxt-ui.input-id')
|
||||||
export const formInputsInjectionKey: InjectionKey<Ref<Record<string, { id?: string, pattern?: RegExp }>>> = Symbol('nuxt-ui.form-inputs')
|
export const formInputsInjectionKey: InjectionKey<Ref<Record<string, { id?: string, pattern?: RegExp }>>> = Symbol('nuxt-ui.form-inputs')
|
||||||
@@ -41,29 +41,27 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const touched = ref(false)
|
function emitFormEvent(type: FormInputEvents, name?: string, eager?: boolean) {
|
||||||
|
|
||||||
function emitFormEvent(type: FormInputEvents, name?: string) {
|
|
||||||
if (formBus && formField && name) {
|
if (formBus && formField && name) {
|
||||||
formBus.emit({ type, name })
|
formBus.emit({ type, name, eager })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitFormBlur() {
|
function emitFormBlur() {
|
||||||
touched.value = true
|
|
||||||
emitFormEvent('blur', formField?.value.name)
|
emitFormEvent('blur', formField?.value.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitFormFocus() {
|
||||||
|
emitFormEvent('focus', formField?.value.name)
|
||||||
|
}
|
||||||
|
|
||||||
function emitFormChange() {
|
function emitFormChange() {
|
||||||
touched.value = true
|
|
||||||
emitFormEvent('change', formField?.value.name)
|
emitFormEvent('change', formField?.value.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const emitFormInput = useDebounceFn(
|
const emitFormInput = useDebounceFn(
|
||||||
() => {
|
() => {
|
||||||
if (!opts?.deferInputValidation || touched.value || formField?.value.eagerValidation) {
|
emitFormEvent('input', formField?.value.name, !opts?.deferInputValidation || formField?.value.eagerValidation)
|
||||||
emitFormEvent('input', formField?.value.name)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
formField?.value.validateOnInputDelay ?? formOptions?.value.validateOnInputDelay ?? 0
|
formField?.value.validateOnInputDelay ?? formOptions?.value.validateOnInputDelay ?? 0
|
||||||
)
|
)
|
||||||
@@ -78,6 +76,7 @@ export function useFormField<T>(props?: Props<T>, opts?: { bind?: boolean, defer
|
|||||||
emitFormBlur,
|
emitFormBlur,
|
||||||
emitFormInput,
|
emitFormInput,
|
||||||
emitFormChange,
|
emitFormChange,
|
||||||
|
emitFormFocus,
|
||||||
ariaAttrs: computed(() => {
|
ariaAttrs: computed(() => {
|
||||||
if (!formField?.value) return
|
if (!formField?.value) return
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
import type { StandardSchemaV1 } from '@standard-schema/spec'
|
||||||
import type { ComputedRef, Ref } from 'vue'
|
import type { ComputedRef, DeepReadonly, Ref } from 'vue'
|
||||||
import type { ZodSchema } from 'zod'
|
import type { ZodSchema } from 'zod'
|
||||||
import type { Schema as JoiSchema } from 'joi'
|
import type { Schema as JoiSchema } from 'joi'
|
||||||
import type { ObjectSchema as YupObjectSchema } from 'yup'
|
import type { ObjectSchema as YupObjectSchema } from 'yup'
|
||||||
@@ -7,17 +7,22 @@ import type { GenericSchema as ValibotSchema, GenericSchemaAsync as ValibotSchem
|
|||||||
import type { GetObjectField } from './utils'
|
import type { GetObjectField } from './utils'
|
||||||
import type { Struct as SuperstructSchema } from 'superstruct'
|
import type { Struct as SuperstructSchema } from 'superstruct'
|
||||||
|
|
||||||
export interface Form<T> {
|
export interface Form<T extends object> {
|
||||||
validate (opts?: { name?: string | string[], silent?: boolean, nested?: boolean, transform?: boolean }): Promise<T | false>
|
validate (opts?: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean }): Promise<T | false>
|
||||||
clear (path?: string): void
|
clear (path?: string): void
|
||||||
errors: Ref<FormError[]>
|
errors: Ref<FormError[]>
|
||||||
setErrors (errs: FormError[], path?: string): void
|
setErrors (errs: FormError[], name?: keyof T): void
|
||||||
getErrors (path?: string): FormError[]
|
getErrors (name?: keyof T): FormError[]
|
||||||
submit (): Promise<void>
|
submit (): Promise<void>
|
||||||
disabled: ComputedRef<boolean>
|
disabled: ComputedRef<boolean>
|
||||||
|
dirty: ComputedRef<boolean>
|
||||||
|
|
||||||
|
dirtyFields: DeepReadonly<Set<keyof T>>
|
||||||
|
touchedFields: DeepReadonly<Set<keyof T>>
|
||||||
|
blurredFields: DeepReadonly<Set<keyof T>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormSchema<T extends Record<string, any>> =
|
export type FormSchema<T extends object> =
|
||||||
| ZodSchema
|
| ZodSchema
|
||||||
| YupObjectSchema<T>
|
| YupObjectSchema<T>
|
||||||
| ValibotSchema
|
| ValibotSchema
|
||||||
@@ -28,7 +33,7 @@ export type FormSchema<T extends Record<string, any>> =
|
|||||||
| SuperstructSchema<any, any>
|
| SuperstructSchema<any, any>
|
||||||
| StandardSchemaV1
|
| StandardSchemaV1
|
||||||
|
|
||||||
export type FormInputEvents = 'input' | 'blur' | 'change'
|
export type FormInputEvents = 'input' | 'blur' | 'change' | 'focus'
|
||||||
|
|
||||||
export interface FormError<P extends string = string> {
|
export interface FormError<P extends string = string> {
|
||||||
name: P
|
name: P
|
||||||
@@ -61,13 +66,14 @@ export type FormChildDetachEvent = {
|
|||||||
formId: string | number
|
formId: string | number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormInputEvent = {
|
export type FormInputEvent<T extends object> = {
|
||||||
type: FormEventType
|
type: FormEventType
|
||||||
name?: string
|
name: keyof T
|
||||||
|
eager?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FormEvent =
|
export type FormEvent<T extends object> =
|
||||||
| FormInputEvent
|
| FormInputEvent<T>
|
||||||
| FormChildAttachEvent
|
| FormChildAttachEvent
|
||||||
| FormChildDetachEvent
|
| FormChildDetachEvent
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,39 @@ describe('Form', () => {
|
|||||||
{ id: 'passwordInput', name: 'password', message: 'Required' }
|
{ id: 'passwordInput', name: 'password', message: 'Required' }
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('touchedFields works', async () => {
|
||||||
|
const emailInput = wrapper.find('#emailInput')
|
||||||
|
|
||||||
|
emailInput.trigger('focus')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(form.value.touchedFields.has('email')).toBe(true)
|
||||||
|
expect(form.value.touchedFields.has('password')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('touchedFields works', async () => {
|
||||||
|
const emailInput = wrapper.find('#emailInput')
|
||||||
|
|
||||||
|
emailInput.trigger('change')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(form.value.dirtyFields.has('email')).toBe(true)
|
||||||
|
expect(form.value.touchedFields.has('email')).toBe(true)
|
||||||
|
|
||||||
|
expect(form.value.dirtyFields.has('password')).toBe(false)
|
||||||
|
expect(form.value.touchedFields.has('password')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blurredFields works', async () => {
|
||||||
|
const emailInput = wrapper.find('#emailInput')
|
||||||
|
|
||||||
|
emailInput.trigger('blur')
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(form.value.blurredFields.has('email')).toBe(true)
|
||||||
|
expect(form.value.blurredFields.has('password')).toBe(false)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('nested', async () => {
|
describe('nested', async () => {
|
||||||
@@ -444,6 +477,7 @@ describe('Form', () => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('form field errorPattern works', async () => {
|
test('form field errorPattern works', async () => {
|
||||||
const wrapper = await mountSuspended({
|
const wrapper = await mountSuspended({
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
Reference in New Issue
Block a user