From c004da556f95affbdc43e6b4ba1d2622d7cf422c Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 22 Jan 2026 15:01:50 -0500 Subject: [PATCH 1/3] feat: Support entering invalid dates in DateField and constrain on blur --- .../date/src/calendars/EthiopicCalendar.ts | 8 + .../date/src/calendars/GregorianCalendar.ts | 8 + .../date/src/calendars/HebrewCalendar.ts | 8 + .../date/src/calendars/IslamicCalendar.ts | 8 + .../date/src/calendars/PersianCalendar.ts | 8 + packages/@internationalized/date/src/types.ts | 4 + .../datepicker/src/useDateSegment.ts | 4 +- .../datepicker/test/DatePicker.test.js | 138 ++++- .../datepicker/test/DateRangePicker.test.js | 12 +- .../@react-stately/datepicker/package.json | 1 + .../datepicker/src/useDateFieldState.ts | 517 ++++++++++++------ yarn.lock | 1 + 12 files changed, 511 insertions(+), 206 deletions(-) diff --git a/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts index c96be3fb827..44f036eb197 100644 --- a/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts +++ b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts @@ -100,6 +100,14 @@ export class EthiopicCalendar implements Calendar { return 365 + getLeapDay(date.year); } + getMaximumMonthsInYear(): number { + return 13; + } + + getMaximumDaysInMonth(): number { + return 30; + } + getYearsInEra(date: AnyCalendarDate): number { // 9999-12-31 gregorian is 9992-20-02 ethiopic. // Round down to 9991 for the last full year. diff --git a/packages/@internationalized/date/src/calendars/GregorianCalendar.ts b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts index 31106c379fe..059b33b7a22 100644 --- a/packages/@internationalized/date/src/calendars/GregorianCalendar.ts +++ b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts @@ -113,6 +113,14 @@ export class GregorianCalendar implements Calendar { return isLeapYear(date.year) ? 366 : 365; } + getMaximumMonthsInYear(): number { + return 12; + } + + getMaximumDaysInMonth(): number { + return 31; + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars getYearsInEra(date: AnyCalendarDate): number { return 9999; diff --git a/packages/@internationalized/date/src/calendars/HebrewCalendar.ts b/packages/@internationalized/date/src/calendars/HebrewCalendar.ts index 52d3f43bc2f..2e8b9fa0b79 100644 --- a/packages/@internationalized/date/src/calendars/HebrewCalendar.ts +++ b/packages/@internationalized/date/src/calendars/HebrewCalendar.ts @@ -180,6 +180,14 @@ export class HebrewCalendar implements Calendar { return getDaysInYear(date.year); } + getMaximumMonthsInYear(): number { + return 13; + } + + getMaximumDaysInMonth(): number { + return 30; + } + getYearsInEra(): number { // 6239 gregorian return 9999; diff --git a/packages/@internationalized/date/src/calendars/IslamicCalendar.ts b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts index 7696e852224..9dad340d16a 100644 --- a/packages/@internationalized/date/src/calendars/IslamicCalendar.ts +++ b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts @@ -77,6 +77,14 @@ export class IslamicCivilCalendar implements Calendar { return isLeapYear(date.year) ? 355 : 354; } + getMaximumMonthsInYear(): number { + return 12; + } + + getMaximumDaysInMonth(): number { + return 30; + } + getYearsInEra(): number { // 9999 gregorian return 9665; diff --git a/packages/@internationalized/date/src/calendars/PersianCalendar.ts b/packages/@internationalized/date/src/calendars/PersianCalendar.ts index 0ff6c86cec5..65ba96e3c8e 100644 --- a/packages/@internationalized/date/src/calendars/PersianCalendar.ts +++ b/packages/@internationalized/date/src/calendars/PersianCalendar.ts @@ -80,6 +80,14 @@ export class PersianCalendar implements Calendar { return isLeapYear ? 30 : 29; } + getMaximumMonthsInYear(): number { + return 12; + } + + getMaximumDaysInMonth(): number { + return 31; + } + getEras(): string[] { return ['AP']; } diff --git a/packages/@internationalized/date/src/types.ts b/packages/@internationalized/date/src/types.ts index 78fba68fe0d..c05e98972a1 100644 --- a/packages/@internationalized/date/src/types.ts +++ b/packages/@internationalized/date/src/types.ts @@ -74,6 +74,10 @@ export interface Calendar { * eras may begin in the middle of a month. */ getMinimumDayInMonth?(date: AnyCalendarDate): number, + /** Returns the maximum months across all years. */ + getMaximumMonthsInYear(): number, + /** Returns the maximum days across all months. */ + getMaximumDaysInMonth(): number, /** * Returns a date that is the first day of the month for the given date. * This is used to determine the month that the given date falls in, if diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index 2aad84bc0ea..fd75216e61d 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -57,7 +57,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: // The ARIA spec says aria-valuenow is optional if there's no value, but aXe seems to require it. // This doesn't seem to have any negative effects with real AT since we also use aria-valuetext. // https://github.com/dequelabs/axe-core/issues/3505 - value: segment.value, + value: segment.value ?? undefined, textValue, minValue: segment.minValue, maxValue: segment.maxValue, @@ -235,7 +235,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: break; } - if (segment.value !== undefined && segment.value >= 12 && numberValue > 1) { + if (segment.value != null && segment.value >= 12 && numberValue > 1) { numberValue += 12; } } else if (segment.maxValue !== undefined && numberValue > segment.maxValue) { diff --git a/packages/@react-spectrum/datepicker/test/DatePicker.test.js b/packages/@react-spectrum/datepicker/test/DatePicker.test.js index 3b931ba8152..ba0e07d2212 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, '); @@ -516,7 +516,7 @@ describe('DatePicker', function () { expect(hour).toHaveAttribute('aria-valuetext', '1 AM'); await user.keyboard('{Backspace}'); - expect(hour).toHaveAttribute('aria-valuetext', '1 AM'); + expect(hour).toHaveAttribute('aria-valuetext', 'Empty'); expect(dialog).toBeVisible(); expect(onChange).toHaveBeenCalledTimes(2); @@ -1175,16 +1175,16 @@ 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, 2, 1), new CalendarDate(2019, 2, 2), new CalendarDate(2019, 2, 28)); + await testArrows('day,', new CalendarDate(2019, 8, 31), new CalendarDate(2019, 8, 1), new CalendarDate(2019, 8, 30)); + await testArrows('day,', new CalendarDate(2019, 8, 1), new CalendarDate(2019, 8, 2), new CalendarDate(2019, 8, 31)); }); 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 () { - await testArrows('day,', new CalendarDate(2019, 2, 5), new CalendarDate(2019, 2, 28), new CalendarDate(2019, 2, 1), {upKey: 'End', downKey: 'Home'}); + await testArrows('day,', new CalendarDate(2019, 8, 5), new CalendarDate(2019, 8, 31), new CalendarDate(2019, 8, 1), {upKey: 'End', downKey: 'Home'}); }); }); @@ -1302,6 +1302,29 @@ describe('DatePicker', function () { expect(queryByTestId('era')).toBeNull(); }); }); + + it('should allow entering invalid dates, and constrain on blur', async function () { + let onChange = jest.fn(); + let {getAllByRole} = render( + + + + ); + + let group = getAllByRole('group')[0]; + await user.tab(); + await user.tab(); + await user.keyboard('{ArrowUp}'); + await user.keyboard('{ArrowUp}'); + expect(onChange).not.toHaveBeenCalled(); + expectPlaceholder(group, '2/30/2026'); + + await user.tab(); + await user.tab(); + await user.tab(); + expect(onChange).not.toHaveBeenCalled(); + expectPlaceholder(group, '2/28/2026'); + }); }); describe('text input', function () { @@ -1323,7 +1346,7 @@ describe('DatePicker', function () { for (let [i, key] of [...keys].entries()) { beforeInput(segment, key); - if (key !== '0' || (moved && i === keys.length - 1) || allowsZero) { + if (key !== '0' || (moved && i === keys.length - 1 && keys !== '00') || (i < keys.length - 1 && allowsZero)) { expect(onChange).toHaveBeenCalledTimes(++count); } expect(segment.textContent).toBe(textContent); @@ -1360,7 +1383,7 @@ describe('DatePicker', function () { for (let [i, key] of [...keys].entries()) { beforeInput(segment, key); - if (key !== '0' || (moved && i === keys.length - 1) || allowsZero) { + if (key !== '0' || (moved && i === keys.length - 1 && keys !== '00') || (i < keys.length - 1 && allowsZero)) { expect(onChange).toHaveBeenCalledTimes(++count); expect(segment.textContent).not.toBe(textContent); } @@ -1514,6 +1537,53 @@ describe('DatePicker', function () { testInput('era,', new CalendarDate(new EthiopicCalendar(), 'AM', 2012, 2, 3), '0', new CalendarDate(new EthiopicCalendar(), 'AA', 2012, 2, 3), false, {locale: 'en-US-u-ca-ethiopic'}); testInput('era,', new CalendarDate(new EthiopicCalendar(), 'AA', 2012, 2, 3), '1', new CalendarDate(new EthiopicCalendar(), 'AM', 2012, 2, 3), false, {locale: 'en-US-u-ca-ethiopic'}); }); + + it('should allow entering invalid dates, and constrain on blur', async function () { + let onChange = jest.fn(); + let {getAllByRole} = render( + + + + ); + + let group = getAllByRole('group')[0]; + await user.tab(); + await user.keyboard('02'); + await user.keyboard('31'); + await user.keyboard('2026'); + expect(onChange).not.toHaveBeenCalled(); + expectPlaceholder(group, '2/31/2026'); + + await user.tab(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(new CalendarDate(2026, 2, 28)); + expectPlaceholder(group, '2/28/2026'); + }); + + it('should allow entering invalid times, and constrain on blur', async function () { + let onChange = jest.fn(); + let {getAllByRole} = render( + + + + ); + + let group = getAllByRole('group')[0]; + await user.tab(); + await user.keyboard('3'); + await user.keyboard('8'); + await user.keyboard('2026'); + await user.keyboard('02'); + await user.keyboard('45'); + expect(onChange).not.toHaveBeenCalled(); + expectPlaceholder(group, '3/8/2026, 2:45 AM PDT'); // this time does not exist (during DST transition) + + await user.tab(); + await user.tab(); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(parseZonedDateTime('2026-03-08T03:45:00[America/Los_Angeles]')); + expectPlaceholder(group, '3/8/2026, 3:45 AM PDT'); + }); }); describe('backspace', function () { @@ -1527,9 +1597,13 @@ describe('DatePicker', function () { act(() => {segment.focus();}); await user.keyboard('{Backspace}'); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith(newValue); - expect(segment.textContent).toBe(textContent); + if (newValue != null) { + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(newValue); + expect(segment.textContent).toBe(textContent); + } else if (label !== 'AM/PM,') { + expect(segment).toHaveAttribute('aria-valuetext', 'Empty'); + } unmount(); // Test uncontrolled mode @@ -1540,13 +1614,15 @@ describe('DatePicker', function () { act(() => {segment.focus();}); await user.keyboard('{Backspace}'); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith(newValue); - if (label === 'AM/PM,') { - expect(segment).toHaveAttribute('data-placeholder', 'true'); - expect(segment).toHaveAttribute('aria-valuetext', 'Empty'); - } else { - expect(segment.textContent).not.toBe(textContent); + if (newValue != null) { + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(newValue); + if (label === 'AM/PM,') { + expect(segment).toHaveAttribute('data-placeholder', 'true'); + expect(segment).toHaveAttribute('aria-valuetext', 'Empty'); + } else { + expect(segment.textContent).not.toBe(textContent); + } } unmount(); } @@ -1617,6 +1693,30 @@ describe('DatePicker', function () { expect(onChange).toHaveBeenCalledWith(new CalendarDate(201, 2, 3)); expect(segment).toHaveTextContent('٢٠١'); }); + + it('should trigger onChange with null when all segments are cleared', async function () { + let onChange = jest.fn(); + let {getAllByRole} = render( + + + + ); + + let group = getAllByRole('group')[0]; + await user.tab({shift: true}); + await user.tab({shift: true}); + await user.keyboard('{Backspace}{Backspace}{Backspace}{Backspace}{Backspace}'); + expect(onChange).toHaveBeenCalledTimes(3); + onChange.mockReset(); + expectPlaceholder(group, '2/3/yyyy'); + await user.keyboard('{Backspace}{Backspace}'); + expect(onChange).not.toHaveBeenCalled(); + expectPlaceholder(group, '2/dd/yyyy'); + await user.keyboard('{Backspace}{Backspace}'); + expectPlaceholder(group, 'mm/dd/yyyy'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(null); + }); }); }); diff --git a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js index cf027ab73ad..46f066c1d0b 100644 --- a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js @@ -103,7 +103,7 @@ describe('DateRangePicker', 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, Start Date, '); @@ -157,7 +157,7 @@ describe('DateRangePicker', 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, Start Date, '); @@ -1433,8 +1433,8 @@ describe('DateRangePicker', function () { expect(segments[1]).toHaveFocus(); expect(onChange).not.toHaveBeenCalled(); - beforeInput(document.activeElement, '3'); - expectPlaceholder(startDate, '2/3/yyyy'); + beforeInput(document.activeElement, '4'); + expectPlaceholder(startDate, '2/4/yyyy'); expect(segments[2]).toHaveFocus(); expect(onChange).not.toHaveBeenCalled(); @@ -1442,7 +1442,7 @@ describe('DateRangePicker', function () { beforeInput(document.activeElement, '0'); beforeInput(document.activeElement, '2'); beforeInput(document.activeElement, '0'); - expectPlaceholder(startDate, '2/3/2020'); + expectPlaceholder(startDate, '2/4/2020'); expect(segments[3]).toHaveFocus(); expect(onChange).not.toHaveBeenCalled(); @@ -1465,7 +1465,7 @@ describe('DateRangePicker', function () { beforeInput(document.activeElement, '2'); expect(onChange).toHaveBeenCalledTimes(4); - expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2020, 2, 3), end: new CalendarDate(2022, 4, 8)}); + expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2020, 2, 4), end: new CalendarDate(2022, 4, 8)}); }); it('should reset to the placeholder if controlled value is set to null', function () { diff --git a/packages/@react-stately/datepicker/package.json b/packages/@react-stately/datepicker/package.json index 5dce97dd379..829790f9d2e 100644 --- a/packages/@react-stately/datepicker/package.json +++ b/packages/@react-stately/datepicker/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "@internationalized/date": "^3.10.1", + "@internationalized/number": "^3.6.5", "@internationalized/string": "^3.2.7", "@react-stately/form": "^3.2.2", "@react-stately/overlays": "^3.6.21", diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 2bf28053575..a0aa37f3e92 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -10,13 +10,14 @@ * governing permissions and limitations under the License. */ -import {Calendar, CalendarIdentifier, DateFormatter, getMinimumDayInMonth, getMinimumMonthInYear, GregorianCalendar, isEqualCalendar, toCalendar} from '@internationalized/date'; +import {AnyDateTime, Calendar, CalendarDate, CalendarIdentifier, CycleTimeOptions, DateField, DateFields, DateFormatter, GregorianCalendar, isEqualCalendar, TimeField, TimeFields, toCalendar} from '@internationalized/date'; import {convertValue, createPlaceholderDate, FieldOptions, FormatterOptions, getFormatOptions, getValidationResult, useDefaultProps} from './utils'; import {DatePickerProps, DateValue, Granularity, MappedDateValue} from '@react-types/datepicker'; import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {getPlaceholder} from './placeholders'; +import {NumberFormatter} from '@internationalized/number'; import {useControlledState} from '@react-stately/utils'; -import {useEffect, useMemo, useRef, useState} from 'react'; +import {useMemo, useState} from 'react'; import {ValidationState} from '@react-types/shared'; export type SegmentType = 'era' | 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'dayPeriod' | 'literal' | 'timeZoneName'; @@ -26,7 +27,7 @@ export interface DateSegment { /** The formatted text for the segment. */ text: string, /** The numeric value for the segment, if applicable. */ - value?: number, + value?: number | null, /** The minimum numeric value for the segment, if applicable. */ minValue?: number, /** The maximum numeric value for the segment, if applicable. */ @@ -185,16 +186,21 @@ export function useDateFieldState(props: DateFi let [initialValue] = useState(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 - // until all segments are set. If the value === null (not undefined), then assume the component - // 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) + let [displayValue, setDisplayValue] = useState( + () => new IncompleteDate(calendar, calendarValue) ); - let val = calendarValue || placeholderDate; - let showEra = calendar.identifier === 'gregory' && val.era === 'BC'; + let [lastValue, setLastValue] = useState(value); + let [lastCalendar, setLastCalendar] = useState(calendar); + if (calendarValue !== lastValue) { + setLastValue(calendarValue); + setDisplayValue(new IncompleteDate(calendar, calendarValue)); + } else if (!isEqualCalendar(calendar, lastCalendar)) { + setLastCalendar(calendar); + setDisplayValue(new IncompleteDate(calendar, calendarValue)); + } + + let showEra = calendar.identifier === 'gregory' && displayValue.era === 'BC'; let formatOpts = useMemo(() => ({ granularity, maxGranularity: props.maxGranularity ?? 'year', @@ -208,117 +214,61 @@ export function useDateFieldState(props: DateFi let dateFormatter = useMemo(() => new DateFormatter(locale, opts), [locale, opts]); let resolvedOptions = useMemo(() => dateFormatter.resolvedOptions(), [dateFormatter]); - - // Determine how many editable segments there are for validation purposes. - // The result is cached for performance. - let allSegments: Partial = useMemo(() => - dateFormatter.formatToParts(new Date()) - .filter(seg => EDITABLE_SEGMENTS[seg.type]) - .reduce((p, seg) => (p[TYPE_MAPPING[seg.type] || seg.type] = true, p), {}) - , [dateFormatter]); - - let [validSegments, setValidSegments] = useState>( - () => props.value || props.defaultValue ? {...allSegments} : {} - ); - - let clearedSegment = useRef(null); - - // Reset placeholder when calendar changes - let lastCalendar = useRef(calendar); - useEffect(() => { - if (!isEqualCalendar(calendar, lastCalendar.current)) { - lastCalendar.current = calendar; - setPlaceholderDate(placeholder => - Object.keys(validSegments).length > 0 - ? toCalendar(placeholder, calendar) - : createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone) - ); - } - }, [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) { - 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) { - validSegments = {}; - setValidSegments(validSegments); - 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) => { + let placeholder = useMemo(() => createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone), [props.placeholderValue, granularity, calendar, defaultTimeZone]); + let displaySegments = useMemo(() => { + let segments: (DateField | TimeField)[] = ['era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond']; + let minIndex = segments.indexOf(props.maxGranularity || 'year'); + let maxIndex = segments.indexOf(granularity); + return segments.slice(minIndex, maxIndex + 1); + }, [props.maxGranularity, granularity]); + + let setValue = (newValue: DateValue | IncompleteDate | null) => { if (props.isDisabled || props.isReadOnly) { return; } - let validKeys = Object.keys(validSegments); - let allKeys = Object.keys(allSegments); - // if all the segments are completed or a timefield with everything but am/pm set the time, also ignore when am/pm cleared - if (newValue == null) { + if (newValue == null || (newValue instanceof IncompleteDate && newValue.isCleared(displaySegments))) { + setDisplayValue(new IncompleteDate(calendar, calendarValue)); setDate(null); - setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); - setValidSegments({}); - } else if ( - (validKeys.length === 0 && clearedSegment.current == null) || - validKeys.length >= allKeys.length || - (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod') - ) { - // If the field was empty (no valid segments) or all segments are completed, commit the new value. - // When committing from an empty state, mark every segment as valid so value is committed. - if (validKeys.length === 0) { - validSegments = {...allSegments}; - setValidSegments(validSegments); - } - + } else if (!(newValue instanceof IncompleteDate)) { // 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()); + setDisplayValue(new IncompleteDate(calendar, calendarValue)); setDate(newValue); } else { - setPlaceholderDate(newValue); + // If the new value is complete and valid, trigger onChange eagerly. + // If it represents an incomplete or invalid value (e.g. February 30th), + // wait until the field is blurred to trigger onChange. + if (newValue.isComplete(displaySegments)) { + let dateValue = newValue.toValue(calendarValue ?? placeholder); + if (newValue.validate(dateValue, displaySegments)) { + let newDateValue = toCalendar(dateValue, v?.calendar || new GregorianCalendar()); + if (!value || newDateValue.compare(value) !== 0) { + setDisplayValue(new IncompleteDate(calendar, calendarValue)); // reset in case prop isn't updated + setDate(newDateValue); + return; + } + } + } + + // Incomplete/invalid value. Set temporary display override. + setDisplayValue(newValue); } - clearedSegment.current = null; }; - let dateValue = useMemo(() => displayValue.toDate(timeZone), [displayValue, timeZone]); - let segments = useMemo(() => - processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity), - [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity]); - - // When the era field appears, mark it valid if the year field is already valid. - // If the era field disappears, remove it from the valid segments. - if (allSegments.era && validSegments.year && !validSegments.era) { - validSegments.era = true; - setValidSegments({...validSegments}); - } else if (!allSegments.era && validSegments.era) { - delete validSegments.era; - setValidSegments({...validSegments}); - } + let dateValue = useMemo(() => { + let v = displayValue.toValue(calendarValue ?? placeholder); + return v.toDate(timeZone); + }, [displayValue, timeZone, calendarValue, placeholder]); - let markValid = (part: Intl.DateTimeFormatPartTypes) => { - validSegments[part] = true; - if (part === 'year' && allSegments.era) { - validSegments.era = true; - } - setValidSegments({...validSegments}); - }; + let segments = useMemo( + () => processSegments(dateValue, displayValue, dateFormatter, resolvedOptions, calendar, locale, granularity), + [dateValue, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity] + ); let adjustSegment = (type: Intl.DateTimeFormatPartTypes, amount: number) => { - if (!validSegments[type]) { - markValid(type); - let validKeys = Object.keys(validSegments); - let allKeys = Object.keys(allSegments); - if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod)) { - setValue(displayValue); - } - } else { - setValue(addSegment(displayValue, type, amount, resolvedOptions)); - } + setValue(addSegment(displayValue, type, amount, resolvedOptions, placeholder)); }; let builtinValidation = useMemo(() => getValidationResult( @@ -367,7 +317,6 @@ export function useDateFieldState(props: DateFi adjustSegment(part, -(PAGE_STEP[part] || 1)); }, setSegment(part, v: string | number) { - markValid(part); setValue(setSegment(displayValue, part, v, resolvedOptions)); }, confirmPlaceholder() { @@ -375,39 +324,34 @@ export function useDateFieldState(props: DateFi return; } - // 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) { - validSegments = {...allSegments}; - setValidSegments(validSegments); - setValue(displayValue.copy()); + // If the display value is complete but invalid, we need to constrain it and emit onChange on blur. + if (displayValue.isComplete(displaySegments)) { + let dateValue = displayValue.toValue(calendarValue ?? placeholder); + let newDateValue = toCalendar(dateValue, v?.calendar || new GregorianCalendar()); + if (!value || newDateValue.compare(value) !== 0) { + setDate(dateValue); + } + setDisplayValue(new IncompleteDate(calendar, calendarValue)); } }, clearSegment(part) { - delete validSegments[part]; - clearedSegment.current = part; - setValidSegments({...validSegments}); - - let placeholder = createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone); let value = displayValue; // Reset day period to default without changing the hour. - if (part === 'dayPeriod' && 'hour' in displayValue && 'hour' in placeholder) { - let isPM = displayValue.hour >= 12; - let shouldBePM = placeholder.hour >= 12; - if (isPM && !shouldBePM) { - value = displayValue.set({hour: displayValue.hour - 12}); - } else if (!isPM && shouldBePM) { - value = displayValue.set({hour: displayValue.hour + 12}); + if (part === 'dayPeriod') { + if (displayValue.hour != null) { + let isPM = displayValue.hour >= 12; + let shouldBePM = 'hour' in placeholder && placeholder.hour >= 12; + if (isPM && !shouldBePM) { + value = displayValue.set({hour: displayValue.hour - 12}); + } else if (!isPM && shouldBePM) { + value = displayValue.set({hour: displayValue.hour + 12}); + } } - } else if (part === 'hour' && 'hour' in displayValue && displayValue.hour >= 12 && validSegments.dayPeriod) { - value = displayValue.set({hour: placeholder['hour'] + 12}); - } else if (part in displayValue) { - value = displayValue.set({[part]: placeholder[part]}); + } else if (part !== 'timeZoneName' && part !== 'literal') { + value = displayValue.clear(part); } - setDate(null); setValue(value); }, formatValue(fieldOptions: FieldOptions) { @@ -427,9 +371,42 @@ export function useDateFieldState(props: DateFi }; } -function processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity) : DateSegment[] { +function processSegments( + dateValue: Date, + displayValue: IncompleteDate, + dateFormatter: Intl.DateTimeFormat, + resolvedOptions: Intl.ResolvedDateTimeFormatOptions, + calendar: Calendar, + locale: string, + granularity: Granularity +) : DateSegment[] { let timeValue = ['hour', 'minute', 'second']; let segments = dateFormatter.formatToParts(dateValue); + + // In order to allow formatting temporarily invalid dates during editing (e.g. February 30th), + // use a NumberFormatter to manually format segments directly from raw numbers. + // When the user blurs the date field, the invalid segments will be constrained. + let numberFormatter = new NumberFormatter(locale, {useGrouping: false}); + let twoDigitFormatter = new NumberFormatter(locale, {useGrouping: false, minimumIntegerDigits: 2}); + for (let segment of segments) { + if (segment.type === 'year' || segment.type === 'month' || segment.type === 'day' || segment.type === 'hour') { + let value = displayValue[segment.type] ?? 0; + if (segment.type === 'hour' && dateFormatter.resolvedOptions().hour12) { + if (value === 0) { + value = 12; + } else if (value > 12) { + value -= 12; + } + } + + if (resolvedOptions[segment.type] === '2-digit') { + segment.value = twoDigitFormatter.format(value); + } else { + segment.value = numberFormatter.format(value); + } + } + } + let processedSegments: DateSegment[] = []; for (let segment of segments) { let type = TYPE_MAPPING[segment.type] || segment.type; @@ -438,7 +415,7 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption isEditable = false; } - let isPlaceholder = EDITABLE_SEGMENTS[type] && !validSegments[type]; + let isPlaceholder = EDITABLE_SEGMENTS[type] && (segment.type === 'dayPeriod' ? displayValue.hour == null : displayValue[segment.type] == null); let placeholder = EDITABLE_SEGMENTS[type] ? getPlaceholder(type, segment.value, locale) : null; let dateSegment = { @@ -495,7 +472,7 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption return processedSegments; } -function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedDateTimeFormatOptions) { +function getSegmentLimits(date: IncompleteDate, type: string, options: Intl.ResolvedDateTimeFormatOptions) { switch (type) { case 'era': { let eras = date.calendar.getEras(); @@ -509,82 +486,95 @@ function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedD return { value: date.year, minValue: 1, - maxValue: date.calendar.getYearsInEra(date) + maxValue: 9999 }; case 'month': return { value: date.month, - minValue: getMinimumMonthInYear(date), - maxValue: date.calendar.getMonthsInYear(date) + minValue: 1, + maxValue: date.calendar.getMaximumMonthsInYear() }; case 'day': return { value: date.day, - minValue: getMinimumDayInMonth(date), - maxValue: date.calendar.getDaysInMonth(date) + minValue: 1, + maxValue: date.calendar.getMaximumDaysInMonth() }; } - if ('hour' in date) { - switch (type) { - case 'dayPeriod': - return { - value: date.hour >= 12 ? 12 : 0, - minValue: 0, - maxValue: 12 - }; - case 'hour': - if (options.hour12) { - let isPM = date.hour >= 12; - return { - value: date.hour, - minValue: isPM ? 12 : 0, - maxValue: isPM ? 23 : 11 - }; - } - + switch (type) { + case 'dayPeriod': { + let hour = date.hour ?? 0; + return { + value: hour >= 12 ? 12 : 0, + minValue: 0, + maxValue: 12 + }; + } + case 'hour': + if (options.hour12) { + let isPM = date.hour != null && date.hour >= 12; return { value: date.hour, - minValue: 0, - maxValue: 23 - }; - case 'minute': - return { - value: date.minute, - minValue: 0, - maxValue: 59 - }; - case 'second': - return { - value: date.second, - minValue: 0, - maxValue: 59 + minValue: isPM ? 12 : 0, + maxValue: isPM ? 23 : 11 }; - } - } + } - return {}; + return { + value: date.hour, + minValue: 0, + maxValue: 23 + }; + case 'minute': + return { + value: date.minute, + minValue: 0, + maxValue: 59 + }; + case 'second': + return { + value: date.second, + minValue: 0, + maxValue: 59 + }; + } } -function addSegment(value: DateValue, part: string, amount: number, options: Intl.ResolvedDateTimeFormatOptions) { +function addSegment( + value: IncompleteDate, + part: string, + amount: number, + options: Intl.ResolvedDateTimeFormatOptions, + placeholder: DateValue +) { switch (part) { case 'era': case 'year': case 'month': case 'day': + if (value[part] == null) { + return value.set({[part]: placeholder[part]}); + } return value.cycle(part, amount, {round: part === 'year'}); } if ('hour' in value) { switch (part) { case 'dayPeriod': { - let hours = value.hour; + if (value.hour == null && 'hour' in placeholder) { + return value.set({hour: placeholder.hour}); + } + let hours = value.hour ?? 0; let isPM = hours >= 12; return value.set({hour: isPM ? hours - 12 : hours + 12}); } case 'hour': case 'minute': case 'second': + if (value[part] == null) { + return value.set({[part]: placeholder[part]}); + } return value.cycle(part, amount, { round: part !== 'hour', hourCycle: options.hour12 ? 12 : 24 @@ -595,7 +585,7 @@ function addSegment(value: DateValue, part: string, amount: number, options: Int throw new Error('Unknown segment: ' + part); } -function setSegment(value: DateValue, part: string, segmentValue: number | string, options: Intl.ResolvedDateTimeFormatOptions) { +function setSegment(value: IncompleteDate, part: string, segmentValue: number | string, options: Intl.ResolvedDateTimeFormatOptions) { switch (part) { case 'day': case 'month': @@ -607,7 +597,7 @@ function setSegment(value: DateValue, part: string, segmentValue: number | strin if ('hour' in value && typeof segmentValue === 'number') { switch (part) { case 'dayPeriod': { - let hours = value.hour; + let hours = value.hour ?? 0; let wasPM = hours >= 12; let isPM = segmentValue >= 12; if (isPM === wasPM) { @@ -618,7 +608,7 @@ function setSegment(value: DateValue, part: string, segmentValue: number | strin case 'hour': // In 12 hour time, ensure that AM/PM does not change if (options.hour12) { - let hours = value.hour; + let hours = value.hour ?? 0; let wasPM = hours >= 12; if (!wasPM && segmentValue === 12) { segmentValue = 0; @@ -636,3 +626,172 @@ function setSegment(value: DateValue, part: string, segmentValue: number | strin throw new Error('Unknown segment: ' + part); } + +class IncompleteDate { + calendar: Calendar; + era: string; + year: number | null; + month: number | null; + day: number | null; + hour: number | null; + minute: number | null; + second: number | null; + millisecond: number | null; + + constructor(calendar: Calendar, dateValue?: Partial> | null) { + let eras = calendar.getEras(); + this.era = dateValue?.era ?? eras[eras.length - 1]; + this.calendar = calendar; + this.year = dateValue?.year ?? null; + this.month = dateValue?.month ?? null; + this.day = dateValue?.day ?? null; + this.hour = dateValue?.hour ?? null; + this.minute = dateValue?.minute ?? null; + this.second = dateValue?.second ?? null; + this.millisecond = dateValue?.millisecond ?? null; + } + + copy(): IncompleteDate { + let res = new IncompleteDate(this.calendar); + res.era = this.era; + res.year = this.year; + res.month = this.month; + res.day = this.day; + res.hour = this.hour; + res.minute = this.minute; + res.second = this.second; + res.millisecond = this.millisecond; + return res; + } + + isComplete(segments: (DateField | TimeField)[]) { + return segments.every(segment => this[segment] != null); + } + + validate(dt: DateValue, segments: (DateField | TimeField)[]) { + return segments.every(segment => this[segment] === dt[segment]); + } + + isCleared(segments: (DateField | TimeField)[]): boolean { + return segments.every(segment => this[segment] === null); + } + + set(fields: DateFields & TimeFields): IncompleteDate { + let result = this.copy(); + for (let key in fields) { + result[key] = fields[key]; + } + return result; + } + + clear(field: DateField | TimeField): IncompleteDate { + let result = this.copy(); + // @ts-ignore + result[field] = null; + if (field === 'year') { + let eras = this.calendar.getEras(); + result.era = eras[eras.length - 1]; + } + return result; + } + + cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): IncompleteDate { + let res = this.copy(); + switch (field) { + case 'era': + case 'year': { + // Use CalendarDate to cycle so that we update the era when going between 1 AD and 1 BC. + let date = new CalendarDate(this.calendar, this.era, this.year ?? 1, 1, 1); + date = date.cycle(field, amount, options); + res.era = date.era; + res.year = date.year; + break; + } + case 'month': + res.month = cycleValue(res.month ?? 1, amount, 1, this.calendar.getMaximumMonthsInYear(), options?.round); + break; + case 'day': + // Allow incrementing up to the maximum number of days in any month. + res.day = cycleValue(res.day ?? 1, amount, 1, this.calendar.getMaximumDaysInMonth(), options?.round); + break; + 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. + let hours = res.hour ?? 0; + let min = 0; + let max = 23; + if (options?.hourCycle === 12) { + let isPM = hours >= 12; + min = isPM ? 12 : 0; + max = isPM ? 23 : 11; + } + res.hour = cycleValue(hours, amount, min, max, options?.round); + break; + } + case 'minute': + res.minute = cycleValue(res.minute ?? 0, amount, 0, 59, options?.round); + break; + case 'second': + res.second = cycleValue(res.second ?? 0, amount, 0, 59, options?.round); + break; + case 'millisecond': + res.millisecond = cycleValue(res.millisecond ?? 0, amount, 0, 999, options?.round); + break; + } + + return res; + } + + toValue(value: DateValue): DateValue { + if ('hour' in value) { + return value.set({ + era: this.era, + year: this.year ?? value.year, + month: this.month ?? value.month, + day: this.day ?? value.day, + hour: this.hour ?? value.hour, + minute: this.minute ?? value.minute, + second: this.second ?? value.second, + millisecond: this.millisecond ?? value.millisecond + }); + } else { + return value.set({ + era: this.era, + year: this.year ?? value.year, + month: this.month ?? value.month, + day: this.day ?? value.day + }); + } + } +} + +function cycleValue(value: number, amount: number, min: number, max: number, round = false) { + if (round) { + value += Math.sign(amount); + + if (value < min) { + value = max; + } + + let div = Math.abs(amount); + if (amount > 0) { + value = Math.ceil(value / div) * div; + } else { + value = Math.floor(value / div) * div; + } + + if (value > max) { + value = min; + } + } else { + value += amount; + if (value < min) { + value = max - (min - value - 1); + } else if (value > max) { + value = min + (value - max - 1); + } + } + + return value; +} diff --git a/yarn.lock b/yarn.lock index 6500c7bc175..953df3a6d7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8082,6 +8082,7 @@ __metadata: resolution: "@react-stately/datepicker@workspace:packages/@react-stately/datepicker" dependencies: "@internationalized/date": "npm:^3.10.1" + "@internationalized/number": "npm:^3.6.5" "@internationalized/string": "npm:^3.2.7" "@react-stately/form": "npm:^3.2.2" "@react-stately/overlays": "npm:^3.6.21" From d97486aab2dc3e617669fafd340e30a675967e9e Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 22 Jan 2026 17:24:48 -0500 Subject: [PATCH 2/3] Fix crash when switching calendar systems --- .../@react-stately/datepicker/src/useDateFieldState.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index a0aa37f3e92..93dbd234126 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -190,14 +190,13 @@ export function useDateFieldState(props: DateFi () => new IncompleteDate(calendar, calendarValue) ); - let [lastValue, setLastValue] = useState(value); + let [lastValue, setLastValue] = useState(calendarValue); let [lastCalendar, setLastCalendar] = useState(calendar); - if (calendarValue !== lastValue) { + if (calendarValue !== lastValue || !isEqualCalendar(calendar, lastCalendar)) { + displayValue = new IncompleteDate(calendar, calendarValue); setLastValue(calendarValue); - setDisplayValue(new IncompleteDate(calendar, calendarValue)); - } else if (!isEqualCalendar(calendar, lastCalendar)) { setLastCalendar(calendar); - setDisplayValue(new IncompleteDate(calendar, calendarValue)); + setDisplayValue(displayValue); } let showEra = calendar.identifier === 'gregory' && displayValue.era === 'BC'; From bcb86f6085e4df62c5cf7b03295041d374032964 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 23 Jan 2026 12:12:42 -0500 Subject: [PATCH 3/3] Refactor to support entering leading zeros --- .../datepicker/src/useDateSegment.ts | 42 +- .../datepicker/test/DatePicker.test.js | 40 +- .../datepicker/test/DateRangePicker.test.js | 12 +- .../datepicker/src/IncompleteDate.ts | 366 +++++++++++++++ .../datepicker/src/useDateFieldState.ts | 432 +++--------------- 5 files changed, 457 insertions(+), 435 deletions(-) create mode 100644 packages/@react-stately/datepicker/src/IncompleteDate.ts diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index fd75216e61d..f83deb13ee3 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -82,15 +82,11 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: }, onIncrementToMax: () => { enteredKeys.current = ''; - if (segment.maxValue !== undefined) { - state.setSegment(segment.type, segment.maxValue); - } + state.incrementToMax(segment.type); }, onDecrementToMin: () => { enteredKeys.current = ''; - if (segment.minValue !== undefined) { - state.setSegment(segment.type, segment.minValue); - } + state.decrementToMin(segment.type); } }); @@ -110,7 +106,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: state.setSegment(segment.type, parsed); } enteredKeys.current = newValue; - } else if (segment.type === 'dayPeriod') { + } else if (segment.type === 'dayPeriod' || segment.type === 'era') { state.clearSegment(segment.type); } }; @@ -193,7 +189,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: if (startsWith(am, key)) { state.setSegment('dayPeriod', 0); } else if (startsWith(pm, key)) { - state.setSegment('dayPeriod', 12); + state.setSegment('dayPeriod', 1); } else { break; } @@ -219,26 +215,7 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: let numberValue = parser.parse(newValue); let segmentValue = numberValue; - let allowsZero = segment.minValue === 0; - if (segment.type === 'hour' && state.dateFormatter.resolvedOptions().hour12) { - switch (state.dateFormatter.resolvedOptions().hourCycle) { - case 'h11': - if (numberValue > 11) { - segmentValue = parser.parse(key); - } - break; - case 'h12': - allowsZero = false; - if (numberValue > 12) { - segmentValue = parser.parse(key); - } - break; - } - - if (segment.value != null && segment.value >= 12 && numberValue > 1) { - numberValue += 12; - } - } else if (segment.maxValue !== undefined && numberValue > segment.maxValue) { + if (segment.maxValue !== undefined && numberValue > segment.maxValue) { segmentValue = parser.parse(key); } @@ -246,16 +223,11 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: return; } - let shouldSetValue = segmentValue !== 0 || allowsZero; - if (shouldSetValue) { - state.setSegment(segment.type, segmentValue); - } + state.setSegment(segment.type, segmentValue); if (segment.maxValue !== undefined && (Number(numberValue + '0') > segment.maxValue || newValue.length >= String(segment.maxValue).length)) { enteredKeys.current = ''; - if (shouldSetValue) { - focusManager.focusNext(); - } + focusManager.focusNext(); } else { enteredKeys.current = newValue; } diff --git a/packages/@react-spectrum/datepicker/test/DatePicker.test.js b/packages/@react-spectrum/datepicker/test/DatePicker.test.js index ba0e07d2212..ae8dcc86720 100644 --- a/packages/@react-spectrum/datepicker/test/DatePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DatePicker.test.js @@ -135,10 +135,10 @@ describe('DatePicker', function () { expect(getTextValue(segments[3])).toBe('12'); expect(segments[3].getAttribute('aria-label')).toBe('hour, '); - expect(segments[3].getAttribute('aria-valuenow')).toBe('0'); + expect(segments[3].getAttribute('aria-valuenow')).toBe('12'); expect(segments[3].getAttribute('aria-valuetext')).toBe('12 AM'); - expect(segments[3].getAttribute('aria-valuemin')).toBe('0'); - expect(segments[3].getAttribute('aria-valuemax')).toBe('11'); + expect(segments[3].getAttribute('aria-valuemin')).toBe('1'); + expect(segments[3].getAttribute('aria-valuemax')).toBe('12'); expect(getTextValue(segments[4])).toBe('00'); expect(segments[4].getAttribute('aria-label')).toBe('minute, '); @@ -1349,7 +1349,11 @@ describe('DatePicker', function () { if (key !== '0' || (moved && i === keys.length - 1 && keys !== '00') || (i < keys.length - 1 && allowsZero)) { expect(onChange).toHaveBeenCalledTimes(++count); } - expect(segment.textContent).toBe(textContent); + if (key === '0' && !allowsZero && label !== 'era,') { + expect(segment.textContent).toBe('0'); + } else { + expect(segment.textContent).toBe(textContent); + } if (i < keys.length - 1) { expect(segment).toHaveFocus(); @@ -1429,12 +1433,11 @@ describe('DatePicker', function () { unmount(); } - function testIgnored(label, value, keys, props) { + function testIgnored(label, value, keys, expected) { let onChange = jest.fn(); - let {getByLabelText, unmount} = render(); + let {getByLabelText, unmount} = render(); let segment = getByLabelText(label); - let textContent = segment.textContent; act(() => {segment.focus();}); for (let key of keys) { @@ -1442,8 +1445,9 @@ describe('DatePicker', function () { } expect(onChange).not.toHaveBeenCalled(); - expect(segment.textContent).toBe(textContent); - expect(segment).toHaveFocus(); + expect(segment.textContent).toBe('0'); + act(() => document.activeElement.blur()); + expect(segment.textContent).toBe(expected); unmount(); } @@ -1452,8 +1456,8 @@ describe('DatePicker', function () { testInput('month,', new CalendarDate(2019, 2, 3), '01', new CalendarDate(2019, 1, 3), true); testInput('month,', new CalendarDate(2019, 2, 3), '12', new CalendarDate(2019, 12, 3), true); testInput('month,', new CalendarDate(2019, 2, 3), '4', new CalendarDate(2019, 4, 3), true); - testIgnored('month,', new CalendarDate(2019, 2, 3), '0'); - testIgnored('month,', new CalendarDate(2019, 2, 3), '00'); + testIgnored('month,', new CalendarDate(2019, 2, 3), '0', '1'); + testIgnored('month,', new CalendarDate(2019, 2, 3), '00', '1'); }); it('should support typing into the day segment', function () { @@ -1461,14 +1465,14 @@ describe('DatePicker', function () { testInput('day,', new CalendarDate(2019, 2, 3), '01', new CalendarDate(2019, 2, 1), true); testInput('day,', new CalendarDate(2019, 2, 3), '12', new CalendarDate(2019, 2, 12), true); testInput('day,', new CalendarDate(2019, 2, 3), '4', new CalendarDate(2019, 2, 4), true); - testIgnored('day,', new CalendarDate(2019, 2, 3), '0'); - testIgnored('day,', new CalendarDate(2019, 2, 3), '00'); + testIgnored('day,', new CalendarDate(2019, 2, 3), '0', '1'); + testIgnored('day,', new CalendarDate(2019, 2, 3), '00', '1'); }); it('should support typing into the year segment', function () { testInput('year,', new CalendarDate(2019, 2, 3), '1993', new CalendarDate(1993, 2, 3), false); testInput('year,', new CalendarDateTime(2019, 2, 3, 8), '1993', new CalendarDateTime(1993, 2, 3, 8), true); - testIgnored('year,', new CalendarDate(2019, 2, 3), '0'); + testIgnored('year,', new CalendarDate(2019, 2, 3), '0', '1'); }); it('should support typing into the hour segment in 12 hour time', function () { @@ -1478,7 +1482,7 @@ describe('DatePicker', function () { testInput('hour,', new CalendarDateTime(2019, 2, 3, 8), '11', new CalendarDateTime(2019, 2, 3, 11), true); testInput('hour,', new CalendarDateTime(2019, 2, 3, 8), '12', new CalendarDateTime(2019, 2, 3, 0), true); testInput('hour,', new CalendarDateTime(2019, 2, 3, 8), '4', new CalendarDateTime(2019, 2, 3, 4), true); - testIgnored('hour,', new CalendarDateTime(2019, 2, 3, 8), '0'); + testIgnored('hour,', new CalendarDateTime(2019, 2, 3, 8), '0', '12'); // PM testInput('hour,', new CalendarDateTime(2019, 2, 3, 20), '1', new CalendarDateTime(2019, 2, 3, 13), false); @@ -1486,7 +1490,7 @@ describe('DatePicker', function () { testInput('hour,', new CalendarDateTime(2019, 2, 3, 20), '11', new CalendarDateTime(2019, 2, 3, 23), true); testInput('hour,', new CalendarDateTime(2019, 2, 3, 20), '12', new CalendarDateTime(2019, 2, 3, 12), true); testInput('hour,', new CalendarDateTime(2019, 2, 3, 20), '4', new CalendarDateTime(2019, 2, 3, 16), true); - testIgnored('hour,', new CalendarDateTime(2019, 2, 3, 20), '0'); + testIgnored('hour,', new CalendarDateTime(2019, 2, 3, 20), '0', '12'); }); it('should support typing into the hour segment in 24 hour time', function () { @@ -2072,9 +2076,7 @@ describe('DatePicker', function () { await user.keyboard('{Backspace}'); } await user.tab(); - for (i = 0; i < 2; i++) { - await user.keyboard('{Backspace}'); - } + await user.keyboard('{Backspace}'); await user.tab(); await user.keyboard('{Backspace}'); diff --git a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js index 46f066c1d0b..3c85574f68c 100644 --- a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js @@ -168,10 +168,10 @@ describe('DateRangePicker', function () { expect(getTextValue(segments[3])).toBe('12'); expect(segments[3].getAttribute('aria-label')).toBe('hour, Start Date, '); - expect(segments[3].getAttribute('aria-valuenow')).toBe('0'); + expect(segments[3].getAttribute('aria-valuenow')).toBe('12'); expect(segments[3].getAttribute('aria-valuetext')).toBe('12 AM'); - expect(segments[3].getAttribute('aria-valuemin')).toBe('0'); - expect(segments[3].getAttribute('aria-valuemax')).toBe('11'); + expect(segments[3].getAttribute('aria-valuemin')).toBe('1'); + expect(segments[3].getAttribute('aria-valuemax')).toBe('12'); expect(getTextValue(segments[4])).toBe('00'); expect(segments[4].getAttribute('aria-label')).toBe('minute, Start Date, '); @@ -214,10 +214,10 @@ describe('DateRangePicker', function () { expect(getTextValue(segments[10])).toBe('12'); expect(segments[10].getAttribute('aria-label')).toBe('hour, End Date, '); - expect(segments[10].getAttribute('aria-valuenow')).toBe('0'); + expect(segments[10].getAttribute('aria-valuenow')).toBe('12'); expect(segments[10].getAttribute('aria-valuetext')).toBe('12 AM'); - expect(segments[10].getAttribute('aria-valuemin')).toBe('0'); - expect(segments[10].getAttribute('aria-valuemax')).toBe('11'); + expect(segments[10].getAttribute('aria-valuemin')).toBe('1'); + expect(segments[10].getAttribute('aria-valuemax')).toBe('12'); expect(getTextValue(segments[11])).toBe('00'); expect(segments[11].getAttribute('aria-label')).toBe('minute, End Date, '); diff --git a/packages/@react-stately/datepicker/src/IncompleteDate.ts b/packages/@react-stately/datepicker/src/IncompleteDate.ts new file mode 100644 index 00000000000..3900cab30da --- /dev/null +++ b/packages/@react-stately/datepicker/src/IncompleteDate.ts @@ -0,0 +1,366 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {AnyDateTime, Calendar, CalendarDate} from '@internationalized/date'; +import {DateValue} from '@react-types/datepicker'; +import {SegmentType} from './useDateFieldState'; + +type HourCycle = 'h12' | 'h11' | 'h23' | 'h24'; + +/** + * This class represents a date that is incomplete or otherwise invalid as a result of user editing. + * For example, it can represent temporary dates such as February 31st if the user edits the day before the month. + * Times are represented according to an hour cycle rather than always in 24 hour time. This enables the user to adjust + * the day period (e.g. am/pm) independently from the hour. + */ +export class IncompleteDate { + calendar: Calendar; + era: string | null; + year: number | null; + month: number | null; + day: number | null; + hour: number | null; + hourCycle: HourCycle; + dayPeriod: number | null; + minute: number | null; + second: number | null; + millisecond: number | null; + + constructor(calendar: Calendar, hourCycle: HourCycle, dateValue?: Partial> | null) { + this.era = dateValue?.era ?? null; + this.calendar = calendar; + this.year = dateValue?.year ?? null; + this.month = dateValue?.month ?? null; + this.day = dateValue?.day ?? null; + this.hour = dateValue?.hour ?? null; + this.hourCycle = hourCycle; + this.dayPeriod = null; + this.minute = dateValue?.minute ?? null; + this.second = dateValue?.second ?? null; + this.millisecond = dateValue?.millisecond ?? null; + + // Convert the hour from 24 hour time to the given hour cycle. + if (this.hour != null) { + let [dayPeriod, hour] = toHourCycle(this.hour, hourCycle); + this.dayPeriod = dayPeriod; + this.hour = hour; + } + } + + copy(): IncompleteDate { + let res = new IncompleteDate(this.calendar, this.hourCycle); + res.era = this.era; + res.year = this.year; + res.month = this.month; + res.day = this.day; + res.hour = this.hour; + res.dayPeriod = this.dayPeriod; + res.minute = this.minute; + res.second = this.second; + res.millisecond = this.millisecond; + return res; + } + + /** Checks whether all the specified segments have a value. */ + isComplete(segments: SegmentType[]) { + return segments.every(segment => this[segment] != null); + } + + /** Checks whether the given date value matches this value for the specified segments. */ + validate(dt: DateValue, segments: SegmentType[]) { + return segments.every(segment => { + if ((segment === 'hour' || segment === 'dayPeriod') && 'hour' in dt) { + let [dayPeriod, hour] = toHourCycle(dt.hour, this.hourCycle); + return this.dayPeriod === dayPeriod && this.hour === hour; + } + return this[segment] === dt[segment]; + }); + } + + /** Checks if the date is empty (i.e. all specified segments are null). */ + isCleared(segments: SegmentType[]): boolean { + return segments.every(segment => this[segment] === null); + } + + /** Sets the given field. */ + set(field: SegmentType, value: number | string, placeholder: DateValue): IncompleteDate { + let result = this.copy(); + result[field] = value; + if (field === 'hour' && result.dayPeriod == null && 'hour' in placeholder) { + result.dayPeriod = toHourCycle(placeholder.hour, this.hourCycle)[0]; + } + if (field === 'year' && result.era == null) { + result.era = placeholder.era; + } + return result; + } + + /** Sets the given field to null. */ + clear(field: SegmentType): IncompleteDate { + let result = this.copy(); + // @ts-ignore + result[field] = null; + if (field === 'year') { + result.era = null; + } + return result; + } + + /** Increments or decrements the given field. If it is null, then it is set to the placeholder value. */ + cycle(field: SegmentType, amount: number, placeholder: DateValue): IncompleteDate { + let res = this.copy(); + + // If field is null, default to placeholder. + if (res[field] == null && field !== 'dayPeriod' && field !== 'era') { + if (field === 'hour' && 'hour' in placeholder) { + let [dayPeriod, hour] = toHourCycle(placeholder.hour, this.hourCycle); + res.dayPeriod = dayPeriod; + res.hour = hour; + } else { + res[field] = placeholder[field]; + } + if (field === 'year' && res.era == null) { + res.era = placeholder.era; + } + + return res; + } + + switch (field) { + case 'era': { + let eras = this.calendar.getEras(); + let index = eras.indexOf(res.era!); + index = cycleValue(index, amount, 0, eras.length - 1); + res.era = eras[index]; + break; + } + case 'year': { + // Use CalendarDate to cycle so that we update the era when going between 1 AD and 1 BC. + let date = new CalendarDate(this.calendar, this.era ?? placeholder.era, this.year ?? placeholder.year, 1, 1); + date = date.cycle(field, amount, {round: field === 'year'}); + res.era = date.era; + res.year = date.year; + break; + } + case 'month': + res.month = cycleValue(res.month ?? 1, amount, 1, this.calendar.getMaximumMonthsInYear()); + break; + case 'day': + // Allow incrementing up to the maximum number of days in any month. + res.day = cycleValue(res.day ?? 1, amount, 1, this.calendar.getMaximumDaysInMonth()); + break; + 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. + let hours = res.hour ?? 0; + let limits = this.getSegmentLimits('hour')!; + res.hour = cycleValue(hours, amount, limits.minValue, limits.maxValue); + if (res.dayPeriod == null && 'hour' in placeholder) { + res.dayPeriod = toHourCycle(placeholder.hour, this.hourCycle)[0]; + } + break; + } + case 'dayPeriod': + res.dayPeriod = cycleValue(res.dayPeriod ?? 0, amount, 0, 1); + break; + case 'minute': + res.minute = cycleValue(res.minute ?? 0, amount, 0, 59, true); + break; + case 'second': + res.second = cycleValue(res.second ?? 0, amount, 0, 59, true); + break; + } + + return res; + } + + /** Converts the incomplete date to a full date value, using the provided value for any unset fields. */ + toValue(value: DateValue): DateValue { + if ('hour' in value) { + let hour = this.hour; + if (hour != null) { + hour = fromHourCycle(hour, this.dayPeriod ?? 0, this.hourCycle); + } else if (this.hourCycle === 'h12' || this.hourCycle === 'h11') { + hour = this.dayPeriod === 1 ? 12 : 0; + } + + return value.set({ + era: this.era ?? value.era, + year: this.year ?? value.year, + month: this.month ?? value.month, + day: this.day ?? value.day, + hour: hour ?? value.hour, + minute: this.minute ?? value.minute, + second: this.second ?? value.second, + millisecond: this.millisecond ?? value.millisecond + }); + } else { + return value.set({ + era: this.era ?? value.era, + year: this.year ?? value.year, + month: this.month ?? value.month, + day: this.day ?? value.day + }); + } + } + + getSegmentLimits(type: string): {value: number | null, minValue: number, maxValue: number} | undefined { + switch (type) { + case 'era': { + let eras = this.calendar.getEras(); + return { + value: this.era != null ? eras.indexOf(this.era) : eras.length - 1, + minValue: 0, + maxValue: eras.length - 1 + }; + } + case 'year': + return { + value: this.year, + minValue: 1, + maxValue: 9999 + }; + case 'month': + return { + value: this.month, + minValue: 1, + maxValue: this.calendar.getMaximumMonthsInYear() + }; + case 'day': + return { + value: this.day, + minValue: 1, + maxValue: this.calendar.getMaximumDaysInMonth() + }; + case 'dayPeriod': { + return { + value: this.dayPeriod, + minValue: 0, + maxValue: 1 + }; + } + case 'hour': { + let minValue = 0; + let maxValue = 23; + if (this.hourCycle === 'h12') { + minValue = 1; + maxValue = 12; + } else if (this.hourCycle === 'h11') { + minValue = 0; + maxValue = 11; + } + + return { + value: this.hour, + minValue, + maxValue + }; + } + case 'minute': + return { + value: this.minute, + minValue: 0, + maxValue: 59 + }; + case 'second': + return { + value: this.second, + minValue: 0, + maxValue: 59 + }; + } + } +} + +function cycleValue(value: number, amount: number, min: number, max: number, round = false) { + if (round) { + value += Math.sign(amount); + + if (value < min) { + value = max; + } + + let div = Math.abs(amount); + if (amount > 0) { + value = Math.ceil(value / div) * div; + } else { + value = Math.floor(value / div) * div; + } + + if (value > max) { + value = min; + } + } else { + value += amount; + if (value < min) { + value = max - (min - value - 1); + } else if (value > max) { + value = min + (value - max - 1); + } + } + + return value; +} + +function toHourCycle(hour: number, hourCycle: HourCycle): [number | null, number] { + let dayPeriod: number | null = hour >= 12 ? 1 : 0; + switch (hourCycle) { + case 'h11': + // Hours are numbered from 0 to 11. Used in Japan. + if (hour >= 12) { + hour -= 12; + } + break; + case 'h12': + // Hours are numbered from 12 (representing 0) to 11. + if (hour === 0) { + hour = 12; + } else if (hour > 12) { + hour -= 12; + } + break; + case 'h23': + // 24 hour time, numbered 0 to 23. + dayPeriod = null; + break; + case 'h24': + // 24 hour time numbered 24 to 23. Unused but supported by Intl.DateTimeFormat. + hour += 1; + dayPeriod = null; + } + + return [dayPeriod, hour]; +} + +function fromHourCycle(hour: number, dayPeriod: number, hourCycle: HourCycle): number { + switch (hourCycle) { + case 'h11': + if (dayPeriod === 1) { + hour += 12; + } + break; + case 'h12': + if (hour === 12) { + hour = 0; + } + if (dayPeriod === 1) { + hour += 12; + } + break; + case 'h24': + hour -= 1; + break; + } + + return hour; +} diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 93dbd234126..0554481c33a 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -10,11 +10,12 @@ * governing permissions and limitations under the License. */ -import {AnyDateTime, Calendar, CalendarDate, CalendarIdentifier, CycleTimeOptions, DateField, DateFields, DateFormatter, GregorianCalendar, isEqualCalendar, TimeField, TimeFields, toCalendar} from '@internationalized/date'; +import {Calendar, CalendarIdentifier, DateFormatter, GregorianCalendar, isEqualCalendar, toCalendar} from '@internationalized/date'; import {convertValue, createPlaceholderDate, FieldOptions, FormatterOptions, getFormatOptions, getValidationResult, useDefaultProps} from './utils'; import {DatePickerProps, DateValue, Granularity, MappedDateValue} from '@react-types/datepicker'; import {FormValidationState, useFormValidationState} from '@react-stately/form'; import {getPlaceholder} from './placeholders'; +import {IncompleteDate} from './IncompleteDate'; import {NumberFormatter} from '@internationalized/number'; import {useControlledState} from '@react-stately/utils'; import {useMemo, useState} from 'react'; @@ -88,6 +89,10 @@ export interface DateFieldState extends FormValidationState { * Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ decrementPage(type: SegmentType): void, + /** Increments the given segment to its maxiumum value. */ + incrementToMax(type: SegmentType): void, + /** Decrements the given segment to its minimum value. */ + decrementToMin(type: SegmentType): void, /** Sets the value of the given segment. */ setSegment(type: 'era', value: string): void, setSegment(type: SegmentType, value: number): void, @@ -174,8 +179,17 @@ export function useDateFieldState(props: DateFi throw new Error('Invalid granularity ' + granularity + ' for value ' + v.toString()); } - let defaultFormatter = useMemo(() => new DateFormatter(locale), [locale]); - let calendar = useMemo(() => createCalendar(defaultFormatter.resolvedOptions().calendar as CalendarIdentifier), [createCalendar, defaultFormatter]); + // Resolve default hour cycle and calendar system. + let [calendar, hourCycle] = useMemo(() => { + let formatter = new DateFormatter(locale, { + dateStyle: 'short', + timeStyle: 'short', + hour12: props.hourCycle != null ? props.hourCycle === 12 : undefined + }); + let opts = formatter.resolvedOptions(); + let calendar = createCalendar(opts.calendar as CalendarIdentifier); + return [calendar, opts.hourCycle!]; + }, [locale, props.hourCycle, createCalendar]); let [value, setDate] = useControlledState | null>( props.value, @@ -185,20 +199,10 @@ export function useDateFieldState(props: DateFi let [initialValue] = useState(value); let calendarValue = useMemo(() => convertValue(value, calendar) ?? null, [value, calendar]); - let [displayValue, setDisplayValue] = useState( - () => new IncompleteDate(calendar, calendarValue) + () => new IncompleteDate(calendar, hourCycle, calendarValue) ); - let [lastValue, setLastValue] = useState(calendarValue); - let [lastCalendar, setLastCalendar] = useState(calendar); - if (calendarValue !== lastValue || !isEqualCalendar(calendar, lastCalendar)) { - displayValue = new IncompleteDate(calendar, calendarValue); - setLastValue(calendarValue); - setLastCalendar(calendar); - setDisplayValue(displayValue); - } - let showEra = calendar.identifier === 'gregory' && displayValue.era === 'BC'; let formatOpts = useMemo(() => ({ granularity, @@ -215,11 +219,23 @@ export function useDateFieldState(props: DateFi let resolvedOptions = useMemo(() => dateFormatter.resolvedOptions(), [dateFormatter]); let placeholder = useMemo(() => createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone), [props.placeholderValue, granularity, calendar, defaultTimeZone]); let displaySegments = useMemo(() => { - let segments: (DateField | TimeField)[] = ['era', 'year', 'month', 'day', 'hour', 'minute', 'second', 'millisecond']; - let minIndex = segments.indexOf(props.maxGranularity || 'year'); - let maxIndex = segments.indexOf(granularity); + let is12HourClock = hourCycle === 'h11' || hourCycle === 'h12'; + let segments: SegmentType[] = ['era', 'year', 'month', 'day', 'hour', ...(is12HourClock ? ['dayPeriod' as const] : []), 'minute', 'second']; + let minIndex = segments.indexOf(props.maxGranularity || 'era'); + let maxIndex = segments.indexOf(granularity === 'hour' && is12HourClock ? 'dayPeriod' : granularity); return segments.slice(minIndex, maxIndex + 1); - }, [props.maxGranularity, granularity]); + }, [props.maxGranularity, granularity, hourCycle]); + + let [lastValue, setLastValue] = useState(calendarValue); + let [lastCalendar, setLastCalendar] = useState(calendar); + let [lastHourCycle, setLastHourCycle] = useState(hourCycle); + if (calendarValue !== lastValue || hourCycle !== lastHourCycle || !isEqualCalendar(calendar, lastCalendar)) { + displayValue = new IncompleteDate(calendar, hourCycle, calendarValue); + setLastValue(calendarValue); + setLastCalendar(calendar); + setLastHourCycle(hourCycle); + setDisplayValue(displayValue); + } let setValue = (newValue: DateValue | IncompleteDate | null) => { if (props.isDisabled || props.isReadOnly) { @@ -227,13 +243,13 @@ export function useDateFieldState(props: DateFi } if (newValue == null || (newValue instanceof IncompleteDate && newValue.isCleared(displaySegments))) { - setDisplayValue(new IncompleteDate(calendar, calendarValue)); + setDisplayValue(new IncompleteDate(calendar, hourCycle, calendarValue)); setDate(null); } else if (!(newValue instanceof IncompleteDate)) { // 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()); - setDisplayValue(new IncompleteDate(calendar, calendarValue)); + setDisplayValue(new IncompleteDate(calendar, hourCycle, calendarValue)); setDate(newValue); } else { // If the new value is complete and valid, trigger onChange eagerly. @@ -244,7 +260,7 @@ export function useDateFieldState(props: DateFi if (newValue.validate(dateValue, displaySegments)) { let newDateValue = toCalendar(dateValue, v?.calendar || new GregorianCalendar()); if (!value || newDateValue.compare(value) !== 0) { - setDisplayValue(new IncompleteDate(calendar, calendarValue)); // reset in case prop isn't updated + setDisplayValue(new IncompleteDate(calendar, hourCycle, calendarValue)); // reset in case prop isn't updated setDate(newDateValue); return; } @@ -266,8 +282,8 @@ export function useDateFieldState(props: DateFi [dateValue, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity] ); - let adjustSegment = (type: Intl.DateTimeFormatPartTypes, amount: number) => { - setValue(addSegment(displayValue, type, amount, resolvedOptions, placeholder)); + let adjustSegment = (type: SegmentType, amount: number) => { + setValue(displayValue.cycle(type, amount, placeholder)); }; let builtinValidation = useMemo(() => getValidationResult( @@ -315,8 +331,20 @@ export function useDateFieldState(props: DateFi decrementPage(part) { adjustSegment(part, -(PAGE_STEP[part] || 1)); }, + incrementToMax(part) { + let maxValue = part === 'hour' && hourCycle === 'h12' + ? 11 + : displayValue.getSegmentLimits(part)!.maxValue; + setValue(displayValue.set(part, maxValue, placeholder)); + }, + decrementToMin(part) { + let minValue = part === 'hour' && hourCycle === 'h12' + ? 12 + : displayValue.getSegmentLimits(part)!.minValue; + setValue(displayValue.set(part, minValue, placeholder)); + }, setSegment(part, v: string | number) { - setValue(setSegment(displayValue, part, v, resolvedOptions)); + setValue(displayValue.set(part, v, placeholder)); }, confirmPlaceholder() { if (props.isDisabled || props.isReadOnly) { @@ -328,26 +356,15 @@ export function useDateFieldState(props: DateFi let dateValue = displayValue.toValue(calendarValue ?? placeholder); let newDateValue = toCalendar(dateValue, v?.calendar || new GregorianCalendar()); if (!value || newDateValue.compare(value) !== 0) { - setDate(dateValue); + setDate(newDateValue); } - setDisplayValue(new IncompleteDate(calendar, calendarValue)); + setDisplayValue(new IncompleteDate(calendar, hourCycle, calendarValue)); } }, clearSegment(part) { let value = displayValue; - // Reset day period to default without changing the hour. - if (part === 'dayPeriod') { - if (displayValue.hour != null) { - let isPM = displayValue.hour >= 12; - let shouldBePM = 'hour' in placeholder && placeholder.hour >= 12; - if (isPM && !shouldBePM) { - value = displayValue.set({hour: displayValue.hour - 12}); - } else if (!isPM && shouldBePM) { - value = displayValue.set({hour: displayValue.hour + 12}); - } - } - } else if (part !== 'timeZoneName' && part !== 'literal') { + if (part !== 'timeZoneName' && part !== 'literal') { value = displayValue.clear(part); } @@ -390,14 +407,6 @@ function processSegments( for (let segment of segments) { if (segment.type === 'year' || segment.type === 'month' || segment.type === 'day' || segment.type === 'hour') { let value = displayValue[segment.type] ?? 0; - if (segment.type === 'hour' && dateFormatter.resolvedOptions().hour12) { - if (value === 0) { - value = 12; - } else if (value > 12) { - value -= 12; - } - } - if (resolvedOptions[segment.type] === '2-digit') { segment.value = twoDigitFormatter.format(value); } else { @@ -414,13 +423,13 @@ function processSegments( isEditable = false; } - let isPlaceholder = EDITABLE_SEGMENTS[type] && (segment.type === 'dayPeriod' ? displayValue.hour == null : displayValue[segment.type] == null); + let isPlaceholder = EDITABLE_SEGMENTS[type] && displayValue[segment.type] == null; let placeholder = EDITABLE_SEGMENTS[type] ? getPlaceholder(type, segment.value, locale) : null; let dateSegment = { type, text: isPlaceholder ? placeholder : segment.value, - ...getSegmentLimits(displayValue, type, resolvedOptions), + ...displayValue.getSegmentLimits(type), isPlaceholder, placeholder, isEditable @@ -434,7 +443,6 @@ function processSegments( processedSegments.push({ type: 'literal', text: '\u2066', - ...getSegmentLimits(displayValue, 'literal', resolvedOptions), isPlaceholder: false, placeholder: '', isEditable: false @@ -445,7 +453,6 @@ function processSegments( processedSegments.push({ type: 'literal', text: '\u2069', - ...getSegmentLimits(displayValue, 'literal', resolvedOptions), isPlaceholder: false, placeholder: '', isEditable: false @@ -457,7 +464,6 @@ function processSegments( processedSegments.push({ type: 'literal', text: '\u2069', - ...getSegmentLimits(displayValue, 'literal', resolvedOptions), isPlaceholder: false, placeholder: '', isEditable: false @@ -470,327 +476,3 @@ function processSegments( return processedSegments; } - -function getSegmentLimits(date: IncompleteDate, type: string, options: Intl.ResolvedDateTimeFormatOptions) { - switch (type) { - case 'era': { - let eras = date.calendar.getEras(); - return { - value: eras.indexOf(date.era), - minValue: 0, - maxValue: eras.length - 1 - }; - } - case 'year': - return { - value: date.year, - minValue: 1, - maxValue: 9999 - }; - case 'month': - return { - value: date.month, - minValue: 1, - maxValue: date.calendar.getMaximumMonthsInYear() - }; - case 'day': - return { - value: date.day, - minValue: 1, - maxValue: date.calendar.getMaximumDaysInMonth() - }; - } - - switch (type) { - case 'dayPeriod': { - let hour = date.hour ?? 0; - return { - value: hour >= 12 ? 12 : 0, - minValue: 0, - maxValue: 12 - }; - } - case 'hour': - if (options.hour12) { - let isPM = date.hour != null && date.hour >= 12; - return { - value: date.hour, - minValue: isPM ? 12 : 0, - maxValue: isPM ? 23 : 11 - }; - } - - return { - value: date.hour, - minValue: 0, - maxValue: 23 - }; - case 'minute': - return { - value: date.minute, - minValue: 0, - maxValue: 59 - }; - case 'second': - return { - value: date.second, - minValue: 0, - maxValue: 59 - }; - } -} - -function addSegment( - value: IncompleteDate, - part: string, - amount: number, - options: Intl.ResolvedDateTimeFormatOptions, - placeholder: DateValue -) { - switch (part) { - case 'era': - case 'year': - case 'month': - case 'day': - if (value[part] == null) { - return value.set({[part]: placeholder[part]}); - } - return value.cycle(part, amount, {round: part === 'year'}); - } - - if ('hour' in value) { - switch (part) { - case 'dayPeriod': { - if (value.hour == null && 'hour' in placeholder) { - return value.set({hour: placeholder.hour}); - } - let hours = value.hour ?? 0; - let isPM = hours >= 12; - return value.set({hour: isPM ? hours - 12 : hours + 12}); - } - case 'hour': - case 'minute': - case 'second': - if (value[part] == null) { - return value.set({[part]: placeholder[part]}); - } - return value.cycle(part, amount, { - round: part !== 'hour', - hourCycle: options.hour12 ? 12 : 24 - }); - } - } - - throw new Error('Unknown segment: ' + part); -} - -function setSegment(value: IncompleteDate, part: string, segmentValue: number | string, options: Intl.ResolvedDateTimeFormatOptions) { - switch (part) { - case 'day': - case 'month': - case 'year': - case 'era': - return value.set({[part]: segmentValue}); - } - - if ('hour' in value && typeof segmentValue === 'number') { - switch (part) { - case 'dayPeriod': { - let hours = value.hour ?? 0; - let wasPM = hours >= 12; - let isPM = segmentValue >= 12; - if (isPM === wasPM) { - return value; - } - return value.set({hour: wasPM ? hours - 12 : hours + 12}); - } - case 'hour': - // In 12 hour time, ensure that AM/PM does not change - if (options.hour12) { - let hours = value.hour ?? 0; - let wasPM = hours >= 12; - if (!wasPM && segmentValue === 12) { - segmentValue = 0; - } - if (wasPM && segmentValue < 12) { - segmentValue += 12; - } - } - // fallthrough - case 'minute': - case 'second': - return value.set({[part]: segmentValue}); - } - } - - throw new Error('Unknown segment: ' + part); -} - -class IncompleteDate { - calendar: Calendar; - era: string; - year: number | null; - month: number | null; - day: number | null; - hour: number | null; - minute: number | null; - second: number | null; - millisecond: number | null; - - constructor(calendar: Calendar, dateValue?: Partial> | null) { - let eras = calendar.getEras(); - this.era = dateValue?.era ?? eras[eras.length - 1]; - this.calendar = calendar; - this.year = dateValue?.year ?? null; - this.month = dateValue?.month ?? null; - this.day = dateValue?.day ?? null; - this.hour = dateValue?.hour ?? null; - this.minute = dateValue?.minute ?? null; - this.second = dateValue?.second ?? null; - this.millisecond = dateValue?.millisecond ?? null; - } - - copy(): IncompleteDate { - let res = new IncompleteDate(this.calendar); - res.era = this.era; - res.year = this.year; - res.month = this.month; - res.day = this.day; - res.hour = this.hour; - res.minute = this.minute; - res.second = this.second; - res.millisecond = this.millisecond; - return res; - } - - isComplete(segments: (DateField | TimeField)[]) { - return segments.every(segment => this[segment] != null); - } - - validate(dt: DateValue, segments: (DateField | TimeField)[]) { - return segments.every(segment => this[segment] === dt[segment]); - } - - isCleared(segments: (DateField | TimeField)[]): boolean { - return segments.every(segment => this[segment] === null); - } - - set(fields: DateFields & TimeFields): IncompleteDate { - let result = this.copy(); - for (let key in fields) { - result[key] = fields[key]; - } - return result; - } - - clear(field: DateField | TimeField): IncompleteDate { - let result = this.copy(); - // @ts-ignore - result[field] = null; - if (field === 'year') { - let eras = this.calendar.getEras(); - result.era = eras[eras.length - 1]; - } - return result; - } - - cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): IncompleteDate { - let res = this.copy(); - switch (field) { - case 'era': - case 'year': { - // Use CalendarDate to cycle so that we update the era when going between 1 AD and 1 BC. - let date = new CalendarDate(this.calendar, this.era, this.year ?? 1, 1, 1); - date = date.cycle(field, amount, options); - res.era = date.era; - res.year = date.year; - break; - } - case 'month': - res.month = cycleValue(res.month ?? 1, amount, 1, this.calendar.getMaximumMonthsInYear(), options?.round); - break; - case 'day': - // Allow incrementing up to the maximum number of days in any month. - res.day = cycleValue(res.day ?? 1, amount, 1, this.calendar.getMaximumDaysInMonth(), options?.round); - break; - 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. - let hours = res.hour ?? 0; - let min = 0; - let max = 23; - if (options?.hourCycle === 12) { - let isPM = hours >= 12; - min = isPM ? 12 : 0; - max = isPM ? 23 : 11; - } - res.hour = cycleValue(hours, amount, min, max, options?.round); - break; - } - case 'minute': - res.minute = cycleValue(res.minute ?? 0, amount, 0, 59, options?.round); - break; - case 'second': - res.second = cycleValue(res.second ?? 0, amount, 0, 59, options?.round); - break; - case 'millisecond': - res.millisecond = cycleValue(res.millisecond ?? 0, amount, 0, 999, options?.round); - break; - } - - return res; - } - - toValue(value: DateValue): DateValue { - if ('hour' in value) { - return value.set({ - era: this.era, - year: this.year ?? value.year, - month: this.month ?? value.month, - day: this.day ?? value.day, - hour: this.hour ?? value.hour, - minute: this.minute ?? value.minute, - second: this.second ?? value.second, - millisecond: this.millisecond ?? value.millisecond - }); - } else { - return value.set({ - era: this.era, - year: this.year ?? value.year, - month: this.month ?? value.month, - day: this.day ?? value.day - }); - } - } -} - -function cycleValue(value: number, amount: number, min: number, max: number, round = false) { - if (round) { - value += Math.sign(amount); - - if (value < min) { - value = max; - } - - let div = Math.abs(amount); - if (amount > 0) { - value = Math.ceil(value / div) * div; - } else { - value = Math.floor(value / div) * div; - } - - if (value > max) { - value = min; - } - } else { - value += amount; - if (value < min) { - value = max - (min - value - 1); - } else if (value > max) { - value = min + (value - max - 1); - } - } - - return value; -}