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( + +
+ (v.year === 2026 ? 'Custom error' : null)} + validationBehavior="native" + /> + +
+ ); + + 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( +
+ + + + {segment => } + + + + + + +
+ + + +
+ {date => } +
+
+
+
+
+ ); + + 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( +
+ + + + {segment => } + + {segment => } + + + + + + +
+ + + +
+ {date => } +
+
+
+
+
+ ); + + 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( +
+ + + {segment => } + + +
+ ); + + 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})) {