import * as React from "react"; //////////////////////////////////////////////////////////////////////////////// //#region Types export type PartialRecord = Partial>; // Remove readonly modifiers from existing types to make them mutable type Mutable = { -readonly [P in keyof T]: T[P]; }; // Restrict object keys to strings, and don't permit number/Symbol type KeyOf = Extract; type SupportedControlTypes = "input" | "textarea" | "select"; type SupportedHTMLElements = | HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; /** * Validation attributes built-in to the browser */ type BuiltInValidationAttr = | "type" | "required" | "minLength" | "maxLength" | "min" | "max" | "pattern"; // Dynamic attribute value function to set validation attribute based on // a current input value type BuiltInValidationAttrsFunction = (fd: FormData) => T | null | undefined; // Types accepted for validation attributes type BuiltInValidationAttrString = | string | BuiltInValidationAttrsFunction; type BuiltInValidationAttrNumber = | number | BuiltInValidationAttrsFunction; type BuiltInValidationAttrBoolean = | boolean | BuiltInValidationAttrsFunction; type BuiltInValidationAttrValue = | BuiltInValidationAttrString | BuiltInValidationAttrNumber | BuiltInValidationAttrBoolean; // Valid attributes by input type. See: // https://html.spec.whatwg.org/multipage/input.html#do-not-apply type InputTextValidationAttrs = { type?: "text" | "search" | "url" | "tel" | "password"; required?: BuiltInValidationAttrBoolean; minLength?: BuiltInValidationAttrNumber; maxLength?: BuiltInValidationAttrNumber; pattern?: BuiltInValidationAttrString; }; type InputEmailValidationAttrs = { type: "email"; multiple?: boolean; required?: BuiltInValidationAttrBoolean; minLength?: BuiltInValidationAttrNumber; maxLength?: BuiltInValidationAttrNumber; pattern?: BuiltInValidationAttrString; }; type InputDateValidationAttrs = { type: "date" | "month" | "week" | "time" | "datetime-local"; required?: BuiltInValidationAttrBoolean; min?: BuiltInValidationAttrString; max?: BuiltInValidationAttrString; }; type InputNumberValidationAttrs = { type: "number"; required?: BuiltInValidationAttrBoolean; min?: BuiltInValidationAttrNumber; max?: BuiltInValidationAttrNumber; }; type InputRangeValidationAttrs = { type: "range"; min?: BuiltInValidationAttrNumber; max?: BuiltInValidationAttrNumber; }; type InputCheckboxValidationAttrs = { type: "checkbox"; required?: BuiltInValidationAttrBoolean; }; type InputRadioValidationAttrs = { type: "radio"; required?: BuiltInValidationAttrBoolean; }; type TextAreaValidationAttrs = { required?: BuiltInValidationAttrBoolean; minLength?: BuiltInValidationAttrNumber; maxLength?: BuiltInValidationAttrNumber; }; type SelectValidationAttrs = { required?: BuiltInValidationAttrBoolean; multiple?: boolean; }; type InputValidationAttrs = | InputTextValidationAttrs | InputEmailValidationAttrs | InputDateValidationAttrs | InputNumberValidationAttrs | InputRangeValidationAttrs | InputCheckboxValidationAttrs | InputRadioValidationAttrs; type ValidityStateKey = KeyOf< Pick< ValidityState, | "typeMismatch" | "valueMissing" | "tooShort" | "tooLong" | "rangeUnderflow" | "rangeOverflow" | "patternMismatch" > >; /** * Custom validation function */ export interface CustomValidations { [key: string]: ( val: string, formData?: FormData ) => boolean | Promise; } /** * Error message - static string or () => string */ export type ErrorMessage = | string | ((attrValue: string | undefined, name: string, value: string) => string); /** * Definition for a single input in a form (validations + error messages) */ interface BaseControlDefinition { customValidations?: CustomValidations; errorMessages?: { [key: string]: ErrorMessage; }; multiple?: boolean; } export interface InputDefinition extends BaseControlDefinition { element?: "input"; validationAttrs?: InputValidationAttrs; } export interface TextAreaDefinition extends BaseControlDefinition { element: "textarea"; validationAttrs?: TextAreaValidationAttrs; } export interface SelectDefinition extends BaseControlDefinition { element: "select"; validationAttrs?: SelectValidationAttrs; } type ControlDefinition = | InputDefinition | TextAreaDefinition | SelectDefinition; /** * Form information (inputs, validations, error messages) */ export interface FormDefinition { inputs: { [key: string]: ControlDefinition; }; errorMessages?: { [key: string]: ErrorMessage; }; } /** * Mutable version of ValidityState that we can write to */ type MutableValidityState = Mutable; /** * Extended ValidityState which weill also contain our custom validations */ export type ExtendedValidityState = MutableValidityState & Record; export type AsyncValidationState = "idle" | "validating" | "done"; /** * Client-side state of the input */ export interface InputInfo { value: string | null; touched: boolean; dirty: boolean; state: AsyncValidationState; validity?: ExtendedValidityState; errorMessages?: Record; } export type ServerOnlyCustomValidations = Partial<{ [key in KeyOf]: CustomValidations; }>; // Server-side only (currently) - validate all specified inputs in the formData export type ServerFormInfo< FormDef extends FormDefinition, FormDefInputs extends FormDef["inputs"] = FormDef["inputs"] > = { submittedValues: { [Key in KeyOf]: FormDefInputs[Key]["element"] extends "textarea" ? FormDefInputs[Key]["multiple"] extends true ? string[] : string : FormDefInputs[Key]["element"] extends "select" ? FormDefInputs[Key]["multiple"] extends true ? string[] : FormDefInputs[Key] extends { validationAttrs: object } ? FormDefInputs[Key]["validationAttrs"] extends { multiple: true } ? string[] : string : string : FormDefInputs[Key] extends { validationAttrs: object } ? FormDefInputs[Key]["validationAttrs"] extends { type: "checkbox" } ? FormDefInputs[Key]["validationAttrs"] extends { required: true } ? string[] : string[] | null : FormDefInputs[Key]["validationAttrs"] extends { type: "email" } ? FormDefInputs[Key]["multiple"] extends true ? string[] : FormDefInputs[Key]["validationAttrs"] extends { multiple: true } ? string[] : string : FormDefInputs[Key]["multiple"] extends true ? string[] : string : FormDefInputs[Key]["multiple"] extends true ? string[] : string; }; inputs: Record, InputInfo | InputInfo[]>; valid: boolean; }; /** * Validator to link HTML attribute to ValidityState key as well as provide an * implementation for server side validation */ interface BuiltInValidator { domKey: ValidityStateKey; validate(value: string, attrValue: string): boolean; errorMessage: ErrorMessage; } interface FormContextObject { formDefinition: T; serverFormInfo?: ServerFormInfo; forceUpdate: any; } /** * See https://github.com/reach/reach-ui/blob/v0.17.0/packages/utils/src/types.ts#L9 */ type AssignableRef = | React.RefCallback | React.MutableRefObject; //#endregion //////////////////////////////////////////////////////////////////////////////// //#region Constants + Utils // Map of ValidityState key -> HTML attribute (i.e., valueMissing -> required) const builtInValidityToAttrMapping: Record< ValidityStateKey, BuiltInValidationAttr > = { typeMismatch: "type", valueMissing: "required", tooShort: "minLength", tooLong: "maxLength", rangeUnderflow: "min", rangeOverflow: "max", patternMismatch: "pattern", }; // Directly from the spec - please do not file issues or submit PRs to change // this unless it becomes out of sync with the spec. // https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address const EMAIL_REGEX = // eslint-disable-next-line no-useless-escape /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; // Mimic browser built-in validations so we can run the on the server const builtInValidations: Record = { type: { domKey: "typeMismatch", validate: (value, attrValue): boolean => { if (value.length === 0) { return true; } if (attrValue === "email") { return EMAIL_REGEX.test(value); } if (attrValue === "url") { try { // URL is available globally in node and most server-worker-API based // environments. If URL is not available globally in your runtime // you'll need to polyfill it // Note - this is fairly lenient but seems to match browser behavior. // For example, http:/something passes and the resulting url.href is // normalized to http://something. It's tempting to do something like // `return new URL(value).href === value` to make sure the incoming URL // doesn't require normalization but that seems to deviate from built-in // browser behavior. Plus then we need to deal with normalized trailing // slashes and such as well. Unsure how deep that rabbit hole might go // so this is fine for now. new URL(value); } catch (e) { return false; } } // email/url are the only types with intrinsic constraints // https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation#semantic_input_types return true; }, errorMessage: (attrValue) => { let messages: Record = { date: "Invalid date", email: "Invalid email", number: "Invalid number", tel: "Invalid phone number", url: "Invalid URL", }; return (attrValue ? messages[attrValue] : null) || "Invalid value"; }, }, required: { domKey: "valueMissing", validate: (value) => value.length > 0, errorMessage: () => `Field is required`, }, minLength: { domKey: "tooShort", validate: (value, attrValue) => value.length === 0 || value.length >= Number(attrValue), errorMessage: (attrValue) => `Value must be at least ${attrValue} characters`, }, maxLength: { domKey: "tooLong", validate: (value, attrValue) => value.length === 0 || value.length <= Number(attrValue), errorMessage: (attrValue) => `Value must be at most ${attrValue} characters`, }, min: { domKey: "rangeUnderflow", validate: (value, attrValue) => value.length === 0 || Number(value) >= Number(attrValue), errorMessage: (attrValue) => `Value must be greater than or equal to ${attrValue}`, }, max: { domKey: "rangeOverflow", validate: (value, attrValue) => value.length === 0 || Number(value) <= Number(attrValue), errorMessage: (attrValue) => `Value must be less than or equal to ${attrValue}`, }, pattern: { domKey: "patternMismatch", validate: (value, attrValue) => value.length === 0 || new RegExp(attrValue).test(value), errorMessage: () => `Value does not match the expected pattern`, }, }; function invariant(value: boolean, message?: string): asserts value; function invariant( value: T | null | undefined, message?: string ): asserts value is T; function invariant(value: any, message?: string) { if (value === false || value === null || typeof value === "undefined") { throw new Error(message); } } function assignRef( ref: AssignableRef | null | undefined, value: RefValueType ) { if (ref == null) return; if (typeof ref === "function") { ref(value); } else { try { ref.current = value; } catch (error) { throw new Error(`Cannot assign value "${value}" to ref "${ref}"`); } } } function useComposedRefs( ...refs: (AssignableRef | null | undefined)[] ): React.RefCallback { return React.useCallback((node) => { for (let ref of refs) { assignRef(ref, node); } // eslint-disable-next-line react-hooks/exhaustive-deps }, refs); } const getInputId = (name: string, reactId: string) => `${name}--${reactId}`; const getErrorsId = (name: string, reactId: string) => `${name}-errors--${reactId}`; const composeClassNames = (classes: Array) => classes.filter((v) => v).join(" "); const omit = ( obj: Record, ...keys: string[] ): Record => Object.entries(obj) .filter(([k]) => !keys.includes(k)) .reduce((acc, [k, v]) => Object.assign(acc, { [k]: v }), {}); function getBaseValidityState(): ExtendedValidityState { return { badInput: false, customError: false, rangeOverflow: false, // max rangeUnderflow: false, // min patternMismatch: false, // pattern stepMismatch: false, // step tooLong: false, // maxlength tooShort: false, // minlength typeMismatch: false, // type="..." valueMissing: false, // required // Is the input valid? valid: true, }; } function IsInputDefinition( inputDef: ControlDefinition ): inputDef is InputDefinition { return inputDef.element === "input" || inputDef.element == null; } // Perform all specified html validations for a single input // Called in a useEffect client side and from validateServerFormIno server-side async function validateInput( inputName: string, validationAttrs: ControlDefinition["validationAttrs"], customValidations: ControlDefinition["customValidations"], value: string, inputEl?: SupportedHTMLElements | SupportedHTMLElements[], // CSR formData?: FormData // SSR ): Promise { let validity = getBaseValidityState(); if (!formData) { let formEl = Array.isArray(inputEl) ? inputEl[0]?.form : inputEl?.form; invariant( formEl, `validateInput expected an inputEl.form to be available for input "${inputName}"` ); formData = new FormData(formEl); } if (validationAttrs) { for (let _attr of Object.keys(validationAttrs)) { let attr = _attr as BuiltInValidationAttr; // Ignoring this "error" since the type narrowing to accomplish this // would be nasty due to the differences in attribute values per input // type. We're going to rely on the *ValidationAttrs types to ensure // users are specifying valid attributes up front in their schemas and // just yolo this lookup // @ts-expect-error let _attrValue = validationAttrs[attr] || null; let attrValue = calculateValidationAttr(_attrValue, formData); // Undefined attr values means the attribute doesn't exist and there's // nothing to validate if (attrValue == null) { continue; } let builtInValidation = builtInValidations[attr]; let isInvalid = false; let isElInvalid = (el?: SupportedHTMLElements) => el?.validity ? el?.validity[builtInValidation.domKey] : !builtInValidation.validate(value, String(attrValue)); if (Array.isArray(inputEl)) { isInvalid = inputEl.every((el) => isElInvalid(el)); } else { isInvalid = isElInvalid(inputEl); } validity[builtInValidation?.domKey || attr] = isInvalid; validity.valid = validity.valid && !isInvalid; } } if (customValidations) { for (let name of Object.keys(customValidations)) { let validate = customValidations[name]; let isInvalid = !(await validate(value, formData)); validity[name] = isInvalid; validity.valid = validity.valid && !isInvalid; } } return validity; } // Perform all validations for a submitted form on the server export async function validateServerFormData( formData: FormData, formDefinition: T, serverCustomValidations?: ServerOnlyCustomValidations ): Promise> { // Unsure if there's a better way to do this type of object mapping while // keeping the keys strongly typed - but this currently complains since we // haven't filled in the required keys yet // @ts-expect-error const inputs: ServerFormInfo["inputs"] = {}; const submittedValues = {} as ServerFormInfo["submittedValues"]; let valid = true; let entries = Object.entries(formDefinition.inputs) as Array< [KeyOf, ControlDefinition] >; await Promise.all( entries.map(async ([inputName, inputDef]) => { if (!formData.has(inputName)) { // No values submitted let inputInfo: InputInfo = { value: null, touched: true, dirty: true, state: "done", validity: await validateInput( inputName, inputDef.validationAttrs, inputDef.customValidations, "", undefined, formData ), }; inputs[inputName] = inputInfo; // FIXME: ??? // @ts-expect-error submittedValues[inputName] = null; valid = valid && inputInfo.validity?.valid === true; } else if ( // Multiple input controls rendered // // inputDef.multiple || // ((inputDef.element == null || inputDef.element === "input") && inputDef.validationAttrs?.type === "email" && inputDef.validationAttrs?.multiple) || // Checkboxes are handled slightly different from normal "render multiple // input controls" since they're inherently "choose one or more" behavior // like a // ((inputDef.element == null || inputDef.element === "input") && inputDef.validationAttrs?.type === "checkbox") ) { // This input can have multiple values submitted for the same name, // so use getAll() and store in a string[] let values = formData.getAll(inputName); for (let value of values) { if (typeof value === "string") { // Always assume inputs have been modified during SSR validation let inputInfo: InputInfo = { value, touched: true, dirty: true, state: "done", validity: await validateInput( inputName, inputDef.validationAttrs, { ...inputDef.customValidations, ...serverCustomValidations?.[inputName], }, value, undefined, formData ), }; if (Array.isArray(submittedValues[inputName])) { // @ts-expect-error submittedValues[inputName].push(value); } else { // @ts-expect-error submittedValues[inputName] = [value]; } if (Array.isArray(inputs[inputName])) { inputs[inputName].push(inputInfo); } else { inputs[inputName] = [inputInfo]; } valid = valid && inputInfo.validity?.valid === true; } else { console.warn( `Skipping non-string value in FormData for field [${inputName}]` ); } } } else { let value = formData.get(inputName); if (typeof value === "string") { // Single value input let inputInfo: InputInfo = { value, touched: true, dirty: true, state: "done", validity: await validateInput( inputName, inputDef.validationAttrs, { ...inputDef.customValidations, ...serverCustomValidations?.[inputName], }, value, undefined, formData ), }; inputs[inputName] = inputInfo; // FIXME: ??? // @ts-expect-error submittedValues[inputName] = value; valid = valid && inputInfo.validity?.valid === true; } else { console.warn( `Skipping non-string value in FormData for field [${inputName}]` ); } } }) ); return { submittedValues, inputs, valid }; } // Determine the defaultValue for a rendered input, properly handling inputs // with multiple values function getInputDefaultValue( name: string, type?: InputValidationAttrs["type"], serverFormInfo?: ServerFormInfo, index?: number ) { let submittedValue = serverFormInfo?.submittedValues?.[name]; if (type === "checkbox") { return undefined; } else if (Array.isArray(submittedValue)) { invariant( index != null && index >= 0, `Expected an "index" value for multiple-submission field "${name}"` ); return submittedValue[index]; } else if (typeof submittedValue === "string") { return submittedValue; } } // Generate a FormData object from our submittedValues structure. This is // needed for the _initial_ render after a document POST submission where we // don't yet have an input ref to access the
, but we need a FormData // instance to determine the initial validation attribute values (in case any // are dynamic). So we can re-construct from what we just submitted. // Subsequent renders then use ne wFormData(inputRef.current.form) function generateFormDataFromServerFormInfo( submittedValues: ServerFormInfo["submittedValues"] ) { let formData = new FormData(); Object.keys(submittedValues).forEach((k) => { let v = submittedValues[k]; if (Array.isArray(v)) { v.forEach((v2) => formData.append(k, v2)); } else if (typeof v === "string") { formData.set(k, v); } }); return formData; } // Calculate a single validation attribute value to render onto an individual input function calculateValidationAttr( attrValue: BuiltInValidationAttrValue, formData: FormData ) { return typeof attrValue === "function" ? attrValue(formData) : attrValue; } // Calculate the validation attribute values to render onto an individual input function calculateValidationAttrs( validationAttrs: ControlDefinition["validationAttrs"], formData: FormData ) { let entries = Object.entries(validationAttrs || {}) as [ BuiltInValidationAttr, BuiltInValidationAttrValue ][]; return entries.reduce((acc, [attrName, attrValue]) => { let value = calculateValidationAttr(attrValue, formData); if (value != null) { acc[attrName] = value; } return acc; }, {} as Record); } // Does our form have any dynamic attribute values that require re-evaluation // on all form changes? function hasDynamicAttributes(formDefinition: FormDefinition) { return Object.values(formDefinition.inputs).some((inputDef) => Object.values(inputDef.validationAttrs || {}).some( (attr) => typeof attr === "function" ) ); } function getClasses( info: InputInfo, type: "label" | SupportedControlTypes, className?: string ) { let { validity, state, touched, dirty } = info; return composeClassNames([ `rvs-${type}`, shouldShowErrors(validity, state, touched) ? `rvs-${type}--invalid` : "", state === "validating" ? `rvs-${type}--validating` : "", touched ? `rvs-${type}--touched` : "", dirty ? `rvs-${type}--dirty` : "", className, ]); } function shouldShowErrors( validity: ExtendedValidityState | undefined, state: AsyncValidationState, touched: boolean ) { return validity?.valid === false && state === "done" && touched; } // Get attributes shared across input/textarea/select elements function getControlAttrs( ctx: ReturnType, controlType: T, name?: string, className?: string, index?: number ) { return { ref: ctx.composedRef, name: ctx.name, id: getInputId(ctx.name, ctx.id), className: getClasses(ctx.info, controlType, className), defaultValue: getInputDefaultValue( ctx.name, controlType === "input" && "type" in ctx.validationAttrs ? (ctx.validationAttrs.type as InputValidationAttrs["type"]) : undefined, ctx.serverFormInfo, index ), ...(shouldShowErrors(ctx.info.validity, ctx.info.state, ctx.info.touched) ? { "aria-invalid": true, "aria-errormessage": getErrorsId(name || ctx.name, ctx.id), } : {}), ...ctx.validationAttrs, }; } // For checkbox/radio inputs, we need to listen across all inputs for the given // name to update the validate as a group function registerMultipleEventListeners( inputEl: SupportedHTMLElements, event: "blur" | "change" | "input", handler: () => void ) { let selector = `input[type="${inputEl.type}"][name="${inputEl.name}"]`; Array.from(inputEl.form?.querySelectorAll(selector) || []).forEach((el) => el.addEventListener(event, handler) ); return () => { Array.from(inputEl?.form?.querySelectorAll(selector) || []).forEach((el) => el.removeEventListener(event, handler) ); }; } // Determine the current error messages to display based on the ExtendedValidityState // On the initial client render, when we don't have a ref, we accept // currentValidationAttrs. On subsequent renders we use the ref and read the // up-to-date attribute value function getCurrentErrorMessages( formDefinition: T, inputName: KeyOf, inputValue: string, validity?: ExtendedValidityState, currentValidationAttrs?: Record, inputEl?: SupportedHTMLElements ) { let messages = Object.entries(validity || {}) .filter((e) => e[0] !== "valid" && e[1]) .reduce((acc, [validation, valid]) => { let attr = builtInValidityToAttrMapping[ validation as ValidityStateKey ] as BuiltInValidationAttr; let message = formDefinition?.inputs?.[inputName]?.errorMessages?.[validation] || formDefinition?.errorMessages?.[validation] || builtInValidations[attr]?.errorMessage; if (typeof message === "function") { let attrValue = inputEl ? inputEl.getAttribute(attr) : currentValidationAttrs?.[attr]; message = message( attrValue != null ? String(attrValue) : undefined, inputName, inputValue ); } return Object.assign(acc, { [validation]: message, }); }, {}); return Object.keys(messages).length > 0 ? messages : undefined; } //#endregion //////////////////////////////////////////////////////////////////////////////// //#region Contexts + Components + Hooks export const FormContext = React.createContext | null>(null); export function useOptionalFormContext< T extends FormDefinition >(): FormContextObject | null { const context = React.useContext>( FormContext as unknown as React.Context> ); if (context) { return context; } return null; } interface UseValidatedControlOpts< T extends FormDefinition, E extends SupportedHTMLElements > { name: KeyOf; formDefinition?: T; serverFormInfo?: ServerFormInfo; ref?: | React.ForwardedRef | React.Ref; index?: number; forceUpdate?: any; } // Handle validations for a single form control function useValidatedControl< T extends FormDefinition, E extends SupportedHTMLElements >(opts: UseValidatedControlOpts) { let ctx = useOptionalFormContext(); let name = opts.name; let formDefinition = opts.formDefinition || ctx?.formDefinition; let forceUpdate = opts.forceUpdate || ctx?.forceUpdate; invariant( formDefinition, "useValidatedControl() must either be used inside a " + "or be passed a `formDefinition` object" ); let inputDef = formDefinition.inputs[name]; invariant( inputDef, `useValidatedControl() could not find a corresponding definition ` + `for the "${name}" input` ); let serverFormInfo = opts.serverFormInfo || ctx?.serverFormInfo; let wasSubmitted = false; let serverValue: string | null = null; let serverValidity: InputInfo["validity"] = undefined; if (serverFormInfo != null) { wasSubmitted = true; let submittedValue = serverFormInfo.submittedValues[name]; let inputInfo = serverFormInfo.inputs[name]; if ( (inputDef.element == null || inputDef.element === "input") && inputDef.validationAttrs?.type === "checkbox" ) { // Checkboxes aren't re-populated like others at the moment :/ } else if (Array.isArray(inputInfo) || Array.isArray(submittedValue)) { invariant( Array.isArray(inputInfo) && Array.isArray(submittedValue), `Incompatible serverFormInfo structure for field "${name}"` ); invariant( opts.index != null && opts.index >= 0, `Expected an "index" value for multiple-submission field "${name}"` ); serverValue = submittedValue[opts.index]; serverValidity = inputInfo[opts.index].validity; } else { serverValue = submittedValue; serverValidity = inputInfo.validity; } } // Setup React state // Need a ref to grab formData for attribute generation let inputRef = React.useRef(null); let formData = inputRef.current?.form ? new FormData(inputRef.current.form) : serverFormInfo ? generateFormDataFromServerFormInfo(serverFormInfo.submittedValues) : new FormData(); let currentValidationAttrs = calculateValidationAttrs( inputDef.validationAttrs, formData ); let id = React.useId(); let prevServerFormInfo = React.useRef | undefined>( serverFormInfo ); let composedRef = useComposedRefs(inputRef, opts.ref); let [value, setValue] = React.useState(serverValue || ""); let [dirty, setDirty] = React.useState(wasSubmitted); let [touched, setTouched] = React.useState(wasSubmitted); let [validationState, setValidationState] = React.useState< InputInfo["state"] >(wasSubmitted ? "done" : "idle"); let [validity, setValidity] = React.useState< InputInfo["validity"] | undefined >(serverValidity); let [currentErrorMessages, setCurrentErrorMessages] = React.useState< Record | undefined >(() => getCurrentErrorMessages( formDefinition!, name, value, validity, currentValidationAttrs, undefined ) ); let controller = React.useRef(null); // Set InputInfo.touched on `blur` events React.useEffect(() => { let inputEl = inputRef.current; if (!inputEl) { return; } let inputType = IsInputDefinition(inputDef) ? inputDef.validationAttrs?.type : null; let handler = () => setTouched(true); if (inputType === "checkbox" || inputType === "radio") { return registerMultipleEventListeners(inputEl, "blur", handler); } inputEl.addEventListener("blur", handler); return () => inputEl?.removeEventListener("blur", handler); }, [inputDef, inputRef, name]); // Set value and InputInfo.dirty on `input` events React.useEffect(() => { let inputEl = inputRef.current; if (!inputEl) { return; } let elementType = !IsInputDefinition(inputDef) ? inputDef.element : null; let inputType = IsInputDefinition(inputDef) ? inputDef.validationAttrs?.type : null; let event: "change" | "input" = elementType === "select" || inputType === "radio" || inputType === "checkbox" ? "change" : "input"; let handler = function (this: E) { setDirty(true); setValue(this.value); }; if (inputType === "checkbox" || inputType === "radio") { return registerMultipleEventListeners(inputEl, event, handler); } inputEl.addEventListener(event, handler); return () => inputEl?.removeEventListener(event, handler); }, [inputDef]); // Run validations on input value changes React.useEffect(() => { async function go() { // If this is the first render after a server validation, consider us // validated and mark dirty/touched to show errors. Then skip re-running // validations on the client if (prevServerFormInfo.current !== serverFormInfo) { prevServerFormInfo.current = serverFormInfo; setDirty(true); setTouched(true); setValidationState("done"); if (serverValidity) { setValidity(serverValidity); } return; } // Abort any ongoing async validations if (controller.current) { controller.current.abort(); } // Validate the input if (!inputDef) { setValidationState("done"); return; } let localController = new AbortController(); controller.current = localController; setValidationState("validating"); let validity: ExtendedValidityState; let inputType = IsInputDefinition(inputDef) ? inputDef.validationAttrs?.type : null; if (inputType === "radio" || inputType === "checkbox") { validity = await validateInput( name, inputDef.validationAttrs, inputDef.customValidations, value, Array.from( inputRef.current?.form?.querySelectorAll( `input[type="${inputType}"][name="${name}"]` ) || [] ) ); } else { validity = await validateInput( name, inputDef.validationAttrs, inputDef.customValidations, value, inputRef.current || undefined ); } if (localController.signal.aborted) { return; } setValidationState("done"); setValidity(validity); // Generate error messages based on the validations if (validity?.valid === false) { invariant(formDefinition, "No formDefinition available in useEffect"); invariant( inputRef.current, "Expected an input to be present for client-side error message generation" ); let messages = getCurrentErrorMessages( formDefinition, name, value, validity, undefined, inputRef.current ); setCurrentErrorMessages(messages); } else { setCurrentErrorMessages(undefined); } } go().catch((e) => console.error("Error in validateInput useEffect", e)); return () => controller.current?.abort(); // Important: forceUpdate must remain included in the deps array for // auto-revalidation on dynamic attribute value changes }, [ forceUpdate, formDefinition, inputDef, name, serverFormInfo, serverValidity, value, ]); let info: InputInfo = { value, dirty, touched, state: validationState, validity, errorMessages: currentErrorMessages, }; // Provide the caller a prop getter to be spread onto the