Files
formplex-react/src/use-form.ts
2022-11-11 00:55:48 +02:00

219 lines
6.2 KiB
TypeScript

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<T>({
initialState = {},
errorMessages = {},
autoValidateBehavior = 'onChange',
onSubmit,
}: UseFormOptions<T> = {}): UseFormReturn<T> {
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<Record<keyof T, FieldOptions<keyof T>>>(
{} as unknown as Record<keyof T, FieldOptions<keyof T>>,
)
const [state, setState] = React.useState<Partial<T>>(initialState ?? {})
const [rawState, setRawState] = React.useState<
Partial<Record<keyof T, string | string[] | number>>
>(
Object.entries(state).reduce(
(acc, [key, value]) => ({ ...acc, [key]: String(value) }),
{} as Partial<Record<keyof T, string | string[] | number>>,
),
)
const [errors, setErrors] = React.useState<Partial<Record<keyof T, ErrorMessage>>>({})
const isValid = React.useMemo(() => !Object.values(errors).some(Boolean), [errors])
function setValue<K extends keyof T>(
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<K extends keyof T>(
key: K,
value: T[K],
options: FieldOptions<T, K>,
): 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<K extends keyof T, E extends HTMLElement = HTMLInputElement>(
value: string | number | string[] | null | undefined,
options?: FieldOptions<T, K>,
): 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<K extends keyof T, E>(key: K, options?: FieldOptions<T, K>): FieldReturn<E> {
fields.current = { ...fields.current, [key]: options }
if (autoValidateBehavior === 'immediate') {
setErrorsFromRaw<K>(key, rawState[key], options)
}
return {
value: rawState[key] ?? '',
onChange: (e: ChangeEvent) => {
const value = parseValue(e.target.value, options)
if (value === undefined) {
return
}
setValueFromRaw<K>(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<K>(key, e.target.value, options)
if (autoValidateBehavior === 'onBlur') {
if (setErrorsFromRaw(key, e.target.value, options)) {
options?.onChange?.(e, value as T[K])
}
}
},
}
}
function setErrorsFromRaw<K extends keyof T>(
key: K,
value: string | string[] | number,
options: FieldOptions<T, K>,
): 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<K extends keyof T>(
key: K,
value: string | string[] | number,
options: FieldOptions<T, K>,
) {
const parsed = parseValue(value, options)
setValue(key, parsed as T[K], value)
}
function setValues(values: Partial<T>): void {
Object.entries(values).forEach(([key, value]) => setValue(key as keyof T, value as T[keyof T]))
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>): 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<T, keyof T>,
)
if (error) {
return { ...acc, [key]: error }
}
return acc
}, {} as Partial<Record<keyof T, ErrorMessage>>)
setErrors(errors)
return Object.values(errors).length === 0
}
return {
field: fieldProps,
errors,
state,
rawState,
isValid,
setValue,
setValues: setValues,
handleSubmit,
validate: validateAll,
}
}