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.

224 lines (219 loc) 7.58 kB
'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]); }