From f84a15805b1ed03397e8bbf22147372afcb67701 Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Wed, 11 Jun 2025 14:55:35 +0100 Subject: [PATCH 01/11] fix: Constrain day on blur --- .../date/src/manipulation.ts | 2 +- .../datepicker/src/useDateSegment.ts | 6 ++++ .../datepicker/src/useDateFieldState.ts | 36 +++++++++++++++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/@internationalized/date/src/manipulation.ts b/packages/@internationalized/date/src/manipulation.ts index 87863dafaf6..1c1c968cc36 100644 --- a/packages/@internationalized/date/src/manipulation.ts +++ b/packages/@internationalized/date/src/manipulation.ts @@ -115,7 +115,7 @@ function balanceDay(date: Mutable) { function constrainMonthDay(date: Mutable) { date.month = Math.max(1, Math.min(date.calendar.getMonthsInYear(date), date.month)); - date.day = Math.max(1, Math.min(date.calendar.getDaysInMonth(date), date.day)); + date.day = Math.max(1, Math.min(31, date.day)); } export function constrain(date: Mutable): void { diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index 2aad84bc0ea..bdf2751f7f3 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -275,6 +275,11 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: selection?.collapse(ref.current); }; + + const onBlur = () => { + state.constrain() + } + let documentRef = useRef(typeof document !== 'undefined' ? document : null); useEvent(documentRef, 'selectionchange', () => { // Enforce that the selection is collapsed when inside a date segment. @@ -420,6 +425,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: onPointerDown(e) { e.stopPropagation(); }, + onBlur, onMouseDown(e) { e.stopPropagation(); } diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 682dabe5018..f2c45e01c49 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -95,7 +95,8 @@ export interface DateFieldState extends FormValidationState { /** Formats the current date value using the given options. */ formatValue(fieldOptions: FieldOptions): string, /** Gets a formatter based on state's props. */ - getDateFormatter(locale: string, formatOptions: FormatterOptions): DateFormatter + getDateFormatter(locale: string, formatOptions: FormatterOptions): DateFormatter, + constrain(): void, } const EDITABLE_SEGMENTS = { @@ -186,6 +187,7 @@ export function useDateFieldState(props: DateFi () => createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone) ); + let val = calendarValue || placeholderDate; let showEra = calendar.identifier === 'gregory' && val.era === 'BC'; let formatOpts = useMemo(() => ({ @@ -404,6 +406,19 @@ export function useDateFieldState(props: DateFi let newOptions = {...formatOpts, ...formatOptions}; let newFormatOptions = getFormatOptions({}, newOptions); return new DateFormatter(locale, newFormatOptions); + }, + constrain() { + setValidSegments(validSegments => { + let validKeys = Object.keys(validSegments); + let allKeys = Object.keys(allSegments); + + if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod)) { + const value = Math.max(1, Math.min( displayValue.calendar.getDaysInMonth(displayValue),displayValue.day)); + console.log(value) + setValue(setSegment(displayValue, "day", value, resolvedOptions)) + } + return validSegments + }) } }; } @@ -421,9 +436,24 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption let isPlaceholder = EDITABLE_SEGMENTS[segment.type] && !validSegments[segment.type]; let placeholder = EDITABLE_SEGMENTS[segment.type] ? getPlaceholder(segment.type, segment.value, locale) : null; + let numberFormatter = new Intl.NumberFormat(locale, { + useGrouping: false + }); + + let twoDigitFormatter = new Intl.NumberFormat(locale, { + useGrouping: false, + minimumIntegerDigits: 2 + }) + + let segmentValue + if(segment.type === "day") segmentValue = displayValue.day + else if(segment.type === "month") segmentValue = displayValue.month + + let value = segment.type === "day" || segment.type === "month" ? twoDigitFormatter.format(segmentValue) : segment.value + let dateSegment = { type: TYPE_MAPPING[segment.type] || segment.type, - text: isPlaceholder ? placeholder : segment.value, + text: isPlaceholder ? placeholder : value, ...getSegmentLimits(displayValue, segment.type, resolvedOptions), isPlaceholder, placeholder, @@ -501,7 +531,7 @@ function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedD return { value: date.day, minValue: getMinimumDayInMonth(date), - maxValue: date.calendar.getDaysInMonth(date) + maxValue: 31 }; } From 2611a8e59b067c1039ee84b6dbcb496f850dad36 Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Wed, 2 Jul 2025 13:21:32 +0100 Subject: [PATCH 02/11] feat: add tests --- .../datepicker/test/DateField.test.js | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index 829607e366b..58c4c45ceae 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {act, pointerMap, render as render_, within} from '@react-spectrum/test-utils-internal'; +import {act, pointerMap, fireEvent, render as render_, within} from '@react-spectrum/test-utils-internal'; import {Button} from '@react-spectrum/button'; import {CalendarDate, CalendarDateTime, ZonedDateTime} from '@internationalized/date'; import {DateField} from '../'; @@ -20,6 +20,12 @@ import React from 'react'; import {theme} from '@react-spectrum/theme-default'; import userEvent from '@testing-library/user-event'; +function beforeInput(target, key) { + // JSDOM doesn't support the beforeinput event + let e = new InputEvent('beforeinput', {cancelable: true, data: key, inputType: 'insertText'}); + fireEvent(target, e); +} + function render(el) { if (el.type === Provider) { return render_(el); @@ -674,4 +680,55 @@ describe('DateField', function () { }); }); }); + +describe("validation", () => { + it("Should limit day to 31", async () => { + let onChange = jest.fn(); + let { getByTestId } = render( + , + ); + + let segment = getByTestId("day"); + act(() => { + segment.focus(); + }); + beforeInput(segment, "32"); + expect(onChange).toHaveBeenCalledWith(new CalendarDate(2019, 2, 31)); + }); + it.only("Constrain day on blur", async () => { + let onChange = jest.fn(); + let { getByTestId } = render( + , + ); + + let segment; + + segment = getByTestId("year"); + act(() => { + segment.focus(); + }); + beforeInput(segment, "2025"); + + segment = getByTestId("month"); + act(() => { + segment.focus(); + }); + beforeInput(segment, "2"); + + segment = getByTestId("day"); + act(() => { + segment.focus(); + }); + beforeInput(segment, "29"); + + act(() => document.activeElement.blur()); + + expect(onChange).toHaveBeenCalledWith(new CalendarDate(2025, 2, 28)); + }); +}); + }); From f130aae9286dec00bb96a8061b9f6c2ac18da5e8 Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Fri, 11 Jul 2025 10:57:29 +0100 Subject: [PATCH 03/11] feat: add blur --- .../datepicker/src/useDateField.ts | 3 +- .../datepicker/src/useDateSegment.ts | 4 -- .../datepicker/src/useDateFieldState.ts | 41 ++++++++----------- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts index 7e4b4daef7a..94c008a6d9b 100644 --- a/packages/@react-aria/datepicker/src/useDateField.ts +++ b/packages/@react-aria/datepicker/src/useDateField.ts @@ -83,7 +83,8 @@ export function useDateField(props: AriaDateFieldOptions state.confirmPlaceholder(); if (state.value !== valueOnFocus.current) { state.commitValidation(); - } + }; + state.onBlur?.(); props.onBlur?.(e); }, onFocusWithinChange: props.onFocusChange diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index bdf2751f7f3..f52cc28a90c 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -276,9 +276,6 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: }; - const onBlur = () => { - state.constrain() - } let documentRef = useRef(typeof document !== 'undefined' ? document : null); useEvent(documentRef, 'selectionchange', () => { @@ -425,7 +422,6 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: onPointerDown(e) { e.stopPropagation(); }, - onBlur, onMouseDown(e) { e.stopPropagation(); } diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 6de146cdc6c..689ed79e03a 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -96,7 +96,7 @@ export interface DateFieldState extends FormValidationState { formatValue(fieldOptions: FieldOptions): string, /** Gets a formatter based on state's props. */ getDateFormatter(locale: string, formatOptions: FormatterOptions): DateFormatter, - constrain(): void, + onBlur(): void } const EDITABLE_SEGMENTS = { @@ -181,6 +181,8 @@ export function useDateFieldState(props: DateFi props.onChange ); + const previousValue = useRef(value) + let calendarValue = useMemo(() => convertValue(value, calendar) ?? null, [value, calendar]); // We keep track of the placeholder date separately in state so that onChange is not called @@ -236,18 +238,17 @@ export function useDateFieldState(props: DateFi }, [calendar, granularity, validSegments, defaultTimeZone, props.placeholderValue]); // If there is a value prop, and some segments were previously placeholders, mark them all as valid. - if (value && Object.keys(validSegments).length < Object.keys(allSegments).length) { + if (value !== previousValue.current && value && Object.keys(validSegments).length < Object.keys(allSegments).length) { validSegments = {...allSegments}; setValidSegments(validSegments); } // If the value is set to null and all segments are valid, reset the placeholder. - if (value == null && Object.keys(validSegments).length === Object.keys(allSegments).length) { + if (value !== previousValue.current && value == null && Object.keys(validSegments).length === Object.keys(allSegments).length) { validSegments = {}; - setValidSegments(validSegments); + setValidSegments(validSegments); //reason setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); } - // If all segments are valid, use the date from state, otherwise use the placeholder date. let displayValue = calendarValue && Object.keys(validSegments).length >= Object.keys(allSegments).length ? calendarValue : placeholderDate; let setValue = (newValue: DateValue) => { @@ -273,17 +274,12 @@ export function useDateFieldState(props: DateFi validSegments = {...allSegments}; setValidSegments(validSegments); } - - // The display calendar should not have any effect on the emitted value. - // Emit dates in the same calendar as the original value, if any, otherwise gregorian. - newValue = toCalendar(newValue, v?.calendar || new GregorianCalendar()); - setDate(newValue); - } else { - setPlaceholderDate(newValue); } + setPlaceholderDate(newValue); clearedSegment.current = null; }; + let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); let segments = useMemo(() => processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity), @@ -404,8 +400,6 @@ export function useDateFieldState(props: DateFi } else if (part in displayValue) { value = displayValue.set({[part]: placeholder[part]}); } - - setDate(null); setValue(value); }, formatValue(fieldOptions: FieldOptions) { @@ -422,19 +416,17 @@ export function useDateFieldState(props: DateFi let newFormatOptions = getFormatOptions({}, newOptions); return new DateFormatter(locale, newFormatOptions); }, - constrain() { - setValidSegments(validSegments => { + onBlur() { let validKeys = Object.keys(validSegments); let allKeys = Object.keys(allSegments); - if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod)) { - const value = Math.max(1, Math.min( displayValue.calendar.getDaysInMonth(displayValue),displayValue.day)); - console.log(value) - setValue(setSegment(displayValue, "day", value, resolvedOptions)) - } - return validSegments - }) - } + if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod')) { + const value = Math.max(1, Math.min(displayValue.calendar.getDaysInMonth(displayValue),displayValue.day)); + setDate(setSegment(displayValue, "day", value, resolvedOptions)) + }else { + setDate(null) + } + }, }; } @@ -593,6 +585,7 @@ function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedD } function addSegment(value: DateValue, part: string, amount: number, options: Intl.ResolvedDateTimeFormatOptions) { + console.log(amount) switch (part) { case 'era': case 'year': From 577bcdb7548e0bde01e99bcca8d0bcd64c5fe92b Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Wed, 16 Jul 2025 11:19:08 +0100 Subject: [PATCH 04/11] feat: update constrain --- .../date/src/calendars/BuddhistCalendar.ts | 2 +- .../date/src/calendars/EthiopicCalendar.ts | 4 ++ .../date/src/calendars/GregorianCalendar.ts | 4 ++ .../date/src/calendars/HebrewCalendar.ts | 4 ++ .../date/src/calendars/IslamicCalendar.ts | 4 ++ .../date/src/calendars/PersianCalendar.ts | 4 ++ .../date/src/manipulation.ts | 4 +- packages/@internationalized/date/src/types.ts | 2 + .../datepicker/src/useDateField.ts | 1 - .../numberfield/src/useNumberField.ts | 2 + .../datepicker/src/useDateFieldState.ts | 58 +++++++++++-------- 11 files changed, 60 insertions(+), 29 deletions(-) diff --git a/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts index f60c8bf1b3d..2de3c6b204b 100644 --- a/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts +++ b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts @@ -49,7 +49,7 @@ export class BuddhistCalendar extends GregorianCalendar { getDaysInMonth(date: AnyCalendarDate): number { return super.getDaysInMonth(toGregorian(date)); } - + balanceDate(): void {} } diff --git a/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts index c96be3fb827..852c020dd0e 100644 --- a/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts +++ b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts @@ -92,6 +92,10 @@ export class EthiopicCalendar implements Calendar { return getDaysInMonth(date.year, date.month); } + getMaxDays(): number { + return 30 + } + getMonthsInYear(): number { return 13; } diff --git a/packages/@internationalized/date/src/calendars/GregorianCalendar.ts b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts index 31106c379fe..ee561a272e5 100644 --- a/packages/@internationalized/date/src/calendars/GregorianCalendar.ts +++ b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts @@ -109,6 +109,10 @@ export class GregorianCalendar implements Calendar { return 12; } + getMaxDays(): number { + return 31 + } + getDaysInYear(date: AnyCalendarDate): number { return isLeapYear(date.year) ? 366 : 365; } diff --git a/packages/@internationalized/date/src/calendars/HebrewCalendar.ts b/packages/@internationalized/date/src/calendars/HebrewCalendar.ts index 52d3f43bc2f..f48b30ec3f6 100644 --- a/packages/@internationalized/date/src/calendars/HebrewCalendar.ts +++ b/packages/@internationalized/date/src/calendars/HebrewCalendar.ts @@ -172,6 +172,10 @@ export class HebrewCalendar implements Calendar { return getDaysInMonth(date.year, date.month); } + getMaxDays(): number { + return 30; + } + getMonthsInYear(date: AnyCalendarDate): number { return isLeapYear(date.year) ? 13 : 12; } diff --git a/packages/@internationalized/date/src/calendars/IslamicCalendar.ts b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts index 7696e852224..3929b13832c 100644 --- a/packages/@internationalized/date/src/calendars/IslamicCalendar.ts +++ b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts @@ -69,6 +69,10 @@ export class IslamicCivilCalendar implements Calendar { return length; } + getMaxDays(): number { + return 30 + } + getMonthsInYear(): number { return 12; } diff --git a/packages/@internationalized/date/src/calendars/PersianCalendar.ts b/packages/@internationalized/date/src/calendars/PersianCalendar.ts index 0ff6c86cec5..df833e0df90 100644 --- a/packages/@internationalized/date/src/calendars/PersianCalendar.ts +++ b/packages/@internationalized/date/src/calendars/PersianCalendar.ts @@ -80,6 +80,10 @@ export class PersianCalendar implements Calendar { return isLeapYear ? 30 : 29; } + getMaxDays(): number { + return 31 + } + getEras(): string[] { return ['AP']; } diff --git a/packages/@internationalized/date/src/manipulation.ts b/packages/@internationalized/date/src/manipulation.ts index 1c1c968cc36..acbd53c9830 100644 --- a/packages/@internationalized/date/src/manipulation.ts +++ b/packages/@internationalized/date/src/manipulation.ts @@ -115,7 +115,7 @@ function balanceDay(date: Mutable) { function constrainMonthDay(date: Mutable) { date.month = Math.max(1, Math.min(date.calendar.getMonthsInYear(date), date.month)); - date.day = Math.max(1, Math.min(31, date.day)); + date.day = Math.max(1, Math.min(date.calendar.getMaxDays(), date.day)); } export function constrain(date: Mutable): void { @@ -284,7 +284,7 @@ export function cycleDate(value: CalendarDate | CalendarDateTime, field: DateFie mutable.month = cycleValue(value.month, amount, 1, value.calendar.getMonthsInYear(value), options?.round); break; case 'day': - mutable.day = cycleValue(value.day, amount, 1, value.calendar.getDaysInMonth(value), options?.round); + mutable.day = cycleValue(value.day, amount, 1, value.calendar.getMaxDays(), options?.round); break; default: throw new Error('Unsupported field ' + field); diff --git a/packages/@internationalized/date/src/types.ts b/packages/@internationalized/date/src/types.ts index 78fba68fe0d..4be08265ce3 100644 --- a/packages/@internationalized/date/src/types.ts +++ b/packages/@internationalized/date/src/types.ts @@ -57,6 +57,8 @@ export interface Calendar { getDaysInMonth(date: AnyCalendarDate): number, /** Returns the number of months in the year of the given date. */ getMonthsInYear(date: AnyCalendarDate): number, + /** Returns the maximum day. */ + getMaxDays(): number, /** Returns the number of years in the era of the given date. */ getYearsInEra(date: AnyCalendarDate): number, /** Returns a list of era identifiers for the calendar. */ diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts index 94c008a6d9b..6a3162fc8a7 100644 --- a/packages/@react-aria/datepicker/src/useDateField.ts +++ b/packages/@react-aria/datepicker/src/useDateField.ts @@ -84,7 +84,6 @@ export function useDateField(props: AriaDateFieldOptions if (state.value !== valueOnFocus.current) { state.commitValidation(); }; - state.onBlur?.(); props.onBlur?.(e); }, onFocusWithinChange: props.onFocusChange diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index d9eed3a4560..617233c7819 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -90,6 +90,8 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt commitValidation } = state; + console.log(numberValue, inputValue) + const stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/numberfield'); let inputId = useId(id); diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 689ed79e03a..e13f18f4477 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -95,8 +95,7 @@ export interface DateFieldState extends FormValidationState { /** Formats the current date value using the given options. */ formatValue(fieldOptions: FieldOptions): string, /** Gets a formatter based on state's props. */ - getDateFormatter(locale: string, formatOptions: FormatterOptions): DateFormatter, - onBlur(): void + getDateFormatter(locale: string, formatOptions: FormatterOptions): DateFormatter } const EDITABLE_SEGMENTS = { @@ -190,10 +189,9 @@ export function useDateFieldState(props: DateFi // is controlled, so use the placeholder as the value until all segments are entered so it doesn't // change from uncontrolled to controlled and emit a warning. let [placeholderDate, setPlaceholderDate] = useState( - () => createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone) + () => createPlaceholderDate(value || props.placeholderValue, granularity, calendar, defaultTimeZone) ); - let val = calendarValue || placeholderDate; let showEra = calendar.identifier === 'gregory' && val.era === 'BC'; let formatOpts = useMemo(() => ({ @@ -241,16 +239,20 @@ export function useDateFieldState(props: DateFi if (value !== previousValue.current && value && Object.keys(validSegments).length < Object.keys(allSegments).length) { validSegments = {...allSegments}; setValidSegments(validSegments); + setPlaceholderDate(value) + previousValue.current = value } + // If the value is set to null and all segments are valid, reset the placeholder. if (value !== previousValue.current && value == null && Object.keys(validSegments).length === Object.keys(allSegments).length) { validSegments = {}; - setValidSegments(validSegments); //reason + setValidSegments(validSegments); //reason setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); + previousValue.current = value } // If all segments are valid, use the date from state, otherwise use the placeholder date. - let displayValue = calendarValue && Object.keys(validSegments).length >= Object.keys(allSegments).length ? calendarValue : placeholderDate; + let displayValue = placeholderDate; let setValue = (newValue: DateValue) => { if (props.isDisabled || props.isReadOnly) { return; @@ -274,12 +276,25 @@ export function useDateFieldState(props: DateFi validSegments = {...allSegments}; setValidSegments(validSegments); } + + // The display calendar should not have any effect on the emitted value. + // Emit dates in the same calendar as the original value, if any, otherwise gregorian. + newValue = toCalendar(newValue, v?.calendar || new GregorianCalendar()); + setDate(newValue); + previousValue.current = newValue + setPlaceholderDate(newValue) + } +}; + + + let updatePlaceholder = (newValue: DateValue) => { + if (props.isDisabled || props.isReadOnly) { + return; } setPlaceholderDate(newValue); clearedSegment.current = null; }; - let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); let segments = useMemo(() => processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity), @@ -362,7 +377,7 @@ export function useDateFieldState(props: DateFi }, setSegment(part, v: string | number) { markValid(part); - setValue(setSegment(displayValue, part, v, resolvedOptions)); + updatePlaceholder(setSegment(displayValue, part, v, resolvedOptions)); }, confirmPlaceholder() { if (props.isDisabled || props.isReadOnly) { @@ -372,10 +387,15 @@ export function useDateFieldState(props: DateFi // Confirm the placeholder if only the day period is not filled in. let validKeys = Object.keys(validSegments); let allKeys = Object.keys(allSegments); - if (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod) { + if (validKeys.length >= allKeys.length || + (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod')) { validSegments = {...allSegments}; setValidSegments(validSegments); - setValue(displayValue.copy()); + const value = Math.max(1, Math.min(displayValue.calendar.getDaysInMonth(displayValue),displayValue.day)); + setValue(setSegment(displayValue, "day", value, resolvedOptions)) + }else { + setDate(null) + previousValue.current = null } }, clearSegment(part) { @@ -400,7 +420,7 @@ export function useDateFieldState(props: DateFi } else if (part in displayValue) { value = displayValue.set({[part]: placeholder[part]}); } - setValue(value); + updatePlaceholder(value); }, formatValue(fieldOptions: FieldOptions) { if (!calendarValue) { @@ -415,18 +435,7 @@ export function useDateFieldState(props: DateFi let newOptions = {...formatOpts, ...formatOptions}; let newFormatOptions = getFormatOptions({}, newOptions); return new DateFormatter(locale, newFormatOptions); - }, - onBlur() { - let validKeys = Object.keys(validSegments); - let allKeys = Object.keys(allSegments); - - if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod')) { - const value = Math.max(1, Math.min(displayValue.calendar.getDaysInMonth(displayValue),displayValue.day)); - setDate(setSegment(displayValue, "day", value, resolvedOptions)) - }else { - setDate(null) - } - }, + } }; } @@ -539,7 +548,7 @@ function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedD return { value: date.day, minValue: getMinimumDayInMonth(date), - maxValue: 31 + maxValue: date.calendar.getMaxDays() }; } @@ -585,7 +594,6 @@ function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedD } function addSegment(value: DateValue, part: string, amount: number, options: Intl.ResolvedDateTimeFormatOptions) { - console.log(amount) switch (part) { case 'era': case 'year': From 56c58490933de4b25de05bc70deeba08822da9ce Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Wed, 16 Jul 2025 13:19:04 +0100 Subject: [PATCH 05/11] feat: change NumberFormat --- .../datepicker/src/useDateFieldState.ts | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index e13f18f4477..263573c61fa 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -165,6 +165,7 @@ export function useDateFieldState(props: DateFi let v: DateValue | null = props.value || props.defaultValue || props.placeholderValue || null; let [granularity, defaultTimeZone] = useDefaultProps(v, props.granularity); let timeZone = defaultTimeZone || 'UTC'; + const isValueConfirmed = useRef(false) // props.granularity must actually exist in the value if one is provided. if (v && !(granularity in v)) { @@ -192,6 +193,7 @@ export function useDateFieldState(props: DateFi () => createPlaceholderDate(value || props.placeholderValue, granularity, calendar, defaultTimeZone) ); + let val = calendarValue || placeholderDate; let showEra = calendar.identifier === 'gregory' && val.era === 'BC'; let formatOpts = useMemo(() => ({ @@ -241,15 +243,18 @@ export function useDateFieldState(props: DateFi setValidSegments(validSegments); setPlaceholderDate(value) previousValue.current = value + isValueConfirmed.current = true } + // If the value is set to null and all segments are valid, reset the placeholder. if (value !== previousValue.current && value == null && Object.keys(validSegments).length === Object.keys(allSegments).length) { validSegments = {}; setValidSegments(validSegments); //reason setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); previousValue.current = value + isValueConfirmed.current = true } // If all segments are valid, use the date from state, otherwise use the placeholder date. let displayValue = placeholderDate; @@ -297,7 +302,7 @@ export function useDateFieldState(props: DateFi let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); let segments = useMemo(() => - processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity), + processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isValueConfirmed.current), [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity]); // When the era field appears, mark it valid if the year field is already valid. @@ -319,6 +324,7 @@ export function useDateFieldState(props: DateFi }; let adjustSegment = (type: Intl.DateTimeFormatPartTypes, amount: number) => { + isValueConfirmed.current = false if (!validSegments[type]) { markValid(type); let validKeys = Object.keys(validSegments); @@ -376,6 +382,7 @@ export function useDateFieldState(props: DateFi adjustSegment(part, -(PAGE_STEP[part] || 1)); }, setSegment(part, v: string | number) { + isValueConfirmed.current = false markValid(part); updatePlaceholder(setSegment(displayValue, part, v, resolvedOptions)); }, @@ -397,8 +404,10 @@ export function useDateFieldState(props: DateFi setDate(null) previousValue.current = null } + isValueConfirmed.current = true }, clearSegment(part) { + isValueConfirmed.current = false delete validSegments[part]; clearedSegment.current = part; setValidSegments({...validSegments}); @@ -439,7 +448,7 @@ export function useDateFieldState(props: DateFi }; } -function processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity) : DateSegment[] { +function processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isConfirmed: boolean) : DateSegment[] { let timeValue = ['hour', 'minute', 'second']; let segments = dateFormatter.formatToParts(dateValue); let processedSegments: DateSegment[] = []; @@ -453,6 +462,9 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption let isPlaceholder = EDITABLE_SEGMENTS[type] && !validSegments[type]; let placeholder = EDITABLE_SEGMENTS[type] ? getPlaceholder(type, segment.value, locale) : null; + let value = segment.value + + if(!isConfirmed && ['day', 'month', 'year'].includes(segment.type)) { let numberFormatter = new Intl.NumberFormat(locale, { useGrouping: false }); @@ -462,11 +474,9 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption minimumIntegerDigits: 2 }) - let segmentValue - if(segment.type === "day") segmentValue = displayValue.day - else if(segment.type === "month") segmentValue = displayValue.month - - let value = segment.type === "day" || segment.type === "month" ? twoDigitFormatter.format(segmentValue) : segment.value + let f = dateFormatter.resolvedOptions()[segment.type] === '2-digit' ? twoDigitFormatter : numberFormatter; + value = f.format(displayValue[segment.type]); + } let dateSegment = { type, From 596af68136b5f019fa7515e04285341303c4b96c Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Thu, 17 Jul 2025 13:53:03 +0100 Subject: [PATCH 06/11] feat: fix tests --- .../datepicker/src/useDateField.ts | 4 +- .../datepicker/test/DateField.test.js | 59 +------------------ 2 files changed, 4 insertions(+), 59 deletions(-) diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts index 6a3162fc8a7..52370f9b2c4 100644 --- a/packages/@react-aria/datepicker/src/useDateField.ts +++ b/packages/@react-aria/datepicker/src/useDateField.ts @@ -81,10 +81,8 @@ export function useDateField(props: AriaDateFieldOptions }, onBlurWithin: (e) => { state.confirmPlaceholder(); - if (state.value !== valueOnFocus.current) { - state.commitValidation(); - }; props.onBlur?.(e); + state.commitValidation(); }, onFocusWithinChange: props.onFocusChange }); diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index 58c4c45ceae..11524821370 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -242,6 +242,7 @@ describe('DateField', function () { ); await user.tab(); await user.keyboard('01011980'); + await user.tab(); expect(tree.getByText('Date unavailable.')).toBeInTheDocument(); }); @@ -450,9 +451,9 @@ describe('DateField', function () { await user.tab({shift: true}); await user.keyboard('2025'); - expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); - expect(input.validity.valid).toBe(false); await user.tab(); + expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); + expect(input.validity.valid).toBe(false); act(() => {getByTestId('form').checkValidity();}); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); @@ -486,12 +487,9 @@ describe('DateField', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Invalid value'); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[ArrowRight][ArrowRight]2024'); expect(getDescription()).toContain('Invalid value'); - expect(input.validity.valid).toBe(true); - await user.tab(); expect(getDescription()).not.toContain('Invalid value'); @@ -680,55 +678,4 @@ describe('DateField', function () { }); }); }); - -describe("validation", () => { - it("Should limit day to 31", async () => { - let onChange = jest.fn(); - let { getByTestId } = render( - , - ); - - let segment = getByTestId("day"); - act(() => { - segment.focus(); - }); - beforeInput(segment, "32"); - expect(onChange).toHaveBeenCalledWith(new CalendarDate(2019, 2, 31)); - }); - it.only("Constrain day on blur", async () => { - let onChange = jest.fn(); - let { getByTestId } = render( - , - ); - - let segment; - - segment = getByTestId("year"); - act(() => { - segment.focus(); - }); - beforeInput(segment, "2025"); - - segment = getByTestId("month"); - act(() => { - segment.focus(); - }); - beforeInput(segment, "2"); - - segment = getByTestId("day"); - act(() => { - segment.focus(); - }); - beforeInput(segment, "29"); - - act(() => document.activeElement.blur()); - - expect(onChange).toHaveBeenCalledWith(new CalendarDate(2025, 2, 28)); - }); -}); - }); From 9a30cc29e36736481eadba542572d6e4bee4f4bc Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Fri, 18 Jul 2025 11:55:55 +0100 Subject: [PATCH 07/11] feat: fix tests --- .../datepicker/src/useDateField.ts | 4 ++- .../datepicker/test/DateField.test.js | 36 +++++++------------ .../datepicker/src/useDateFieldState.ts | 10 +++++- 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts index 52370f9b2c4..9a1d768aa5c 100644 --- a/packages/@react-aria/datepicker/src/useDateField.ts +++ b/packages/@react-aria/datepicker/src/useDateField.ts @@ -82,7 +82,9 @@ export function useDateField(props: AriaDateFieldOptions onBlurWithin: (e) => { state.confirmPlaceholder(); props.onBlur?.(e); - state.commitValidation(); + if (state.isValueChanged) { + state.commitValidation(); + }; }, onFocusWithinChange: props.onFocusChange }); diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index 11524821370..bab1c771c70 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -20,12 +20,6 @@ import React from 'react'; import {theme} from '@react-spectrum/theme-default'; import userEvent from '@testing-library/user-event'; -function beforeInput(target, key) { - // JSDOM doesn't support the beforeinput event - let e = new InputEvent('beforeinput', {cancelable: true, data: key, inputType: 'insertText'}); - fireEvent(target, e); -} - function render(el) { if (el.type === Provider) { return render_(el); @@ -376,6 +370,7 @@ describe('DateField', function () { expect(input).toHaveAttribute('name', 'date'); await user.tab(); await user.keyboard('{ArrowUp}'); + await user.keyboard('[Tab][Tab][Tab]'); expect(getDescription()).toBe('Selected Date: March 3, 2020'); expect(input).toHaveValue('2020-03-03'); @@ -402,7 +397,7 @@ describe('DateField', function () { expect(input.validity.valid).toBe(false); expect(group).not.toHaveAttribute('aria-describedby'); - act(() => {getByTestId('form').checkValidity();}); + act(() => {getByTestId('form').checkValidity();});630 expect(group).toHaveAttribute('aria-describedby'); let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); @@ -440,14 +435,10 @@ describe('DateField', function () { expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][Tab][ArrowUp]'); - - expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); - expect(input.validity.valid).toBe(true); - - await user.tab(); + await user.keyboard('[Tab][Tab][ArrowUp][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2020 or later.'); + expect(input.validity.valid).toBe(true); await user.tab({shift: true}); await user.keyboard('2025'); @@ -459,12 +450,9 @@ describe('DateField', function () { expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][Tab][ArrowDown]'); - expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); - expect(input.validity.valid).toBe(true); - await user.tab(); - + await user.keyboard('[Tab][Tab][ArrowDown][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); + expect(input.validity.valid).toBe(true); }); it('supports validate function', async () => { @@ -609,10 +597,12 @@ describe('DateField', function () { await user.keyboard('232023'); expect(group).toHaveAttribute('aria-describedby'); - expect(input.validity.valid).toBe(true); + expect(input.validity.valid).toBe(false); await user.tab(); expect(getDescription()).not.toContain('Constraints not satisfied'); + expect(group).toHaveAttribute('aria-describedby'); + expect(input.validity.valid).toBe(true); }); }); @@ -630,13 +620,13 @@ describe('DateField', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); - await user.keyboard('[Tab][Tab][Tab][ArrowUp]'); + await user.keyboard('[Tab][Tab][Tab][ArrowUp][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2020 or later.'); - await user.keyboard('[ArrowUp][ArrowUp][ArrowUp][ArrowUp][ArrowUp]'); + await user.keyboard('[Tab][Tab][Tab][ArrowUp][ArrowUp][ArrowUp][ArrowUp][ArrowUp][Tab]'); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); - await user.keyboard('[ArrowDown]'); + await user.keyboard('[Tab][Tab][Tab][ArrowDown][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); }); @@ -654,7 +644,7 @@ describe('DateField', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Invalid value'); - await user.keyboard('[Tab][ArrowRight][ArrowRight]2024'); + await user.keyboard('[Tab][ArrowRight][ArrowRight]2024[Tab]'); expect(getDescription()).not.toContain('Invalid value'); }); diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 263573c61fa..44ed18ba21f 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -69,6 +69,8 @@ export interface DateFieldState extends FormValidationState { isReadOnly: boolean, /** Whether the field is required. */ isRequired: boolean, + /** Whether the field is changed. */ + isValueChanged: boolean /** Increments the given segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ increment(type: SegmentType): void, /** Decrements the given segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ @@ -166,6 +168,7 @@ export function useDateFieldState(props: DateFi let [granularity, defaultTimeZone] = useDefaultProps(v, props.granularity); let timeZone = defaultTimeZone || 'UTC'; const isValueConfirmed = useRef(false) + const [isValueChanged, setIsValueChanged] = useState(false) // props.granularity must actually exist in the value if one is provided. if (v && !(granularity in v)) { @@ -244,6 +247,7 @@ export function useDateFieldState(props: DateFi setPlaceholderDate(value) previousValue.current = value isValueConfirmed.current = true + setIsValueChanged(false) } @@ -255,6 +259,7 @@ export function useDateFieldState(props: DateFi setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); previousValue.current = value isValueConfirmed.current = true + setIsValueChanged(false) } // If all segments are valid, use the date from state, otherwise use the placeholder date. let displayValue = placeholderDate; @@ -325,6 +330,7 @@ export function useDateFieldState(props: DateFi let adjustSegment = (type: Intl.DateTimeFormatPartTypes, amount: number) => { isValueConfirmed.current = false + setIsValueChanged(true) if (!validSegments[type]) { markValid(type); let validKeys = Object.keys(validSegments); @@ -333,7 +339,7 @@ export function useDateFieldState(props: DateFi setValue(displayValue); } } else { - setValue(addSegment(displayValue, type, amount, resolvedOptions)); + updatePlaceholder(addSegment(displayValue, type, amount, resolvedOptions)); } }; @@ -369,6 +375,7 @@ export function useDateFieldState(props: DateFi isDisabled, isReadOnly, isRequired, + isValueChanged, increment(part) { adjustSegment(part, 1); }, @@ -383,6 +390,7 @@ export function useDateFieldState(props: DateFi }, setSegment(part, v: string | number) { isValueConfirmed.current = false + setIsValueChanged(true) markValid(part); updatePlaceholder(setSegment(displayValue, part, v, resolvedOptions)); }, From 56fe492568719df6e67e9fe5ab7cb8c07ec1b1ec Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Fri, 18 Jul 2025 11:59:34 +0100 Subject: [PATCH 08/11] fix: remove unnecessary changes --- packages/@react-aria/datepicker/src/useDateField.ts | 4 ++-- packages/@react-aria/datepicker/src/useDateSegment.ts | 2 -- packages/@react-aria/numberfield/src/useNumberField.ts | 2 -- packages/@react-spectrum/datepicker/test/DateField.test.js | 2 +- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts index 9a1d768aa5c..06c32fff5b7 100644 --- a/packages/@react-aria/datepicker/src/useDateField.ts +++ b/packages/@react-aria/datepicker/src/useDateField.ts @@ -81,10 +81,10 @@ export function useDateField(props: AriaDateFieldOptions }, onBlurWithin: (e) => { state.confirmPlaceholder(); - props.onBlur?.(e); - if (state.isValueChanged) { + if (state.isValueChanged) { state.commitValidation(); }; + props.onBlur?.(e); }, onFocusWithinChange: props.onFocusChange }); diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index f52cc28a90c..2aad84bc0ea 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -275,8 +275,6 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: selection?.collapse(ref.current); }; - - let documentRef = useRef(typeof document !== 'undefined' ? document : null); useEvent(documentRef, 'selectionchange', () => { // Enforce that the selection is collapsed when inside a date segment. diff --git a/packages/@react-aria/numberfield/src/useNumberField.ts b/packages/@react-aria/numberfield/src/useNumberField.ts index 617233c7819..d9eed3a4560 100644 --- a/packages/@react-aria/numberfield/src/useNumberField.ts +++ b/packages/@react-aria/numberfield/src/useNumberField.ts @@ -90,8 +90,6 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt commitValidation } = state; - console.log(numberValue, inputValue) - const stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/numberfield'); let inputId = useId(id); diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index bab1c771c70..36c2e8557b6 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -397,7 +397,7 @@ describe('DateField', function () { expect(input.validity.valid).toBe(false); expect(group).not.toHaveAttribute('aria-describedby'); - act(() => {getByTestId('form').checkValidity();});630 + act(() => {getByTestId('form').checkValidity();}); expect(group).toHaveAttribute('aria-describedby'); let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); From dd9675b2364ae90d2776b9e9e2cdb6920faf83aa Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Fri, 18 Jul 2025 12:11:25 +0100 Subject: [PATCH 09/11] fix: improve tests --- .../date/src/calendars/BuddhistCalendar.ts | 1 - .../datepicker/test/DateField.test.js | 17 +++++++++++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts index 2de3c6b204b..b7a60283aa6 100644 --- a/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts +++ b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts @@ -49,7 +49,6 @@ export class BuddhistCalendar extends GregorianCalendar { getDaysInMonth(date: AnyCalendarDate): number { return super.getDaysInMonth(toGregorian(date)); } - balanceDate(): void {} } diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index 36c2e8557b6..1bdac3f9b56 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -435,13 +435,22 @@ describe('DateField', function () { expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][Tab][ArrowUp][Tab]'); + await user.keyboard('[Tab][Tab][ArrowUp]'); + + expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); + expect(input.validity.valid).toBe(false); + + await user.tab(); expect(getDescription()).not.toContain('Value must be 2/3/2020 or later.'); expect(input.validity.valid).toBe(true); await user.tab({shift: true}); await user.keyboard('2025'); + + expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); + expect(input.validity.valid).toBe(true); + await user.tab(); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); expect(input.validity.valid).toBe(false); @@ -450,7 +459,11 @@ describe('DateField', function () { expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][Tab][ArrowDown][Tab]'); + await user.keyboard('[Tab][Tab][ArrowDown]'); + expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); + expect(input.validity.valid).toBe(false); + await user.tab(); + expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); expect(input.validity.valid).toBe(true); }); From ac477ecd332f41b75c26c929bc19d11e370ef988 Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Fri, 18 Jul 2025 12:18:12 +0100 Subject: [PATCH 10/11] fix: improve tests --- packages/@react-spectrum/datepicker/test/DateField.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index 1bdac3f9b56..dbed7380c32 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -370,7 +370,7 @@ describe('DateField', function () { expect(input).toHaveAttribute('name', 'date'); await user.tab(); await user.keyboard('{ArrowUp}'); - await user.keyboard('[Tab][Tab][Tab]'); + await user.tab({shift: true}); expect(getDescription()).toBe('Selected Date: March 3, 2020'); expect(input).toHaveValue('2020-03-03'); @@ -491,6 +491,8 @@ describe('DateField', function () { await user.keyboard('[ArrowRight][ArrowRight]2024'); expect(getDescription()).toContain('Invalid value'); + expect(input.validity.valid).toBe(false); + await user.tab(); expect(getDescription()).not.toContain('Invalid value'); From ab552f92788b8d754f48a52cd91cb39e395c5919 Mon Sep 17 00:00:00 2001 From: Soufiane Boutahlil Date: Tue, 22 Jul 2025 14:04:24 +0100 Subject: [PATCH 11/11] fix: fix tests --- .../date/src/calendars/BuddhistCalendar.ts | 1 + .../datepicker/test/DatePicker.test.js | 100 ++++++++++++------ .../datepicker/src/useDateFieldState.ts | 5 +- 3 files changed, 72 insertions(+), 34 deletions(-) diff --git a/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts index b7a60283aa6..7084158bfa9 100644 --- a/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts +++ b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts @@ -49,6 +49,7 @@ export class BuddhistCalendar extends GregorianCalendar { getDaysInMonth(date: AnyCalendarDate): number { return super.getDaysInMonth(toGregorian(date)); } + balanceDate(): void {} } diff --git a/packages/@react-spectrum/datepicker/test/DatePicker.test.js b/packages/@react-spectrum/datepicker/test/DatePicker.test.js index 1e8636b95a8..1f5b267412d 100644 --- a/packages/@react-spectrum/datepicker/test/DatePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DatePicker.test.js @@ -91,7 +91,7 @@ describe('DatePicker', function () { expect(segments[1].getAttribute('aria-valuenow')).toBe('3'); expect(segments[1].getAttribute('aria-valuetext')).toBe('3'); expect(segments[1].getAttribute('aria-valuemin')).toBe('1'); - expect(segments[1].getAttribute('aria-valuemax')).toBe('28'); + expect(segments[1].getAttribute('aria-valuemax')).toBe('31'); expect(getTextValue(segments[2])).toBe('2019'); expect(segments[2].getAttribute('aria-label')).toBe('year, '); @@ -124,7 +124,7 @@ describe('DatePicker', function () { expect(segments[1].getAttribute('aria-valuenow')).toBe('3'); expect(segments[1].getAttribute('aria-valuetext')).toBe('3'); expect(segments[1].getAttribute('aria-valuemin')).toBe('1'); - expect(segments[1].getAttribute('aria-valuemax')).toBe('28'); + expect(segments[1].getAttribute('aria-valuemax')).toBe('31'); expect(getTextValue(segments[2])).toBe('2019'); expect(segments[2].getAttribute('aria-label')).toBe('year, '); @@ -466,6 +466,7 @@ describe('DatePicker', function () { act(() => hour.focus()); await user.keyboard('{ArrowUp}'); + act(() => button.focus()); expect(hour).toHaveAttribute('aria-valuetext', '9 AM'); @@ -715,6 +716,7 @@ describe('DatePicker', function () { act(() => minute.focus()); await user.keyboard('{ArrowUp}'); await user.keyboard('{ArrowUp}'); + act(() => button.focus()); expect(minute).toHaveAttribute('aria-valuetext', '01'); @@ -1062,7 +1064,7 @@ describe('DatePicker', function () { let onChange = jest.fn(); // Test controlled mode - let {getByLabelText, unmount} = render( + let {getByLabelText, unmount, getByRole} = render( @@ -1072,11 +1074,15 @@ describe('DatePicker', function () { act(() => {segment.focus();}); await user.keyboard(`{${options?.upKey || 'ArrowUp'}}`); + let button = getByRole('button') + act(() => button.focus()) expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(incremented); expect(segment.textContent).toBe(textContent); + act(() => {segment.focus();}); await user.keyboard(`{${options?.downKey || 'ArrowDown'}}`); + act(() => button.focus()) expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenCalledWith(decremented); expect(segment.textContent).toBe(textContent); @@ -1094,6 +1100,8 @@ describe('DatePicker', function () { act(() => {segment.focus();}); await user.keyboard(`{${options?.upKey || 'ArrowUp'}}`); + button = getByRole('button') + act(() => {button.focus();}); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(incremented); expect(segment.textContent).not.toBe(textContent); @@ -1111,6 +1119,8 @@ describe('DatePicker', function () { act(() => {segment.focus();}); await user.keyboard(`{${options?.downKey || 'ArrowDown'}}`); + button = getByRole('button') + act(() => {button.focus();}); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(decremented); expect(segment.textContent).not.toBe(textContent); @@ -1175,12 +1185,12 @@ describe('DatePicker', function () { }); it('should wrap around when incrementing and decrementing the day', async function () { - await testArrows('day,', new CalendarDate(2019, 2, 28), new CalendarDate(2019, 2, 1), new CalendarDate(2019, 2, 27)); + await testArrows('day,', new CalendarDate(2019, 8, 31), new CalendarDate(2019, 8, 1), new CalendarDate(2019, 8, 30)); await testArrows('day,', new CalendarDate(2019, 2, 1), new CalendarDate(2019, 2, 2), new CalendarDate(2019, 2, 28)); }); it('should support using the page up and down keys to increment and decrement the day by 7', async function () { - await testArrows('day,', new CalendarDate(2019, 2, 3), new CalendarDate(2019, 2, 10), new CalendarDate(2019, 2, 24), {upKey: 'PageUp', downKey: 'PageDown'}); + await testArrows('day,', new CalendarDate(2019, 2, 3), new CalendarDate(2019, 2, 10), new CalendarDate(2019, 2, 27), {upKey: 'PageUp', downKey: 'PageDown'}); }); it('should support using the home and end keys to jump to the min and max day', async function () { @@ -1308,7 +1318,7 @@ describe('DatePicker', function () { function testInput(label, value, keys, newValue, moved, props) { let onChange = jest.fn(); // Test controlled mode - let {getByLabelText, getAllByRole, unmount} = render( + let {getByLabelText, getAllByRole, unmount, getByRole} = render( @@ -1324,17 +1334,14 @@ describe('DatePicker', function () { beforeInput(segment, key); if (key !== '0' || (moved && i === keys.length - 1) || allowsZero) { - expect(onChange).toHaveBeenCalledTimes(++count); + expect(onChange).toHaveBeenCalledTimes(0); } - expect(segment.textContent).toBe(textContent); if (i < keys.length - 1) { expect(segment).toHaveFocus(); } } - expect(onChange).toHaveBeenCalledWith(newValue); - if (moved) { let segments = getAllByRole('spinbutton'); let nextSegment = segments[segments.indexOf(segment) + 1]; @@ -1343,6 +1350,11 @@ describe('DatePicker', function () { expect(segment).toHaveFocus(); } + let button = getByRole('button') + act (() => button.focus()) + expect(onChange).toHaveBeenCalledWith(newValue); + expect(segment.textContent).toBe(textContent); + unmount(); // Test uncontrolled mode @@ -1361,8 +1373,7 @@ describe('DatePicker', function () { beforeInput(segment, key); if (key !== '0' || (moved && i === keys.length - 1) || allowsZero) { - expect(onChange).toHaveBeenCalledTimes(++count); - expect(segment.textContent).not.toBe(textContent); + expect(onChange).toHaveBeenCalledTimes(0); } if (i < keys.length - 1) { @@ -1370,8 +1381,6 @@ describe('DatePicker', function () { } } - expect(onChange).toHaveBeenCalledWith(newValue); - if (moved) { let segments = getAllByRole('spinbutton'); let nextSegment = segments[segments.indexOf(segment) + 1]; @@ -1380,6 +1389,11 @@ describe('DatePicker', function () { expect(segment).toHaveFocus(); } + button = getByRole('button') + act (() => button.focus()) + expect(onChange).toHaveBeenCalledWith(newValue); + expect(segment.textContent).not.toBe(textContent); + unmount(); // Test read only mode @@ -1521,12 +1535,14 @@ describe('DatePicker', function () { let onChange = jest.fn(); // Test controlled mode - let {getByLabelText, unmount} = render(); + let {getByLabelText, unmount, getByRole} = render(); let segment = getByLabelText(label); let textContent = segment.textContent; act(() => {segment.focus();}); await user.keyboard('{Backspace}'); + let button = getByRole('button') + act(() => {button.focus();}); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(newValue); expect(segment.textContent).toBe(textContent); @@ -1540,6 +1556,8 @@ describe('DatePicker', function () { act(() => {segment.focus();}); await user.keyboard('{Backspace}'); + button = getByRole('button') + act(() => {button.focus();}); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(newValue); if (label === 'AM/PM,') { @@ -1613,6 +1631,7 @@ describe('DatePicker', function () { act(() => {segment.focus();}); await user.keyboard('{Backspace}'); + await user.tab() expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(new CalendarDate(201, 2, 3)); expect(segment).toHaveTextContent('٢٠١'); @@ -1633,10 +1652,13 @@ describe('DatePicker', function () { let year = getByLabelText('year,'); act(() => year.focus()); await user.keyboard('{ArrowDown}'); + await user.tab() expect(getByTestId('invalid-icon')).toBeVisible(); + act(() => year.focus()); await user.keyboard('{ArrowUp}'); + await user.tab() expect(queryByTestId('invalid-icon')).toBeNull(); }); @@ -1652,10 +1674,13 @@ describe('DatePicker', function () { let year = getByLabelText('year,'); act(() => year.focus()); await user.keyboard('{ArrowUp}'); + await user.tab(); expect(getByTestId('invalid-icon')).toBeVisible(); + act(() => year.focus()); await user.keyboard('{ArrowDown}'); + await user.tab(); expect(queryByTestId('invalid-icon')).toBeNull(); }); }); @@ -1758,7 +1783,7 @@ describe('DatePicker', function () { expectPlaceholder(combobox, formatter.format(value.toDate(getLocalTimeZone()))); }); - it('should enter a date to modify placeholder (uncontrolled)', function () { + it('should enter a date to modify placeholder (uncontrolled)', async function () { let onChange = jest.fn(); let {getAllByRole} = render(); @@ -1787,20 +1812,21 @@ describe('DatePicker', function () { expectPlaceholder(combobox, `${month}/${day}/yyyy`); beforeInput(document.activeElement, '2'); - expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(0); beforeInput(document.activeElement, '0'); - expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledTimes(0); beforeInput(document.activeElement, '2'); - expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenCalledTimes(0); beforeInput(document.activeElement, '0'); expect(segments[2]).toHaveFocus(); - expect(onChange).toHaveBeenCalledTimes(4); + await user.tab() + expect(onChange).toHaveBeenCalledTimes(1); value = new CalendarDate(2020, 4, 5); expect(onChange).toHaveBeenCalledWith(value); expectPlaceholder(combobox, formatter.format(value.toDate(getLocalTimeZone()))); }); - it('should enter a date to modify placeholder (controlled)', function () { + it('should enter a date to modify placeholder (controlled)', async function () { let onChange = jest.fn(); let {getAllByRole, rerender} = render(); @@ -1828,10 +1854,18 @@ describe('DatePicker', function () { let day = parts.find(p => p.type === 'day').value; expectPlaceholder(combobox, `${month}/${day}/yyyy`); - beforeInput(document.activeElement, '2'); + beforeInput(document.activeElement, '2'); + expect(onChange).not.toHaveBeenCalled(); + value = today(getLocalTimeZone()).set({month: 4, day: 5, year: 2}); + parts = formatter.formatToParts(value.toDate(getLocalTimeZone())); + month = parts.find(p => p.type === 'month').value; + day = parts.find(p => p.type === 'day').value; + let year = parts.find(p => p.type === 'year').value; + expectPlaceholder(combobox, `${month}/${day}/${year}`); + + await user.tab(); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(new CalendarDate(2, 4, 5)); - expect(segments[2]).toHaveFocus(); expectPlaceholder(combobox, 'mm/dd/yyyy'); // controlled value = new CalendarDate(2020, 4, 5); @@ -2033,6 +2067,7 @@ describe('DatePicker', function () { expect(input).toHaveAttribute('name', 'date'); await user.tab(); await user.keyboard('{ArrowUp}'); + await user.tab({shift: true}); expect(getDescription()).toBe('Selected Date: March 3, 2020'); expect(input).toHaveValue('2020-03-03'); @@ -2100,27 +2135,30 @@ describe('DatePicker', function () { await user.keyboard('[Tab][Tab][ArrowUp]'); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); - expect(input.validity.valid).toBe(true); + expect(input.validity.valid).toBe(false); await user.tab(); expect(getDescription()).not.toContain('Value must be 2/3/2020 or later.'); + expect(input.validity.valid).toBe(true); await user.tab({shift: true}); await user.keyboard('2025'); expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); - expect(input.validity.valid).toBe(false); + expect(input.validity.valid).toBe(true); await user.tab(); act(() => {getByTestId('form').checkValidity();}); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); + expect(input.validity.valid).toBe(false); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); await user.keyboard('[Tab][Tab][ArrowDown]'); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); - expect(input.validity.valid).toBe(true); + expect(input.validity.valid).toBe(false); await user.tab(); + expect(input.validity.valid).toBe(true); expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); }); @@ -2148,7 +2186,7 @@ describe('DatePicker', function () { await user.keyboard('[ArrowRight][ArrowRight]2024'); expect(getDescription()).toContain('Invalid value'); - expect(input.validity.valid).toBe(true); + expect(input.validity.valid).toBe(false); await user.tab(); @@ -2284,13 +2322,13 @@ describe('DatePicker', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); - await user.keyboard('[Tab][Tab][Tab][ArrowUp]'); + await user.keyboard('[Tab][Tab][Tab][ArrowUp][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2020 or later.'); - await user.keyboard('[ArrowUp][ArrowUp][ArrowUp][ArrowUp][ArrowUp]'); + await user.keyboard('[Tab][Tab][Tab][Tab][ArrowUp][ArrowUp][ArrowUp][ArrowUp][ArrowUp][Tab]'); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); - await user.keyboard('[ArrowDown]'); + await user.keyboard('[Tab][Tab][Tab][Tab][ArrowDown][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); }); @@ -2308,7 +2346,7 @@ describe('DatePicker', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Invalid value'); - await user.keyboard('[Tab][ArrowRight][ArrowRight]2024'); + await user.keyboard('[Tab][ArrowRight][ArrowRight]2024[Tab]'); expect(getDescription()).not.toContain('Invalid value'); }); diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 44ed18ba21f..5b619eb53a0 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -167,7 +167,7 @@ export function useDateFieldState(props: DateFi let v: DateValue | null = props.value || props.defaultValue || props.placeholderValue || null; let [granularity, defaultTimeZone] = useDefaultProps(v, props.granularity); let timeZone = defaultTimeZone || 'UTC'; - const isValueConfirmed = useRef(false) + const isValueConfirmed = useRef(props.value || props.defaultValue ? true : false) const [isValueChanged, setIsValueChanged] = useState(false) // props.granularity must actually exist in the value if one is provided. @@ -262,7 +262,7 @@ export function useDateFieldState(props: DateFi setIsValueChanged(false) } // If all segments are valid, use the date from state, otherwise use the placeholder date. - let displayValue = placeholderDate; + let displayValue = isValueConfirmed.current && value ? value: placeholderDate ; let setValue = (newValue: DateValue) => { if (props.isDisabled || props.isReadOnly) { return; @@ -302,7 +302,6 @@ export function useDateFieldState(props: DateFi return; } setPlaceholderDate(newValue); - clearedSegment.current = null; }; let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]);