@react-stately/form
Version:
Spectrum UI components in React
259 lines (221 loc) • 9.16 kB
text/typescript
/*
* 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
};
}