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 (
+
+ );
+ }
+
+ 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,