diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 59f87da8..a43a8179 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -1,19 +1,24 @@ +import { PartialDeep } from 'type-fest'; import { shallowRef } from 'vue'; import { getSiteLocale } from './i18n/getSiteLocale'; +import { merge } from '../../shared/src'; interface Config { locale: string; + validation: { + disableHtmlValidation: boolean; + }; } const currentConfig = shallowRef({ locale: getSiteLocale(), + validation: { + disableHtmlValidation: false, + }, }); -export function configure(config: Partial) { - currentConfig.value = { - ...currentConfig.value, - ...config, - }; +export function configure(config: PartialDeep) { + currentConfig.value = merge({ ...currentConfig.value }, config); } export function getConfig() { diff --git a/packages/core/src/useCheckbox/useCheckbox.ts b/packages/core/src/useCheckbox/useCheckbox.ts index 54c18dfe..5cee752a 100644 --- a/packages/core/src/useCheckbox/useCheckbox.ts +++ b/packages/core/src/useCheckbox/useCheckbox.ts @@ -36,6 +36,8 @@ export interface CheckboxProps { indeterminate?: boolean; schema?: TypedSchema; + + disableHtmlValidation?: boolean; } export interface CheckboxDomInputProps extends AriaLabelableProps, InputBaseAttributes { @@ -64,7 +66,7 @@ export function useCheckbox( const group: CheckboxGroupContext | null = inject(CheckboxGroupKey, null); const inputRef = elementRef || ref(); const field = useCheckboxField(props); - useInputValidity({ inputRef, field }); + useInputValidity({ inputRef, field, disableHtmlValidation: props.disableHtmlValidation }); const { fieldValue, isTouched, setTouched, setValue, errorMessage, setErrors } = field; const checked = computed({ diff --git a/packages/core/src/useForm/useForm.ts b/packages/core/src/useForm/useForm.ts index 1bcbdc46..f336ba27 100644 --- a/packages/core/src/useForm/useForm.ts +++ b/packages/core/src/useForm/useForm.ts @@ -18,12 +18,14 @@ import { FormTransactionManager, useFormTransactions } from './useFormTransactio import { useFormActions } from './useFormActions'; import { useFormSnapshots } from './formSnapshot'; import { findLeaf } from '../utils/path'; +import { getConfig } from '../config'; export interface FormOptions { id: string; initialValues: MaybeGetter>; initialTouched: TouchedSchema; schema: TypedSchema; + disableHtmlValidation: boolean; } export interface FormContext @@ -31,6 +33,7 @@ export interface FormContext { requestValidation(): Promise>; onSubmitAttempt(cb: () => void): void; + isHtmlValidationDisabled(): boolean; onValidationDispatch( cb: (enqueue: (promise: Promise) => void) => void, ): void; @@ -47,6 +50,7 @@ export function useForm opts?.disableHtmlValidation ?? getConfig().validation.disableHtmlValidation; const values = reactive(cloneDeep(valuesSnapshot.originals.value)) as TForm; const touched = reactive(cloneDeep(touchedSnapshot.originals.value)) as TouchedSchema; const disabled = {} as DisabledSchema; @@ -103,6 +107,7 @@ export function useForm); if (ctx.getValidationMode() === 'schema') { diff --git a/packages/core/src/useFormGroup/useFormGroup.spec.ts b/packages/core/src/useFormGroup/useFormGroup.spec.ts index 34e23929..4acb1f70 100644 --- a/packages/core/src/useFormGroup/useFormGroup.spec.ts +++ b/packages/core/src/useFormGroup/useFormGroup.spec.ts @@ -6,6 +6,7 @@ import { useTextField } from '../useTextField'; import { useForm } from '../useForm'; import { fireEvent, render, screen } from '@testing-library/vue'; import { flush } from '@test-utils/flush'; +import { configure } from '../config'; function createInputComponent(): Component { return { @@ -13,7 +14,12 @@ function createInputComponent(): Component { setup: (_, { attrs }) => { const name = (attrs.name || 'test') as string; const schema = attrs.schema as TypedSchema; - const { errorMessage, inputProps } = useTextField({ name, label: name, schema }); + const { errorMessage, inputProps } = useTextField({ + name, + label: name, + schema, + disableHtmlValidation: attrs.disableHtmlValidation as any, + }); return { errorMessage: errorMessage, inputProps, name, attrs }; }, @@ -30,7 +36,7 @@ function createGroupComponent(fn?: (fg: ReturnType) => void setup: (_, { attrs }) => { const name = (attrs.name || 'test') as string; const schema = attrs.schema as TypedSchema; - const fg = useFormGroup({ name, label: name, schema }); + const fg = useFormGroup({ name, label: name, schema, disableHtmlValidation: attrs.disableHtmlValidation as any }); fn?.(fg); return {}; @@ -384,3 +390,136 @@ test('submission combines group data with form data', async () => { third: 'third', }); }); + +describe('disabling HTML validation', () => { + test('can be disabled on the group level', async () => { + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent() }, + setup() { + useForm(); + + return {}; + }, + template: ` + + + + + + `, + }); + + await flush(); + await fireEvent.touch(screen.getByTestId('field1')); + await fireEvent.touch(screen.getByTestId('field2')); + + const errors = screen.getAllByTestId('err'); + expect(errors[0]).toHaveTextContent(''); + expect(errors[1]).toHaveTextContent('Constraints not satisfied'); + }); + + test('can be disabled on the form level', async () => { + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent() }, + setup() { + useForm({ disableHtmlValidation: true }); + + return {}; + }, + template: ` + + + + + + + + + + `, + }); + + await flush(); + await fireEvent.touch(screen.getByTestId('field1')); + await fireEvent.touch(screen.getByTestId('field2')); + await fireEvent.touch(screen.getByTestId('field3')); + + const errors = screen.getAllByTestId('err'); + expect(errors[0]).toHaveTextContent(''); + expect(errors[1]).toHaveTextContent(''); + expect(errors[2]).toHaveTextContent('Constraints not satisfied'); + }); + + test('can be disabled on the field level', async () => { + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent() }, + setup() { + useForm(); + + return {}; + }, + template: ` + + + + + + + `, + }); + + await flush(); + await fireEvent.touch(screen.getByTestId('field1')); + await fireEvent.touch(screen.getByTestId('field2')); + await fireEvent.touch(screen.getByTestId('field3')); + + const errors = screen.getAllByTestId('err'); + expect(errors[0]).toHaveTextContent('Constraints not satisfied'); + expect(errors[1]).toHaveTextContent(''); + expect(errors[2]).toHaveTextContent(''); + }); + + test('can be disabled globally and overridden', async () => { + configure({ + validation: { + disableHtmlValidation: true, + }, + }); + + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent() }, + setup() { + useForm({ disableHtmlValidation: true }); + + return {}; + }, + template: ` + + + + + + + + + + `, + }); + + await flush(); + await fireEvent.touch(screen.getByTestId('field1')); + await fireEvent.touch(screen.getByTestId('field2')); + await fireEvent.touch(screen.getByTestId('field3')); + + const errors = screen.getAllByTestId('err'); + expect(errors[0]).toHaveTextContent(''); + expect(errors[1]).toHaveTextContent(''); + expect(errors[2]).toHaveTextContent('Constraints not satisfied'); + + configure({ + validation: { + disableHtmlValidation: false, + }, + }); + }); +}); diff --git a/packages/core/src/useFormGroup/useFormGroup.ts b/packages/core/src/useFormGroup/useFormGroup.ts index c39c7f08..ca8e406b 100644 --- a/packages/core/src/useFormGroup/useFormGroup.ts +++ b/packages/core/src/useFormGroup/useFormGroup.ts @@ -28,11 +28,13 @@ import { FormKey } from '../useForm'; import { useValidationProvider } from '../validation/useValidationProvider'; import { FormValidationMode } from '../useForm/formContext'; import { prefixPath as _prefixPath } from '../utils/path'; +import { getConfig } from '@core/config'; export interface FormGroupProps { name: string; label?: string; schema?: TypedSchema; + disableHtmlValidation?: boolean; } interface GroupProps extends AriaLabelableProps { @@ -45,6 +47,7 @@ interface FormGroupContext { onValidationDispatch(cb: (enqueue: (promise: Promise) => void) => void): void; requestValidation(): Promise>; getValidationMode(): FormValidationMode; + isHtmlValidationDisabled(): boolean; } export const FormGroupKey: InjectionKey = Symbol('FormGroup'); @@ -58,6 +61,10 @@ export function useFormGroup toValue(props.name); const groupRef = elementRef || shallowRef(); const form = inject(FormKey, null); + const isHtmlValidationDisabled = () => + toValue(props.disableHtmlValidation) ?? + form?.isHtmlValidationDisabled() ?? + getConfig().validation.disableHtmlValidation; const { validate, onValidationDispatch, defineValidationRequest } = useValidationProvider({ getValues, getPath, @@ -138,6 +145,7 @@ export function useFormGroup (props.schema ? 'schema' : 'aggregate'), + isHtmlValidationDisabled, }; // Whenever the form is validated, it is deferred to the form group to do that. diff --git a/packages/core/src/useNumberField/useNumberField.ts b/packages/core/src/useNumberField/useNumberField.ts index a6cdcf91..717a7d3f 100644 --- a/packages/core/src/useNumberField/useNumberField.ts +++ b/packages/core/src/useNumberField/useNumberField.ts @@ -63,6 +63,8 @@ export interface NumberFieldProps { formatOptions?: Intl.NumberFormatOptions; schema?: TypedSchema; + + disableHtmlValidation?: boolean; } export function useNumberField( @@ -81,7 +83,7 @@ export function useNumberField( schema: props.schema, }); - const { validityDetails } = useInputValidity({ inputRef, field }); + const { validityDetails } = useInputValidity({ inputRef, field, disableHtmlValidation: props.disableHtmlValidation }); const { displayError } = useErrorDisplay(field); const { fieldValue, setValue, setTouched, isTouched, errorMessage } = field; const formattedText = computed(() => { diff --git a/packages/core/src/useRadio/useRadioGroup.ts b/packages/core/src/useRadio/useRadioGroup.ts index 4d4ee8bf..0c6d1693 100644 --- a/packages/core/src/useRadio/useRadioGroup.ts +++ b/packages/core/src/useRadio/useRadioGroup.ts @@ -31,6 +31,7 @@ export interface RadioGroupContext { required: boolean; readonly modelValue: TValue | undefined; + setErrors(message: Arrayable): void; setValue(value: TValue): void; setTouched(touched: boolean): void; @@ -59,6 +60,8 @@ export interface RadioGroupProps { required?: boolean; schema?: TypedSchema; + + disableHtmlValidation?: boolean; } interface RadioGroupDomProps extends AriaLabelableProps, AriaDescribableProps, AriaValidatableProps { diff --git a/packages/core/src/useSearchField/useSearchField.ts b/packages/core/src/useSearchField/useSearchField.ts index 57a70110..af055acd 100644 --- a/packages/core/src/useSearchField/useSearchField.ts +++ b/packages/core/src/useSearchField/useSearchField.ts @@ -55,6 +55,8 @@ export interface SearchFieldProps { schema?: TypedSchema; onSubmit?: (value: string) => void; + + disableHtmlValidation?: boolean; } export function useSearchField( @@ -71,7 +73,11 @@ export function useSearchField( schema: props.schema, }); - const { validityDetails, updateValidity } = useInputValidity({ inputRef, field }); + const { validityDetails, updateValidity } = useInputValidity({ + inputRef, + field, + disableHtmlValidation: props.disableHtmlValidation, + }); const { displayError } = useErrorDisplay(field); const { fieldValue, setValue, isTouched, setTouched, errorMessage, isValid, errors } = field; diff --git a/packages/core/src/useSwitch/useSwitch.ts b/packages/core/src/useSwitch/useSwitch.ts index 9607a54f..da65e48c 100644 --- a/packages/core/src/useSwitch/useSwitch.ts +++ b/packages/core/src/useSwitch/useSwitch.ts @@ -53,6 +53,8 @@ export type SwitchProps = { falseValue?: unknown; schema?: TypedSchema; + + disableHtmlValidation?: boolean; }; export function useSwitch(_props: Reactivify, elementRef?: Ref) { @@ -72,7 +74,7 @@ export function useSwitch(_props: Reactivify, elementRef? schema: props.schema, }); - useInputValidity({ field, inputRef }); + useInputValidity({ field, inputRef, disableHtmlValidation: props.disableHtmlValidation }); const { fieldValue, setValue, isTouched, setTouched, errorMessage } = field; const { errorMessageProps, accessibleErrorProps } = createAccessibleErrorMessageProps({ inputId, diff --git a/packages/core/src/useTextField/useTextField.ts b/packages/core/src/useTextField/useTextField.ts index 31de4397..2f7cf900 100644 --- a/packages/core/src/useTextField/useTextField.ts +++ b/packages/core/src/useTextField/useTextField.ts @@ -57,6 +57,8 @@ export interface TextFieldProps { disabled?: boolean; schema?: TypedSchema; + + disableHtmlValidation?: boolean; } export function useTextField( @@ -73,7 +75,7 @@ export function useTextField( schema: props.schema, }); - const { validityDetails } = useInputValidity({ inputRef, field }); + const { validityDetails } = useInputValidity({ inputRef, field, disableHtmlValidation: props.disableHtmlValidation }); const { displayError } = useErrorDisplay(field); const { fieldValue, setValue, isTouched, setTouched, errorMessage, isValid, errors, setErrors } = field; const { labelProps, labelledByProps } = useLabel({ diff --git a/packages/core/src/validation/useInputValidity.ts b/packages/core/src/validation/useInputValidity.ts index 1f553eeb..cf2fd353 100644 --- a/packages/core/src/validation/useInputValidity.ts +++ b/packages/core/src/validation/useInputValidity.ts @@ -1,13 +1,15 @@ -import { Ref, inject, nextTick, onMounted, shallowRef, watch } from 'vue'; +import { Ref, inject, nextTick, onMounted, shallowRef, watch, MaybeRefOrGetter, toValue } from 'vue'; import { useEventListener } from '../helpers/useEventListener'; import { FormKey } from '../useForm'; import { Maybe, ValidationResult } from '../types'; import { FormField } from '../useFormField'; import { isInputElement, normalizeArrayable } from '../utils/common'; import { FormGroupKey } from '../useFormGroup'; +import { getConfig } from '../config'; interface InputValidityOptions { inputRef?: Ref>; + disableHtmlValidation?: MaybeRefOrGetter; field: FormField; events?: string[]; } @@ -18,6 +20,10 @@ export function useInputValidity(opts: InputValidityOptions) { const { setErrors, errorMessage, schema, validate: validateField, getPath } = opts.field; const validityDetails = shallowRef(); useMessageCustomValiditySync(errorMessage, opts.inputRef); + const isHtmlValidationDisabled = () => + toValue(opts.disableHtmlValidation) ?? + (formGroup || form)?.isHtmlValidationDisabled() ?? + getConfig().validation.disableHtmlValidation; function validateNative(mutate?: boolean): ValidationResult { const baseReturns: Omit = { @@ -26,7 +32,7 @@ export function useInputValidity(opts: InputValidityOptions) { }; const inputEl = opts.inputRef?.value; - if (!isInputElement(inputEl)) { + if (!isInputElement(inputEl) || isHtmlValidationDisabled()) { return { ...baseReturns, isValid: true,