diff --git a/docs/content/3.components/form.md b/docs/content/3.components/form.md index bd617b9c..083eb01b 100644 --- a/docs/content/3.components/form.md +++ b/docs/content/3.components/form.md @@ -195,9 +195,13 @@ This will give you access to the following: | Name | Type | | ---- | ---- | | `submit()`{lang="ts-type"} | `Promise`{lang="ts-type"}

Triggers form submission.

| -| `validate(opts: { name?: string \| string[], silent?: boolean, nested?: boolean, transform?: boolean })`{lang="ts-type"} | `Promise`{lang="ts-type"}

Triggers form validation. Will raise any errors unless `opts.silent` is set to true.

| -| `clear(path?: string)`{lang="ts-type"} | `void`

Clears form errors associated with a specific path. If no path is provided, clears all form errors.

| -| `getErrors(path?: string)`{lang="ts-type"} | `FormError[]`{lang="ts-type"}

Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.

| -| `setErrors(errors: FormError[], path?: string)`{lang="ts-type"} | `void`

Sets form errors for a given path. If no path is provided, overrides all errors.

| +| `validate(opts: { name?: keyof T \| (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean })`{lang="ts-type"} | `Promise`{lang="ts-type"}

Triggers form validation. Will raise any errors unless `opts.silent` is set to true.

| +| `clear(path?: keyof T)`{lang="ts-type"} | `void`

Clears form errors associated with a specific path. If no path is provided, clears all form errors.

| +| `getErrors(path?: keyof T)`{lang="ts-type"} | `FormError[]`{lang="ts-type"}

Retrieves form errors associated with a specific path. If no path is provided, returns all form errors.

| +| `setErrors(errors: FormError[], path?: keyof T)`{lang="ts-type"} | `void`

Sets form errors for a given path. If no path is provided, overrides all errors.

| | `errors`{lang="ts-type"} | `Ref`{lang="ts-type"}

A reference to the array containing validation errors. Use this to access or manipulate the error information.

| | `disabled`{lang="ts-type"} | `Ref`{lang="ts-type"} | +| `dirty`{lang="ts-type"} | `Ref`{lang="ts-type"} `true` if at least one form field has been updated by the user.| +| `dirtyFields`{lang="ts-type"} | `DeepReadonly>`{lang="ts-type"} Tracks fields that have been modified by the user. | +| `touchedFields`{lang="ts-type"} | `DeepReadonly>`{lang="ts-type"} Tracks fields that the user interacted with. | +| `blurredFields`{lang="ts-type"} | `DeepReadonly>`{lang="ts-type"} Tracks fields blurred by the user. | diff --git a/src/runtime/components/Form.vue b/src/runtime/components/Form.vue index 04325557..2054c412 100644 --- a/src/runtime/components/Form.vue +++ b/src/runtime/components/Form.vue @@ -5,6 +5,7 @@ import theme from '#build/ui/form' import { extendDevtoolsMeta } from '../composables/extendDevtoolsMeta' import { tv } from '../utils/tv' 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 } } @@ -52,7 +53,7 @@ defineSlots() const formId = props.id ?? useId() as string -const bus = useEventBus(`form-${formId}`) +const bus = useEventBus>(`form-${formId}`) const parentBus = inject( formBusInjectionKey, undefined @@ -68,8 +69,24 @@ onMounted(async () => { nestedForms.value.set(event.formId, { validate: event.validate }) } else if (event.type === 'detach') { nestedForms.value.delete(event.formId) - } else if (props.validateOn?.includes(event.type as FormInputEvents)) { - await _validate({ name: event.name, silent: true, nested: false }) + } else if (props.validateOn?.includes(event.type)) { + 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([]) provide('form-errors', errors) -const inputs = ref>({}) -provide(formInputsInjectionKey, inputs) +const inputs = ref<{ [P in keyof T]?: { id?: string, pattern?: RegExp } }>({}) +provide(formInputsInjectionKey, inputs as any) + +const dirtyFields = new Set() +const touchedFields = new Set() +const blurredFields = new Set() function resolveErrorIds(errs: FormError[]): FormErrorWithId[] { return errs.map(err => ({ @@ -121,8 +142,8 @@ async function getErrors(): Promise { return resolveErrorIds(errs) } -async function _validate(opts: { name?: string | string[], silent?: boolean, nested?: boolean, transform?: boolean } = { silent: false, nested: true, transform: false }): Promise { - const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as string[] +async function _validate(opts: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean } = { silent: false, nested: true, transform: false }): Promise { + const names = opts.name && !Array.isArray(opts.name) ? [opts.name] : opts.name as (keyof T)[] const nestedValidatePromises = !names && opts.nested ? Array.from(nestedForms.value.values()).map( @@ -203,7 +224,7 @@ defineExpose>({ validate: _validate, errors, - setErrors(errs: FormError[], name?: string) { + setErrors(errs: FormError[], name?: keyof T) { if (name) { errors.value = errors.value .filter(error => error.name !== name) @@ -217,7 +238,7 @@ defineExpose>({ await onSubmitWrapper(new Event('submit')) }, - getErrors(name?: string) { + getErrors(name?: keyof T) { if (name) { return errors.value.filter(err => err.name === name) } @@ -232,7 +253,12 @@ defineExpose>({ } }, - disabled + disabled, + dirty: computed(() => !!dirtyFields.size), + + dirtyFields: readonly(dirtyFields) as DeepReadonly>, + blurredFields: readonly(blurredFields) as DeepReadonly>, + touchedFields: readonly(touchedFields) as DeepReadonly> }) diff --git a/src/runtime/components/Input.vue b/src/runtime/components/Input.vue index d2be2d4e..538966f3 100644 --- a/src/runtime/components/Input.vue +++ b/src/runtime/components/Input.vue @@ -75,7 +75,7 @@ const slots = defineSlots() const [modelValue, modelModifiers] = defineModel() -const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props, { deferInputValidation: true }) +const { emitFormBlur, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, emitFormFocus, ariaAttrs } = useFormField(props, { deferInputValidation: true }) const { orientation, size: buttonGroupSize } = useButtonGroup(props) const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(props) @@ -170,6 +170,7 @@ onMounted(() => { @input="onInput" @blur="onBlur" @change="onChange" + @focus="emitFormFocus" > diff --git a/src/runtime/components/InputMenu.vue b/src/runtime/components/InputMenu.vue index 59740c5a..07c78c54 100644 --- a/src/runtime/components/InputMenu.vue +++ b/src/runtime/components/InputMenu.vue @@ -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 arrowProps = toRef(() => props.arrow as ComboboxArrowProps) -const { emitFormBlur, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props) +const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props) const { orientation, size: buttonGroupSize } = useButtonGroup(props) 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) { emits('focus', event) + emitFormFocus() } function onUpdateOpen(value: boolean) { diff --git a/src/runtime/components/InputNumber.vue b/src/runtime/components/InputNumber.vue index 81a7cd0f..c5ada76d 100644 --- a/src/runtime/components/InputNumber.vue +++ b/src/runtime/components/InputNumber.vue @@ -92,7 +92,7 @@ defineSlots() 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(props) +const { emitFormBlur, emitFormFocus, emitFormChange, emitFormInput, id, color, size, name, highlight, disabled, ariaAttrs } = useFormField(props) const { t, code: codeLocale } = useLocale() const locale = computed(() => props.locale || codeLocale.value) @@ -158,6 +158,7 @@ defineExpose({ :required="required" :class="ui.base({ class: props.ui?.base })" @blur="onBlur" + @focus="emitFormFocus" />
diff --git a/src/runtime/components/PinInput.vue b/src/runtime/components/PinInput.vue index 4b9cb378..a7a8053f 100644 --- a/src/runtime/components/PinInput.vue +++ b/src/runtime/components/PinInput.vue @@ -50,7 +50,7 @@ const props = withDefaults(defineProps(), { const emits = defineEmits() 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(props) +const { emitFormInput, emitFormFocus, emitFormChange, emitFormBlur, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props) const ui = computed(() => pinInput({ color: color.value, @@ -92,6 +92,7 @@ function onBlur(event: FocusEvent) { v-bind="$attrs" :disabled="disabled" @blur="onBlur" + @focus="emitFormFocus" /> diff --git a/src/runtime/components/Select.vue b/src/runtime/components/Select.vue index 964e8b44..a5e59722 100644 --- a/src/runtime/components/Select.vue +++ b/src/runtime/components/Select.vue @@ -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 arrowProps = toRef(() => props.arrow as SelectArrowProps) -const { emitFormChange, emitFormInput, emitFormBlur, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props) +const { emitFormChange, emitFormInput, emitFormBlur, emitFormFocus, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props) const { orientation, size: buttonGroupSize } = useButtonGroup(props) const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown }))) @@ -179,6 +179,7 @@ function onUpdateOpen(value: boolean) { } else { const event = new FocusEvent('focus') emits('focus', event) + emitFormFocus() } } diff --git a/src/runtime/components/SelectMenu.vue b/src/runtime/components/SelectMenu.vue index c4570005..28c8052d 100644 --- a/src/runtime/components/SelectMenu.vue +++ b/src/runtime/components/SelectMenu.vue @@ -168,7 +168,7 @@ const contentProps = toRef(() => defu(props.content, { side: 'bottom', sideOffse const arrowProps = toRef(() => props.arrow as ComboboxArrowProps) 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(props) +const { emitFormBlur, emitFormFocus, emitFormInput, emitFormChange, size: formGroupSize, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props) const { orientation, size: buttonGroupSize } = useButtonGroup(props) const { isLeading, isTrailing, leadingIconName, trailingIconName } = useComponentIcons(toRef(() => defu(props, { trailingIcon: appConfig.ui.icons.chevronDown }))) @@ -272,6 +272,7 @@ function onUpdateOpen(value: boolean) { } else { const event = new FocusEvent('focus') emits('focus', event) + emitFormFocus() clearTimeout(timeoutId) } } diff --git a/src/runtime/components/Textarea.vue b/src/runtime/components/Textarea.vue index 86979e75..4fb96b23 100644 --- a/src/runtime/components/Textarea.vue +++ b/src/runtime/components/Textarea.vue @@ -66,7 +66,7 @@ const emits = defineEmits() const [modelValue, modelModifiers] = defineModel() -const { emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props, { deferInputValidation: true }) +const { emitFormFocus, emitFormBlur, emitFormInput, emitFormChange, size, color, id, name, highlight, disabled, ariaAttrs } = useFormField(props, { deferInputValidation: true }) const ui = computed(() => textarea({ color: color.value, @@ -189,6 +189,7 @@ onMounted(() => { @input="onInput" @blur="onBlur" @change="onChange" + @focus="emitFormFocus" /> diff --git a/src/runtime/composables/useFormField.ts b/src/runtime/composables/useFormField.ts index 0dcd903a..ea95edc8 100644 --- a/src/runtime/composables/useFormField.ts +++ b/src/runtime/composables/useFormField.ts @@ -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 { FormFieldProps } from '../types' import type { FormEvent, FormInputEvents, FormFieldInjectedOptions, FormInjectedOptions } from '../types/form' @@ -14,7 +14,7 @@ type Props = { } export const formOptionsInjectionKey: InjectionKey> = Symbol('nuxt-ui.form-options') -export const formBusInjectionKey: InjectionKey> = Symbol('nuxt-ui.form-events') +export const formBusInjectionKey: InjectionKey, string>> = Symbol('nuxt-ui.form-events') export const formFieldInjectionKey: InjectionKey>> = Symbol('nuxt-ui.form-field') export const inputIdInjectionKey: InjectionKey> = Symbol('nuxt-ui.input-id') export const formInputsInjectionKey: InjectionKey>> = Symbol('nuxt-ui.form-inputs') @@ -41,29 +41,27 @@ export function useFormField(props?: Props, opts?: { bind?: boolean, defer } } - const touched = ref(false) - - function emitFormEvent(type: FormInputEvents, name?: string) { + function emitFormEvent(type: FormInputEvents, name?: string, eager?: boolean) { if (formBus && formField && name) { - formBus.emit({ type, name }) + formBus.emit({ type, name, eager }) } } function emitFormBlur() { - touched.value = true emitFormEvent('blur', formField?.value.name) } + function emitFormFocus() { + emitFormEvent('focus', formField?.value.name) + } + function emitFormChange() { - touched.value = true emitFormEvent('change', formField?.value.name) } const emitFormInput = useDebounceFn( () => { - if (!opts?.deferInputValidation || touched.value || formField?.value.eagerValidation) { - emitFormEvent('input', formField?.value.name) - } + emitFormEvent('input', formField?.value.name, !opts?.deferInputValidation || formField?.value.eagerValidation) }, formField?.value.validateOnInputDelay ?? formOptions?.value.validateOnInputDelay ?? 0 ) @@ -78,6 +76,7 @@ export function useFormField(props?: Props, opts?: { bind?: boolean, defer emitFormBlur, emitFormInput, emitFormChange, + emitFormFocus, ariaAttrs: computed(() => { if (!formField?.value) return diff --git a/src/runtime/types/form.ts b/src/runtime/types/form.ts index 8718558a..5d21282d 100644 --- a/src/runtime/types/form.ts +++ b/src/runtime/types/form.ts @@ -1,5 +1,5 @@ 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 { Schema as JoiSchema } from 'joi' 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 { Struct as SuperstructSchema } from 'superstruct' -export interface Form { - validate (opts?: { name?: string | string[], silent?: boolean, nested?: boolean, transform?: boolean }): Promise +export interface Form { + validate (opts?: { name?: keyof T | (keyof T)[], silent?: boolean, nested?: boolean, transform?: boolean }): Promise clear (path?: string): void errors: Ref - setErrors (errs: FormError[], path?: string): void - getErrors (path?: string): FormError[] + setErrors (errs: FormError[], name?: keyof T): void + getErrors (name?: keyof T): FormError[] submit (): Promise disabled: ComputedRef + dirty: ComputedRef + + dirtyFields: DeepReadonly> + touchedFields: DeepReadonly> + blurredFields: DeepReadonly> } -export type FormSchema> = +export type FormSchema = | ZodSchema | YupObjectSchema | ValibotSchema @@ -28,7 +33,7 @@ export type FormSchema> = | SuperstructSchema | StandardSchemaV1 -export type FormInputEvents = 'input' | 'blur' | 'change' +export type FormInputEvents = 'input' | 'blur' | 'change' | 'focus' export interface FormError

{ name: P @@ -61,13 +66,14 @@ export type FormChildDetachEvent = { formId: string | number } -export type FormInputEvent = { +export type FormInputEvent = { type: FormEventType - name?: string + name: keyof T + eager?: boolean } -export type FormEvent = - | FormInputEvent +export type FormEvent = + | FormInputEvent | FormChildAttachEvent | FormChildDetachEvent diff --git a/test/components/Form.spec.ts b/test/components/Form.spec.ts index 144a698f..8df0c132 100644 --- a/test/components/Form.spec.ts +++ b/test/components/Form.spec.ts @@ -278,6 +278,39 @@ describe('Form', () => { { 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 () => { @@ -444,6 +477,7 @@ describe('Form', () => { } ) }) + test('form field errorPattern works', async () => { const wrapper = await mountSuspended({ components: {