diff --git a/packages/core/src/form/formContext.ts b/packages/core/src/form/formContext.ts index ef176457..2f279423 100644 --- a/packages/core/src/form/formContext.ts +++ b/packages/core/src/form/formContext.ts @@ -1,6 +1,6 @@ -import { DisabledSchema, FormObject, Path, PathValue, TouchedSchema } from '../types'; -import { cloneDeep, merge } from '../utils/common'; -import { escapePath, getFromPath, isPathSet, setInPath, unsetPath as unsetInObject } from '../utils/path'; +import { Arrayable, DisabledSchema, FormObject, Path, PathValue, TouchedSchema, ValiditySchema } from '../types'; +import { cloneDeep, merge, normalizeArrayable } from '../utils/common'; +import { escapePath, findLeaf, getFromPath, isPathSet, setInPath, unsetPath as unsetInObject } from '../utils/path'; import { FormSnapshot } from './formSnapshot'; export interface FormContext { @@ -18,6 +18,9 @@ export interface FormContext { setInitialValues: (newValues: Partial, opts?: SetValueOptions) => void; setInitialTouched: (newTouched: Partial>, opts?: SetValueOptions) => void; setFieldDisabled>(path: TPath, value: boolean): void; + getFieldErrors>(path: TPath): string[]; + setFieldErrors>(path: TPath, message: Arrayable): void; + hasErrors: () => boolean; getValues: () => TForm; setValues: (newValues: Partial, opts?: SetValueOptions) => void; revertValues: () => void; @@ -33,6 +36,7 @@ export interface FormContextCreateOptions values: TForm; touched: TouchedSchema; disabled: DisabledSchema; + errors: ValiditySchema; snapshots: { values: FormSnapshot; touched: FormSnapshot>; @@ -43,6 +47,7 @@ export function createFormContext({ id, values, disabled, + errors, touched, snapshots, }: FormContextCreateOptions): FormContext { @@ -70,12 +75,14 @@ export function createFormContext({ unsetInObject(values, path, true); unsetInObject(touched, path, true); unsetInObject(disabled, escapePath(path), true); + unsetInObject(errors, escapePath(path), true); } function unsetPath>(path: TPath) { unsetInObject(values, path, false); unsetInObject(touched, path, false); unsetInObject(disabled, escapePath(path), false); + unsetInObject(errors, escapePath(path), false); } function getFieldInitialValue>(path: TPath) { @@ -94,6 +101,10 @@ export function createFormContext({ setInPath(disabled, escapePath(path), value); } + function hasErrors() { + return !!findLeaf(errors, l => Array.isArray(l) && l.length > 0); + } + function setInitialValues(newValues: Partial, opts?: SetValueOptions) { if (opts?.mode === 'merge') { snapshots.values.initials.value = merge(cloneDeep(snapshots.values.initials.value), cloneDeep(newValues)); @@ -140,6 +151,14 @@ export function createFormContext({ }); } + function getFieldErrors>(path: TPath) { + return [...(getFromPath(errors, escapePath(path), []) || [])]; + } + + function setFieldErrors>(path: TPath, message: Arrayable) { + setInPath(errors, escapePath(path), message ? normalizeArrayable(message) : []); + } + function setTouched(newTouched: Partial>, opts?: SetValueOptions) { if (opts?.mode === 'merge') { merge(touched, newTouched); @@ -182,5 +201,8 @@ export function createFormContext({ setInitialTouched, getFieldOriginalValue, setFieldDisabled, + setFieldErrors, + getFieldErrors, + hasErrors, }; } diff --git a/packages/core/src/form/useErrorDisplay.spec.ts b/packages/core/src/form/useErrorDisplay.spec.ts new file mode 100644 index 00000000..1a175186 --- /dev/null +++ b/packages/core/src/form/useErrorDisplay.spec.ts @@ -0,0 +1,39 @@ +import { renderSetup } from '@test-utils/index'; +import { useErrorDisplay } from './useErrorDisplay'; +import { useFormField } from './useFormField'; + +test('displays field errors only if they are touched', async () => { + const { setErrors, isValid, errorMessage, displayError, setTouched } = await renderSetup(() => { + const field = useFormField({ initialValue: 'bar' }); + const { displayError } = useErrorDisplay(field); + + return { ...field, displayError }; + }); + + expect(isValid.value).toBe(true); + expect(errorMessage.value).toBe(''); + expect(displayError()).toBe(''); + + setErrors('error'); + expect(errorMessage.value).toBe('error'); + expect(displayError()).toBe(''); + expect(isValid.value).toBe(false); + + setTouched(true); + expect(displayError()).toBe('error'); +}); + +test('controls display of custom messages as well', async () => { + const { isValid, displayError, setTouched } = await renderSetup(() => { + const field = useFormField({ initialValue: 'bar' }); + const { displayError } = useErrorDisplay(field); + + return { ...field, displayError }; + }); + + expect(displayError('custom error')).toBe(''); + expect(isValid.value).toBe(true); + + setTouched(true); + expect(displayError('custom error')).toBe('custom error'); +}); diff --git a/packages/core/src/form/useErrorDisplay.ts b/packages/core/src/form/useErrorDisplay.ts new file mode 100644 index 00000000..ce881c84 --- /dev/null +++ b/packages/core/src/form/useErrorDisplay.ts @@ -0,0 +1,11 @@ +import { FormField } from './useFormField'; + +export function useErrorDisplay(field: FormField) { + function displayError(msg?: string) { + const error = msg || field.errorMessage.value; + + return field.isTouched.value ? error : ''; + } + + return { displayError }; +} diff --git a/packages/core/src/form/useForm.spec.ts b/packages/core/src/form/useForm.spec.ts index c34351e9..5291535e 100644 --- a/packages/core/src/form/useForm.spec.ts +++ b/packages/core/src/form/useForm.spec.ts @@ -1,7 +1,9 @@ import { renderSetup } from '@test-utils/index'; import { useForm } from './useForm'; import { useFormField } from './useFormField'; -import { nextTick, ref } from 'vue'; +import { nextTick, Ref, ref } from 'vue'; +import { useInputValidity } from '../validation/useInputValidity'; +import { fireEvent, render, screen } from '@testing-library/vue'; describe('form values', () => { test('it initializes form values', async () => { @@ -309,3 +311,97 @@ describe('form dirty state', () => { expect(field.isDirty.value).toBe(false); }); }); + +describe('validation', () => { + function createInputComponent(inputRef: Ref) { + return { + setup: () => { + const field = useFormField({ path: 'test' }); + useInputValidity({ inputRef, field }); + + return { input: inputRef, errorMessage: field.errorMessage }; + }, + template: ` + + {{ errorMessage }} + `, + }; + } + + test('updates the form errors', async () => { + const input = ref(); + + await render({ + components: { Child: createInputComponent(input) }, + setup() { + const { getError } = useForm(); + + return { getError }; + }, + template: ` +
+ + + {{ getError('test') }} + + `, + }); + + await fireEvent.blur(screen.getByTestId('input')); + expect(screen.getByTestId('err').textContent).toBe('Constraints not satisfied'); + expect(screen.getByTestId('form-err').textContent).toBe('Constraints not satisfied'); + }); + + test('updates the form isValid', async () => { + const input = ref(); + + await render({ + components: { Child: createInputComponent(input) }, + setup() { + const { isValid } = useForm(); + + return { isValid }; + }, + template: ` +
+ + + Form is valid + Form is invalid + + `, + }); + + expect(screen.getByText('Form is valid')).toBeDefined(); + await fireEvent.blur(screen.getByTestId('input')); + expect(screen.getByText('Form is invalid')).toBeDefined(); + }); + + test('prevents submission if the form is not valid', async () => { + const input = ref(); + const handler = vi.fn(); + + await render({ + components: { Child: createInputComponent(input) }, + setup() { + const { handleSubmit } = useForm(); + + return { onSubmit: handleSubmit(handler) }; + }, + template: ` +
+ + + + + `, + }); + + await nextTick(); + await fireEvent.click(screen.getByText('Submit')); + expect(handler).not.toHaveBeenCalled(); + await fireEvent.change(screen.getByTestId('input'), { target: { value: 'test' } }); + await fireEvent.click(screen.getByText('Submit')); + expect(handler).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/core/src/form/useForm.ts b/packages/core/src/form/useForm.ts index b4fd16ec..06210ac5 100644 --- a/packages/core/src/form/useForm.ts +++ b/packages/core/src/form/useForm.ts @@ -1,6 +1,6 @@ import { computed, InjectionKey, provide, reactive, readonly } from 'vue'; import { cloneDeep, isEqual, useUniqId } from '../utils/common'; -import { FormObject, MaybeAsync, MaybeGetter, TouchedSchema, Path } from '../types'; +import { FormObject, MaybeAsync, MaybeGetter, TouchedSchema, DisabledSchema, ValiditySchema, Path } from '../types'; import { createFormContext, FormContext } from './formContext'; import { FormTransactionManager, useFormTransactions } from './useFormTransactions'; import { useFormActions } from './useFormActions'; @@ -29,27 +29,33 @@ export function useForm(opts?: Partial; - const disabled = {} as Partial, boolean>>; - - const isTouched = computed(() => { - return !!findLeaf(touched, l => l === true); - }); - - const isDirty = computed(() => { - return !isEqual(values, valuesSnapshot.originals.value); - }); + const disabled = {} as DisabledSchema; + const errors = reactive({}) as ValiditySchema; const ctx = createFormContext({ id: opts?.id || useUniqId('form'), values, touched, disabled, + errors, snapshots: { values: valuesSnapshot, touched: touchedSnapshot, }, }); + const isTouched = computed(() => { + return !!findLeaf(touched, l => l === true); + }); + + const isDirty = computed(() => { + return !isEqual(values, valuesSnapshot.originals.value); + }); + + const isValid = computed(() => { + return !ctx.hasErrors(); + }); + function onAsyncInit(v: TForm) { ctx.setValues(v, { mode: 'merge' }); } @@ -57,6 +63,10 @@ export function useForm(opts?: Partial>(path: TPath): string | undefined { + return ctx.getFieldErrors(path)[0]; + } + provide(FormKey, { ...ctx, ...transactionsManager, @@ -69,11 +79,14 @@ export function useForm(opts?: Partial( e.preventDefault(); isSubmitting.value = true; await dispatchSubmit(); + + // Prevent submission if the form has errors + if (form.hasErrors()) { + isSubmitting.value = false; + + return; + } + // Clone the values to prevent mutation or reactive leaks const values = cloneDeep(form.getValues()); const disabledPaths = Object.entries(disabled) diff --git a/packages/core/src/form/useFormField.spec.ts b/packages/core/src/form/useFormField.spec.ts index f4b9af98..aa808f22 100644 --- a/packages/core/src/form/useFormField.spec.ts +++ b/packages/core/src/form/useFormField.spec.ts @@ -99,3 +99,18 @@ test('formless fields maintain their own dirty state', async () => { setValue('bar'); expect(isDirty.value).toBe(false); }); + +test('formless fields maintain their own error state', async () => { + const { setErrors, isValid, errorMessage, errors } = await renderSetup(() => { + return useFormField({ initialValue: 'bar' }); + }); + + expect(isValid.value).toBe(true); + expect(errorMessage.value).toBe(''); + expect(errors.value).toEqual([]); + setErrors('error'); + + expect(isValid.value).toBe(false); + expect(errorMessage.value).toBe('error'); + expect(errors.value).toEqual(['error']); +}); diff --git a/packages/core/src/form/useFormField.ts b/packages/core/src/form/useFormField.ts index 4e28c05e..52e79f6a 100644 --- a/packages/core/src/form/useFormField.ts +++ b/packages/core/src/form/useFormField.ts @@ -11,9 +11,9 @@ import { watch, } from 'vue'; import { FormContextWithTransactions, FormKey } from './useForm'; -import { Getter } from '../types'; +import { Arrayable, Getter } from '../types'; import { useSyncModel } from '../reactivity/useModelSync'; -import { cloneDeep, isEqual } from '../utils/common'; +import { cloneDeep, isEqual, normalizeArrayable } from '../utils/common'; interface FormFieldOptions { path: MaybeRefOrGetter | undefined; @@ -24,13 +24,24 @@ interface FormFieldOptions { disabled: MaybeRefOrGetter; } -export function useFormField(opts?: Partial>) { +export type FormField = { + fieldValue: Ref; + isTouched: Ref; + isDirty: Ref; + isValid: Ref; + errors: Ref; + errorMessage: Ref; + setValue: (value: TValue | undefined) => void; + setTouched: (touched: boolean) => void; + setErrors: (messages: Arrayable) => void; +}; + +export function useFormField(opts?: Partial>): FormField { const form = inject(FormKey, null); const getPath = () => toValue(opts?.path); const { fieldValue, pathlessValue, setValue } = useFieldValue(getPath, form, opts?.initialValue); - const { isTouched, pathlessTouched, setTouched } = form - ? createFormTouchedRef(getPath, form) - : createTouchedRef(false); + const { isTouched, pathlessTouched, setTouched } = useFieldTouched(getPath, form); + const { errors, setErrors, isValid, errorMessage, pathlessValidity } = useFieldValidity(getPath, form); const isDirty = computed(() => { if (!form) { @@ -53,12 +64,16 @@ export function useFormField(opts?: Partial = { fieldValue: readonly(fieldValue) as Ref, isTouched: readonly(isTouched) as Ref, isDirty, + isValid, + errors, + errorMessage, setValue, setTouched, + setErrors, }; if (!form) { @@ -109,6 +124,7 @@ export function useFormField(opts?: Partial(opts?: Partial, form?: FormContextWithTransactions | null) { + const validity = form ? createFormValidityRef(getPath, form) : createLocalValidity(); + const errorMessage = computed(() => validity.errors.value[0] ?? ''); + const isValid = computed(() => validity.errors.value.length === 0); + + return { + ...validity, + isValid, + errorMessage, + }; +} + function useFieldValue( getPath: Getter, form?: FormContextWithTransactions | null, initialValue?: TValue, ) { - return form ? createFormValueRef(getPath, form, initialValue) : createValueRef(initialValue); + return form ? createFormValueRef(getPath, form, initialValue) : createLocalValueRef(initialValue); +} + +function useFieldTouched(getPath: Getter, form?: FormContextWithTransactions | null) { + return form ? createFormTouchedRef(getPath, form) : createLocalTouchedRef(false); } -function createTouchedRef(initialTouched?: boolean) { +function createLocalTouchedRef(initialTouched?: boolean) { const isTouched = shallowRef(initialTouched ?? false); return { @@ -156,7 +188,7 @@ function createFormTouchedRef(getPath: Getter, form: FormCon const isTouched = computed(() => { const path = getPath(); - return path ? form.isFieldTouched(path) : false; + return path ? form.isFieldTouched(path) : pathlessTouched.value; }) as Ref; function setTouched(value: boolean) { @@ -204,7 +236,7 @@ function createFormValueRef( }; } -function createValueRef(initialValue?: TValue) { +function createLocalValueRef(initialValue?: TValue) { const fieldValue = shallowRef(toValue(initialValue ?? undefined)) as Ref; return { @@ -233,12 +265,52 @@ function initFormPathIfNecessary( // If form does have a path set and the value is different from the initial value, set it. nextTick(() => { - form.transaction((_, { INIT_PATH }) => ({ + form.transaction((tf, { INIT_PATH }) => ({ kind: INIT_PATH, path, value: initialValue ?? form.getFieldInitialValue(path), touched: initialTouched, disabled, + errors: [...tf.getFieldErrors(path)], })); }); } + +function createFormValidityRef(getPath: Getter, form: FormContextWithTransactions) { + const pathlessValidity = createLocalValidity(); + const errors = computed(() => { + const path = getPath(); + + return path ? form.getFieldErrors(path) : pathlessValidity.errors.value; + }) as Ref; + + function setErrors(messages: Arrayable) { + pathlessValidity.setErrors(messages); + const path = getPath(); + if (path) { + form.setFieldErrors(path, messages); + } + } + + return { + pathlessValidity, + errors, + setErrors, + }; +} + +function createLocalValidity() { + const errors = shallowRef([]); + + const api = { + errors, + setErrors(messages: Arrayable) { + errors.value = messages ? normalizeArrayable(messages) : []; + }, + }; + + return { + pathlessValidity: api, + ...api, + }; +} diff --git a/packages/core/src/form/useFormTransactions.ts b/packages/core/src/form/useFormTransactions.ts index 8b50f7c7..8e94d465 100644 --- a/packages/core/src/form/useFormTransactions.ts +++ b/packages/core/src/form/useFormTransactions.ts @@ -8,6 +8,7 @@ interface SetPathStateTransaction { value: PathValue>; touched: boolean; disabled: boolean; + errors: string[]; } interface UnsetPathStateTransaction { @@ -26,6 +27,7 @@ interface InitializeFieldTransaction { value: PathValue>; touched: boolean; disabled: boolean; + errors: string[]; } export type FormTransaction = @@ -47,7 +49,10 @@ const TransactionKind = { export interface FormTransactionManager { transaction( tr: ( - formCtx: Pick, 'getValues' | 'getFieldValue' | 'isFieldSet' | 'isFieldTouched'>, + formCtx: Pick< + FormContext, + 'getValues' | 'getFieldValue' | 'isFieldSet' | 'isFieldTouched' | 'getFieldErrors' + >, codes: typeof TransactionKind, ) => FormTransaction | null, ): void; @@ -88,6 +93,7 @@ export function useFormTransactions(form: FormContext< form.setFieldValue(tr.path, tr.value); form.setFieldTouched(tr.path, tr.touched); form.setFieldDisabled(tr.path, tr.disabled); + form.setFieldErrors(tr.path, tr.errors); continue; } @@ -107,6 +113,7 @@ export function useFormTransactions(form: FormContext< form.setFieldDisabled(tr.path, tr.disabled); form.setFieldTouched(tr.path, tr.touched); form.unsetInitialValue(tr.path); + form.setFieldErrors(tr.path, tr.errors); continue; } } diff --git a/packages/core/src/types/forms.ts b/packages/core/src/types/forms.ts index 80c2d488..0b7f8c8d 100644 --- a/packages/core/src/types/forms.ts +++ b/packages/core/src/types/forms.ts @@ -5,3 +5,5 @@ import { Path } from './paths'; export type TouchedSchema = Simplify>; export type DisabledSchema = Partial, boolean>>; + +export type ValiditySchema = Partial, string[]>>; diff --git a/packages/core/src/useCheckbox/useCheckbox.ts b/packages/core/src/useCheckbox/useCheckbox.ts index 1c71c307..a7f279d9 100644 --- a/packages/core/src/useCheckbox/useCheckbox.ts +++ b/packages/core/src/useCheckbox/useCheckbox.ts @@ -100,7 +100,7 @@ export function useCheckbox( return { ...baseHandlers, onInvalid() { - group?.setValidity(inputRef.value?.validationMessage ?? ''); + group?.setErrors(inputRef.value?.validationMessage ?? ''); }, }; } @@ -137,7 +137,7 @@ export function useCheckbox( focus(); group?.toggleValue(getTrueValue(), force); nextTick(() => { - group?.setValidity(inputRef.value?.validationMessage ?? ''); + group?.setErrors(inputRef.value?.validationMessage ?? ''); }); return true; diff --git a/packages/core/src/useCheckbox/useCheckboxGroup.ts b/packages/core/src/useCheckbox/useCheckboxGroup.ts index 08c2b3c6..6bd0906d 100644 --- a/packages/core/src/useCheckbox/useCheckboxGroup.ts +++ b/packages/core/src/useCheckbox/useCheckboxGroup.ts @@ -8,11 +8,13 @@ import { AriaValidatableProps, Direction, Reactivify, + Arrayable, } from '../types'; import { useUniqId, createDescribedByProps, normalizeProps, isEqual } from '../utils/common'; import { useLocale } from '../i18n/useLocale'; import { useFormField } from '../form/useFormField'; import { FieldTypePrefixes } from '../constants'; +import { useErrorDisplay } from '../form/useErrorDisplay'; export type CheckboxGroupValue = TCheckbox[]; @@ -28,7 +30,7 @@ export interface CheckboxGroupContext { readonly modelValue: CheckboxGroupValue | undefined; readonly isTouched: boolean; - setValidity(message: string): void; + setErrors(message: Arrayable): void; setValue(value: CheckboxGroupValue): void; hasValue(value: TCheckbox): boolean; toggleValue(value: TCheckbox, force?: boolean): void; @@ -74,12 +76,14 @@ export function useCheckboxGroup(_props: Reactivify(_props: Reactivify(_props: Reactivify(); - const { errorMessage, validityDetails, isInvalid } = useInputValidity(inputRef); const { locale } = useLocale(); const parser = useNumberParser(() => toValue(props.locale) ?? locale.value, props.formatOptions); - const { fieldValue, setValue, setTouched, isTouched } = useFormField({ + const field = useFormField({ path: props.name, initialValue: toValue(props.modelValue), disabled: props.disabled, }); + const { validityDetails } = useInputValidity({ inputRef, field }); + const { displayError } = useErrorDisplay(field); + const { fieldValue, setValue, setTouched, isTouched, errorMessage } = field; const formattedText = computed(() => { if (Number.isNaN(fieldValue.value) || isEmpty(fieldValue.value)) { return ''; @@ -191,11 +194,17 @@ export function useNumberField( errorMessageProps, descriptionProps, validityDetails, - isInvalid, + isValid: field.isValid, incrementButtonProps, decrementButtonProps, isTouched, + errors: field.errors, + + setValue, increment, decrement, + setTouched, + setErrors: field.setErrors, + displayError, }; } diff --git a/packages/core/src/useRadio/useRadio.ts b/packages/core/src/useRadio/useRadio.ts index ee00bcf7..f8536a5c 100644 --- a/packages/core/src/useRadio/useRadio.ts +++ b/packages/core/src/useRadio/useRadio.ts @@ -69,10 +69,10 @@ export function useRadio( return { ...baseHandlers, onChange() { - group?.setValidity(inputRef.value?.validationMessage ?? ''); + group?.setErrors(inputRef.value?.validationMessage ?? ''); }, onInvalid() { - group?.setValidity(inputRef.value?.validationMessage ?? ''); + group?.setErrors(inputRef.value?.validationMessage ?? ''); }, }; } @@ -106,7 +106,7 @@ export function useRadio( group?.setValue(toValue(props.value) as TValue); focus(); nextTick(() => { - group?.setValidity(inputRef.value?.validationMessage ?? ''); + group?.setErrors(inputRef.value?.validationMessage ?? ''); }); return true; diff --git a/packages/core/src/useRadio/useRadioGroup.ts b/packages/core/src/useRadio/useRadioGroup.ts index 0dd35330..bd89a3df 100644 --- a/packages/core/src/useRadio/useRadioGroup.ts +++ b/packages/core/src/useRadio/useRadioGroup.ts @@ -8,11 +8,13 @@ import { AriaValidatableProps, Direction, Reactivify, + Arrayable, } from '../types'; import { useUniqId, createDescribedByProps, getNextCycleArrIdx, normalizeProps, isEmpty } from '../utils/common'; import { useLocale } from '../i18n/useLocale'; import { useFormField } from '../form/useFormField'; import { FieldTypePrefixes } from '../constants'; +import { useErrorDisplay } from '../form/useErrorDisplay'; export interface RadioGroupContext { name: string; @@ -21,7 +23,7 @@ export interface RadioGroupContext { required: boolean; readonly modelValue: TValue | undefined; - setValidity(message: string): void; + setErrors(message: Arrayable): void; setValue(value: TValue): void; setTouched(touched: boolean): void; useRadioRegistration(radio: RadioItemContext): { canReceiveFocus(): boolean }; @@ -83,13 +85,16 @@ export function useRadioGroup(_props: Reactivify({ + const field = useFormField({ path: props.name, initialValue: toValue(props.modelValue) as TValue, disabled: props.disabled, }); - const { setValidity, errorMessage } = useInputValidity(); + const { validityDetails } = useInputValidity({ field }); + const { displayError } = useErrorDisplay(field); + const { fieldValue, setValue, isTouched, setTouched, errorMessage, errors } = field; + const { describedBy, descriptionProps, errorMessageProps } = createDescribedByProps({ inputId: groupId, errorMessage, @@ -184,7 +189,7 @@ export function useRadioGroup(_props: Reactivify toValue(props.readonly) ?? false), required: computed(() => toValue(props.required) ?? false), modelValue: fieldValue, - setValidity, + setErrors: field.setErrors, setValue, setTouched, useRadioRegistration, @@ -200,5 +205,12 @@ export function useRadioGroup(_props: Reactivify, const props = normalizeProps(_props, ['onSubmit']); const inputId = useUniqId(FieldTypePrefixes.SearchField); const inputRef = elementRef || ref(); - - const { errorMessage, updateValidity, validityDetails, isInvalid } = useInputValidity(inputRef); - const { fieldValue, setValue, isTouched, setTouched } = useFormField({ + const field = useFormField({ path: props.name, initialValue: toValue(props.modelValue), disabled: props.disabled, }); + const { validityDetails, updateValidity } = useInputValidity({ inputRef, field }); + const { displayError } = useErrorDisplay(field); + const { fieldValue, setValue, isTouched, setTouched, errorMessage, isValid, errors } = field; + const { labelProps, labelledByProps } = useLabel({ for: inputId, label: props.label, @@ -102,7 +105,7 @@ export function useSearchField(_props: Reactivify, if (e.key === 'Enter' && !inputRef.value?.form && props.onSubmit) { e.preventDefault(); setTouched(true); - if (!isInvalid.value) { + if (isValid.value) { props.onSubmit(fieldValue.value || ''); } } @@ -138,7 +141,13 @@ export function useSearchField(_props: Reactivify, descriptionProps, clearBtnProps, validityDetails, - isInvalid, + isValid, isTouched, + errors, + + setErrors: field.setErrors, + setValue: field.setValue, + setTouched: field.setTouched, + displayError, }; } diff --git a/packages/core/src/useTextField/index.ts b/packages/core/src/useTextField/index.ts index afcd5652..56163051 100644 --- a/packages/core/src/useTextField/index.ts +++ b/packages/core/src/useTextField/index.ts @@ -13,6 +13,7 @@ import { useInputValidity } from '../validation/useInputValidity'; import { useLabel } from '../a11y/useLabel'; import { useFormField } from '../form/useFormField'; import { FieldTypePrefixes } from '../constants'; +import { useErrorDisplay } from '../form/useErrorDisplay'; export type TextInputDOMType = 'text' | 'password' | 'email' | 'number' | 'tel' | 'url'; @@ -55,13 +56,15 @@ export function useTextField( const props = normalizeProps(_props); const inputId = useUniqId(FieldTypePrefixes.TextField); const inputRef = elementRef || shallowRef(); - const { errorMessage, validityDetails, isInvalid } = useInputValidity(inputRef); - const { fieldValue, setValue, isTouched, setTouched } = useFormField({ + const field = useFormField({ path: props.name, initialValue: toValue(props.modelValue), disabled: props.disabled, }); + const { validityDetails } = useInputValidity({ inputRef, field }); + const { displayError } = useErrorDisplay(field); + const { fieldValue, setValue, isTouched, setTouched, errorMessage, isValid, errors, setErrors } = field; const { labelProps, labelledByProps } = useLabel({ for: inputId, label: props.label, @@ -114,7 +117,13 @@ export function useTextField( errorMessageProps, descriptionProps, validityDetails, - isInvalid, isTouched, + isValid, + errors, + + setErrors, + setValue, + setTouched, + displayError, }; } diff --git a/packages/core/src/utils/common.ts b/packages/core/src/utils/common.ts index 66ac50ec..529e8d77 100644 --- a/packages/core/src/utils/common.ts +++ b/packages/core/src/utils/common.ts @@ -129,7 +129,7 @@ export function isEmpty(value: unknown): value is null | undefined | '' { export const isSSR = typeof window === 'undefined'; export function normalizeArrayable(value: Arrayable): T[] { - return Array.isArray(value) ? value : [value]; + return Array.isArray(value) ? [...value] : [value]; } export const isObject = (obj: unknown): obj is Record => diff --git a/packages/core/src/validation/useInputValidity.spec.ts b/packages/core/src/validation/useInputValidity.spec.ts index faf9abdd..68443a60 100644 --- a/packages/core/src/validation/useInputValidity.spec.ts +++ b/packages/core/src/validation/useInputValidity.spec.ts @@ -1,15 +1,17 @@ -import { ref } from 'vue'; +import { nextTick, ref } from 'vue'; import { useInputValidity } from './useInputValidity'; import { fireEvent, render, screen } from '@testing-library/vue'; +import { FormField, useFormField } from '../form'; test('updates the validity state on blur events', async () => { const input = ref(); await render({ setup: () => { - const { errorMessage } = useInputValidity(input); + const field = useFormField(); + useInputValidity({ inputRef: input, field }); - return { input, errorMessage }; + return { input, errorMessage: field.errorMessage }; }, template: `
@@ -28,9 +30,10 @@ test('updates the validity state on change events', async () => { await render({ setup: () => { - const { errorMessage } = useInputValidity(input); + const field = useFormField(); + useInputValidity({ inputRef: input, field }); - return { input, errorMessage }; + return { input, errorMessage: field.errorMessage }; }, template: ` @@ -51,9 +54,10 @@ test('updates the validity on specified events', async () => { await render({ setup: () => { - const { errorMessage } = useInputValidity(input, { events: ['input'] }); + const field = useFormField(); + useInputValidity({ inputRef: input, field, events: ['input'] }); - return { input, errorMessage }; + return { input, errorMessage: field.errorMessage }; }, template: ` @@ -68,3 +72,27 @@ test('updates the validity on specified events', async () => { await fireEvent.input(screen.getByTestId('input'), { target: { value: 'test' } }); expect(screen.getByTestId('err').textContent).toBe(''); }); + +test('updates the input native validity with custom validity errors', async () => { + const input = ref(); + let field!: FormField; + await render({ + setup: () => { + field = useFormField(); + useInputValidity({ inputRef: input, field, events: ['input'] }); + + return { input, errorMessage: field.errorMessage }; + }, + template: ` + + + {{ errorMessage }} + + `, + }); + + field.setErrors('Custom error'); + await nextTick(); + expect(screen.getByTestId('err').textContent).toBe('Custom error'); + expect(input.value?.validationMessage).toBe('Custom error'); +}); diff --git a/packages/core/src/validation/useInputValidity.ts b/packages/core/src/validation/useInputValidity.ts index 7cb590f6..1b708ae2 100644 --- a/packages/core/src/validation/useInputValidity.ts +++ b/packages/core/src/validation/useInputValidity.ts @@ -1,37 +1,51 @@ -import { Ref, computed, nextTick, ref, shallowRef } from 'vue'; +import { Ref, inject, nextTick, onMounted, shallowRef, watch } from 'vue'; import { useEventListener } from '../helpers/useEventListener'; +import { FormField, FormKey } from '../form'; interface InputValidityOptions { + inputRef?: Ref; + field: FormField; events?: string[]; } -export function useInputValidity( - inputRef?: Ref, - opts?: InputValidityOptions, -) { - const errorMessage = ref(); +export function useInputValidity(opts: InputValidityOptions) { + const { setErrors, errorMessage } = opts.field; const validityDetails = shallowRef(); - const isInvalid = computed(() => !!errorMessage.value); + const form = inject(FormKey, null); - function setValidity(message: string) { - errorMessage.value = message; - inputRef?.value?.setCustomValidity(message); - validityDetails.value = inputRef?.value?.validity; + function updateValiditySync() { + validityDetails.value = opts.inputRef?.value?.validity; + // TODO: Only do that if native field/validation is enabled + setErrors(opts.inputRef?.value?.validationMessage || []); } async function updateValidity() { await nextTick(); - errorMessage.value = inputRef?.value?.validationMessage; - validityDetails.value = inputRef?.value?.validity; + updateValiditySync(); } - useEventListener(inputRef, opts?.events || ['invalid', 'change', 'blur'], updateValidity); + useEventListener(opts.inputRef, opts?.events || ['invalid', 'change', 'blur'], updateValidity); + + form?.onSubmitted(updateValiditySync); + + if (opts.inputRef) { + watch(errorMessage, msg => { + const inputMsg = opts.inputRef?.value?.validationMessage; + if (inputMsg !== msg) { + opts.inputRef?.value?.setCustomValidity(msg || ''); + } + }); + } + + /** + * Validity is always updated on mount. + */ + onMounted(() => { + nextTick(updateValidity); + }); return { - errorMessage, validityDetails, - isInvalid, - setValidity, updateValidity, }; } diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 32851d13..b221d205 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,8 +1,8 @@