import * as React from 'react' import { BlurEvent, ChangeEvent, ErrorMessage, ErrorStrings, FieldOptions, FieldReturn, UseFormOptions, UseFormReturn, } from './types' /** * The main hook for using forms. See each option and return property for more information * * @typeParam T - The type of the form state. * @param options Form options * @returns Object containing the form state, errors, and field registration. * @see {@link UseFormOptions} * @see {@link UseFormReturn} */ export function useForm({ initialState = {}, errorMessages = {}, autoValidateBehavior = 'onChange', onSubmit, }: UseFormOptions = {}): UseFormReturn { errorMessages = { required: 'Required', minLength: (n) => `Must be at least ${n} characters long`, maxLength: (n) => `Must be no more than ${n} characters long`, ...errorMessages, } const fields = React.useRef>>( {} as unknown as Record>, ) const [state, setState] = React.useState>(initialState ?? {}) const [rawState, setRawState] = React.useState< Partial> >( Object.entries(state).reduce( (acc, [key, value]) => ({ ...acc, [key]: String(value) }), {} as Partial>, ), ) const [errors, setErrors] = React.useState>>({}) const isValid = React.useMemo(() => !Object.values(errors).some(Boolean), [errors]) function setValue( key: K, value: T[K] | string | string[] | number, raw: T[K] | string | string[] | number = value, ) { setRawState((prev) => ({ ...prev, [key]: raw })) setState((s) => ({ ...s, [key]: value })) } function validate( key: K, value: T[K], options: FieldOptions, ): ErrorMessage | undefined { const parseStr = (o: string | ((...args: unknown[]) => string), val: unknown) => typeof o === 'function' ? o(val) : o const errorStrings = { ...errorMessages, ...(options?.errorMessages ?? {}), } as ErrorStrings // Required if (options?.required && !value) { return { error: 'required', message: errorStrings.required } } // Min length if (options?.minLength && value && (value as string | string[]).length < options.minLength) { return { error: 'minLength', message: parseStr(errorStrings.minLength, options.minLength) } } // Max length if (options?.maxLength && value && (value as string | string[]).length > options.maxLength) { return { error: 'maxLength', message: parseStr(errorStrings.maxLength, options.maxLength) } } // Custom validations const validRes = options?.validate?.(value as T[K]) if (![undefined, null, ''].includes(validRes)) { return { error: 'validate', message: validRes } } return undefined } // eslint-disable-next-line @typescript-eslint/no-unused-vars function parseValue( value: string | number | string[] | null | undefined, options?: FieldOptions, ): string | number | string[] | undefined | T[K] { const parsed = options?.parse ? options.parse(value as string) : value if (value !== '' && options?.pattern) { if (!new RegExp(options.pattern).test(parsed as string)) { return } } return parsed! } function fieldProps(key: K, options?: FieldOptions): FieldReturn { fields.current = { ...fields.current, [key]: options } if (autoValidateBehavior === 'immediate') { setErrorsFromRaw(key, rawState[key], options) } return { value: rawState[key] ?? '', onChange: (e: ChangeEvent) => { const value = parseValue(e.target.value, options) if (value === undefined) { return } setValueFromRaw(key, e.target.value, options) if (autoValidateBehavior === 'onChange') { if (setErrorsFromRaw(key, e.target.value, options)) { options?.onChange?.(e, value as T[K]) } } }, onBlur: (e: BlurEvent) => { const value = parseValue(e.target.value, options) if (value === undefined) { return } setValueFromRaw(key, e.target.value, options) if (autoValidateBehavior === 'onBlur') { if (setErrorsFromRaw(key, e.target.value, options)) { options?.onChange?.(e, value as T[K]) } } }, } } function setErrorsFromRaw( key: K, value: string | string[] | number, options: FieldOptions, ): boolean { const parsed = parseValue(value, options) const error = validate(key, parsed as T[K], options ?? {}) if (error) { setErrors((e) => ({ ...e, [key]: error })) return false } setErrors((e) => { const copy = { ...e } delete copy[key] return copy }) return true } function setValueFromRaw( key: K, value: string | string[] | number, options: FieldOptions, ) { const parsed = parseValue(value, options) setValue(key, parsed as T[K], value) } function setValues(values: Partial): void { Object.entries(values).forEach(([key, value]) => setValue(key as keyof T, value as T[keyof T])) } function handleSubmit(e: React.FormEvent): void { e.preventDefault() if (isValid) { onSubmit?.(state as T, e) } } function validateAll(): boolean { const errors = Object.entries(rawState).reduce((acc, [key, value]) => { const error = validate( key as keyof T, value as T[keyof T], (fields.current[key as keyof T] ?? {}) as FieldOptions, ) if (error) { return { ...acc, [key]: error } } return acc }, {} as Partial>) setErrors(errors) return Object.values(errors).length === 0 } return { field: fieldProps, errors, state, rawState, isValid, setValue, setValues: setValues, handleSubmit, validate: validateAll, } }