diff --git a/packages/@adobe/react-spectrum/test/datepicker/DateField.test.js b/packages/@adobe/react-spectrum/test/datepicker/DateField.test.js
index 8949e1cb327..feb376d098a 100644
--- a/packages/@adobe/react-spectrum/test/datepicker/DateField.test.js
+++ b/packages/@adobe/react-spectrum/test/datepicker/DateField.test.js
@@ -764,6 +764,492 @@ describe('DateField', function () {
await user.tab();
expect(getDescription()).not.toContain('Constraints not satisfied');
});
+
+ it('should signal valueMissing when a complete date is made partial by clearing a segment (Bug #9624)', async () => {
+ // Regression (per devongovett's direction in #9624): a partially-filled required
+ // DateField should be marked invalid on blur. onChange(null) is deliberately NOT
+ // fired for partial values; getValidationResult compensates by surfacing the
+ // localized "Please enter a value." message via builtinValidation.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+
+ // Fill a complete valid date: 4/28/2026
+ await user.tab();
+ await user.keyboard('4');
+ await user.keyboard('28');
+ await user.keyboard('2026');
+ expect(input.validity.valid).toBe(true);
+
+ // Refocus the month and clear it
+ let segments = within(group).getAllByRole('spinbutton');
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // Tab forward past day and year to exit the group
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ // The hidden input should reflect missing value and validation should surface
+ expect(input.validity.valid).toBe(false);
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Re-focus month and complete the date with a different valid value
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('5');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ expect(input.validity.valid).toBe(true);
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should signal validation for partial values regardless of isRequired (Bug #9958)', async () => {
+ // partial values are flagged invalid even without isRequired so that
+ // min/max/unavailable/validate configurations also block submission.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Still focused: no error is displayed yet, but submission is already blocked
+ // (the hidden input is required while partial and its value is '').
+ expect(input).toHaveValue('');
+ expect(input).toBeRequired();
+ expect(input.validity.valueMissing).toBe(true);
+ expect(input.validity.valid).toBe(false);
+ expect(getDescription()).not.toContain('Please enter a value.');
+
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ expect(input.validity.valid).toBe(false);
+ expect(getDescription()).toContain('Please enter a value.');
+ });
+
+ it('should replace the incomplete message with the constraint error once the value is completed (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment and blur -> the generic incomplete message is shown,
+ // even though the committed value also violates minValue.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+ expect(getDescription()).not.toContain('Value must be 1/1/2030 or later.');
+
+ // Refilling the month completes the (still min-violating) value -> the descriptive
+ // constraint error replaces the incomplete message without another blur.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('4');
+ expect(getDescription()).toContain('Value must be 1/1/2030 or later.');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should clear the partial-value error on form reset (Bug #9958)', async () => {
+ let {getByRole, getByTestId} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Reset restores the default value and clears the displayed error.
+ await user.click(getByTestId('reset'));
+ expect(input).toHaveValue('2026-04-28');
+ expect(getDescription()).not.toContain('Please enter a value.');
+
+ // The armed state is also reset: clearing a segment again shows no error until blur.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+ });
+
+ it('should clear the partial-value error when an optional field is fully cleared (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment and blur -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Clearing the remaining segments empties the field entirely. An empty optional
+ // field is valid, so the error must clear without waiting for another blur.
+ act(() => {
+ segments[1].focus();
+ });
+ await user.keyboard('{Backspace}{Backspace}');
+ act(() => {
+ segments[2].focus();
+ });
+ await user.keyboard('{Backspace}{Backspace}{Backspace}{Backspace}');
+ expect(segments[1]).toHaveAttribute('aria-valuetext', 'Empty');
+ expect(segments[2]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ expect(getDescription()).not.toContain('Please enter a value.');
+ expect(input).toHaveValue('');
+ expect(input.validity.valid).toBe(true);
+ });
+
+ it('should block form submission while the value is partial (Bug #9958)', async () => {
+ let {getByRole, getByTestId} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment and attempt to submit without ever blurring the field.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(input.validity.valueMissing).toBe(true);
+
+ let valid;
+ act(() => {
+ valid = getByTestId('form').checkValidity();
+ });
+ expect(valid).toBe(false);
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Refilling the month completes the value; the form submits cleanly again.
+ await user.keyboard('4');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ act(() => {
+ valid = getByTestId('form').checkValidity();
+ });
+ expect(valid).toBe(true);
+ });
+
+ it('should not surface an error when blurring an untouched or fully cleared optional field (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Tab through the untouched placeholder field and out -> no error.
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).not.toContain('Please enter a value.');
+
+ // Type into a segment, then clear it back before ever blurring. The field returns
+ // to fully cleared, so blurring must not surface a partial error.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('4');
+ // Typing auto-advances focus to the day segment; refocus the month to clear it.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).not.toContain('Please enter a value.');
+ expect(input.validity.valid).toBe(true);
+ });
+
+ it('should clear the partial-value error when completing the value with the arrow keys (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment and blur -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Spinning the empty segment with ArrowUp fills it, completing the value.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{ArrowUp}');
+ expect(segments[0]).not.toHaveAttribute('aria-valuetext', 'Empty');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should reset the partial state when the controlled value changes externally (Bug #9958)', async () => {
+ function Test() {
+ let [value, setValue] = React.useState(new CalendarDate(2026, 4, 28));
+ return (
+
+
+
+ );
+ }
+
+ let {getByRole, getByTestId} = render();
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment and blur -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // The parent replaces the value from outside while the display is partial.
+ // The new complete value takes over and the stale error clears.
+ await user.click(getByTestId('set-value'));
+ expect(input).toHaveValue('2027-06-15');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ expect(input.validity.valid).toBe(true);
+ });
+
+ it('should show the incomplete message instead of a validate() error while the value is partial (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment and blur -> the incomplete message appears. The validate()
+ // error is suppressed: it would be computed against the stale committed value, which
+ // is not what the user sees (same reasoning as min/max while partial).
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+ expect(getDescription()).not.toContain('Custom error');
+
+ // Refilling the month completes the (still validate-failing) value -> the custom
+ // error replaces the incomplete message.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('4');
+ expect(getDescription()).toContain('Custom error');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
});
describe('validationBehavior=aria', () => {
@@ -850,6 +1336,102 @@ describe('DateField', function () {
await user.keyboard('[Tab][ArrowRight][ArrowRight]2024[Tab]');
expect(getDescription()).not.toContain('Invalid value');
});
+
+ it('should clear the hidden input value when partial in aria mode (Bug #9624)', async () => {
+ // Aria-mode counterpart to the native-mode #9624 regression: the hidden input must
+ // reflect the partial display state regardless of validationBehavior so any consumer
+ // (e.g. FormData) sees the missing value.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ expect(input).toHaveValue('2026-04-28');
+
+ let segments = within(group).getAllByRole('spinbutton');
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ expect(input).toHaveValue('');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // No error while still editing — it appears once the field is blurred.
+ expect(getDescription()).not.toContain('Please enter a value.');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Refilling the segment completes the value and clears the error immediately.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('5');
+ expect(input).toHaveValue('2026-05-28');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should keep the partial-value error across focus cycles while still partial (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment and blur -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Refocusing without fixing anything must not clear the displayed error,
+ // and blurring again (still partial) keeps it.
+ act(() => {
+ segments[0].focus();
+ });
+ expect(getDescription()).toContain('Please enter a value.');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+ });
});
});
});
diff --git a/packages/@adobe/react-spectrum/test/datepicker/DatePicker.test.js b/packages/@adobe/react-spectrum/test/datepicker/DatePicker.test.js
index bfbbb5eba8e..71e329fa3e7 100644
--- a/packages/@adobe/react-spectrum/test/datepicker/DatePicker.test.js
+++ b/packages/@adobe/react-spectrum/test/datepicker/DatePicker.test.js
@@ -3149,6 +3149,367 @@ describe('DatePicker', function () {
expect(getDescription()).not.toContain('Constraints not satisfied');
});
+ it('should signal validation when a complete date is made partial by clearing the month segment (Bug #9958)', async () => {
+ // getValidationResult treats partial display state as
+ // invalid via the lifted-up isValuePartial flag in useDatePickerState, surfacing
+ // a localized error message.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+
+ // Fill a complete valid date: 4/28/2026
+ await user.tab();
+ await user.keyboard('4');
+ await user.keyboard('28');
+ await user.keyboard('2026');
+ expect(input.validity.valid).toBe(true);
+
+ // Refocus the month segment and clear it (single Backspace for single-digit '4')
+ let segments = within(group).getAllByRole('spinbutton');
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // Tab forward out of the group (month -> day -> year -> calendar trigger)
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ // Field is now partial — expect validation error surfaced
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(input.validity.valid).toBe(false);
+ expect(getDescription()).toContain('Please enter a value.');
+ });
+
+ it('should signal validation when a date with minValue is made partial without isRequired (Bug #9958)', async () => {
+ // DatePicker in a form in the docs using only
+ // minValue/maxValue / isDateUnavailable / validate (no isRequired) — clearing month
+ // or day must still block submission and surface an error.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ // Clear the month segment (single-digit '4' goes straight to null).
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let description = (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ expect(input.validity.valid).toBe(false);
+ expect(description).toContain('Please enter a value.');
+ });
+
+ it('should surface the generic incomplete message (not a constraint error) when a min-violating value is made partial (Bug #9958)', async () => {
+ // Constraints can't be evaluated against the stale committed value while partial;
+ // see getValidationResult.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let description = (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ expect(input.validity.valid).toBe(false);
+ expect(description).toContain('Please enter a value.');
+ expect(description).not.toContain('Value must be 1/1/2030 or later.');
+ });
+
+ it('should clear the partial-value error on the first calendar selection that completes the date (Bug #9958)', async () => {
+ let {getByRole, getAllByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ // Clear the month segment, then blur the field -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Open the calendar and select a complete, valid date in a single click.
+ await user.click(getByRole('button'));
+ let cells = getAllByRole('gridcell');
+ let selected = cells.find(cell => cell.getAttribute('aria-selected') === 'true');
+ await user.click(selected.nextSibling.children[0]);
+
+ // The value is now complete and valid
+ expect(input.validity.valid).toBe(true);
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should keep the partial-value error when the calendar is closed without a selection (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment, then blur the field -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Open the calendar, then dismiss it without selecting -> the error must remain.
+ await user.click(getByRole('button'));
+ expect(getByRole('dialog')).toBeVisible();
+ await user.keyboard('{Escape}');
+ act(() => jest.runAllTimers());
+
+ expect(getDescription()).toContain('Please enter a value.');
+ expect(input.validity.valid).toBe(false);
+ });
+
+ it('should clear the partial-value error when re-selecting the previously committed date (Bug #9958)', async () => {
+ let {getByRole, getAllByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment, then blur the field -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Select the SAME date that was previously committed. Even though the committed
+ // value does not change, the selection completes the display and clears the error.
+ await user.click(getByRole('button'));
+ let cells = getAllByRole('gridcell');
+ let selected = cells.find(cell => cell.getAttribute('aria-selected') === 'true');
+ await user.click(selected.children[0]);
+
+ expect(input).toHaveValue('2019-02-03');
+ expect(input.validity.valid).toBe(true);
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should clear the partial-value error on form reset (Bug #9958)', async () => {
+ let {getByRole, getByTestId} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment, then blur the field -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Reset restores the default value and clears the displayed error.
+ await user.click(getByTestId('reset'));
+ expect(input).toHaveValue('2019-02-03');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should signal validation when a time segment is cleared on a CalendarDateTime value (Bug #9801)', async () => {
+ // Issue #9801 is TimeField — the same IncompleteDate partial-validation path applies
+ // when a DatePicker has CalendarDateTime granularity and a time segment is cleared.
+ // The committed value is unchanged but the display buffer is partial; the field must
+ // be invalid and surface "Please enter a value." until the segment is refilled.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ // Confirm the full value is valid.
+ expect(input.validity.valid).toBe(true);
+
+ // Clear the hour segment (10 → two Backspaces needed for two digits).
+ act(() => {
+ segments[3].focus();
+ });
+ await user.keyboard('{Backspace}{Backspace}');
+ expect(segments[3]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // Tab out of the group so blur-triggered validation runs.
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(input.validity.valid).toBe(false);
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Refill the hour segment and confirm the error clears.
+ act(() => {
+ segments[3].focus();
+ });
+ await user.keyboard('10');
+ await user.tab();
+ expect(input.validity.valid).toBe(true);
+ });
+
it('supports minValue and maxValue', async () => {
let {getByRole, getByTestId} = render(
@@ -3495,6 +3856,225 @@ describe('DatePicker', function () {
await user.keyboard('[Tab][ArrowRight][ArrowRight]2024[Tab]');
expect(getDescription()).not.toContain('Invalid value');
});
+
+ it('should signal validation when a complete date is made partial by clearing the month segment (Bug #9958)', async () => {
+ // getValidationResult treats partial display state as
+ // invalid via the lifted-up isValuePartial flag in useDatePickerState, surfacing
+ // a localized error message.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Fill a complete valid date: 4/28/2026
+ await user.tab();
+ await user.keyboard('4');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ await user.keyboard('28');
+ await user.keyboard('2026');
+
+ // Refocus the month segment and clear it (single Backspace for single-digit '4')
+ let segments = within(group).getAllByRole('spinbutton');
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // Tab forward out of the group (month -> day -> year -> calendar trigger)
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ // Field is now partial — expect validation error surfaced
+ expect(getDescription()).toContain('Please enter a value.');
+ });
+
+ it('should signal validation when a date with minValue is made partial without isRequired (Bug #9958)', async () => {
+ // DatePicker in a form in the docs using only
+ // minValue/maxValue / isDateUnavailable / validate (no isRequired) — clearing month
+ // or day must still block submission and surface an error.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ // Clear the month segment (single-digit '4' goes straight to null).
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let description = (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ expect(description).toContain('Please enter a value.');
+ });
+
+ it('should surface the generic incomplete message (not a constraint error) when a min-violating value is made partial (Bug #9958)', async () => {
+ // Constraints can't be evaluated against the stale committed value while partial;
+ // see getValidationResult.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let description = (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ expect(description).toContain('Please enter a value.');
+ expect(description).not.toContain('Value must be 1/1/2030 or later.');
+ });
+
+ it('should clear the partial-value error on the first calendar selection that completes the date (Bug #9958)', async () => {
+ let {getByRole, getAllByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ // Clear the month segment, then blur the field -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Open the calendar and select a complete, valid date in a single click.
+ await user.click(getByRole('button'));
+ let cells = getAllByRole('gridcell');
+ let selected = cells.find(cell => cell.getAttribute('aria-selected') === 'true');
+ await user.click(selected.nextSibling.children[0]);
+
+ // The value is now complete and valid
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should signal validation when a time segment is cleared on a CalendarDateTime value (Bug #9801)', async () => {
+ // Issue #9801 is TimeField — the same IncompleteDate partial-validation path applies
+ // when a DatePicker has CalendarDateTime granularity and a time segment is cleared.
+ // The committed value is unchanged but the display buffer is partial; the field must
+ // be invalid and surface "Please enter a value." until the segment is refilled.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ // Clear the hour segment (10 → two Backspaces needed for two digits).
+ act(() => {
+ segments[3].focus();
+ });
+ await user.keyboard('{Backspace}{Backspace}');
+ expect(segments[3]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // Tab out of the group so blur-triggered validation runs.
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Refill the hour segment and confirm the error clears.
+ act(() => {
+ segments[3].focus();
+ });
+ await user.keyboard('10');
+ await user.tab();
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
});
});
});
diff --git a/packages/@adobe/react-spectrum/test/datepicker/DateRangePicker.test.js b/packages/@adobe/react-spectrum/test/datepicker/DateRangePicker.test.js
index 13e69ebfa9c..702d52f21cd 100644
--- a/packages/@adobe/react-spectrum/test/datepicker/DateRangePicker.test.js
+++ b/packages/@adobe/react-spectrum/test/datepicker/DateRangePicker.test.js
@@ -1924,6 +1924,185 @@ describe('DateRangePicker', function () {
expect(getDescription()).not.toContain('Constraints not satisfied');
});
+ it('should signal validation when an endpoint date is made partial by clearing a segment (Bug #9958)', async () => {
+ // each endpoint has its own DateFieldState with isValuePartial. Clearing a segment of either endpoint should
+ // fire validation on blur.
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // Tab past remaining start segments, end segments, and calendar trigger
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('4');
+ expect(getDescription()).not.toContain('Please enter a value.');
+
+ // Same flow for the end date: clear a segment, blur -> error; refill -> error clears.
+ act(() => {
+ segments[3].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[3]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // Tab past the remaining end segments and onto the calendar trigger
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ act(() => {
+ segments[3].focus();
+ });
+ await user.keyboard('5');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should keep the partial-value error until both endpoints are complete (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Make both endpoints partial, then blur out of the field.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ act(() => {
+ segments[3].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Completing only the start date keeps the error (the end date is still partial).
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('4');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Completing the end date as well clears it.
+ act(() => {
+ segments[3].focus();
+ });
+ await user.keyboard('5');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it("should merge the incomplete message with the other endpoint's constraint error (Bug #9958)", async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Make the start date partial, then blur. The start endpoint reports the generic
+ // incomplete message (its constraints can't be evaluated), while the end endpoint
+ // is complete and reports its real max-violation alongside it.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+ expect(getDescription()).toContain('Value must be 5/1/2026 or earlier.');
+
+ // Completing the start date drops the incomplete message; the end violation stays.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('4');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ expect(getDescription()).toContain('Value must be 5/1/2026 or earlier.');
+ });
+
it('supports minValue and maxValue', async () => {
let {getByRole, getByTestId} = render(
@@ -2214,6 +2393,62 @@ describe('DateRangePicker', function () {
expect(input.validity.valid).toBe(true);
expect(getDescription()).not.toContain('Constraints not satisfied');
});
+
+ it('should clear the partial-value error on the first calendar selection that completes the range (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let startInput = document.querySelector('input[name=start]');
+ let segments = within(group).getAllByRole('spinbutton');
+
+ // Clear the start month segment, then blur the field -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+ // Tab past remaining start segments, end segments, and calendar trigger button.
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Open the calendar and select a complete range with two clicks on the focused cell.
+ // The calendar opens with focus on the highlighted start date; clicking it sets the
+ // range start, clicking the same focused cell again sets the range end.
+ await user.click(getByRole('button'));
+ await user.click(document.activeElement);
+ await user.click(document.activeElement);
+
+ // The range is now complete and valid
+ expect(startInput.validity.valid).toBe(true);
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
});
describe('validationBehavior=aria', () => {
@@ -2313,6 +2548,106 @@ describe('DateRangePicker', function () {
await user.keyboard('[Tab][ArrowRight][ArrowRight]2024[Tab]');
expect(getDescription()).not.toContain('Invalid start date. Invalid end date.');
});
+
+ it('should signal validation only after blur when an endpoint is made partial (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the start month segment.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // No error while still editing — aria validation must not flash mid-edit.
+ expect(getDescription()).not.toContain('Please enter a value.');
+
+ // Tab past remaining start segments, end segments, and the calendar trigger.
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Refilling the start month completes the range and clears the error in realtime.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('4');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should clear the partial-value error on the first calendar selection that completes the range (Bug #9958)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the start month segment, then blur the field -> partial error appears.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Open the calendar and select a complete range with two clicks on the focused cell.
+ await user.click(getByRole('button'));
+ await user.click(document.activeElement);
+ await user.click(document.activeElement);
+
+ // The range is now complete and valid.
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
});
});
});
diff --git a/packages/@adobe/react-spectrum/test/datepicker/TimeField.test.js b/packages/@adobe/react-spectrum/test/datepicker/TimeField.test.js
index 3bc3ff3fbbc..4fa2d0a4486 100644
--- a/packages/@adobe/react-spectrum/test/datepicker/TimeField.test.js
+++ b/packages/@adobe/react-spectrum/test/datepicker/TimeField.test.js
@@ -66,6 +66,41 @@ describe('TimeField', function () {
}
});
+ it('should not announce a fabricated selected time while the value is partial (Bug #9801)', async function () {
+ let {getByRole, getAllByRole} = render(
+
+ );
+
+ let group = getByRole('group');
+ let segments = getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(getDescription()).toContain('Selected Time: 1:30 PM');
+
+ // Clear the AM/PM segment. The display buffer now has hour=1 with no day period, which
+ // previously formatted as the fabricated "1:30 AM". While partial, no selected-value
+ // description must be announced at all.
+ act(() => {
+ segments[2].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[2]).toHaveAttribute('aria-valuetext', 'Empty');
+ expect(getDescription()).not.toContain('Selected Time:');
+ expect(getDescription()).not.toContain('1:30 AM');
+
+ // Refilling the segment restores the real description. (Backspace moves focus to the
+ // previous segment, so refocus the day period first.)
+ act(() => {
+ segments[2].focus();
+ });
+ await user.keyboard('p');
+ expect(getDescription()).toContain('Selected Time: 1:30 PM');
+ expect(getDescription()).not.toContain('1:30 AM');
+ });
+
it('should support focusing via a ref', function () {
let ref = React.createRef();
let {getAllByRole} = render();
@@ -419,6 +454,48 @@ describe('TimeField', function () {
expect(input).toHaveValue('08:30:00');
});
+ it('should clear the hidden input value when a segment is cleared making the field partial (Bug #9801)', async () => {
+ function Test() {
+ return (
+
+ );
+ }
+
+ let {getAllByRole, getByRole} = render();
+ let group = getByRole('group');
+ let input = document.querySelector('input[name=time]');
+ let segments = getAllByRole('spinbutton');
+
+ expect(input).toHaveValue('13:30:00');
+
+ // Clear the hour segment (display shows '1 PM' for 13 — single digit → Empty in one Backspace)
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // Field is partial
+ expect(input).toHaveValue('');
+
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // No error while still editing — it appears once the field is blurred.
+ expect(getDescription()).not.toContain('Please enter a value.');
+
+ // Tab out of the group (hour -> minute -> dayPeriod -> out).
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+ });
+
if (parseInt(React.version, 10) >= 19) {
it('resets to defaultValue when submitting form action', async () => {
function Test() {
@@ -792,6 +869,95 @@ describe('TimeField', function () {
await user.keyboard('[Tab][ArrowUp][Tab][Tab][Tab]');
expect(getDescription()).not.toContain('Invalid value');
});
+
+ it('should signal validation only after blur when a time segment is cleared (Bug #9801)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the hour segment ('1 PM' -> single digit, one Backspace).
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(segments[0]).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // No error while still editing — aria validation must not flash mid-edit.
+ expect(getDescription()).not.toContain('Please enter a value.');
+
+ // Tab out of the group (hour -> minute -> dayPeriod -> out) -> error appears.
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Refilling the hour completes the value and clears the error in realtime.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('1');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
+ it('should signal validation when only the dayPeriod segment is cleared (Bug #9801)', async () => {
+ let {getByRole} = render(
+
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear only the dayPeriod (AM/PM) segment — hour and minute stay filled.
+ let dayPeriod = segments[2];
+ act(() => {
+ dayPeriod.focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(dayPeriod).toHaveAttribute('aria-valuetext', 'Empty');
+
+ // No error while still editing; the error appears after blurring out.
+ expect(getDescription()).not.toContain('Please enter a value.');
+ act(() => document.activeElement.blur());
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Refilling the dayPeriod completes the value and clears the error in realtime.
+ act(() => {
+ dayPeriod.focus();
+ });
+ await user.keyboard('p');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
});
});
});
diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js
index 43d0e94ff00..27f0ec97523 100644
--- a/packages/react-aria-components/test/DateField.test.js
+++ b/packages/react-aria-components/test/DateField.test.js
@@ -466,6 +466,48 @@ describe('DateField', () => {
expect(group).not.toHaveAttribute('data-invalid');
});
+ it('should not display the partial-value error while editing with validationBehavior="aria" (Bug #9958)', async () => {
+ let {getByRole, getAllByRole} = render(
+
+
+ {segment => }
+
+
+ );
+
+ let group = getByRole('group');
+ let segments = getAllByRole('spinbutton');
+
+ // Filling segments one at a time from an empty state must not flash the error
+ // mid-edit, even though the value is momentarily partial.
+ await user.click(segments[0]);
+ await user.keyboard('4');
+ expect(group).not.toHaveAttribute('data-invalid');
+ await user.keyboard('28');
+ expect(group).not.toHaveAttribute('data-invalid');
+
+ // Blur while the year is still missing -> the partial error appears.
+ await user.tab();
+ expect(group).toHaveAttribute('data-invalid');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Deleting a filled segment must not change the displayed error until blur,
+ // and completing the value clears it in realtime.
+ await user.click(segments[2]);
+ await user.keyboard('2026');
+ expect(group).not.toHaveAttribute('data-invalid');
+ expect(getDescription()).not.toContain('Please enter a value.');
+
+ await user.keyboard('{Backspace}{Backspace}{Backspace}{Backspace}');
+ expect(segments[2]).toHaveAttribute('aria-valuetext', 'Empty');
+ expect(group).not.toHaveAttribute('data-invalid');
+ });
+
it('should focus previous segment when backspacing on an empty date segment', async () => {
let {getAllByRole} = render(
diff --git a/packages/react-aria-components/test/DatePicker.test.js b/packages/react-aria-components/test/DatePicker.test.js
index 4fcba8fcdef..12a7bb7a9f1 100644
--- a/packages/react-aria-components/test/DatePicker.test.js
+++ b/packages/react-aria-components/test/DatePicker.test.js
@@ -283,6 +283,71 @@ describe('DatePicker', () => {
expect(datepicker).not.toHaveAttribute('data-invalid');
});
+ it('should surface the partial-value error and clear it on the first calendar selection (Bug #9958)', async () => {
+ let {getByRole, getAllByRole} = render(
+
+ );
+
+ let group = getByRole('group');
+ let datepicker = group.closest('.react-aria-DatePicker');
+ let input = document.querySelector('input[name=date]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let button = within(group).getByRole('button');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the month segment. No error is displayed until the field is blurred,
+ // but the hidden input is already empty so submission is blocked.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(input).toHaveValue('');
+ expect(datepicker).not.toHaveAttribute('data-invalid');
+
+ // Tab through the remaining segments onto the calendar button -> the field blurs
+ // and the partial error appears.
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(datepicker).toHaveAttribute('data-invalid');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Selecting a date from the calendar completes the value and clears the error.
+ await user.click(button);
+ let cells = getAllByRole('gridcell');
+ let selected = cells.find(cell => cell.getAttribute('aria-selected') === 'true');
+ await user.click(selected.nextSibling.children[0]);
+
+ expect(input).toHaveValue('2019-02-04');
+ expect(datepicker).not.toHaveAttribute('data-invalid');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
it('should support close on select = true', async () => {
let {getByRole, getAllByRole} = render();
diff --git a/packages/react-aria-components/test/DateRangePicker.test.js b/packages/react-aria-components/test/DateRangePicker.test.js
index bfaee202336..cc422251df4 100644
--- a/packages/react-aria-components/test/DateRangePicker.test.js
+++ b/packages/react-aria-components/test/DateRangePicker.test.js
@@ -310,6 +310,78 @@ describe('DateRangePicker', () => {
expect(datepicker).not.toHaveAttribute('data-invalid');
});
+ it('should surface the partial-value error and clear it on the first range selection (Bug #9958)', async () => {
+ let {getByRole, getAllByRole} = render(
+
+ );
+
+ let group = getByRole('group');
+ let datepicker = group.closest('.react-aria-DateRangePicker');
+ let startInput = document.querySelector('input[name=start]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let button = within(group).getByRole('button');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the start month segment. No error until the field is blurred.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(startInput).toHaveValue('');
+ expect(datepicker).not.toHaveAttribute('data-invalid');
+
+ // Tab through the remaining segments and onto the calendar button.
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(datepicker).toHaveAttribute('data-invalid');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Selecting a complete range from the calendar clears the error on the first selection.
+ await user.click(button);
+ let cells = getAllByRole('gridcell');
+ let selected = cells.find(cell => cell.getAttribute('aria-selected') === 'true');
+ await user.click(selected.children[0]);
+ await user.click(selected.children[0]);
+
+ expect(startInput.validity.valid).toBe(true);
+ expect(datepicker).not.toHaveAttribute('data-invalid');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
+
it('should support close on select = true', async () => {
let {getByRole, getAllByRole} = render(
{
expect(getDescription()).not.toContain('Constraints not satisfied');
});
+
+ it('should surface the partial-value error after blur and clear it when refilled (Bug #9801)', async () => {
+ let {getByRole} = render(
+
+ );
+
+ let group = getByRole('group');
+ let timefield = group.closest('.react-aria-TimeField');
+ let input = document.querySelector('input[name=time]');
+ let segments = within(group).getAllByRole('spinbutton');
+ let getDescription = () =>
+ (group.getAttribute('aria-describedby') || '')
+ .split(' ')
+ .map(d => document.getElementById(d)?.textContent || '')
+ .join(' ');
+
+ // Clear the hour segment. No error until blur, but the hidden input is already empty.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('{Backspace}');
+ expect(input).toHaveValue('');
+ expect(timefield).not.toHaveAttribute('data-invalid');
+
+ // Tab out of the field (hour -> minute -> dayPeriod -> out) -> the error appears.
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ expect(timefield).toHaveAttribute('data-invalid');
+ expect(getDescription()).toContain('Please enter a value.');
+
+ // Refilling the hour completes the value and clears the error immediately.
+ act(() => {
+ segments[0].focus();
+ });
+ await user.keyboard('1');
+ expect(input).toHaveValue('13:30:00');
+ expect(timefield).not.toHaveAttribute('data-invalid');
+ expect(getDescription()).not.toContain('Please enter a value.');
+ });
});
diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts
index 00ecdeabcea..890537113fd 100644
--- a/packages/react-aria/src/datepicker/useDateField.ts
+++ b/packages/react-aria/src/datepicker/useDateField.ts
@@ -25,6 +25,7 @@ import {filterDOMProps} from '../utils/filterDOMProps';
import {InputHTMLAttributes, useEffect, useMemo, useRef} from 'react';
import intlMessages from '../../intl/datepicker/*.json';
import {mergeProps} from '../utils/mergeProps';
+import {privateSetIsValuePartialProp} from 'react-stately/private/form/useFormValidationState';
import {TimeFieldState, TimePickerProps, TimeValue} from 'react-stately/useTimeFieldState';
import {useDatePickerGroup} from './useDatePickerGroup';
import {useDescription} from '../utils/useDescription';
@@ -108,7 +109,8 @@ export function useDateField(
},
onBlurWithin: e => {
state.confirmPlaceholder();
- if (state.value !== valueOnFocus.current) {
+ // Also fire for partial display values: `value` is never updated for those by design.
+ if (state.value !== valueOnFocus.current || state.isValuePartial) {
state.commitValidation();
}
props.onBlur?.(e);
@@ -120,9 +122,13 @@ export function useDateField(
let message =
state.maxGranularity === 'hour' ? 'selectedTimeDescription' : 'selectedDateDescription';
let field = state.maxGranularity === 'hour' ? 'time' : 'date';
- let description = state.value
- ? stringFormatter.format(message, {[field]: state.formatValue({month: 'long'})})
- : '';
+ // While the display is partial, formatValue would describe a chimera of display segments
+ // and committed-value fallbacks (e.g. "1:30 AM" after clearing AM/PM on "1:30 PM"), so
+ // announce no selected value — matching the field's empty submission value.
+ let description =
+ state.value && !state.isValuePartial
+ ? stringFormatter.format(message, {[field]: state.formatValue({month: 'long'})})
+ : '';
let descProps = useDescription(description);
// If within a date picker or date range picker, the date field will have role="presentation" and an aria-describedby
@@ -186,11 +192,25 @@ export function useDateField(
props.inputRef
);
+ // When wrapped by DatePicker / DateRangePicker, the picker owns the validation pipeline
+ // (via privateValidationStateProp). Push our local partial state up so the picker's
+ // getValidationResult sees it; standalone fields handle this in useDateFieldState directly.
+ let setParentIsValuePartial = (props as any)[privateSetIsValuePartialProp] as
+ | ((isPartial: boolean) => void)
+ | undefined;
+ useEffect(() => {
+ if (setParentIsValuePartial) {
+ setParentIsValuePartial(state.isValuePartial);
+ return () => setParentIsValuePartial(false);
+ }
+ }, [setParentIsValuePartial, state.isValuePartial]);
+
+ // Empty when partial so the native `required` constraint sees a missing value.
let inputProps: InputHTMLAttributes = {
type: 'hidden',
name: props.name,
form: props.form,
- value: state.value?.toString() || '',
+ value: state.isValuePartial ? '' : state.value?.toString() || '',
disabled: props.isDisabled
};
@@ -199,7 +219,9 @@ export function useDateField(
// so that an empty value blocks HTML form submission when the field is required.
inputProps.type = 'text';
inputProps.hidden = true;
- inputProps.required = props.isRequired;
+ // A partial value is also required: its input value is empty (see above), so this blocks
+ // submission natively even before the partial error has been committed for display.
+ inputProps.required = props.isRequired || state.isValuePartial;
// Ignore react warning.
inputProps.onChange = () => {};
}
@@ -247,7 +269,8 @@ export function useTimeField(
ref: RefObject
): DateFieldAria {
let res = useDateField(props, state, ref);
+ // Same partial-state guard as the DateField hidden input.
// oxlint-disable-next-line react/react-compiler
- res.inputProps.value = state.timeValue?.toString() || '';
+ res.inputProps.value = state.isValuePartial ? '' : state.timeValue?.toString() || '';
return res;
}
diff --git a/packages/react-aria/src/datepicker/useDatePicker.ts b/packages/react-aria/src/datepicker/useDatePicker.ts
index f5beb4bb533..1c5d61b045e 100644
--- a/packages/react-aria/src/datepicker/useDatePicker.ts
+++ b/packages/react-aria/src/datepicker/useDatePicker.ts
@@ -29,7 +29,10 @@ import {filterDOMProps} from '../utils/filterDOMProps';
import intlMessages from '../../intl/datepicker/*.json';
import {mergeProps} from '../utils/mergeProps';
import {nodeContains} from '../utils/shadowdom/DOMFunctions';
-import {privateValidationStateProp} from 'react-stately/private/form/useFormValidationState';
+import {
+ privateSetIsValuePartialProp,
+ privateValidationStateProp
+} from 'react-stately/private/form/useFormValidationState';
import {roleSymbol} from './useDateField';
import {useDatePickerGroup} from './useDatePickerGroup';
import {useDescription} from '../utils/useDescription';
@@ -177,6 +180,9 @@ export function useDatePicker(
validationBehavior: props.validationBehavior,
// DatePicker owns the validation state for the date field.
[privateValidationStateProp]: state,
+ // Forwarded so the field can push its partial-edit state up into the picker's
+ // validation pipeline (so partial values invalidate even without isRequired).
+ [privateSetIsValuePartialProp]: (state as any)[privateSetIsValuePartialProp],
autoFocus: props.autoFocus,
name: props.name,
form: props.form
diff --git a/packages/react-aria/src/datepicker/useDateRangePicker.ts b/packages/react-aria/src/datepicker/useDateRangePicker.ts
index 29d7833c254..08890b139da 100644
--- a/packages/react-aria/src/datepicker/useDateRangePicker.ts
+++ b/packages/react-aria/src/datepicker/useDateRangePicker.ts
@@ -33,6 +33,7 @@ import {
import {
DEFAULT_VALIDATION_RESULT,
mergeValidation,
+ privateSetIsValuePartialProp,
privateValidationStateProp
} from 'react-stately/private/form/useFormValidationState';
import {filterDOMProps} from '../utils/filterDOMProps';
@@ -237,7 +238,9 @@ export function useDateRangePicker(
},
resetValidation: state.resetValidation,
commitValidation: state.commitValidation
- }
+ },
+ // Push the start field's partial-edit state up into the range's validation pipeline.
+ [privateSetIsValuePartialProp]: (state as any)[`${privateSetIsValuePartialProp}-start`]
},
endFieldProps: {
...endFieldProps,
@@ -256,7 +259,9 @@ export function useDateRangePicker(
},
resetValidation: state.resetValidation,
commitValidation: state.commitValidation
- }
+ },
+ // Push the end field's partial-edit state up into the range's validation pipeline.
+ [privateSetIsValuePartialProp]: (state as any)[`${privateSetIsValuePartialProp}-end`]
},
descriptionProps,
errorMessageProps,
diff --git a/packages/react-stately/exports/private/form/useFormValidationState.ts b/packages/react-stately/exports/private/form/useFormValidationState.ts
index 4d99ecefe83..3fd0ce74ec7 100644
--- a/packages/react-stately/exports/private/form/useFormValidationState.ts
+++ b/packages/react-stately/exports/private/form/useFormValidationState.ts
@@ -2,6 +2,7 @@ export {
FormValidationContext,
useFormValidationState,
privateValidationStateProp,
+ privateSetIsValuePartialProp,
type FormValidationState,
DEFAULT_VALIDATION_RESULT,
mergeValidation,
diff --git a/packages/react-stately/intl/datepicker/ar-AE.json b/packages/react-stately/intl/datepicker/ar-AE.json
index 3b0f019b1da..9c5532f5c6a 100644
--- a/packages/react-stately/intl/datepicker/ar-AE.json
+++ b/packages/react-stately/intl/datepicker/ar-AE.json
@@ -2,5 +2,6 @@
"rangeOverflow": "يجب أن تكون القيمة {maxValue} أو قبل ذلك.",
"rangeReversed": "تاريخ البدء يجب أن يكون قبل تاريخ الانتهاء.",
"rangeUnderflow": "يجب أن تكون القيمة {minValue} أو بعد ذلك.",
- "unavailableDate": "البيانات المحددة غير متاحة."
+ "unavailableDate": "البيانات المحددة غير متاحة.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/bg-BG.json b/packages/react-stately/intl/datepicker/bg-BG.json
index 13e7af5290f..fbdd59e56ae 100644
--- a/packages/react-stately/intl/datepicker/bg-BG.json
+++ b/packages/react-stately/intl/datepicker/bg-BG.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Стойността трябва да е {maxValue} или по-ранна.",
"rangeReversed": "Началната дата трябва да е преди крайната.",
"rangeUnderflow": "Стойността трябва да е {minValue} или по-късно.",
- "unavailableDate": "Избраната дата не е налична."
+ "unavailableDate": "Избраната дата не е налична.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/cs-CZ.json b/packages/react-stately/intl/datepicker/cs-CZ.json
index 09c6c0dd327..44f574353d3 100644
--- a/packages/react-stately/intl/datepicker/cs-CZ.json
+++ b/packages/react-stately/intl/datepicker/cs-CZ.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Hodnota musí být {maxValue} nebo dřívější.",
"rangeReversed": "Datum zahájení musí předcházet datu ukončení.",
"rangeUnderflow": "Hodnota musí být {minValue} nebo pozdější.",
- "unavailableDate": "Vybrané datum není k dispozici."
+ "unavailableDate": "Vybrané datum není k dispozici.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/da-DK.json b/packages/react-stately/intl/datepicker/da-DK.json
index 734f245e5e0..6991a7c676f 100644
--- a/packages/react-stately/intl/datepicker/da-DK.json
+++ b/packages/react-stately/intl/datepicker/da-DK.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Værdien skal være {maxValue} eller tidligere.",
"rangeReversed": "Startdatoen skal være før slutdatoen.",
"rangeUnderflow": "Værdien skal være {minValue} eller nyere.",
- "unavailableDate": "Den valgte dato er ikke tilgængelig."
+ "unavailableDate": "Den valgte dato er ikke tilgængelig.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/de-DE.json b/packages/react-stately/intl/datepicker/de-DE.json
index a071ad8c4f1..5b1afffc9b9 100644
--- a/packages/react-stately/intl/datepicker/de-DE.json
+++ b/packages/react-stately/intl/datepicker/de-DE.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Der Wert muss {maxValue} oder früher sein.",
"rangeReversed": "Das Startdatum muss vor dem Enddatum liegen.",
"rangeUnderflow": "Der Wert muss {minValue} oder später sein.",
- "unavailableDate": "Das ausgewählte Datum ist nicht verfügbar."
+ "unavailableDate": "Das ausgewählte Datum ist nicht verfügbar.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/el-GR.json b/packages/react-stately/intl/datepicker/el-GR.json
index d93e837a685..60e6939f44c 100644
--- a/packages/react-stately/intl/datepicker/el-GR.json
+++ b/packages/react-stately/intl/datepicker/el-GR.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Η τιμή πρέπει να είναι {maxValue} ή παλαιότερη.",
"rangeReversed": "Η ημερομηνία έναρξης πρέπει να είναι πριν από την ημερομηνία λήξης.",
"rangeUnderflow": "Η τιμή πρέπει να είναι {minValue} ή μεταγενέστερη.",
- "unavailableDate": "Η επιλεγμένη ημερομηνία δεν είναι διαθέσιμη."
+ "unavailableDate": "Η επιλεγμένη ημερομηνία δεν είναι διαθέσιμη.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/en-US.json b/packages/react-stately/intl/datepicker/en-US.json
index a2985739ad2..3e2d1bee190 100644
--- a/packages/react-stately/intl/datepicker/en-US.json
+++ b/packages/react-stately/intl/datepicker/en-US.json
@@ -1,4 +1,5 @@
{
+ "incompleteValue": "Please enter a value.",
"rangeUnderflow": "Value must be {minValue} or later.",
"rangeOverflow": "Value must be {maxValue} or earlier.",
"rangeReversed": "Start date must be before end date.",
diff --git a/packages/react-stately/intl/datepicker/es-ES.json b/packages/react-stately/intl/datepicker/es-ES.json
index a8ff4756927..0763acd3855 100644
--- a/packages/react-stately/intl/datepicker/es-ES.json
+++ b/packages/react-stately/intl/datepicker/es-ES.json
@@ -2,5 +2,6 @@
"rangeOverflow": "El valor debe ser {maxValue} o anterior.",
"rangeReversed": "La fecha de inicio debe ser anterior a la fecha de finalización.",
"rangeUnderflow": "El valor debe ser {minValue} o posterior.",
- "unavailableDate": "Fecha seleccionada no disponible."
+ "unavailableDate": "Fecha seleccionada no disponible.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/et-EE.json b/packages/react-stately/intl/datepicker/et-EE.json
index 84000193071..3dcddfab13d 100644
--- a/packages/react-stately/intl/datepicker/et-EE.json
+++ b/packages/react-stately/intl/datepicker/et-EE.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Väärtus peab olema {maxValue} või varasem.",
"rangeReversed": "Alguskuupäev peab olema enne lõppkuupäeva.",
"rangeUnderflow": "Väärtus peab olema {minValue} või hilisem.",
- "unavailableDate": "Valitud kuupäev pole saadaval."
+ "unavailableDate": "Valitud kuupäev pole saadaval.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/fi-FI.json b/packages/react-stately/intl/datepicker/fi-FI.json
index a31f31e9e10..9238d395e8c 100644
--- a/packages/react-stately/intl/datepicker/fi-FI.json
+++ b/packages/react-stately/intl/datepicker/fi-FI.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Arvon on oltava {maxValue} tai sitä aikaisempi.",
"rangeReversed": "Aloituspäivän on oltava ennen lopetuspäivää.",
"rangeUnderflow": "Arvon on oltava {minValue} tai sitä myöhäisempi.",
- "unavailableDate": "Valittu päivämäärä ei ole käytettävissä."
+ "unavailableDate": "Valittu päivämäärä ei ole käytettävissä.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/fr-FR.json b/packages/react-stately/intl/datepicker/fr-FR.json
index 70d6e4a0aff..cd1485760dd 100644
--- a/packages/react-stately/intl/datepicker/fr-FR.json
+++ b/packages/react-stately/intl/datepicker/fr-FR.json
@@ -2,5 +2,6 @@
"rangeOverflow": "La valeur doit être {maxValue} ou antérieure.",
"rangeReversed": "La date de début doit être antérieure à la date de fin.",
"rangeUnderflow": "La valeur doit être {minValue} ou ultérieure.",
- "unavailableDate": "La date sélectionnée n’est pas disponible."
+ "unavailableDate": "La date sélectionnée n’est pas disponible.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/he-IL.json b/packages/react-stately/intl/datepicker/he-IL.json
index e28b795968e..eb16b62069b 100644
--- a/packages/react-stately/intl/datepicker/he-IL.json
+++ b/packages/react-stately/intl/datepicker/he-IL.json
@@ -2,5 +2,6 @@
"rangeOverflow": "הערך חייב להיות {maxValue} או מוקדם יותר.",
"rangeReversed": "תאריך ההתחלה חייב להיות לפני תאריך הסיום.",
"rangeUnderflow": "הערך חייב להיות {minValue} או מאוחר יותר.",
- "unavailableDate": "התאריך הנבחר אינו זמין."
+ "unavailableDate": "התאריך הנבחר אינו זמין.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/hr-HR.json b/packages/react-stately/intl/datepicker/hr-HR.json
index 124c8c37347..e05385388ca 100644
--- a/packages/react-stately/intl/datepicker/hr-HR.json
+++ b/packages/react-stately/intl/datepicker/hr-HR.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Vrijednost mora biti {maxValue} ili ranije.",
"rangeReversed": "Datum početka mora biti prije datuma završetka.",
"rangeUnderflow": "Vrijednost mora biti {minValue} ili kasnije.",
- "unavailableDate": "Odabrani datum nije dostupan."
+ "unavailableDate": "Odabrani datum nije dostupan.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/hu-HU.json b/packages/react-stately/intl/datepicker/hu-HU.json
index 657ba120ace..e0e49e519b3 100644
--- a/packages/react-stately/intl/datepicker/hu-HU.json
+++ b/packages/react-stately/intl/datepicker/hu-HU.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Az értéknek {maxValue} vagy korábbinak kell lennie.",
"rangeReversed": "A kezdő dátumnak a befejező dátumnál korábbinak kell lennie.",
"rangeUnderflow": "Az értéknek {minValue} vagy későbbinek kell lennie.",
- "unavailableDate": "A kiválasztott dátum nem érhető el."
+ "unavailableDate": "A kiválasztott dátum nem érhető el.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/it-IT.json b/packages/react-stately/intl/datepicker/it-IT.json
index 0f5181756d5..0de7d93d43e 100644
--- a/packages/react-stately/intl/datepicker/it-IT.json
+++ b/packages/react-stately/intl/datepicker/it-IT.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Il valore deve essere {maxValue} o precedente.",
"rangeReversed": "La data di inizio deve essere antecedente alla data di fine.",
"rangeUnderflow": "Il valore deve essere {minValue} o successivo.",
- "unavailableDate": "Data selezionata non disponibile."
+ "unavailableDate": "Data selezionata non disponibile.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/ja-JP.json b/packages/react-stately/intl/datepicker/ja-JP.json
index a015bd99619..efa99734ba0 100644
--- a/packages/react-stately/intl/datepicker/ja-JP.json
+++ b/packages/react-stately/intl/datepicker/ja-JP.json
@@ -2,5 +2,6 @@
"rangeOverflow": "値は {maxValue} 以下にする必要があります。",
"rangeReversed": "開始日は終了日より前にする必要があります。",
"rangeUnderflow": "値は {minValue} 以上にする必要があります。",
- "unavailableDate": "選択した日付は使用できません。"
+ "unavailableDate": "選択した日付は使用できません。",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/ko-KR.json b/packages/react-stately/intl/datepicker/ko-KR.json
index 4b813ba32d0..d93936274ed 100644
--- a/packages/react-stately/intl/datepicker/ko-KR.json
+++ b/packages/react-stately/intl/datepicker/ko-KR.json
@@ -2,5 +2,6 @@
"rangeOverflow": "값은 {maxValue} 이전이어야 합니다.",
"rangeReversed": "시작일은 종료일 이전이어야 합니다.",
"rangeUnderflow": "값은 {minValue} 이후여야 합니다.",
- "unavailableDate": "선택한 날짜를 사용할 수 없습니다."
+ "unavailableDate": "선택한 날짜를 사용할 수 없습니다.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/lt-LT.json b/packages/react-stately/intl/datepicker/lt-LT.json
index ea4f911c500..b982689cef9 100644
--- a/packages/react-stately/intl/datepicker/lt-LT.json
+++ b/packages/react-stately/intl/datepicker/lt-LT.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Reikšmė turi būti {maxValue} arba ankstesnė.",
"rangeReversed": "Pradžios data turi būti ankstesnė nei pabaigos data.",
"rangeUnderflow": "Reikšmė turi būti {minValue} arba naujesnė.",
- "unavailableDate": "Pasirinkta data nepasiekiama."
+ "unavailableDate": "Pasirinkta data nepasiekiama.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/lv-LV.json b/packages/react-stately/intl/datepicker/lv-LV.json
index 99aa0615654..cb6bde7608a 100644
--- a/packages/react-stately/intl/datepicker/lv-LV.json
+++ b/packages/react-stately/intl/datepicker/lv-LV.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Vērtībai ir jābūt {maxValue} vai agrākai.",
"rangeReversed": "Sākuma datumam ir jābūt pirms beigu datuma.",
"rangeUnderflow": "Vērtībai ir jābūt {minValue} vai vēlākai.",
- "unavailableDate": "Atlasītais datums nav pieejams."
+ "unavailableDate": "Atlasītais datums nav pieejams.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/nb-NO.json b/packages/react-stately/intl/datepicker/nb-NO.json
index b721b2964aa..1509839963d 100644
--- a/packages/react-stately/intl/datepicker/nb-NO.json
+++ b/packages/react-stately/intl/datepicker/nb-NO.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Verdien må være {maxValue} eller tidligere.",
"rangeReversed": "Startdatoen må være før sluttdatoen.",
"rangeUnderflow": "Verdien må være {minValue} eller senere.",
- "unavailableDate": "Valgt dato utilgjengelig."
+ "unavailableDate": "Valgt dato utilgjengelig.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/nl-NL.json b/packages/react-stately/intl/datepicker/nl-NL.json
index 7d2ea1479d3..51d7cb66028 100644
--- a/packages/react-stately/intl/datepicker/nl-NL.json
+++ b/packages/react-stately/intl/datepicker/nl-NL.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Waarde moet {maxValue} of eerder zijn.",
"rangeReversed": "De startdatum moet voor de einddatum liggen.",
"rangeUnderflow": "Waarde moet {minValue} of later zijn.",
- "unavailableDate": "Geselecteerde datum niet beschikbaar."
+ "unavailableDate": "Geselecteerde datum niet beschikbaar.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/pl-PL.json b/packages/react-stately/intl/datepicker/pl-PL.json
index 964c502dfb4..fe5ba93b635 100644
--- a/packages/react-stately/intl/datepicker/pl-PL.json
+++ b/packages/react-stately/intl/datepicker/pl-PL.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Wartość musi mieć wartość {maxValue} lub wcześniejszą.",
"rangeReversed": "Data rozpoczęcia musi być wcześniejsza niż data zakończenia.",
"rangeUnderflow": "Wartość musi mieć wartość {minValue} lub późniejszą.",
- "unavailableDate": "Wybrana data jest niedostępna."
+ "unavailableDate": "Wybrana data jest niedostępna.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/pt-BR.json b/packages/react-stately/intl/datepicker/pt-BR.json
index a010d9b8a30..f0998c3b19d 100644
--- a/packages/react-stately/intl/datepicker/pt-BR.json
+++ b/packages/react-stately/intl/datepicker/pt-BR.json
@@ -2,5 +2,6 @@
"rangeOverflow": "O valor deve ser {maxValue} ou anterior.",
"rangeReversed": "A data inicial deve ser anterior à data final.",
"rangeUnderflow": "O valor deve ser {minValue} ou posterior.",
- "unavailableDate": "Data selecionada indisponível."
+ "unavailableDate": "Data selecionada indisponível.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/pt-PT.json b/packages/react-stately/intl/datepicker/pt-PT.json
index 09e60a5883c..8eefc585858 100644
--- a/packages/react-stately/intl/datepicker/pt-PT.json
+++ b/packages/react-stately/intl/datepicker/pt-PT.json
@@ -2,5 +2,6 @@
"rangeOverflow": "O valor tem de ser {maxValue} ou anterior.",
"rangeReversed": "A data de início deve ser anterior à data de fim.",
"rangeUnderflow": "O valor tem de ser {minValue} ou posterior.",
- "unavailableDate": "Data selecionada indisponível."
+ "unavailableDate": "Data selecionada indisponível.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/ro-RO.json b/packages/react-stately/intl/datepicker/ro-RO.json
index 13e1d3d61f8..1f634325cec 100644
--- a/packages/react-stately/intl/datepicker/ro-RO.json
+++ b/packages/react-stately/intl/datepicker/ro-RO.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Valoarea trebuie să fie {maxValue} sau anterioară.",
"rangeReversed": "Data de început trebuie să fie anterioară datei de sfârșit.",
"rangeUnderflow": "Valoarea trebuie să fie {minValue} sau ulterioară.",
- "unavailableDate": "Data selectată nu este disponibilă."
+ "unavailableDate": "Data selectată nu este disponibilă.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/ru-RU.json b/packages/react-stately/intl/datepicker/ru-RU.json
index 574a82ce173..b3a3c841dda 100644
--- a/packages/react-stately/intl/datepicker/ru-RU.json
+++ b/packages/react-stately/intl/datepicker/ru-RU.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Значение должно быть не позже {maxValue}.",
"rangeReversed": "Дата начала должна предшествовать дате окончания.",
"rangeUnderflow": "Значение должно быть не раньше {minValue}.",
- "unavailableDate": "Выбранная дата недоступна."
+ "unavailableDate": "Выбранная дата недоступна.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/sk-SK.json b/packages/react-stately/intl/datepicker/sk-SK.json
index 81aa69ba24b..741efacad94 100644
--- a/packages/react-stately/intl/datepicker/sk-SK.json
+++ b/packages/react-stately/intl/datepicker/sk-SK.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Hodnota musí byť {maxValue} alebo skoršia.",
"rangeReversed": "Dátum začiatku musí byť skorší ako dátum konca.",
"rangeUnderflow": "Hodnota musí byť {minValue} alebo neskoršia.",
- "unavailableDate": "Vybratý dátum je nedostupný."
+ "unavailableDate": "Vybratý dátum je nedostupný.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/sl-SI.json b/packages/react-stately/intl/datepicker/sl-SI.json
index fdd641d5d7f..491f1fdc456 100644
--- a/packages/react-stately/intl/datepicker/sl-SI.json
+++ b/packages/react-stately/intl/datepicker/sl-SI.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Vrednost mora biti {maxValue} ali starejša.",
"rangeReversed": "Začetni datum mora biti pred končnim datumom.",
"rangeUnderflow": "Vrednost mora biti {minValue} ali novejša.",
- "unavailableDate": "Izbrani datum ni na voljo."
+ "unavailableDate": "Izbrani datum ni na voljo.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/sr-SP.json b/packages/react-stately/intl/datepicker/sr-SP.json
index 57bf1027456..e03eb773917 100644
--- a/packages/react-stately/intl/datepicker/sr-SP.json
+++ b/packages/react-stately/intl/datepicker/sr-SP.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Vrednost mora da bude {maxValue} ili starija.",
"rangeReversed": "Datum početka mora biti pre datuma završetka.",
"rangeUnderflow": "Vrednost mora da bude {minValue} ili novija.",
- "unavailableDate": "Izabrani datum nije dostupan."
+ "unavailableDate": "Izabrani datum nije dostupan.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/sv-SE.json b/packages/react-stately/intl/datepicker/sv-SE.json
index b32d8b705f5..5320c0216c1 100644
--- a/packages/react-stately/intl/datepicker/sv-SE.json
+++ b/packages/react-stately/intl/datepicker/sv-SE.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Värdet måste vara {maxValue} eller tidigare.",
"rangeReversed": "Startdatumet måste vara före slutdatumet.",
"rangeUnderflow": "Värdet måste vara {minValue} eller senare.",
- "unavailableDate": "Det valda datumet är inte tillgängligt."
+ "unavailableDate": "Det valda datumet är inte tillgängligt.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/tr-TR.json b/packages/react-stately/intl/datepicker/tr-TR.json
index dd86809610d..0c6b0259d80 100644
--- a/packages/react-stately/intl/datepicker/tr-TR.json
+++ b/packages/react-stately/intl/datepicker/tr-TR.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Değer, {maxValue} veya öncesi olmalıdır.",
"rangeReversed": "Başlangıç tarihi bitiş tarihinden önce olmalıdır.",
"rangeUnderflow": "Değer, {minValue} veya sonrası olmalıdır.",
- "unavailableDate": "Seçilen tarih kullanılamıyor."
+ "unavailableDate": "Seçilen tarih kullanılamıyor.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/uk-UA.json b/packages/react-stately/intl/datepicker/uk-UA.json
index d5e12390bfd..950f0f649d3 100644
--- a/packages/react-stately/intl/datepicker/uk-UA.json
+++ b/packages/react-stately/intl/datepicker/uk-UA.json
@@ -2,5 +2,6 @@
"rangeOverflow": "Значення має бути не пізніше {maxValue}.",
"rangeReversed": "Дата початку має передувати даті завершення.",
"rangeUnderflow": "Значення має бути не раніше {minValue}.",
- "unavailableDate": "Вибрана дата недоступна."
+ "unavailableDate": "Вибрана дата недоступна.",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/zh-CN.json b/packages/react-stately/intl/datepicker/zh-CN.json
index 4879c113fcd..6e53dfa0f14 100644
--- a/packages/react-stately/intl/datepicker/zh-CN.json
+++ b/packages/react-stately/intl/datepicker/zh-CN.json
@@ -2,5 +2,6 @@
"rangeOverflow": "值必须是 {maxValue} 或更早日期。",
"rangeReversed": "开始日期必须早于结束日期。",
"rangeUnderflow": "值必须是 {minValue} 或更晚日期。",
- "unavailableDate": "所选日期不可用。"
+ "unavailableDate": "所选日期不可用。",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/intl/datepicker/zh-TW.json b/packages/react-stately/intl/datepicker/zh-TW.json
index 61b0e36556a..b7ba589cf11 100644
--- a/packages/react-stately/intl/datepicker/zh-TW.json
+++ b/packages/react-stately/intl/datepicker/zh-TW.json
@@ -2,5 +2,6 @@
"rangeOverflow": "值必須是 {maxValue} 或更早。",
"rangeReversed": "開始日期必須在結束日期之前。",
"rangeUnderflow": "值必須是 {minValue} 或更晚。",
- "unavailableDate": "所選日期無法使用。"
+ "unavailableDate": "所選日期無法使用。",
+ "incompleteValue": "Please enter a value."
}
diff --git a/packages/react-stately/src/datepicker/useDateFieldState.ts b/packages/react-stately/src/datepicker/useDateFieldState.ts
index 2103656abf0..42f237efd2c 100644
--- a/packages/react-stately/src/datepicker/useDateFieldState.ts
+++ b/packages/react-stately/src/datepicker/useDateFieldState.ts
@@ -25,15 +25,16 @@ import {
FormatterOptions,
getFormatOptions,
getValidationResult,
- useDefaultProps
+ useDefaultProps,
+ usePartialFormValidationState
} from './utils';
import {DatePickerProps, DateValue, Granularity, MappedDateValue} from './types';
-import {FormValidationState, useFormValidationState} from '../form/useFormValidationState';
+import {FormValidationState} from '../form/useFormValidationState';
import {getPlaceholder} from './placeholders';
import {IncompleteDate} from './IncompleteDate';
import {NumberFormatter} from '@internationalized/number';
+import {useCallback, useMemo, useState} from 'react';
import {useControlledState} from '../utils/useControlledState';
-import {useMemo, useState} from 'react';
import {ValidationState} from '@react-types/shared';
export type DateSegmentType =
@@ -100,6 +101,13 @@ export interface DateFieldState extends FormValidationState {
isReadOnly: boolean;
/** Whether the field is required. */
isRequired: boolean;
+ /**
+ * Whether the display value is partially filled — some editable segments have values and
+ * some are still placeholders.
+ *
+ * @private
+ */
+ isValuePartial: boolean;
/**
* Increments the given segment. Upon reaching the minimum or maximum value, the value wraps
* around to the opposite limit.
@@ -363,16 +371,27 @@ export function useDateFieldState(
setValue(displayValue.cycle(type, amount, placeholder, displaySegments));
};
- let builtinValidation = useMemo(
- () => getValidationResult(value, minValue, maxValue, isDateUnavailable, formatOpts),
+ let isValuePartial =
+ !displayValue.isComplete(displaySegments) && !displayValue.isCleared(displaySegments);
+
+ let getBuiltinValidation = useCallback(
+ (displayPartialError: boolean) =>
+ getValidationResult(
+ value,
+ minValue,
+ maxValue,
+ isDateUnavailable,
+ formatOpts,
+ displayPartialError
+ ),
[value, minValue, maxValue, isDateUnavailable, formatOpts]
);
- let validation = useFormValidationState({
- ...props,
- value: value as MappedDateValue | null,
- builtinValidation
- });
+ let validation = usePartialFormValidationState(
+ {...props, value: value as MappedDateValue | null},
+ isValuePartial,
+ getBuiltinValidation
+ );
let isValueInvalid = validation.displayValidation.isInvalid;
let validationState: ValidationState | null =
@@ -394,6 +413,7 @@ export function useDateFieldState(
isDisabled,
isReadOnly,
isRequired,
+ isValuePartial,
increment(part) {
adjustSegment(part, 1);
},
diff --git a/packages/react-stately/src/datepicker/useDatePickerState.ts b/packages/react-stately/src/datepicker/useDatePickerState.ts
index f4a38ab56a7..46b59cf6ef1 100644
--- a/packages/react-stately/src/datepicker/useDatePickerState.ts
+++ b/packages/react-stately/src/datepicker/useDatePickerState.ts
@@ -23,12 +23,13 @@ import {
getFormatOptions,
getPlaceholderTime,
getValidationResult,
- useDefaultProps
+ useDefaultProps,
+ usePartialFormValidationState
} from './utils';
-import {FormValidationState, useFormValidationState} from '../form/useFormValidationState';
+import {FormValidationState, privateSetIsValuePartialProp} from '../form/useFormValidationState';
import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState';
+import {useCallback, useMemo, useState} from 'react';
import {useControlledState} from '../utils/useControlledState';
-import {useMemo, useState} from 'react';
import {ValidationState} from '@react-types/shared';
export interface DatePickerStateOptions extends DatePickerProps {
@@ -95,7 +96,7 @@ export function useDatePickerState(
props: DatePickerStateOptions
): DatePickerState {
let overlayState = useOverlayTriggerState(props);
- let [value, setValue] = useControlledState | null>(
+ let [value, setValueInternal] = useControlledState | null>(
props.value,
props.defaultValue || null,
props.onChange
@@ -144,16 +145,37 @@ export function useDatePickerState(
);
let {minValue, maxValue, isDateUnavailable} = props;
- let builtinValidation = useMemo(
- () => getValidationResult(value, minValue, maxValue, isDateUnavailable, formatOpts),
+
+ // Partial-state lifted up from the inner DateField via `privateSetIsValuePartialProp`
+ // on fieldProps. The field calls our setter when its IncompleteDate has some-but-not-all
+ // editable segments filled, so the parent's validation pipeline can surface the error.
+ let [isValuePartial, setIsValuePartial] = useState(false);
+
+ // Wrap the raw setter so any committed value (complete or null) always resets the partial flag.
+ // This prevents stale isValuePartial state when a consumer calls state.setValue() directly.
+ let setValue = (newValue: DateValue | null) => {
+ setIsValuePartial(false);
+ setValueInternal(newValue);
+ };
+
+ let getBuiltinValidation = useCallback(
+ (displayPartialError: boolean) =>
+ getValidationResult(
+ value,
+ minValue,
+ maxValue,
+ isDateUnavailable,
+ formatOpts,
+ displayPartialError
+ ),
[value, minValue, maxValue, isDateUnavailable, formatOpts]
);
- let validation = useFormValidationState({
- ...props,
- value: value as MappedDateValue | null,
- builtinValidation
- });
+ let validation = usePartialFormValidationState(
+ {...props, value: value as MappedDateValue | null},
+ isValuePartial,
+ getBuiltinValidation
+ );
let isValueInvalid = validation.displayValidation.isInvalid;
let validationState: ValidationState | null =
@@ -199,6 +221,7 @@ export function useDatePickerState(
return {
...validation,
+ [privateSetIsValuePartialProp]: setIsValuePartial,
value,
defaultValue: props.defaultValue ?? initialValue,
setValue,
diff --git a/packages/react-stately/src/datepicker/useDateRangePickerState.ts b/packages/react-stately/src/datepicker/useDateRangePickerState.ts
index 3701896563d..36cbf58ce95 100644
--- a/packages/react-stately/src/datepicker/useDateRangePickerState.ts
+++ b/packages/react-stately/src/datepicker/useDateRangePickerState.ts
@@ -25,13 +25,14 @@ import {
getFormatOptions,
getPlaceholderTime,
getRangeValidationResult,
- useDefaultProps
+ useDefaultProps,
+ usePartialFormValidationState
} from './utils';
-import {FormValidationState, useFormValidationState} from '../form/useFormValidationState';
+import {FormValidationState, privateSetIsValuePartialProp} from '../form/useFormValidationState';
import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState';
import {RangeValue, ValidationState} from '@react-types/shared';
+import {useCallback, useMemo, useState} from 'react';
import {useControlledState} from '../utils/useControlledState';
-import {useMemo, useState} from 'react';
export interface DateRangePickerStateOptions<
T extends DateValue = DateValue
@@ -127,7 +128,12 @@ export function useDateRangePickerState(
let value = controlledValue || placeholderValue;
- let setValue = (newValue: RangeValue | null) => {
+ // Partial-state hoisted before setValue so the wrapper can reference the setters.
+ // Lifted from the inner start/end DateFields via privateSetIsValuePartialProp.
+ let [startIsValuePartial, setStartIsValuePartial] = useState(false);
+ let [endIsValuePartial, setEndIsValuePartial] = useState(false);
+
+ let setValueInternal = (newValue: RangeValue | null) => {
// oxlint-disable-next-line react/react-compiler
value = newValue || {start: null, end: null};
setPlaceholderValue(value);
@@ -138,6 +144,14 @@ export function useDateRangePickerState(
}
};
+ // Wrap the setter so any committed range (complete or null) always resets both partial flags,
+ // preventing stale isValuePartial state when a consumer calls state.setValue() directly.
+ let setValue = (newValue: RangeValue | null) => {
+ setStartIsValuePartial(false);
+ setEndIsValuePartial(false);
+ setValueInternal(newValue);
+ };
+
let v = value?.start || value?.end || props.placeholderValue || null;
let [granularity, defaultTimeZone] = useDefaultProps(v, props.granularity);
let hasTime = granularity === 'hour' || granularity === 'minute' || granularity === 'second';
@@ -226,27 +240,44 @@ export function useDateRangePickerState(
);
let {minValue, maxValue, isDateUnavailable} = props;
- let builtinValidation = useMemo(
- () =>
+
+ // The display flag from usePartialFormValidationState is `(start || end) && armed`;
+ // `startIsValuePartial && displayPartialError` reduces to `startIsValuePartial && armed`,
+ // recovering the per-endpoint gating (same for end).
+ let getBuiltinValidation = useCallback(
+ (displayPartialError: boolean) =>
getRangeValidationResult(
value,
minValue,
maxValue,
isDateUnavailable ? date => isDateUnavailable(date, null) : undefined,
- formatOpts
+ formatOpts,
+ startIsValuePartial && displayPartialError,
+ endIsValuePartial && displayPartialError
),
- [value, minValue, maxValue, isDateUnavailable, formatOpts]
+ [
+ value,
+ minValue,
+ maxValue,
+ isDateUnavailable,
+ formatOpts,
+ startIsValuePartial,
+ endIsValuePartial
+ ]
);
- let validation = useFormValidationState({
- ...props,
- value: controlledValue as RangeValue> | null,
- name: useMemo(
- () => [props.startName, props.endName].filter(n => n != null),
- [props.startName, props.endName]
- ),
- builtinValidation
- });
+ let validation = usePartialFormValidationState(
+ {
+ ...props,
+ value: controlledValue as RangeValue> | null,
+ name: useMemo(
+ () => [props.startName, props.endName].filter(n => n != null),
+ [props.startName, props.endName]
+ )
+ },
+ startIsValuePartial || endIsValuePartial,
+ getBuiltinValidation
+ );
let isValueInvalid = validation.displayValidation.isInvalid;
let validationState: ValidationState | null =
@@ -254,6 +285,9 @@ export function useDateRangePickerState(
return {
...validation,
+ // Two setters since the range has two independent fields with separate partial state.
+ [`${privateSetIsValuePartialProp}-start`]: setStartIsValuePartial,
+ [`${privateSetIsValuePartialProp}-end`]: setEndIsValuePartial,
value,
defaultValue: props.defaultValue ?? initialValue,
setValue,
diff --git a/packages/react-stately/src/datepicker/utils.ts b/packages/react-stately/src/datepicker/utils.ts
index 151a47daaf4..55c42fec0c2 100644
--- a/packages/react-stately/src/datepicker/utils.ts
+++ b/packages/react-stately/src/datepicker/utils.ts
@@ -21,11 +21,17 @@ import {
toCalendarDateTime
} from '@internationalized/date';
import {DatePickerProps, DateValue, Granularity, TimeValue} from './types';
+import {
+ FormValidationProps,
+ FormValidationState,
+ mergeValidation,
+ useFormValidationState,
+ VALID_VALIDITY_STATE
+} from '../form/useFormValidationState';
import i18nMessages from '../../intl/datepicker/*.json';
import {LocalizedStringDictionary, LocalizedStringFormatter} from '@internationalized/string';
-import {mergeValidation, VALID_VALIDITY_STATE} from '../form/useFormValidationState';
import {RangeValue, ValidationResult} from '@react-types/shared';
-import {useState} from 'react';
+import {useEffect, useMemo, useState} from 'react';
const dictionary = new LocalizedStringDictionary(i18nMessages);
@@ -49,8 +55,29 @@ export function getValidationResult(
minValue: DateValue | null | undefined,
maxValue: DateValue | null | undefined,
isDateUnavailable: ((v: DateValue) => boolean) | undefined,
- options: FormatterOptions
+ options: FormatterOptions,
+ isValuePartial: boolean = false
): ValidationResult {
+ // A partial value blocks submission (invalid + valueMissing). While partial, `value` holds
+ // the last committed (complete) value, not what the user currently sees — so
+ // min/max/unavailable cannot be evaluated. Like native date inputs, report the value as
+ // missing until the date is complete rather than guessing at constraint violations.
+ if (isValuePartial) {
+ let strings =
+ LocalizedStringDictionary.getGlobalDictionaryForPackage('@react-stately/datepicker') ||
+ dictionary;
+ let formatter = new LocalizedStringFormatter(getLocale(), strings);
+ return {
+ isInvalid: true,
+ validationErrors: [formatter.format('incompleteValue')],
+ validationDetails: {
+ ...VALID_VALIDITY_STATE,
+ valueMissing: true,
+ valid: false
+ }
+ };
+ }
+
let rangeOverflow = value != null && maxValue != null && value.compare(maxValue) > 0;
let rangeUnderflow = value != null && minValue != null && value.compare(minValue) < 0;
let isUnavailable = (value != null && isDateUnavailable?.(value)) || false;
@@ -106,19 +133,83 @@ export function getValidationResult(
};
}
+/**
+ * Wraps useFormValidationState with display gating for partial (incomplete) date values,
+ * shared by useDateFieldState, useDatePickerState, and useDateRangePickerState.
+ *
+ * A value is always momentarily partial while the user is typing it, and builtinValidation
+ * is displayed in realtime with validationBehavior="aria" — so the raw partial flag cannot
+ * feed the validation result directly. Instead the partial error is armed when validation
+ * is committed while partial (e.g. on blur), and disarmed in realtime once the value is
+ * completed or fully cleared.
+ *
+ * `getBuiltinValidation` receives the gated flag (partial AND armed) and must be memoized
+ * by the caller; the result is recomputed only when the callback or the flag changes.
+ */
+export function usePartialFormValidationState(
+ props: FormValidationProps,
+ isValuePartial: boolean,
+ getBuiltinValidation: (displayPartialError: boolean) => ValidationResult
+): FormValidationState {
+ let [showPartialError, setShowPartialError] = useState(false);
+ let displayPartialError = isValuePartial && showPartialError;
+
+ let builtinValidation = useMemo(
+ () => getBuiltinValidation(displayPartialError),
+ [getBuiltinValidation, displayPartialError]
+ );
+
+ let validation = useFormValidationState({
+ ...props,
+ // While the partial error is displayed, `value` is the stale committed value, not what
+ // the user sees — hide it so a custom validate() can't surface errors against it and
+ // override the incomplete message (mirroring how min/max are skipped while partial).
+ value: displayPartialError ? null : props.value,
+ builtinValidation
+ });
+
+ // Once the partial state resolves (the value is completed or fully cleared), disarm and
+ // commit so a displayed error clears immediately. The blur handler cannot cover this:
+ // completing the value back to the previously committed one produces no value change to
+ // commit on. Runs as an effect so the commit lands after the partial state has settled.
+ useEffect(() => {
+ if (showPartialError && !isValuePartial) {
+ // oxlint-disable-next-line react/react-compiler
+ setShowPartialError(false);
+ validation.commitValidation();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [showPartialError, isValuePartial]);
+
+ return {
+ ...validation,
+ commitValidation() {
+ // Arm the partial error before committing so the committed (and, for
+ // validationBehavior="aria", realtime) validation result includes it.
+ if (isValuePartial) {
+ setShowPartialError(true);
+ }
+ validation.commitValidation();
+ }
+ };
+}
+
export function getRangeValidationResult(
value: RangeValue | null,
minValue: DateValue | null | undefined,
maxValue: DateValue | null | undefined,
isDateUnavailable: ((v: DateValue) => boolean) | undefined,
- options: FormatterOptions
+ options: FormatterOptions,
+ startIsValuePartial: boolean = false,
+ endIsValuePartial: boolean = false
): ValidationResult {
let startValidation = getValidationResult(
value?.start ?? null,
minValue,
maxValue,
isDateUnavailable,
- options
+ options,
+ startIsValuePartial
);
let endValidation = getValidationResult(
@@ -126,7 +217,8 @@ export function getRangeValidationResult(
minValue,
maxValue,
isDateUnavailable,
- options
+ options,
+ endIsValuePartial
);
let result = mergeValidation(startValidation, endValidation);
diff --git a/packages/react-stately/src/form/useFormValidationState.ts b/packages/react-stately/src/form/useFormValidationState.ts
index 85931255d2d..f63d6861739 100644
--- a/packages/react-stately/src/form/useFormValidationState.ts
+++ b/packages/react-stately/src/form/useFormValidationState.ts
@@ -51,7 +51,11 @@ export const FormValidationContext: Context = createContext extends Validation {
+// Private prop for DatePicker / DateRangePicker to receive partial-state notifications
+// from its inner DateField(s). See useDateFieldState.isValuePartial.
+export const privateSetIsValuePartialProp: string = '__reactAriaDatePickerSetIsValuePartial';
+
+export interface FormValidationProps extends Validation {
builtinValidation?: ValidationResult;
name?: string | string[];
value: T | null;
diff --git a/scripts/missingTranslations.js b/scripts/missingTranslations.js
index 96e226841e5..b67289ecf74 100644
--- a/scripts/missingTranslations.js
+++ b/scripts/missingTranslations.js
@@ -1,7 +1,15 @@
const glob = require('glob');
const fs = require('fs');
-for (let dir of glob.sync('packages/**/intl')) {
+// Collect both top-level intl dirs and one level of named subdirs (e.g. intl/datepicker).
+let dirs = [...glob.sync('packages/**/intl'), ...glob.sync('packages/**/intl/*/')].map(d =>
+ d.endsWith('/') ? d.slice(0, -1) : d
+);
+
+for (let dir of dirs) {
+ if (!fs.existsSync(`${dir}/en-US.json`)) {
+ continue;
+ }
let en = JSON.parse(fs.readFileSync(`${dir}/en-US.json`, 'utf8'));
for (let file of glob.sync('*.json', {cwd: dir})) {