UNPKG

@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.

239 lines (234 loc) 8.44 kB
'use client'; import * as React from 'react'; import { EMPTY_OBJECT } from '@base-ui-components/utils/empty'; import { useTimeout } from '@base-ui-components/utils/useTimeout'; import { useStableCallback } from '@base-ui-components/utils/useStableCallback'; import { useLabelableContext } from "../../labelable-provider/LabelableContext.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 useFieldValidation(params) { const { formRef, clearErrors } = useFormContext(); const { setValidityData, validate, validityData, validationDebounceTime, invalid, markedDirtyRef, state, name, shouldValidateOnChange } = params; const { controlId, getDescriptionProps } = useLabelableContext(); const timeout = useTimeout(); const inputRef = React.useRef(null); const commit = useStableCallback(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; const validateOnChange = shouldValidateOnChange(); if (element.validationMessage && !validateOnChange) { // not validating on change, if there is a `validationMessage` from // native validity, set errors and skip calling the custom validate fn defaultValidationMessage = element.validationMessage; validationErrors = [element.validationMessage]; } else { // call the validate function because either // - validating on change, or // - native constraint validations passed, custom validity check is next const formValues = Array.from(formRef.current.fields.values()).reduce((acc, field) => { if (field.name) { acc[field.name] = field.getValue(); } 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); } } else if (validateOnChange) { // validate function returned no errors, if validating on change // we need to clear the custom validity state element.setCustomValidity(''); nextState.customError = false; if (element.validationMessage) { defaultValidationMessage = element.validationMessage; validationErrors = [element.validationMessage]; } else if (element.validity.valid && !nextState.valid) { nextState.valid = true; } } } 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(getDescriptionProps, state.valid === false ? { 'aria-invalid': true } : EMPTY_OBJECT, externalProps), [getDescriptionProps, 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 (!shouldValidateOnChange()) { commit(event.currentTarget.value, true); return; } if (invalid) { return; } const element = event.currentTarget; if (element.value === '') { // Ignore the debounce time for empty values. commit(element.value); return; } timeout.clear(); if (validationDebounceTime) { timeout.start(validationDebounceTime, () => { commit(element.value); }); } else { commit(element.value); } } }, getValidationProps(externalProps)), [getValidationProps, clearErrors, name, timeout, commit, invalid, validationDebounceTime, shouldValidateOnChange]); return React.useMemo(() => ({ getValidationProps, getInputValidationProps, inputRef, commit }), [getValidationProps, getInputValidationProps, commit]); }