diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 0a3363652c6..7b7d9954079 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -256,6 +256,48 @@ describe('NumberField', () => { expect(numberfield).not.toHaveAttribute('data-invalid'); }); + it('should clear validation errors when a controlled value is updated externally', async () => { + function ControlledNumberField() { + let [value, setValue] = useState(1); + + return ( +
+ (v % 2 ? 'Odd values are invalid' : null)}> + + + + + + + + + +
+ ); + } + + let {getByRole, getByTestId} = render(); + let input = getByRole('textbox'); + + act(() => { + getByTestId('form').checkValidity(); + }); + + let describedBy = input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy)).toHaveTextContent('Odd values are invalid'); + + await user.click(getByRole('button', {name: 'Set to 10'})); + + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + }); + it('supports pasting value in another numbering system', async () => { let {getByRole, rerender} = render(); let input = getByRole('textbox'); diff --git a/packages/react-aria-components/test/TextField.test.js b/packages/react-aria-components/test/TextField.test.js index 79355365192..61ff62213c4 100644 --- a/packages/react-aria-components/test/TextField.test.js +++ b/packages/react-aria-components/test/TextField.test.js @@ -11,10 +11,11 @@ */ import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {Button} from '../src/Button'; import {FieldError} from '../src/FieldError'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; -import React from 'react'; +import React, {useState} from 'react'; import {Text} from '../src/Text'; import {TextArea} from '../src/TextArea'; import {TextField, TextFieldContext} from '../src/TextField'; @@ -266,6 +267,103 @@ describe('TextField', () => { expect(input).not.toHaveAttribute('aria-describedby'); }); + it('should clear validation errors when a controlled value is updated externally', async () => { + let Component = component; + function ControlledTextField() { + let [value, setValue] = useState(''); + + return ( +
+ + + + + + +
+ ); + } + + let {getByRole, getByTestId} = render(); + let input = getByRole('textbox'); + + act(() => { + getByTestId('form').checkValidity(); + }); + + let describedBy = input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy)).toHaveTextContent('Constraints not satisfied'); + + await user.click(getByRole('button', {name: 'Set to Devon'})); + + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input).not.toHaveAttribute('aria-invalid'); + }); + + it('should show validation errors when a controlled value is updated externally to exceed maxLength', async () => { + let Component = component; + function ControlledTextField() { + let [value, setValue] = useState(''); + + return ( +
+ + + + + + +
+ ); + } + + let {getByRole} = render(); + let input = getByRole('textbox'); + expect(input).not.toHaveAttribute('aria-describedby'); + + await user.click(getByRole('button', {name: 'Set too long'})); + + let describedBy = input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy)).toHaveTextContent( + 'Please shorten this text to 10 characters or less' + ); + expect(input).toHaveAttribute('aria-invalid'); + }); + + it('should show validation errors when a controlled value is updated externally to be below minLength', async () => { + let Component = component; + function ControlledTextField() { + let [value, setValue] = useState(''); + + return ( +
+ + + + + + +
+ ); + } + + let {getByRole} = render(); + let input = getByRole('textbox'); + + expect(input).not.toHaveAttribute('aria-describedby'); + + await user.click(getByRole('button', {name: 'Set too short'})); + + let describedBy = input.getAttribute('aria-describedby'); + expect(describedBy).toBeTruthy(); + expect(document.getElementById(describedBy)).toHaveTextContent( + 'Please lengthen this text to 10 characters or more' + ); + expect(input).toHaveAttribute('aria-invalid'); + }); + it('should render the id attribute only on the input element', async () => { let {getAllByTestId, getByRole} = render(); let outerEl = getAllByTestId('text-field-test'); diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts index 00ecdeabcea..b16963eb9a7 100644 --- a/packages/react-aria/src/datepicker/useDateField.ts +++ b/packages/react-aria/src/datepicker/useDateField.ts @@ -100,14 +100,17 @@ export function useDateField( }); let valueOnFocus = useRef(null); + let isFocused = useRef(false); let {focusWithinProps} = useFocusWithin({ ...props, onFocusWithin(e) { valueOnFocus.current = state.value; + isFocused.current = true; props.onFocus?.(e); }, onBlurWithin: e => { state.confirmPlaceholder(); + isFocused.current = false; if (state.value !== valueOnFocus.current) { state.commitValidation(); } @@ -178,6 +181,7 @@ export function useDateField( useFormValidation( { ...props, + isFocusWithin: () => isFocused.current, focus() { focusManager.focusFirst(); } diff --git a/packages/react-aria/src/form/useFormValidation.ts b/packages/react-aria/src/form/useFormValidation.ts index 5a26df343a4..0eab2169aac 100644 --- a/packages/react-aria/src/form/useFormValidation.ts +++ b/packages/react-aria/src/form/useFormValidation.ts @@ -12,7 +12,7 @@ import {FormValidationState} from 'react-stately/private/form/useFormValidationState'; -import {getEventTarget} from '../utils/shadowdom/DOMFunctions'; +import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; import {RefObject, Validation, ValidationResult} from '@react-types/shared'; import {setInteractionModality} from '../interactions/useFocusVisible'; import {useEffect, useRef} from 'react'; @@ -23,6 +23,12 @@ type ValidatableElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEle interface FormValidationProps extends Validation { focus?: () => void; + /** + * Whether the field, or any part of a composite field, is currently focused. + * Used to detect external value changes in complex components where + * the validated input is not the visually active element. + */ + isFocusWithin?: boolean | (() => boolean); } export function useFormValidation( @@ -30,7 +36,8 @@ export function useFormValidation( state: FormValidationState, ref: RefObject | undefined ): void { - let {validationBehavior, focus} = props; + let {validationBehavior, focus, isFocusWithin} = props; + let lastValue = useRef(undefined); // This is a useLayoutEffect so that it runs before the useEffect in useFormValidationState, which commits the validation change. useLayoutEffect(() => { @@ -40,9 +47,28 @@ export function useFormValidation( 'setCustomValidity' in ref.current && !ref.current.disabled ) { - let errorMessage = state.realtimeValidation.isInvalid - ? state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.' - : ''; + let currentValue = ref.current.value; + let valueChanged = lastValue.current !== undefined && lastValue.current !== currentValue; + lastValue.current = currentValue; + + // Clear custom validity to accurately read the raw DOM state. + ref.current.setCustomValidity(''); + + let validityDetails = getValidity(ref.current); + let isProgrammaticViolation = validityDetails.tooLong || validityDetails.tooShort; + + // Use native validity to block form submission if constraints fail. + // Fall back to React state for server/custom errors. + let errorMessage = ''; + if (isProgrammaticViolation) { + if (validityDetails.tooLong) { + errorMessage = `Please shorten this text to ${ref.current.getAttribute('maxlength')} characters or less (you are currently using ${ref.current.value.length} characters).`; + } else if (validityDetails.tooShort) { + errorMessage = `Please lengthen this text to ${ref.current.getAttribute('minlength')} characters or more (you are currently using ${ref.current.value.length} characters).`; + } + } else if (state.realtimeValidation.isInvalid) { + errorMessage = state.realtimeValidation.validationErrors.join(' ') || 'Invalid value.'; + } ref.current.setCustomValidity(errorMessage); // Prevent default tooltip for validation message. @@ -51,8 +77,22 @@ export function useFormValidation( ref.current.title = ''; } - if (!state.realtimeValidation.isInvalid) { - state.updateValidation(getNativeValidity(ref.current)); + let nativeValidity = getNativeValidity(ref.current); + if (!state.realtimeValidation.isInvalid || isProgrammaticViolation) { + state.updateValidation(nativeValidity); + } + + // Commit validation immediately if the value changes while the field is unfocused. + // This clears stale errors or displays programmatic constraint violations. + let isFocused = + (typeof isFocusWithin === 'function' ? isFocusWithin() : isFocusWithin) ?? + (typeof document !== 'undefined' && getActiveElement() === ref.current); + + if (valueChanged && !isFocused) { + let isNowValid = !nativeValidity.isInvalid && !state.realtimeValidation.isInvalid; + if (isNowValid || isProgrammaticViolation) { + state.commitValidation(); + } } } }); @@ -141,6 +181,26 @@ function getValidity(input: ValidatableElement) { // The native ValidityState object is live, meaning each property is a getter that returns the current state. // We need to create a snapshot of the validity state at the time this function is called to avoid unpredictable React renders. let validity = input.validity; + + // Polyfill: Native DOM ignores programmatic maxLength violations. + let tooLong = validity.tooLong; + let maxLength = input.getAttribute('maxlength'); + if (maxLength !== null && input.value.length > parseInt(maxLength, 10)) { + tooLong = true; + } + + // Polyfill: Native DOM ignores programmatic minLength violations. + // Note: minLength only applies if the value is not empty. + let tooShort = validity.tooShort; + let minLength = input.getAttribute('minlength'); + if ( + minLength !== null && + input.value.length > 0 && + input.value.length < parseInt(minLength, 10) + ) { + tooShort = true; + } + return { badInput: validity.badInput, customError: validity.customError, @@ -148,19 +208,33 @@ function getValidity(input: ValidatableElement) { rangeOverflow: validity.rangeOverflow, rangeUnderflow: validity.rangeUnderflow, stepMismatch: validity.stepMismatch, - tooLong: validity.tooLong, - tooShort: validity.tooShort, + tooLong: tooLong, + tooShort: tooShort, typeMismatch: validity.typeMismatch, valueMissing: validity.valueMissing, - valid: validity.valid + valid: validity.valid && !tooLong && !tooShort }; } function getNativeValidity(input: ValidatableElement): ValidationResult { + let validityDetails = getValidity(input); + let isInvalid = !validityDetails.valid; + + let validationMessage = input.validationMessage; + + // Fallback for our polyfills since the native DOM doesn't generate a message for programmatic errors. + if (isInvalid && !validationMessage) { + if (validityDetails.tooLong) { + validationMessage = `Please shorten this text to ${input.getAttribute('maxlength')} characters or less (you are currently using ${input.value.length} characters).`; + } else if (validityDetails.tooShort) { + validationMessage = `Please lengthen this text to ${input.getAttribute('minlength')} characters or more (you are currently using ${input.value.length} characters).`; + } + } + return { - isInvalid: !input.validity.valid, - validationDetails: getValidity(input), - validationErrors: input.validationMessage ? [input.validationMessage] : [] + isInvalid: isInvalid, + validationDetails: validityDetails, + validationErrors: validationMessage ? [validationMessage] : [] }; } diff --git a/packages/react-aria/src/select/HiddenSelect.tsx b/packages/react-aria/src/select/HiddenSelect.tsx index fee8d126111..4317da103e7 100644 --- a/packages/react-aria/src/select/HiddenSelect.tsx +++ b/packages/react-aria/src/select/HiddenSelect.tsx @@ -96,6 +96,7 @@ export function useHiddenSelect( useFormValidation( { validationBehavior, + isFocusWithin: state.isFocused, focus: () => triggerRef.current?.focus() }, state,