UNPKG

@react-stately/form

Version:
259 lines (221 loc) • 9.16 kB
/* * Copyright 2023 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ import {Context, createContext, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {Validation, ValidationErrors, ValidationFunction, ValidationResult} from '@react-types/shared'; export const VALID_VALIDITY_STATE: ValidityState = { badInput: false, customError: false, patternMismatch: false, rangeOverflow: false, rangeUnderflow: false, stepMismatch: false, tooLong: false, tooShort: false, typeMismatch: false, valueMissing: false, valid: true }; const CUSTOM_VALIDITY_STATE: ValidityState = { ...VALID_VALIDITY_STATE, customError: true, valid: false }; export const DEFAULT_VALIDATION_RESULT: ValidationResult = { isInvalid: false, validationDetails: VALID_VALIDITY_STATE, validationErrors: [] }; export const FormValidationContext: Context<ValidationErrors> = createContext<ValidationErrors>({}); export const privateValidationStateProp: string = '__formValidationState' + Date.now(); interface FormValidationProps<T> extends Validation<T> { builtinValidation?: ValidationResult, name?: string | string[], value: T | null } export interface FormValidationState { /** Realtime validation results, updated as the user edits the value. */ realtimeValidation: ValidationResult, /** Currently displayed validation results, updated when the user commits their changes. */ displayValidation: ValidationResult, /** Updates the current validation result. Not displayed to the user until `commitValidation` is called. */ updateValidation(result: ValidationResult): void, /** Resets the displayed validation state to valid when the user resets the form. */ resetValidation(): void, /** Commits the realtime validation so it is displayed to the user. */ commitValidation(): void } export function useFormValidationState<T>(props: FormValidationProps<T>): FormValidationState { // Private prop for parent components to pass state to children. if (props[privateValidationStateProp]) { let {realtimeValidation, displayValidation, updateValidation, resetValidation, commitValidation} = props[privateValidationStateProp] as FormValidationState; return {realtimeValidation, displayValidation, updateValidation, resetValidation, commitValidation}; } // eslint-disable-next-line react-hooks/rules-of-hooks return useFormValidationStateImpl(props); } function useFormValidationStateImpl<T>(props: FormValidationProps<T>): FormValidationState { let {isInvalid, validationState, name, value, builtinValidation, validate, validationBehavior = 'aria'} = props; // backward compatibility. if (validationState) { isInvalid ||= validationState === 'invalid'; } // If the isInvalid prop is controlled, update validation result in realtime. let controlledError: ValidationResult | null = isInvalid !== undefined ? { isInvalid, validationErrors: [], validationDetails: CUSTOM_VALIDITY_STATE } : null; // Perform custom client side validation. let clientError: ValidationResult | null = useMemo(() => { if (!validate || value == null) { return null; } let validateErrors = runValidate(validate, value); return getValidationResult(validateErrors); }, [validate, value]); if (builtinValidation?.validationDetails.valid) { builtinValidation = undefined; } // Get relevant server errors from the form. let serverErrors = useContext(FormValidationContext); let serverErrorMessages = useMemo(() => { if (name) { return Array.isArray(name) ? name.flatMap(name => asArray(serverErrors[name])) : asArray(serverErrors[name]); } return []; }, [serverErrors, name]); // Show server errors when the form gets a new value, and clear when the user changes the value. let [lastServerErrors, setLastServerErrors] = useState(serverErrors); let [isServerErrorCleared, setServerErrorCleared] = useState(false); if (serverErrors !== lastServerErrors) { setLastServerErrors(serverErrors); setServerErrorCleared(false); } let serverError: ValidationResult | null = useMemo(() => getValidationResult(isServerErrorCleared ? [] : serverErrorMessages), [isServerErrorCleared, serverErrorMessages] ); // Track the next validation state in a ref until commitValidation is called. let nextValidation = useRef(DEFAULT_VALIDATION_RESULT); let [currentValidity, setCurrentValidity] = useState(DEFAULT_VALIDATION_RESULT); let lastError = useRef(DEFAULT_VALIDATION_RESULT); let commitValidation = () => { if (!commitQueued) { return; } setCommitQueued(false); let error = clientError || builtinValidation || nextValidation.current; if (!isEqualValidation(error, lastError.current)) { lastError.current = error; setCurrentValidity(error); } }; let [commitQueued, setCommitQueued] = useState(false); useEffect(commitValidation); // realtimeValidation is used to update the native input element's state based on custom validation logic. // displayValidation is the currently displayed validation state that the user sees (e.g. on input change/form submit). // With validationBehavior="aria", all errors are displayed in realtime rather than on submit. let realtimeValidation = controlledError || serverError || clientError || builtinValidation || DEFAULT_VALIDATION_RESULT; let displayValidation = validationBehavior === 'native' ? controlledError || serverError || currentValidity : controlledError || serverError || clientError || builtinValidation || currentValidity; return { realtimeValidation, displayValidation, updateValidation(value) { // If validationBehavior is 'aria', update in realtime. Otherwise, store in a ref until commit. if (validationBehavior === 'aria' && !isEqualValidation(currentValidity, value)) { setCurrentValidity(value); } else { nextValidation.current = value; } }, resetValidation() { // Update the currently displayed validation state to valid on form reset, // even if the native validity says it isn't. It'll show again on the next form submit. let error = DEFAULT_VALIDATION_RESULT; if (!isEqualValidation(error, lastError.current)) { lastError.current = error; setCurrentValidity(error); } // Do not commit validation after the next render. This avoids a condition where // useSelect calls commitValidation inside an onReset handler. if (validationBehavior === 'native') { setCommitQueued(false); } setServerErrorCleared(true); }, commitValidation() { // Commit validation state so the user sees it on blur/change/submit. Also clear any server errors. // Wait until after the next render to commit so that the latest value has been validated. if (validationBehavior === 'native') { setCommitQueued(true); } setServerErrorCleared(true); } }; } function asArray<T>(v: T | T[]): T[] { if (!v) { return []; } return Array.isArray(v) ? v : [v]; } function runValidate<T>(validate: ValidationFunction<T>, value: T): string[] { if (typeof validate === 'function') { let e = validate(value); if (e && typeof e !== 'boolean') { return asArray(e); } } return []; } function getValidationResult(errors: string[]): ValidationResult | null { return errors.length ? { isInvalid: true, validationErrors: errors, validationDetails: CUSTOM_VALIDITY_STATE } : null; } function isEqualValidation(a: ValidationResult | null, b: ValidationResult | null): boolean { if (a === b) { return true; } return !!a && !!b && a.isInvalid === b.isInvalid && a.validationErrors.length === b.validationErrors.length && a.validationErrors.every((a, i) => a === b.validationErrors[i]) && Object.entries(a.validationDetails).every(([k, v]) => b.validationDetails[k] === v); } export function mergeValidation(...results: ValidationResult[]): ValidationResult { let errors = new Set<string>(); let isInvalid = false; let validationDetails = { ...VALID_VALIDITY_STATE }; for (let v of results) { for (let e of v.validationErrors) { errors.add(e); } // Only these properties apply for checkboxes. isInvalid ||= v.isInvalid; for (let key in validationDetails) { validationDetails[key] ||= v.validationDetails[key]; } } validationDetails.valid = !isInvalid; return { isInvalid, validationErrors: [...errors], validationDetails }; }