@base-ui-components/react
Version:
Base UI is a library of headless ('unstyled') React components and low-level hooks. You gain complete control over your app's CSS and accessibility features.
224 lines (219 loc) • 7.58 kB
JavaScript
'use client';
import * as React from 'react';
import { useTimeout } from '@base-ui-components/utils/useTimeout';
import { useEventCallback } from '@base-ui-components/utils/useEventCallback';
import { useFieldRootContext } from "../root/FieldRootContext.js";
import { mergeProps } from "../../merge-props/index.js";
import { DEFAULT_VALIDITY_STATE } from "../utils/constants.js";
import { useFormContext } from "../../form/FormContext.js";
import { getCombinedFieldValidityData } from "../utils/getCombinedFieldValidityData.js";
const validityKeys = Object.keys(DEFAULT_VALIDITY_STATE);
function isOnlyValueMissing(state) {
if (!state || state.valid || !state.valueMissing) {
return false;
}
let onlyValueMissing = false;
for (const key of validityKeys) {
if (key === 'valid') {
continue;
}
if (key === 'valueMissing') {
onlyValueMissing = state[key];
}
if (state[key]) {
onlyValueMissing = false;
}
}
return onlyValueMissing;
}
export function useFieldControlValidation() {
const {
setValidityData,
validate,
messageIds,
validityData,
validationMode,
validationDebounceTime,
invalid,
markedDirtyRef,
controlId,
state,
name
} = useFieldRootContext();
const {
formRef,
clearErrors
} = useFormContext();
const timeout = useTimeout();
const inputRef = React.useRef(null);
const commitValidation = useEventCallback(async (value, revalidate = false) => {
const element = inputRef.current;
if (!element) {
return;
}
if (revalidate) {
if (state.valid !== false) {
return;
}
const currentNativeValidity = element.validity;
if (!currentNativeValidity.valueMissing) {
// The 'valueMissing' (required) condition has been resolved by the user typing.
// Temporarily mark the field as valid for this onChange event.
// Other native errors (e.g., typeMismatch) will be caught by full validation on blur or submit.
const nextValidityData = {
value,
state: {
...DEFAULT_VALIDITY_STATE,
valid: true
},
error: '',
errors: [],
initialValue: validityData.initialValue
};
element.setCustomValidity('');
if (controlId) {
const currentFieldData = formRef.current.fields.get(controlId);
if (currentFieldData) {
formRef.current.fields.set(controlId, {
...currentFieldData,
...getCombinedFieldValidityData(nextValidityData, false) // invalid = false
});
}
}
setValidityData(nextValidityData);
return;
}
// Value is still missing, or other conditions apply.
// Let's use a representation of current validity for isOnlyValueMissing.
const currentNativeValidityObject = validityKeys.reduce((acc, key) => {
acc[key] = currentNativeValidity[key];
return acc;
}, {});
// If it's (still) natively invalid due to something other than just valueMissing,
// then bail from this revalidation on change to avoid "scolding" for other errors.
if (!currentNativeValidityObject.valid && !isOnlyValueMissing(currentNativeValidityObject)) {
return;
}
// If valueMissing is still true AND it's the only issue, or if the field is now natively valid,
// let it fall through to the main validation logic below.
}
function getState(el) {
const computedState = validityKeys.reduce((acc, key) => {
acc[key] = el.validity[key];
return acc;
}, {});
let hasOnlyValueMissingError = false;
for (const key of validityKeys) {
if (key === 'valid') {
continue;
}
if (key === 'valueMissing' && computedState[key]) {
hasOnlyValueMissingError = true;
} else if (computedState[key]) {
return computedState;
}
}
// Only make `valueMissing` mark the field invalid if it's been changed
// to reduce error noise.
if (hasOnlyValueMissingError && !markedDirtyRef.current) {
computedState.valid = true;
computedState.valueMissing = false;
}
return computedState;
}
timeout.clear();
let result = null;
let validationErrors = [];
const nextState = getState(element);
let defaultValidationMessage;
if (element.validationMessage) {
defaultValidationMessage = element.validationMessage;
validationErrors = [element.validationMessage];
} else {
const formValues = Array.from(formRef.current.fields.values()).reduce((acc, field) => {
if (field.name && field.getValueRef) {
acc[field.name] = field.getValueRef.current?.();
}
return acc;
}, {});
const resultOrPromise = validate(value, formValues);
if (typeof resultOrPromise === 'object' && resultOrPromise !== null && 'then' in resultOrPromise) {
result = await resultOrPromise;
} else {
result = resultOrPromise;
}
if (result !== null) {
nextState.valid = false;
nextState.customError = true;
if (Array.isArray(result)) {
validationErrors = result;
element.setCustomValidity(result.join('\n'));
} else if (result) {
validationErrors = [result];
element.setCustomValidity(result);
}
}
}
const nextValidityData = {
value,
state: nextState,
error: defaultValidationMessage ?? (Array.isArray(result) ? result[0] : result ?? ''),
errors: validationErrors,
initialValue: validityData.initialValue
};
if (controlId) {
const currentFieldData = formRef.current.fields.get(controlId);
if (currentFieldData) {
formRef.current.fields.set(controlId, {
...currentFieldData,
...getCombinedFieldValidityData(nextValidityData, invalid)
});
}
}
setValidityData(nextValidityData);
});
const getValidationProps = React.useCallback((externalProps = {}) => mergeProps({
...(messageIds.length && {
'aria-describedby': messageIds.join(' ')
}),
...(state.valid === false && {
'aria-invalid': true
})
}, externalProps), [messageIds, state.valid]);
const getInputValidationProps = React.useCallback((externalProps = {}) => mergeProps({
onChange(event) {
// Workaround for https://github.com/facebook/react/issues/9023
if (event.nativeEvent.defaultPrevented) {
return;
}
clearErrors(name);
if (validationMode !== 'onChange') {
commitValidation(event.currentTarget.value, true);
return;
}
if (invalid) {
return;
}
const element = event.currentTarget;
if (element.value === '') {
// Ignore the debounce time for empty values.
commitValidation(element.value);
return;
}
timeout.clear();
if (validationDebounceTime) {
timeout.start(validationDebounceTime, () => {
commitValidation(element.value);
});
} else {
commitValidation(element.value);
}
}
}, getValidationProps(externalProps)), [getValidationProps, clearErrors, name, timeout, commitValidation, invalid, validationMode, validationDebounceTime]);
return React.useMemo(() => ({
getValidationProps,
getInputValidationProps,
inputRef,
commitValidation
}), [getValidationProps, getInputValidationProps, commitValidation]);
}