Skip to content

Conversation

@devongovett
Copy link
Member

@devongovett devongovett commented Jan 22, 2026

Fixes #3256, fixes #5965, closes #8385, fixes #6004

This is a slimmed down version of #8385 (thanks @boutahlilsoufiane for getting it started!). It refactors the way state is stored in useDateFieldState to use a new IncompleteDate class instead of types from @internationalized/date. This lets us temporarily store incomplete or invalid date values where some of the fields are null or represent dates or times that don't exist (e.g. February 31st, or 2am during a forward DST transition).

Instead of constraining the value immediately as the user types, these values are not emitted via onChange until the user blurs, at which point we constrain to a valid date. This lets users more easily edit dates that are temporarily invalid, e.g. when changing the day before the month. We still emit onChange in real time whenever possible, but not when the displayed date is invalid or incomplete.

Since Intl.DateTimeFormat cannot format invalid dates, we only use it to get the expected order of the fields, and then format the individual segments with Intl.NumberFormat.

This refactor also enabled entering zeros in all fields, even when zero is not a valid value (e.g. month/day). Doing this for 12 hour time required making IncompleteDate represent time in the user displayed hour cycle rather than always storing it in 24 hour time. This way we can store zero as a value and distinguish it from 12am.

@rspbot
Copy link

rspbot commented Jan 22, 2026

case 'hour': {
// TODO: in the case of a "fall back" DST transition, the 1am hour repeats twice.
// With this logic, it's no longer possible to select the second instance.
// Using cycle from ZonedDateTime works as expected, but requires the date already be complete.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge case! Previously you could enter 11/7/2021, 1:00 AM PST by first entering 11/7/2021, 1:00 AM PDT and then incrementing the hour field to switch from PDT to PST (try it here), but now that's impossible.

We could potentially check if the value is already complete (meaning we have all of the date fields and the hour), and in that case delegate to ZonedDateTime to handle the cycling. But it's impossible if we don't know the date yet – the user would need to enter everything and then go back and press the up arrow. Should we handle this case?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe related, but in the TimeField 12hr, if it's empty with just the "AM", then clicking that and pressing "up arrow" causes it to set the hours to "12" and takes a second press to get it to cycle to "PM"

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was also the case before but I refactored the way time is stored anyway to support entering zeros.

Copy link
Member

@snowystinger snowystinger left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In TimeField s2 storybook it's displaying "AM" as "am" lowercase. In the s2 docs though, it's displaying correctly
Locale difference moving from US to AUS

In TimeField 12hr, aria-valuemin is 0 and valuemax is 11, but i think it should be 1-12? This is pre-existing.

<TimeField hourCycle={12} placeholderValue={new Time(20, 30, 0)} />

For some reason this is displaying blank, just dashes. Using ArrowUp on the hour field will show 8 though, and the display of pm is correct

case 'hour': {
// TODO: in the case of a "fall back" DST transition, the 1am hour repeats twice.
// With this logic, it's no longer possible to select the second instance.
// Using cycle from ZonedDateTime works as expected, but requires the date already be complete.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe related, but in the TimeField 12hr, if it's empty with just the "AM", then clicking that and pressing "up arrow" causes it to set the hours to "12" and takes a second press to get it to cycle to "PM"

@devongovett
Copy link
Member Author

@snowystinger

In TimeField 12hr, aria-valuemin is 0 and valuemax is 11, but i think it should be 1-12? This is pre-existing.

I did change this to be 1-12 but it's sort of incorrect because really 12 is the minimum and 11 is the maximum (12 hour time is weird). Noticed that chrome's native date picker does set it like this though.

For some reason this is displaying blank, just dashes. Using ArrowUp on the hour field will show 8 though, and the display of pm is correct

I think because you set a placeholderValue, not a value/defaultValue, so it doesn't display.

displayValue = new IncompleteDate(calendar, hourCycle, calendarValue);
setLastValue(calendarValue);
setLastCalendar(calendar);
setLastHourCycle(hourCycle);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we want to support the case where the user starts entering a date/time, and then changes the calendar/hour cycle before completing it, we will need more complex logic here. Right now, this would simply reset the display value back to the current props.value rather than converting the partially entered value (which may be impossible in some cases).

@rspbot
Copy link

rspbot commented Jan 23, 2026

@rspbot
Copy link

rspbot commented Jan 23, 2026

## API Changes

react-aria-components

/react-aria-components:DateSegmentRenderProps

 DateSegmentRenderProps {
   isDisabled: boolean
   isFocusVisible: boolean
   isFocused: boolean
   isHovered: boolean
   isInvalid: boolean
   isPlaceholder: boolean
   isReadOnly: boolean
   maxValue?: number
   minValue?: number
   placeholder: string
   text: string
   type: SegmentType
-  value?: number
+  value?: number | null
 }

/react-aria-components:DateFieldState

 DateFieldState {
   calendar: Calendar
   clearSegment: (SegmentType) => void
   commitValidation: () => void
   confirmPlaceholder: () => void
   dateFormatter: DateFormatter
   dateValue: Date
   decrement: (SegmentType) => void
   decrementPage: (SegmentType) => void
+  decrementToMin: (SegmentType) => void
   defaultValue: DateValue | null
   displayValidation: ValidationResult
   formatValue: (FieldOptions) => string
   getDateFormatter: (string, FormatterOptions) => DateFormatter
   granularity: Granularity
   increment: (SegmentType) => void
   incrementPage: (SegmentType) => void
+  incrementToMax: (SegmentType) => void
   isDisabled: boolean
   isInvalid: boolean
   isReadOnly: boolean
   isRequired: boolean
   realtimeValidation: ValidationResult
   resetValidation: () => void
   segments: Array<DateSegment>
   setSegment: (SegmentType, number) => void
   setValue: (DateValue | null) => void
   updateValidation: (ValidationResult) => void
   value: DateValue | null
 }

/react-aria-components:TimeFieldState

 TimeFieldState {
   calendar: Calendar
   clearSegment: (SegmentType) => void
   commitValidation: () => void
   confirmPlaceholder: () => void
   dateFormatter: DateFormatter
   dateValue: Date
   decrement: (SegmentType) => void
   decrementPage: (SegmentType) => void
+  decrementToMin: (SegmentType) => void
   defaultValue: DateValue | null
   displayValidation: ValidationResult
   formatValue: (FieldOptions) => string
   getDateFormatter: (string, FormatterOptions) => DateFormatter
   granularity: Granularity
   increment: (SegmentType) => void
   incrementPage: (SegmentType) => void
+  incrementToMax: (SegmentType) => void
   isDisabled: boolean
   isInvalid: boolean
   isReadOnly: boolean
   isRequired: boolean
   realtimeValidation: ValidationResult
   resetValidation: () => void
   segments: Array<DateSegment>
   setSegment: (SegmentType, number) => void
   setValue: (DateValue | null) => void
   timeValue: Time
   updateValidation: (ValidationResult) => void
   value: DateValue | null
 }

@internationalized/date

/@internationalized/date:Calendar

 Calendar {
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getEras: () => Array<string>
   getFormattableMonth: (AnyCalendarDate) => CalendarDate
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMinimumDayInMonth: (AnyCalendarDate) => number
   getMinimumMonthInYear: (AnyCalendarDate) => number
   getMonthsInYear: (AnyCalendarDate) => number
   getYearsInEra: (AnyCalendarDate) => number
   isEqual: (Calendar) => boolean
   toJulianDay: (AnyCalendarDate) => number
 }

/@internationalized/date:GregorianCalendar

 GregorianCalendar {
   balanceDate: (Mutable<AnyCalendarDate>) => void
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: (AnyCalendarDate) => number
   getYearsInEra: (AnyCalendarDate) => number
   identifier: CalendarIdentifier
   isInverseEra: (AnyCalendarDate) => boolean
 }

/@internationalized/date:JapaneseCalendar

 JapaneseCalendar {
   balanceDate: (Mutable<AnyCalendarDate>) => void
   constrainDate: (Mutable<AnyCalendarDate>) => void
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMinimumDayInMonth: (AnyCalendarDate) => number
   getMinimumMonthInYear: (AnyCalendarDate) => number
   getMonthsInYear: (AnyCalendarDate) => number
   getYearsInEra: (AnyCalendarDate) => number
   isInverseEra: (AnyCalendarDate) => boolean
   toJulianDay: (AnyCalendarDate) => number
 }

/@internationalized/date:BuddhistCalendar

 BuddhistCalendar {
   balanceDate: () => void
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: (AnyCalendarDate) => number
   getYearsInEra: (AnyCalendarDate) => number
   identifier: CalendarIdentifier
   isInverseEra: (AnyCalendarDate) => boolean
 }

/@internationalized/date:TaiwanCalendar

 TaiwanCalendar {
   balanceDate: (Mutable<AnyCalendarDate>) => void
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: (AnyCalendarDate) => number
   getYearsInEra: (AnyCalendarDate) => number
   identifier: CalendarIdentifier
   isInverseEra: (AnyCalendarDate) => boolean
 }

/@internationalized/date:PersianCalendar

 PersianCalendar {
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: () => number
   getYearsInEra: () => number
   identifier: CalendarIdentifier
   toJulianDay: (AnyCalendarDate) => number

/@internationalized/date:IndianCalendar

 IndianCalendar {
   balanceDate: () => void
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: (AnyCalendarDate) => number
   getYearsInEra: () => number
   identifier: CalendarIdentifier
   isInverseEra: (AnyCalendarDate) => boolean
 }

/@internationalized/date:IslamicCivilCalendar

 IslamicCivilCalendar {
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: () => number
   getYearsInEra: () => number
   identifier: CalendarIdentifier
   toJulianDay: (AnyCalendarDate) => number

/@internationalized/date:IslamicTabularCalendar

 IslamicTabularCalendar {
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: () => number
   getYearsInEra: () => number
   identifier: CalendarIdentifier
   toJulianDay: (AnyCalendarDate) => number

/@internationalized/date:IslamicUmalquraCalendar

 IslamicUmalquraCalendar {
   constructor: () => void
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: () => number
   getYearsInEra: () => number
   identifier: CalendarIdentifier
   toJulianDay: (AnyCalendarDate) => number

/@internationalized/date:HebrewCalendar

 HebrewCalendar {
   balanceYearMonth: (Mutable<AnyCalendarDate>, AnyCalendarDate) => void
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: (AnyCalendarDate) => number
   getYearsInEra: () => number
   identifier: CalendarIdentifier
   toJulianDay: (AnyCalendarDate) => number

/@internationalized/date:EthiopicCalendar

 EthiopicCalendar {
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: () => number
   getYearsInEra: (AnyCalendarDate) => number
   identifier: CalendarIdentifier
   toJulianDay: (AnyCalendarDate) => number

/@internationalized/date:EthiopicAmeteAlemCalendar

 EthiopicAmeteAlemCalendar {
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: () => number
   getYearsInEra: () => number
   identifier: CalendarIdentifier
   toJulianDay: (AnyCalendarDate) => number

/@internationalized/date:CopticCalendar

 CopticCalendar {
   balanceDate: (Mutable<AnyCalendarDate>) => void
   fromJulianDay: (number) => CalendarDate
   getDaysInMonth: (AnyCalendarDate) => number
   getDaysInYear: (AnyCalendarDate) => number
   getEras: () => Array<string>
+  getMaximumDaysInMonth: () => number
+  getMaximumMonthsInYear: () => number
   getMonthsInYear: () => number
   getYearsInEra: (AnyCalendarDate) => number
   identifier: CalendarIdentifier
   isInverseEra: (AnyCalendarDate) => boolean
 }

@react-stately/datepicker

/@react-stately/datepicker:DateFieldState

 DateFieldState {
   calendar: Calendar
   clearSegment: (SegmentType) => void
   commitValidation: () => void
   confirmPlaceholder: () => void
   dateFormatter: DateFormatter
   dateValue: Date
   decrement: (SegmentType) => void
   decrementPage: (SegmentType) => void
+  decrementToMin: (SegmentType) => void
   defaultValue: DateValue | null
   displayValidation: ValidationResult
   formatValue: (FieldOptions) => string
   getDateFormatter: (string, FormatterOptions) => DateFormatter
   granularity: Granularity
   increment: (SegmentType) => void
   incrementPage: (SegmentType) => void
+  incrementToMax: (SegmentType) => void
   isDisabled: boolean
   isInvalid: boolean
   isReadOnly: boolean
   isRequired: boolean
   realtimeValidation: ValidationResult
   resetValidation: () => void
   segments: Array<DateSegment>
   setSegment: (SegmentType, number) => void
   setValue: (DateValue | null) => void
   updateValidation: (ValidationResult) => void
   value: DateValue | null
 }

/@react-stately/datepicker:DateSegment

 DateSegment {
   isEditable: boolean
   isPlaceholder: boolean
   maxValue?: number
   minValue?: number
   placeholder: string
   text: string
   type: SegmentType
-  value?: number
+  value?: number | null
 }

/@react-stately/datepicker:TimeFieldState

 TimeFieldState {
   calendar: Calendar
   clearSegment: (SegmentType) => void
   commitValidation: () => void
   confirmPlaceholder: () => void
   dateFormatter: DateFormatter
   dateValue: Date
   decrement: (SegmentType) => void
   decrementPage: (SegmentType) => void
+  decrementToMin: (SegmentType) => void
   defaultValue: DateValue | null
   displayValidation: ValidationResult
   formatValue: (FieldOptions) => string
   getDateFormatter: (string, FormatterOptions) => DateFormatter
   granularity: Granularity
   increment: (SegmentType) => void
   incrementPage: (SegmentType) => void
+  incrementToMax: (SegmentType) => void
   isDisabled: boolean
   isInvalid: boolean
   isReadOnly: boolean
   isRequired: boolean
   realtimeValidation: ValidationResult
   resetValidation: () => void
   segments: Array<DateSegment>
   setSegment: (SegmentType, number) => void
   setValue: (DateValue | null) => void
   timeValue: Time
   updateValidation: (ValidationResult) => void
   value: DateValue | null
 }

case 'hour': {
// TODO: in the case of a "fall back" DST transition, the 1am hour repeats twice.
// With this logic, it's no longer possible to select the second instance.
// Using cycle from ZonedDateTime works as expected, but requires the date already be complete.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repeating my previous comment since the code moved: Edge case! Previously you could enter 11/7/2021, 1:00 AM PST by first entering 11/7/2021, 1:00 AM PDT and then incrementing the hour field to switch from PDT to PST (try it here), but now that's impossible.

We could potentially check if the value is already complete (meaning we have all of the date fields and the hour), and in that case delegate to ZonedDateTime to handle the cycling. But it's impossible if we don't know the date yet – the user would need to enter everything and then go back and press the up arrow. Should we handle this case?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants