diff --git a/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts index c96be3fb827..e9642958fa2 100644 --- a/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts +++ b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts @@ -92,10 +92,18 @@ export class EthiopicCalendar implements Calendar { return getDaysInMonth(date.year, date.month); } + getMaxDays(): number { + return 30; + } + getMonthsInYear(): number { return 13; } + getMaxMonths(): number { + return 13; + } + getDaysInYear(date: AnyCalendarDate): number { return 365 + getLeapDay(date.year); } diff --git a/packages/@internationalized/date/src/calendars/GregorianCalendar.ts b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts index 31106c379fe..5f9864aaf8a 100644 --- a/packages/@internationalized/date/src/calendars/GregorianCalendar.ts +++ b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts @@ -109,6 +109,14 @@ export class GregorianCalendar implements Calendar { return 12; } + getMaxMonths(): number { + return 12 + } + + getMaxDays(): number { + return 31; + } + getDaysInYear(date: AnyCalendarDate): number { return isLeapYear(date.year) ? 366 : 365; } diff --git a/packages/@internationalized/date/src/calendars/HebrewCalendar.ts b/packages/@internationalized/date/src/calendars/HebrewCalendar.ts index 52d3f43bc2f..d2ec347fb38 100644 --- a/packages/@internationalized/date/src/calendars/HebrewCalendar.ts +++ b/packages/@internationalized/date/src/calendars/HebrewCalendar.ts @@ -172,10 +172,18 @@ export class HebrewCalendar implements Calendar { return getDaysInMonth(date.year, date.month); } + getMaxDays(): number { + return 30; + } + getMonthsInYear(date: AnyCalendarDate): number { return isLeapYear(date.year) ? 13 : 12; } + getMaxMonths(): number { + return 13 + } + getDaysInYear(date: AnyCalendarDate): number { return getDaysInYear(date.year); } diff --git a/packages/@internationalized/date/src/calendars/IslamicCalendar.ts b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts index 7696e852224..39789a8740f 100644 --- a/packages/@internationalized/date/src/calendars/IslamicCalendar.ts +++ b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts @@ -69,10 +69,18 @@ export class IslamicCivilCalendar implements Calendar { return length; } + getMaxDays(): number { + return 30; + } + getMonthsInYear(): number { return 12; } + getMaxMonths(): number { + return 12 + } + getDaysInYear(date: AnyCalendarDate): number { return isLeapYear(date.year) ? 355 : 354; } diff --git a/packages/@internationalized/date/src/calendars/PersianCalendar.ts b/packages/@internationalized/date/src/calendars/PersianCalendar.ts index 0ff6c86cec5..0a850ecabaf 100644 --- a/packages/@internationalized/date/src/calendars/PersianCalendar.ts +++ b/packages/@internationalized/date/src/calendars/PersianCalendar.ts @@ -67,6 +67,10 @@ export class PersianCalendar implements Calendar { return 12; } + getMaxMonths(): number { + return 12 + } + getDaysInMonth(date: AnyCalendarDate): number { if (date.month <= 6) { return 31; @@ -80,6 +84,10 @@ export class PersianCalendar implements Calendar { return isLeapYear ? 30 : 29; } + getMaxDays(): number { + return 31; + } + getEras(): string[] { return ['AP']; } diff --git a/packages/@internationalized/date/src/conversion.ts b/packages/@internationalized/date/src/conversion.ts index 7b1ae398d1b..c1acef4a03c 100644 --- a/packages/@internationalized/date/src/conversion.ts +++ b/packages/@internationalized/date/src/conversion.ts @@ -26,7 +26,7 @@ export function epochFromDate(date: AnyDateTime): number { return epochFromParts(year, date.month, date.day, date.hour, date.minute, date.second, date.millisecond); } -function epochFromParts(year: number, month: number, day: number, hour: number, minute: number, second: number, millisecond: number): number { +export function epochFromParts(year: number, month: number, day: number, hour: number, minute: number, second: number, millisecond: number): number { // Note: Date.UTC() interprets one and two-digit years as being in the // 20th century, so don't use it let date = new Date(); diff --git a/packages/@internationalized/date/src/types.ts b/packages/@internationalized/date/src/types.ts index 78fba68fe0d..561ad0e4657 100644 --- a/packages/@internationalized/date/src/types.ts +++ b/packages/@internationalized/date/src/types.ts @@ -57,6 +57,10 @@ export interface Calendar { getDaysInMonth(date: AnyCalendarDate): number, /** Returns the number of months in the year of the given date. */ getMonthsInYear(date: AnyCalendarDate): number, + /** Returns the maximum months across all years. */ + getMaxMonths(): number, + /** Returns the maximum days across all months. */ + getMaxDays(): number, /** Returns the number of years in the era of the given date. */ getYearsInEra(date: AnyCalendarDate): number, /** Returns a list of era identifiers for the calendar. */ diff --git a/packages/@react-aria/datepicker/src/useDateField.ts b/packages/@react-aria/datepicker/src/useDateField.ts index 5a04a465421..8d25e07ceea 100644 --- a/packages/@react-aria/datepicker/src/useDateField.ts +++ b/packages/@react-aria/datepicker/src/useDateField.ts @@ -81,9 +81,10 @@ export function useDateField(props: AriaDateFieldOptions }, onBlurWithin: (e) => { state.confirmPlaceholder(); - if (state.value !== valueOnFocus.current) { + if (state.shouldValidate) { state.commitValidation(); - } + state.setShouldValidate(false); + }; props.onBlur?.(e); }, onFocusWithinChange: props.onFocusChange diff --git a/packages/@react-aria/datepicker/src/useDateSegment.ts b/packages/@react-aria/datepicker/src/useDateSegment.ts index 2aad84bc0ea..d1be8905b33 100644 --- a/packages/@react-aria/datepicker/src/useDateSegment.ts +++ b/packages/@react-aria/datepicker/src/useDateSegment.ts @@ -83,13 +83,13 @@ export function useDateSegment(segment: DateSegment, state: DateFieldState, ref: onIncrementToMax: () => { enteredKeys.current = ''; if (segment.maxValue !== undefined) { - state.setSegment(segment.type, segment.maxValue); + state.incrementToMinMax(segment.type, segment.maxValue); } }, onDecrementToMin: () => { enteredKeys.current = ''; if (segment.minValue !== undefined) { - state.setSegment(segment.type, segment.minValue); + state.incrementToMinMax(segment.type, segment.minValue); } } }); diff --git a/packages/@react-spectrum/datepicker/test/DateField.test.js b/packages/@react-spectrum/datepicker/test/DateField.test.js index 23f67cd79c1..b2fb69b9ac6 100644 --- a/packages/@react-spectrum/datepicker/test/DateField.test.js +++ b/packages/@react-spectrum/datepicker/test/DateField.test.js @@ -235,7 +235,7 @@ describe('DateField', function () { errorMessage="Date unavailable." /> ); await user.tab(); - await user.keyboard('01011980'); + await user.keyboard('01011980[Tab]'); expect(tree.getByText('Date unavailable.')).toBeInTheDocument(); }); @@ -369,6 +369,7 @@ describe('DateField', function () { expect(input).toHaveAttribute('name', 'date'); await user.tab(); await user.keyboard('{ArrowUp}'); + await user.tab(); expect(getDescription()).toBe('Selected Date: March 3, 2020'); expect(input).toHaveValue('2020-03-03'); @@ -455,9 +456,8 @@ describe('DateField', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[Tab][Tab][ArrowUp]'); - + expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); expect(input.validity.valid).toBe(true); @@ -467,9 +467,13 @@ describe('DateField', function () { await user.tab({shift: true}); await user.keyboard('2025'); + expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); - expect(input.validity.valid).toBe(false); + expect(input.validity.valid).toBe(true); + await user.tab(); + expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); + expect(input.validity.valid).toBe(false); act(() => {getByTestId('form').checkValidity();}); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); @@ -503,12 +507,11 @@ describe('DateField', function () { expect(group).toHaveAttribute('aria-describedby'); expect(getDescription()).toContain('Invalid value'); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); - await user.keyboard('[ArrowRight][ArrowRight]2024'); expect(getDescription()).toContain('Invalid value'); - expect(input.validity.valid).toBe(true); - + expect(input.validity.valid).toBe(false); + await user.tab(); expect(getDescription()).not.toContain('Invalid value'); @@ -628,10 +631,12 @@ describe('DateField', function () { await user.keyboard('232023'); expect(group).toHaveAttribute('aria-describedby'); - expect(input.validity.valid).toBe(true); + expect(input.validity.valid).toBe(false); await user.tab(); expect(getDescription()).not.toContain('Constraints not satisfied'); + expect(group).toHaveAttribute('aria-describedby'); + expect(input.validity.valid).toBe(true); }); }); @@ -649,13 +654,13 @@ describe('DateField', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); - await user.keyboard('[Tab][Tab][Tab][ArrowUp]'); + await user.keyboard('[Tab][Tab][Tab][ArrowUp][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2020 or later.'); - await user.keyboard('[ArrowUp][ArrowUp][ArrowUp][ArrowUp][ArrowUp]'); + await user.keyboard('[Tab][Tab][Tab][ArrowUp][ArrowUp][ArrowUp][ArrowUp][ArrowUp][Tab]'); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); - await user.keyboard('[ArrowDown]'); + await user.keyboard('[Tab][Tab][Tab][ArrowDown][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); }); @@ -673,7 +678,7 @@ describe('DateField', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Invalid value'); - await user.keyboard('[Tab][ArrowRight][ArrowRight]2024'); + await user.keyboard('[Tab][ArrowRight][ArrowRight]2024[Tab]'); expect(getDescription()).not.toContain('Invalid value'); }); diff --git a/packages/@react-spectrum/datepicker/test/DatePicker.test.js b/packages/@react-spectrum/datepicker/test/DatePicker.test.js index 3b931ba8152..d484b6abedb 100644 --- a/packages/@react-spectrum/datepicker/test/DatePicker.test.js +++ b/packages/@react-spectrum/datepicker/test/DatePicker.test.js @@ -91,7 +91,7 @@ describe('DatePicker', function () { expect(segments[1].getAttribute('aria-valuenow')).toBe('3'); expect(segments[1].getAttribute('aria-valuetext')).toBe('3'); expect(segments[1].getAttribute('aria-valuemin')).toBe('1'); - expect(segments[1].getAttribute('aria-valuemax')).toBe('28'); + expect(segments[1].getAttribute('aria-valuemax')).toBe('31'); expect(getTextValue(segments[2])).toBe('2019'); expect(segments[2].getAttribute('aria-label')).toBe('year, '); @@ -124,7 +124,7 @@ describe('DatePicker', function () { expect(segments[1].getAttribute('aria-valuenow')).toBe('3'); expect(segments[1].getAttribute('aria-valuetext')).toBe('3'); expect(segments[1].getAttribute('aria-valuemin')).toBe('1'); - expect(segments[1].getAttribute('aria-valuemax')).toBe('28'); + expect(segments[1].getAttribute('aria-valuemax')).toBe('31'); expect(getTextValue(segments[2])).toBe('2019'); expect(segments[2].getAttribute('aria-label')).toBe('year, '); @@ -466,7 +466,6 @@ describe('DatePicker', function () { act(() => hour.focus()); await user.keyboard('{ArrowUp}'); - expect(hour).toHaveAttribute('aria-valuetext', '9 AM'); expect(dialog).toBeVisible(); @@ -512,16 +511,20 @@ describe('DatePicker', function () { expect(hour).toHaveAttribute('aria-valuetext', '10 AM'); act(() => hour.focus()); + expect(hour).toHaveAttribute('role', 'spinbutton'); + await user.keyboard('{Backspace}'); expect(hour).toHaveAttribute('aria-valuetext', '1 AM'); await user.keyboard('{Backspace}'); - expect(hour).toHaveAttribute('aria-valuetext', '1 AM'); + expect(hour).toHaveAttribute('aria-valuetext', 'Empty'); + + act(() => button.focus()); expect(dialog).toBeVisible(); - expect(onChange).toHaveBeenCalledTimes(2); - expect(onChange).toHaveBeenCalledWith(new CalendarDateTime(2019, 2, 4, 1, 45)); - expect(getTextValue(combobox)).toBe('2/4/2019, 1:45 AM'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(new CalendarDateTime(2019, 2, 4, 10, 45)); + expect(getTextValue(combobox)).toBe('2/4/2019, 10:45 AM'); }); it('should fire onChange until both date and time are selected', async function () { @@ -1014,8 +1017,9 @@ describe('DatePicker', function () { expect(segments[2]).toHaveFocus(); }); - it('should focus the previous segment when the era is removed', async function () { - let {getByTestId, queryByTestId} = render(); + // This test should be reviewed + it('should focus the button when the era is removed', async function () { + let {getByTestId, queryByTestId, getByRole} = render(); let field = getByTestId('date-field'); let era = getByTestId('era'); expect(era).toBe(within(field).getAllByRole('spinbutton').pop()); @@ -1023,12 +1027,16 @@ describe('DatePicker', function () { act(() => era.focus()); await user.keyboard('{ArrowUp}'); + const button = getByRole('button'); + act(() => button.focus()); + expect(queryByTestId('era')).toBeNull(); - expect(document.activeElement).toBe(within(field).getAllByRole('spinbutton').pop()); + expect(document.activeElement).toBe(button); }); - it('should focus the next segment when the era is removed and is the first segment', async function () { - let {getByTestId, queryByTestId} = render( + // This test should be reviewed + it('should focus the button when the era is removed and is the first segment', async function () { + let {getByTestId, queryByTestId, getByRole} = render( @@ -1040,8 +1048,10 @@ describe('DatePicker', function () { act(() => era.focus()); await user.keyboard('{ArrowUp}'); + const button = getByRole('button'); + act(() => button.focus()); expect(queryByTestId('era')).toBeNull(); - expect(document.activeElement.textContent.replace(/[\u2066-\u2069]/g, '')).toBe('3'); + expect(document.activeElement).toBe(button); }); it('does not try to shift focus when the entire datepicker is unmounted while focused', function () { @@ -1068,7 +1078,7 @@ describe('DatePicker', function () { ); let segment = getByLabelText(label); - let textContent = segment.textContent; + let textContent = segment.textContent; act(() => {segment.focus();}); await user.keyboard(`{${options?.upKey || 'ArrowUp'}}`); @@ -1076,6 +1086,7 @@ describe('DatePicker', function () { expect(onChange).toHaveBeenCalledWith(incremented); expect(segment.textContent).toBe(textContent); + act(() => {segment.focus();}); await user.keyboard(`{${options?.downKey || 'ArrowDown'}}`); expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenCalledWith(decremented); @@ -1175,7 +1186,7 @@ describe('DatePicker', function () { }); it('should wrap around when incrementing and decrementing the day', async function () { - await testArrows('day,', new CalendarDate(2019, 2, 28), new CalendarDate(2019, 2, 1), new CalendarDate(2019, 2, 27)); + await testArrows('day,', new CalendarDate(2019, 8, 31), new CalendarDate(2019, 8, 1), new CalendarDate(2019, 8, 30)); await testArrows('day,', new CalendarDate(2019, 2, 1), new CalendarDate(2019, 2, 2), new CalendarDate(2019, 2, 28)); }); @@ -1308,7 +1319,7 @@ describe('DatePicker', function () { function testInput(label, value, keys, newValue, moved, props) { let onChange = jest.fn(); // Test controlled mode - let {getByLabelText, getAllByRole, unmount} = render( + let {getByLabelText, getAllByRole, unmount, getByRole} = render( @@ -1319,22 +1330,18 @@ describe('DatePicker', function () { act(() => {segment.focus();}); let allowsZero = (label.indexOf('hour') === 0 && props?.hourCycle === 24) || label.indexOf('minute') === 0 || label.indexOf('second') === 0; - let count = 0; for (let [i, key] of [...keys].entries()) { beforeInput(segment, key); if (key !== '0' || (moved && i === keys.length - 1) || allowsZero) { - expect(onChange).toHaveBeenCalledTimes(++count); + expect(onChange).toHaveBeenCalledTimes(0); } - expect(segment.textContent).toBe(textContent); if (i < keys.length - 1) { expect(segment).toHaveFocus(); } } - expect(onChange).toHaveBeenCalledWith(newValue); - if (moved) { let segments = getAllByRole('spinbutton'); let nextSegment = segments[segments.indexOf(segment) + 1]; @@ -1343,6 +1350,11 @@ describe('DatePicker', function () { expect(segment).toHaveFocus(); } + let button = getByRole('button'); + act(() => button.focus()); + expect(onChange).toHaveBeenCalledWith(newValue); + expect(segment.textContent).toBe(textContent); + unmount(); // Test uncontrolled mode @@ -1356,13 +1368,11 @@ describe('DatePicker', function () { textContent = segment.textContent; act(() => {segment.focus();}); - count = 0; for (let [i, key] of [...keys].entries()) { beforeInput(segment, key); if (key !== '0' || (moved && i === keys.length - 1) || allowsZero) { - expect(onChange).toHaveBeenCalledTimes(++count); - expect(segment.textContent).not.toBe(textContent); + expect(onChange).toHaveBeenCalledTimes(0); } if (i < keys.length - 1) { @@ -1370,8 +1380,6 @@ describe('DatePicker', function () { } } - expect(onChange).toHaveBeenCalledWith(newValue); - if (moved) { let segments = getAllByRole('spinbutton'); let nextSegment = segments[segments.indexOf(segment) + 1]; @@ -1380,6 +1388,11 @@ describe('DatePicker', function () { expect(segment).toHaveFocus(); } + button = getByRole('button'); + act(() => button.focus()); + expect(onChange).toHaveBeenCalledWith(newValue); + expect(segment.textContent).not.toBe(textContent); + unmount(); // Test read only mode @@ -1521,12 +1534,14 @@ describe('DatePicker', function () { let onChange = jest.fn(); // Test controlled mode - let {getByLabelText, unmount} = render(); + let {getByLabelText, unmount, getByRole} = render(); let segment = getByLabelText(label); let textContent = segment.textContent; act(() => {segment.focus();}); await user.keyboard('{Backspace}'); + let button = getByRole('button'); + act(() => {button.focus();}); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(newValue); expect(segment.textContent).toBe(textContent); @@ -1540,6 +1555,8 @@ describe('DatePicker', function () { act(() => {segment.focus();}); await user.keyboard('{Backspace}'); + button = getByRole('button'); + act(() => {button.focus();}); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(newValue); if (label === 'AM/PM,') { @@ -1613,6 +1630,7 @@ describe('DatePicker', function () { act(() => {segment.focus();}); await user.keyboard('{Backspace}'); + await user.tab(); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(new CalendarDate(201, 2, 3)); expect(segment).toHaveTextContent('٢٠١'); @@ -1633,10 +1651,13 @@ describe('DatePicker', function () { let year = getByLabelText('year,'); act(() => year.focus()); await user.keyboard('{ArrowDown}'); + await user.tab(); expect(getByTestId('invalid-icon')).toBeVisible(); + act(() => year.focus()); await user.keyboard('{ArrowUp}'); + await user.tab(); expect(queryByTestId('invalid-icon')).toBeNull(); }); @@ -1652,10 +1673,13 @@ describe('DatePicker', function () { let year = getByLabelText('year,'); act(() => year.focus()); await user.keyboard('{ArrowUp}'); + await user.tab(); expect(getByTestId('invalid-icon')).toBeVisible(); + act(() => year.focus()); await user.keyboard('{ArrowDown}'); + await user.tab(); expect(queryByTestId('invalid-icon')).toBeNull(); }); }); @@ -1758,7 +1782,7 @@ describe('DatePicker', function () { expectPlaceholder(combobox, formatter.format(value.toDate(getLocalTimeZone()))); }); - it('should enter a date to modify placeholder (uncontrolled)', function () { + it('should enter a date to modify placeholder (uncontrolled)', async function () { let onChange = jest.fn(); let {getAllByRole} = render(); @@ -1787,20 +1811,21 @@ describe('DatePicker', function () { expectPlaceholder(combobox, `${month}/${day}/yyyy`); beforeInput(document.activeElement, '2'); - expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(0); beforeInput(document.activeElement, '0'); - expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledTimes(0); beforeInput(document.activeElement, '2'); - expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenCalledTimes(0); beforeInput(document.activeElement, '0'); expect(segments[2]).toHaveFocus(); - expect(onChange).toHaveBeenCalledTimes(4); + await user.tab(); + expect(onChange).toHaveBeenCalledTimes(1); value = new CalendarDate(2020, 4, 5); expect(onChange).toHaveBeenCalledWith(value); expectPlaceholder(combobox, formatter.format(value.toDate(getLocalTimeZone()))); }); - it('should enter a date to modify placeholder (controlled)', function () { + it('should enter a date to modify placeholder (controlled)', async function () { let onChange = jest.fn(); let {getAllByRole, rerender} = render(); @@ -1828,10 +1853,18 @@ describe('DatePicker', function () { let day = parts.find(p => p.type === 'day').value; expectPlaceholder(combobox, `${month}/${day}/yyyy`); - beforeInput(document.activeElement, '2'); + beforeInput(document.activeElement, '2'); + expect(onChange).not.toHaveBeenCalled(); + value = today(getLocalTimeZone()).set({month: 4, day: 5, year: 2}); + parts = formatter.formatToParts(value.toDate(getLocalTimeZone())); + month = parts.find(p => p.type === 'month').value; + day = parts.find(p => p.type === 'day').value; + let year = parts.find(p => p.type === 'year').value; + expectPlaceholder(combobox, `${month}/${day}/${year}`); + + await user.tab(); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(new CalendarDate(2, 4, 5)); - expect(segments[2]).toHaveFocus(); expectPlaceholder(combobox, 'mm/dd/yyyy'); // controlled value = new CalendarDate(2020, 4, 5); @@ -2033,6 +2066,7 @@ describe('DatePicker', function () { expect(input).toHaveAttribute('name', 'date'); await user.tab(); await user.keyboard('{ArrowUp}'); + await user.tab({shift: true}); expect(getDescription()).toBe('Selected Date: March 3, 2020'); expect(input).toHaveValue('2020-03-03'); @@ -2132,11 +2166,12 @@ describe('DatePicker', function () { await user.tab({shift: true}); await user.keyboard('2025'); expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); - expect(input.validity.valid).toBe(false); + expect(input.validity.valid).toBe(true); await user.tab(); act(() => {getByTestId('form').checkValidity();}); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); + expect(input.validity.valid).toBe(false); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); await user.keyboard('[Tab][Tab][ArrowDown]'); @@ -2171,7 +2206,7 @@ describe('DatePicker', function () { await user.keyboard('[ArrowRight][ArrowRight]2024'); expect(getDescription()).toContain('Invalid value'); - expect(input.validity.valid).toBe(true); + expect(input.validity.valid).toBe(false); await user.tab(); @@ -2307,13 +2342,13 @@ describe('DatePicker', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Value must be 2/3/2020 or later.'); - await user.keyboard('[Tab][Tab][Tab][ArrowUp]'); + await user.keyboard('[Tab][Tab][Tab][ArrowUp][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2020 or later.'); - await user.keyboard('[ArrowUp][ArrowUp][ArrowUp][ArrowUp][ArrowUp]'); + await user.keyboard('[Tab][Tab][Tab][Tab][ArrowUp][ArrowUp][ArrowUp][ArrowUp][ArrowUp][Tab]'); expect(getDescription()).toContain('Value must be 2/3/2024 or earlier.'); - await user.keyboard('[ArrowDown]'); + await user.keyboard('[Tab][Tab][Tab][Tab][ArrowDown][Tab]'); expect(getDescription()).not.toContain('Value must be 2/3/2024 or earlier.'); }); @@ -2331,7 +2366,7 @@ describe('DatePicker', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Invalid value'); - await user.keyboard('[Tab][ArrowRight][ArrowRight]2024'); + await user.keyboard('[Tab][ArrowRight][ArrowRight]2024[Tab]'); expect(getDescription()).not.toContain('Invalid value'); }); diff --git a/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js b/packages/@react-spectrum/datepicker/test/DateRangePicker.test.js index cf027ab73ad..072dfddeba1 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, '); @@ -548,7 +548,7 @@ describe('DateRangePicker', function () { expect(hour).toHaveAttribute('aria-valuetext', '11 AM'); expect(dialog).toBeVisible(); - expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenCalledTimes(4); expect(onChange).toHaveBeenCalledWith({start: new CalendarDateTime(2019, 2, 10, 9, 45), end: new CalendarDateTime(2019, 2, 17, 11, 45)}); expect(getTextValue(startDate)).toBe('2/10/2019, 9:45 AM'); expect(getTextValue(endDate)).toBe('2/17/2019, 11:45 AM'); @@ -764,6 +764,7 @@ describe('DateRangePicker', function () { for (let timeField of [startTimeField, endTimeField]) { let hour = within(timeField).getByLabelText('hour,'); + act(() => hour.focus()); fireEvent.keyDown(hour, {key: 'ArrowUp'}); fireEvent.keyUp(hour, {key: 'ArrowUp'}); @@ -1140,7 +1141,7 @@ describe('DateRangePicker', function () { fireEvent.keyDown(endYear, {key: 'ArrowUp'}); expect(endYear).toHaveTextContent('2020'); // uncontrolled - expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledTimes(3); expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2019, 1, 3), end: new CalendarDate(2020, 5, 6)}); }); @@ -1168,11 +1169,11 @@ describe('DateRangePicker', function () { fireEvent.keyDown(endYear, {key: 'ArrowUp'}); expect(endYear).toHaveTextContent('2019'); // controlled - expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledTimes(3); expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2019, 2, 3), end: new CalendarDate(2020, 5, 6)}); }); - it('should edit a date range by entering text (uncontrolled)', function () { + it('should edit a date range by entering text (uncontrolled)', async function () { let onChange = jest.fn(); let {getByLabelText} = render( {endYear.blur();}); + expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2019, 8, 3), end: new CalendarDate(2022, 5, 6)}); }); - it('should edit a date range by entering text (controlled)', function () { + it('should edit a date range by entering text (controlled)', async function () { let onChange = jest.fn(); let {getByLabelText} = render( {startMonth.focus();}); beforeInput(startMonth, '8'); - expect(startMonth).toHaveTextContent('2'); // controlled + expect(startMonth).toHaveTextContent('8'); // controlled + await user.keyboard('[Tab][Tab]'); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2019, 8, 3), end: new CalendarDate(2019, 5, 6)}); - expect(getByLabelText('day, Start Date,')).toHaveFocus(); - let endDay = getByLabelText('day, End Date,'); expect(endDay).toHaveTextContent('6'); act(() => {endDay.focus();}); beforeInput(endDay, '4'); - expect(endDay).toHaveTextContent('6'); // controlled + expect(endDay).toHaveTextContent('4'); // controlled + await user.keyboard('[Tab][Tab]'); expect(onChange).toHaveBeenCalledTimes(2); expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2019, 2, 3), end: new CalendarDate(2019, 5, 4)}); }); @@ -1246,13 +1249,13 @@ describe('DateRangePicker', function () { expect(endYear).toHaveTextContent('2019'); act(() => {endYear.focus();}); fireEvent.keyDown(endYear, {key: 'Backspace'}); - + act(() => {endYear.blur();}); expect(endYear).toHaveTextContent('201'); // uncontrolled expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2019, 2, 3), end: new CalendarDate(201, 5, 6)}); }); - it('should support backspace (controlled)', function () { + it('should support backspace (controlled)', async function () { let onChange = jest.fn(); let {getByLabelText} = render( {endYear.focus();}); fireEvent.keyDown(endYear, {key: 'Backspace'}); - + act(() => {endYear.blur();}); expect(endYear).toHaveTextContent('2019'); // controlled expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2019, 2, 3), end: new CalendarDate(201, 5, 6)}); @@ -1415,7 +1418,7 @@ describe('DateRangePicker', function () { expectPlaceholder(endDate, 'mm/dd/yyyy'); }); - it('should not fire onChange until both start and end dates have been entered', function () { + it('should not fire onChange until both start and end dates have been entered', async function () { let onChange = jest.fn(); let {getByTestId, getAllByRole} = render(); @@ -1433,8 +1436,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 +1445,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(); @@ -1457,15 +1460,15 @@ describe('DateRangePicker', function () { expect(onChange).not.toHaveBeenCalled(); beforeInput(document.activeElement, '2'); - expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledTimes(0); beforeInput(document.activeElement, '0'); - expect(onChange).toHaveBeenCalledTimes(2); + expect(onChange).toHaveBeenCalledTimes(0); beforeInput(document.activeElement, '2'); - expect(onChange).toHaveBeenCalledTimes(3); + expect(onChange).toHaveBeenCalledTimes(0); beforeInput(document.activeElement, '2'); - expect(onChange).toHaveBeenCalledTimes(4); - - expect(onChange).toHaveBeenCalledWith({start: new CalendarDate(2020, 2, 3), end: new CalendarDate(2022, 4, 8)}); + expect(onChange).toHaveBeenCalledTimes(0); + await user.keyboard('[Tab]'); + 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 () { @@ -1662,8 +1665,8 @@ describe('DateRangePicker', function () { await user.keyboard('[ArrowRight][ArrowRight]2026'); expect(getDescription()).toContain('Invalid value'); - expect(startInput.validity.valid).toBe(true); - expect(endInput.validity.valid).toBe(true); + expect(startInput.validity.valid).toBe(false); + expect(endInput.validity.valid).toBe(false); await user.tab(); expect(getDescription()).not.toContain('Invalid value'); diff --git a/packages/@react-spectrum/datepicker/test/TimeField.test.js b/packages/@react-spectrum/datepicker/test/TimeField.test.js index 22dad189813..026462f0230 100644 --- a/packages/@react-spectrum/datepicker/test/TimeField.test.js +++ b/packages/@react-spectrum/datepicker/test/TimeField.test.js @@ -287,6 +287,7 @@ describe('TimeField', function () { expect(input).toHaveAttribute('name', 'time'); fireEvent.keyDown(segments[0], {key: 'ArrowUp'}); fireEvent.keyUp(segments[0], {key: 'ArrowUp'}); + await user.keyboard('[Tab][Tab][Tab][Tab]'); expect(getDescription()).toBe('Selected Time: 9:30 AM'); expect(input).toHaveValue('09:30:00'); @@ -381,6 +382,7 @@ describe('TimeField', function () { await user.tab({shift: true}); expect(getDescription()).not.toContain('Value must be 9:00 AM or later.'); + expect(input.validity.valid).toBe(true); await user.tab(); await user.keyboard('6[Tab][ArrowUp]'); @@ -390,6 +392,7 @@ describe('TimeField', function () { act(() => {getByTestId('form').checkValidity();}); expect(getDescription()).toContain('Value must be 5:00 PM or earlier.'); + expect(input.validity.valid).toBe(false); expect(document.activeElement).toBe(within(group).getAllByRole('spinbutton')[0]); await user.keyboard('[ArrowDown]'); @@ -398,6 +401,7 @@ describe('TimeField', function () { act(() => document.activeElement.blur()); expect(getDescription()).not.toContain('Value must be 5:00 PM or earlier.'); + expect(input.validity.valid).toBe(true); }); it('supports validate function', async () => { @@ -424,7 +428,7 @@ describe('TimeField', function () { await user.keyboard('10'); expect(getDescription()).toContain('Invalid value'); - expect(input.validity.valid).toBe(true); + expect(input.validity.valid).toBe(false); act(() => document.activeElement.blur()); @@ -530,13 +534,13 @@ describe('TimeField', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Value must be 9:00 AM or later.'); - await user.keyboard('[Tab][ArrowUp]'); + await user.keyboard('[Tab][ArrowUp][Tab][Tab][Tab]'); expect(getDescription()).not.toContain('Value must be 9:00 AM or later'); - await user.keyboard('6[Tab][ArrowUp]'); + await user.keyboard('[Tab]6[Tab][ArrowUp][Tab][Tab][Tab]'); expect(getDescription()).toContain('Value must be 5:00 PM or earlier'); - await user.keyboard('[Tab][Tab][ArrowDown]'); + await user.keyboard('[Tab][ArrowDown][Tab][Tab]'); expect(getDescription()).not.toContain('Value must be 5:00 PM or earlier'); }); @@ -554,7 +558,7 @@ describe('TimeField', function () { let getDescription = () => group.getAttribute('aria-describedby').split(' ').map(d => document.getElementById(d).textContent).join(' '); expect(getDescription()).toContain('Invalid value'); - await user.keyboard('[Tab]10'); + await user.keyboard('[Tab]10[Tab][Tab]'); expect(getDescription()).not.toContain('Invalid value'); }); diff --git a/packages/@react-stately/datepicker/src/IncompleteDate.ts b/packages/@react-stately/datepicker/src/IncompleteDate.ts new file mode 100644 index 00000000000..c3960a04e65 --- /dev/null +++ b/packages/@react-stately/datepicker/src/IncompleteDate.ts @@ -0,0 +1,316 @@ +import {AnyCalendarDate, cycleDate, CycleOptions, cycleTime, cycleZoned, DateField, DateFields, set, setTime, setZoned, toZoned} from './manipulation'; +import {Calendar, CalendarDate, CalendarDateTime, CycleTimeOptions, Disambiguation, GregorianCalendar, TimeField, TimeFields, ZonedDateTime} from '@internationalized/date'; +import {compareDate, compareTime} from '../../../@internationalized/date/src/queries'; +import {dateTimeToString, dateToString, toDate, toIncompleteDateTime, zonedDateTimeToString, zonedToDate} from './conversion'; + +function shiftArgs(args: any[]) { + let calendar: Calendar = typeof args[0] === 'object' + ? args.shift() + : new GregorianCalendar(); + + let era: string; + if (typeof args[0] === 'string') { + era = args.shift(); + } else { + let eras = calendar.getEras(); + era = eras[eras.length - 1]; + } + + let year = args.shift(); + let month = args.shift(); + let day = args.shift(); + + return [calendar, era, year, month, day]; +} + +export class IncompleteDate { + // This prevents TypeScript from allowing other types with the same fields to match. + // i.e. a ZonedDateTime should not be be passable to a parameter that expects CalendarDate. + // If that behavior is desired, use the AnyCalendarDate interface instead. + // @ts-ignore + #type; + /** The calendar system associated with this date, e.g. Gregorian. */ + public readonly calendar: Calendar; + /** The calendar era for this date, e.g. "BC" or "AD". */ + public readonly era: string; + /** The year of this date within the era. */ + public readonly year: number; + /** + * The month number within the year. Note that some calendar systems such as Hebrew + * may have a variable number of months per year. Therefore, month numbers may not + * always correspond to the same month names in different years. + */ + public readonly month: number; + /** The day number within the month. */ + public readonly day: number; + + constructor(); + constructor(year: number, month: number, day: number); + constructor(era: string, year: number, month: number, day: number); + constructor(calendar: Calendar, year: number, month: number, day: number); + constructor(calendar: Calendar, era: string, year: number, month: number, day: number); + constructor(...args: any[]) { + let [calendar, era, year, month, day] = shiftArgs(args); + this.calendar = calendar; + this.era = era; + this.year = year; + this.month = month; + this.day = day; + } + + + /** Returns a copy of this date. */ + copy(): IncompleteDate { + if (this.era) { + return new IncompleteDate(this.calendar, this.era, this.year, this.month, this.day); + } else { + return new IncompleteDate(this.calendar, this.year, this.month, this.day); + } + } + + /** Returns a new `CalendarDate` with the given fields set to the provided values. Other fields will be constrained accordingly. */ + set(fields: DateFields): IncompleteDate { + return set(this, fields); + } + + /** + * Returns a new `CalendarDate` with the given field adjusted by a specified amount. + * When the resulting value reaches the limits of the field, it wraps around. + */ + cycle(field: DateField, amount: number, options?: CycleOptions): IncompleteDate { + return cycleDate(this, field, amount, options); + } + + /** Converts the date to a native JavaScript Date object, with the time set to midnight in the given time zone. */ + toDate(timeZone: string): Date { + return toDate(this, timeZone); + } + + toCalendar() : CalendarDate { + if (this.era) { + return new CalendarDate(this.calendar, this.era, this.year, this.month, this.day); + } else { + return new CalendarDate(this.calendar, this.year, this.month, this.day); + } + } + + /** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */ + compare(b: AnyCalendarDate): number { + return compareDate(this, b); + } + + /** Converts the date to an ISO 8601 formatted string. */ + toString(): string { + return dateToString(this); + } + +} + +/** A CalendarDateTime represents a date and time without a time zone, in a specific calendar system. */ +export class IncompleteDateTime { + // This prevents TypeScript from allowing other types with the same fields to match. + // @ts-ignore + #type; + /** The calendar system associated with this date, e.g. Gregorian. */ + public readonly calendar: Calendar; + /** The calendar era for this date, e.g. "BC" or "AD". */ + public readonly era: string; + /** The year of this date within the era. */ + public readonly year: number; + /** + * The month number within the year. Note that some calendar systems such as Hebrew + * may have a variable number of months per year. Therefore, month numbers may not + * always correspond to the same month names in different years. + */ + public readonly month: number; + /** The day number within the month. */ + public readonly day: number; + /** The hour in the day, numbered from 0 to 23. */ + public readonly hour: number; + /** The minute in the hour. */ + public readonly minute: number; + /** The second in the minute. */ + public readonly second: number; + /** The millisecond in the second. */ + public readonly millisecond: number; + + constructor(year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number); + constructor(era: string, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number); + constructor(calendar: Calendar, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number); + constructor(calendar: Calendar, era: string, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number); + constructor(...args: any[]) { + let [calendar, era, year, month, day] = shiftArgs(args); + this.calendar = calendar; + this.era = era; + this.year = year; + this.month = month; + this.day = day; + this.hour = args.shift() || 0; + this.minute = args.shift() || 0; + this.second = args.shift() || 0; + this.millisecond = args.shift() || 0; + } + + /** Returns a copy of this date. */ + copy(): IncompleteDateTime { + if (this.era) { + return new IncompleteDateTime(this.calendar, this.era, this.year, this.month, this.day, this.hour, this.minute, this.second, this.millisecond); + } else { + return new IncompleteDateTime(this.calendar, this.year, this.month, this.day, this.hour, this.minute, this.second, this.millisecond); + } + } + + /** Returns a new `CalendarDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */ + set(fields: DateFields & TimeFields): IncompleteDateTime { + return set(setTime(this, fields), fields); + } + + + /** + * Returns a new `IncompleteDateTime` with the given field adjusted by a specified amount. + * When the resulting value reaches the limits of the field, it wraps around. + */ + cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): IncompleteDateTime { + switch (field) { + case 'era': + case 'year': + case 'month': + case 'day': + return cycleDate(this, field, amount, options); + default: + return cycleTime(this, field, amount, options); + } + } + + /** Converts the date to a native JavaScript Date object in the given time zone. */ + toDate(timeZone: string, disambiguation?: Disambiguation): Date { + return toDate(this, timeZone, disambiguation); + } + + + toCalendar() : CalendarDateTime { + if (this.era) { + return new CalendarDateTime(this.calendar, this.era, this.year, this.month, this.day, this.hour, this.minute, this.second, this.millisecond); + } else { + return new CalendarDateTime(this.calendar, this.year, this.month, this.day, this.hour, this.minute, this.second, this.millisecond); + } + } + + /** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */ + compare(b: IncompleteDate | IncompleteDateTime | IncompleteZonedDateTime): number { + let res = compareDate(this, b); + if (res === 0) { + return compareTime(this, toIncompleteDateTime(b)); + } + + return res; + } + + /** Converts the date to an ISO 8601 formatted string. */ + toString(): string { + return dateTimeToString(this); + } +} + + +/** A ZonedDateTime represents a date and time in a specific time zone and calendar system. */ +export class IncompleteZonedDateTime { + // This prevents TypeScript from allowing other types with the same fields to match. + // @ts-ignore + #type; + /** The calendar system associated with this date, e.g. Gregorian. */ + public readonly calendar: Calendar; + /** The calendar era for this date, e.g. "BC" or "AD". */ + public readonly era: string; + /** The year of this date within the era. */ + public readonly year: number; + /** + * The month number within the year. Note that some calendar systems such as Hebrew + * may have a variable number of months per year. Therefore, month numbers may not + * always correspond to the same month names in different years. + */ + public readonly month: number; + /** The day number within the month. */ + public readonly day: number; + /** The hour in the day, numbered from 0 to 23. */ + public readonly hour: number; + /** The minute in the hour. */ + public readonly minute: number; + /** The second in the minute. */ + public readonly second: number; + /** The millisecond in the second. */ + public readonly millisecond: number; + /** The IANA time zone identifier that this date and time is represented in. */ + public readonly timeZone: string; + /** The UTC offset for this time, in milliseconds. */ + public readonly offset: number; + + constructor(year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number); + constructor(era: string, year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number); + constructor(calendar: Calendar, year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number); + constructor(calendar: Calendar, era: string, year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number); + constructor(...args: any[]) { + let [calendar, era, year, month, day] = shiftArgs(args); + let timeZone = args.shift(); + let offset = args.shift(); + this.calendar = calendar; + this.era = era; + this.year = year; + this.month = month; + this.day = day; + this.timeZone = timeZone; + this.offset = offset; + this.hour = args.shift() || 0; + this.minute = args.shift() || 0; + this.second = args.shift() || 0; + this.millisecond = args.shift() || 0; + } + + /** Returns a copy of this date. */ + copy(): IncompleteZonedDateTime { + if (this.era) { + return new IncompleteZonedDateTime(this.calendar, this.era, this.year, this.month, this.day, this.timeZone, this.offset, this.hour, this.minute, this.second, this.millisecond); + } else { + return new IncompleteZonedDateTime(this.calendar, this.year, this.month, this.day, this.timeZone, this.offset, this.hour, this.minute, this.second, this.millisecond); + } + } + + /** Returns a new `ZonedDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */ + set(fields: DateFields & TimeFields, disambiguation?: Disambiguation): IncompleteZonedDateTime { + return setZoned(this, fields, disambiguation); + } + + + /** + * Returns a new `ZonedDateTime` with the given field adjusted by a specified amount. + * When the resulting value reaches the limits of the field, it wraps around. + */ + cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): IncompleteZonedDateTime { + return cycleZoned(this, field, amount, options); + } + + /** Converts the date to a native JavaScript Date object. */ + toDate(): Date { + return zonedToDate(this); + + } + + /** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */ + compare(b: IncompleteDate | IncompleteDateTime | IncompleteZonedDateTime): number { + // TODO: Is this a bad idea?? + return this.toDate().getTime() - toZoned(b, this.timeZone).toDate().getTime(); + } + + toCalendar() { + if (this.era) { + return new ZonedDateTime(this.calendar, this.era, this.year, this.month, this.day, this.timeZone, this.offset, this.hour, this.minute, this.second, this.millisecond); + } else { + return new ZonedDateTime(this.calendar, this.year, this.month, this.day, this.timeZone, this.offset, this.hour, this.minute, this.second, this.millisecond); + } + } + + /** Converts the date to an ISO 8601 formatted string, including the UTC offset and time zone identifier. */ + toString(): string { + return zonedDateTimeToString(this); + } +} diff --git a/packages/@react-stately/datepicker/src/conversion.ts b/packages/@react-stately/datepicker/src/conversion.ts new file mode 100644 index 00000000000..b64b9f31b49 --- /dev/null +++ b/packages/@react-stately/datepicker/src/conversion.ts @@ -0,0 +1,263 @@ +import {AnyCalendarDate, AnyDateTime, Calendar, CalendarDate, CalendarDateTime, Disambiguation, getLocalTimeZone, GregorianCalendar, isEqualCalendar, ZonedDateTime} from '@internationalized/date'; +import {epochFromParts, getTimeZoneOffset} from '../../../@internationalized/date/src/conversion'; +import {getExtendedYear} from '../../../@internationalized/date/src/calendars/GregorianCalendar'; +import {IncompleteDate, IncompleteDateTime, IncompleteZonedDateTime} from './IncompleteDate'; +import {Mutable} from './manipulation'; +import {timeToString} from '../../../@internationalized/date/src/string'; + +const DAYMILLIS = 86400000; + +/** An interface that is compatible with any object with time fields. */ +export interface AnyTime { + readonly hour: number, + readonly minute: number, + readonly second: number, + readonly millisecond: number, + copy(): this +} + +export function toIncompleteDate(dateTime: AnyCalendarDate) { + return new IncompleteDate(dateTime.calendar, dateTime.era, dateTime.year, dateTime.month, dateTime.day); +} + +export function toIncompleteZonedDateTime(date: ZonedDateTime) { + return new IncompleteZonedDateTime(date.calendar, date.era, date.year, date.month, date.day, date.timeZone, date.offset, date.hour, date.minute, date.second, date.millisecond); +} + +export function toDate(dateTime: IncompleteDate | IncompleteDateTime, timeZone: string, disambiguation: Disambiguation = 'compatible'): Date { + return new Date(toAbsolute(dateTime, timeZone, disambiguation)); +} + +export function toAbsolute(date: IncompleteDate | IncompleteDateTime, timeZone: string, disambiguation: Disambiguation = 'compatible'): number { + let dateTime = toIncompleteDateTime(date); + + // Fast path: if the time zone is UTC, use native Date. + if (timeZone === 'UTC') { + return epochFromDate(dateTime); + } + + // Fast path: if the time zone is the local timezone and disambiguation is compatible, use native Date. + if (timeZone === getLocalTimeZone() && disambiguation === 'compatible') { + dateTime = toCalendar(dateTime, new GregorianCalendar()); + + // Don't use Date constructor here because two-digit years are interpreted in the 20th century. + let date = new Date(); + let year = getExtendedYear(dateTime.era, dateTime.year); + date.setFullYear(year, dateTime.month - 1, dateTime.day); + date.setHours(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond); + return date.getTime(); + } + + let ms = epochFromDate(dateTime); + let offsetBefore = getTimeZoneOffset(ms - DAYMILLIS, timeZone); + let offsetAfter = getTimeZoneOffset(ms + DAYMILLIS, timeZone); + let valid = getValidWallTimes(dateTime, timeZone, ms - offsetBefore, ms - offsetAfter); + + if (valid.length === 1) { + return valid[0]; + } + + if (valid.length > 1) { + switch (disambiguation) { + // 'compatible' means 'earlier' for "fall back" transitions + case 'compatible': + case 'earlier': + return valid[0]; + case 'later': + return valid[valid.length - 1]; + case 'reject': + throw new RangeError('Multiple possible absolute times found'); + } + } + + switch (disambiguation) { + case 'earlier': + return Math.min(ms - offsetBefore, ms - offsetAfter); + // 'compatible' means 'later' for "spring forward" transitions + case 'compatible': + case 'later': + return Math.max(ms - offsetBefore, ms - offsetAfter); + case 'reject': + throw new RangeError('No such absolute time found'); + } +} + +export function toIncompleteDateTime(date: IncompleteDate | IncompleteDateTime | IncompleteZonedDateTime | ZonedDateTime, time?: AnyTime): IncompleteDateTime { + let hour = 0, minute = 0, second = 0, millisecond = 0; + if ('timeZone' in date) { + ({hour, minute, second, millisecond} = date); + } else if ('hour' in date && !time) { + return date; + } + + if (time) { + ({hour, minute, second, millisecond} = time); + } + + + return new IncompleteDateTime( + date.calendar, + date.era, + date.year, + date.month, + date.day, + hour, + minute, + second, + millisecond + ); +} + +/** Converts a date from one calendar system to another. */ +export function toCalendar(date: T, calendar: Calendar): T { + if (isEqualCalendar(date.calendar, calendar)) { + return date; + } + + let calendarDate = calendar.fromJulianDay(date.calendar.toJulianDay(date)); + let copy: Mutable = date.copy(); + copy.calendar = calendar; + copy.era = calendarDate.era; + copy.year = calendarDate.year; + copy.month = calendarDate.month; + copy.day = calendarDate.day; + return copy; +} + +export function epochFromDate(date: AnyDateTime): number { + date = toCalendar(date, new GregorianCalendar()); + let year = getExtendedYear(date.era, date.year); + return epochFromParts(year, date.month, date.day, date.hour, date.minute, date.second, date.millisecond); +} + +function getValidWallTimes(date: IncompleteDateTime, timeZone: string, earlier: number, later: number): number[] { + let found = earlier === later ? [earlier] : [earlier, later]; + return found.filter(absolute => isValidWallTime(date, timeZone, absolute)); +} + +function isValidWallTime(date: IncompleteDateTime, timeZone: string, absolute: number) { + let parts = getTimeZoneParts(absolute, timeZone); + return date.year === parts.year + && date.month === parts.month + && date.day === parts.day + && date.hour === parts.hour + && date.minute === parts.minute + && date.second === parts.second; +} + +const formattersByTimeZone = new Map(); + +function getTimeZoneParts(ms: number, timeZone: string) { + let formatter = formattersByTimeZone.get(timeZone); + if (!formatter) { + formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + hour12: false, + era: 'short', + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + second: 'numeric' + }); + + formattersByTimeZone.set(timeZone, formatter); + } + + let parts = formatter.formatToParts(new Date(ms)); + let namedParts: {[name: string]: string} = {}; + for (let part of parts) { + if (part.type !== 'literal') { + namedParts[part.type] = part.value; + } + } + + + return { + // Firefox returns B instead of BC... https://bugzilla.mozilla.org/show_bug.cgi?id=1752253 + year: namedParts.era === 'BC' || namedParts.era === 'B' ? -namedParts.year + 1 : +namedParts.year, + month: +namedParts.month, + day: +namedParts.day, + hour: namedParts.hour === '24' ? 0 : +namedParts.hour, // bugs.chromium.org/p/chromium/issues/detail?id=1045791 + minute: +namedParts.minute, + second: +namedParts.second + }; +} + +export function fromCalendarToIncompleteDate(date: CalendarDate | CalendarDateTime | ZonedDateTime) { + if (date instanceof CalendarDate) { + return new IncompleteDate(date.calendar, date.era, date.year, date.month, date.day); + } else if (date instanceof CalendarDateTime) { + return new IncompleteDateTime(date.calendar, date.era, date.year, date.month, date.day, date.hour, date.minute, date.second, date.millisecond); + } else { + return new IncompleteZonedDateTime(date.calendar, date.era, date.year, date.month, date.day, date.timeZone, date.offset, date.hour, date.minute, date.second, date.millisecond); + } +} + +/** + * Takes a Unix epoch (milliseconds since 1970) and converts it to the provided time zone. + */ +export function fromAbsolute(ms: number, timeZone: string): IncompleteZonedDateTime { + let offset = getTimeZoneOffset(ms, timeZone); + let date = new Date(ms + offset); + let year = date.getUTCFullYear(); + let month = date.getUTCMonth() + 1; + let day = date.getUTCDate(); + let hour = date.getUTCHours(); + let minute = date.getUTCMinutes(); + let second = date.getUTCSeconds(); + let millisecond = date.getUTCMilliseconds(); + + return new IncompleteZonedDateTime(year < 1 ? 'BC' : 'AD', year < 1 ? -year + 1 : year, month, day, timeZone, offset, hour, minute, second, millisecond); +} + + +/** Converts a `ZonedDateTime` from one time zone to another. */ +export function toTimeZone(date: IncompleteZonedDateTime, timeZone: string): IncompleteZonedDateTime { + let ms = epochFromDate(date) - date.offset; + return toCalendar(fromAbsolute(ms, timeZone), date.calendar); +} + + +export function zonedToDate(date: IncompleteZonedDateTime): Date { + let ms = epochFromDate(date) - date.offset; + return new Date(ms); +} + +export function dateToString(date: IncompleteDate): string { + let gregorianDate = toCalendar(date, new GregorianCalendar()); + let year: string; + if (gregorianDate.era === 'BC') { + year = gregorianDate.year === 1 + ? '0000' + : '-' + String(Math.abs(1 - gregorianDate.year)).padStart(6, '00'); + } else { + year = String(gregorianDate.year).padStart(4, '0'); + } + return `${year}-${String(gregorianDate.month).padStart(2, '0')}-${String(gregorianDate.day).padStart(2, '0')}`; +} + +export function dateTimeToString(date: AnyDateTime): string { + // @ts-ignore + return `${dateToString(date)}T${timeToString(date)}`; +} + + +export function zonedDateTimeToString(date: IncompleteZonedDateTime): string { + return `${dateTimeToString(date)}${offsetToString(date.offset)}[${date.timeZone}]`; +} + +function offsetToString(offset: number) { + let sign = Math.sign(offset) < 0 ? '-' : '+'; + offset = Math.abs(offset); + let offsetHours = Math.floor(offset / (60 * 60 * 1000)); + let offsetMinutes = Math.floor((offset % (60 * 60 * 1000)) / (60 * 1000)); + let offsetSeconds = Math.floor((offset % (60 * 60 * 1000)) % (60 * 1000) / 1000); + let stringOffset = `${sign}${String(offsetHours).padStart(2, '0')}:${String(offsetMinutes).padStart(2, '0')}`; + if (offsetSeconds !== 0) { + stringOffset += `:${String(offsetSeconds).padStart(2, '0')}`; + } + + return stringOffset; +} diff --git a/packages/@react-stately/datepicker/src/manipulation.ts b/packages/@react-stately/datepicker/src/manipulation.ts new file mode 100644 index 00000000000..7834540d0cd --- /dev/null +++ b/packages/@react-stately/datepicker/src/manipulation.ts @@ -0,0 +1,290 @@ +import {AnyTime, epochFromDate, fromAbsolute, toAbsolute, toCalendar, toIncompleteDateTime, toTimeZone} from './conversion'; +import {Calendar, CycleTimeOptions, Disambiguation, GregorianCalendar, TimeField, TimeFields} from '@internationalized/date'; +import {IncompleteDate, IncompleteDateTime, IncompleteZonedDateTime} from './IncompleteDate'; + +export interface DateFields { + era?: string, + year?: number, + month?: number, + day?: number +} + +export type DateField = keyof DateFields; + +export type Mutable = { + -readonly[P in keyof T]: T[P] +}; + +export interface CycleOptions { + /** Whether to round the field value to the nearest interval of the amount. */ + round?: boolean +} + +export interface AnyCalendarDate { + readonly calendar: Calendar, + readonly era: string, + readonly year: number, + readonly month: number, + readonly day: number, + copy(): this +} + +const ONE_HOUR = 3600000; + + +export function set(date: IncompleteDate, fields: DateFields): IncompleteDate; +export function set(date: IncompleteDateTime, fields: DateFields): IncompleteDateTime; +export function set(date: IncompleteDate | IncompleteDateTime, fields: DateFields): Mutable { + let mutableDate: Mutable = date.copy(); + + if (fields.era != null) { + mutableDate.era = fields.era; + } + + if (fields.year != null) { + mutableDate.year = fields.year; + } + + if (fields.month != null) { + mutableDate.month = fields.month; + } + + if (fields.day != null) { + mutableDate.day = fields.day; + } + + return mutableDate; +} + +export function cycleDate(value: IncompleteDate, field: DateField, amount: number, options?: CycleOptions): IncompleteDate; +export function cycleDate(value: IncompleteDateTime, field: DateField, amount: number, options?: CycleOptions): IncompleteDateTime; +export function cycleDate(value: IncompleteDate | IncompleteDateTime, field: DateField, amount: number, options?: CycleOptions): Mutable { + let mutable: Mutable = value.copy(); + + switch (field) { + case 'era': { + let eras = value.calendar.getEras(); + let eraIndex = eras.indexOf(value.era); + eraIndex = cycleValue(eraIndex, amount, 0, eras.length - 1, options?.round); + mutable.era = eras[eraIndex]; + break; + } + case 'year': { + if (mutable.calendar.isInverseEra?.(mutable)) { + amount = -amount; + } + + // The year field should not cycle within the era as that can cause weird behavior affecting other fields. + // We need to also allow values < 1 so that decrementing goes to the previous era. If we get -Infinity back + // we know we wrapped around after reaching 9999 (the maximum), so set the year back to 1. + mutable.year = cycleValue(value.year, amount, -Infinity, 9999, options?.round); + if (mutable.year === -Infinity) { + mutable.year = 1; + } + + break; + } + case 'month': + mutable.month = cycleValue(value.month, amount, 1, value.calendar.getMaxMonths(), options?.round); + break; + default: + mutable.day = cycleValue(value.day, amount, 1, value.calendar.getDaysInMonth(value), options?.round); + } + + if (value.calendar.balanceDate) { + value.calendar.balanceDate(mutable); + } + return mutable; +} + + +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; +} + +export function cycleTime(value: IncompleteDateTime, field: TimeField, amount: number, options?: CycleTimeOptions): IncompleteDateTime; +export function cycleTime(value: IncompleteDateTime, field: TimeField, amount: number, options?: CycleTimeOptions): Mutable { + let mutable: Mutable = value.copy(); + + switch (field) { + case 'hour': { + let hours = value.hour; + let min = 0; + let max = 23; + if (options?.hourCycle === 12) { + let isPM = hours >= 12; + min = isPM ? 12 : 0; + max = isPM ? 23 : 11; + } + mutable.hour = cycleValue(hours, amount, min, max, options?.round); + break; + } + case 'minute': + mutable.minute = cycleValue(value.minute, amount, 0, 59, options?.round); + break; + case 'second': + mutable.second = cycleValue(value.second, amount, 0, 59, options?.round); + break; + case 'millisecond': + mutable.millisecond = cycleValue(value.millisecond, amount, 0, 999, options?.round); + break; + default: + throw new Error('Unsupported field ' + field); + } + + return mutable; +} + +export function setTime(value: IncompleteDateTime, fields: TimeFields): IncompleteDateTime; +export function setTime(value: IncompleteDateTime, fields: TimeFields): Mutable { + let mutableValue: Mutable = value.copy(); + + if (fields.hour != null) { + mutableValue.hour = fields.hour; + } + + if (fields.minute != null) { + mutableValue.minute = fields.minute; + } + + if (fields.second != null) { + mutableValue.second = fields.second; + } + + if (fields.millisecond != null) { + mutableValue.millisecond = fields.millisecond; + } + + constrainTime(mutableValue); + return mutableValue; +} + +export function constrainTime(time: Mutable): void { + time.millisecond = Math.max(0, Math.min(time.millisecond, 1000)); + time.second = Math.max(0, Math.min(time.second, 59)); + time.minute = Math.max(0, Math.min(time.minute, 59)); + time.hour = Math.max(0, Math.min(time.hour, 23)); +} + +export function setZoned(dateTime: IncompleteZonedDateTime, fields: DateFields & TimeFields, disambiguation?: Disambiguation): IncompleteZonedDateTime { + // Set the date/time fields, and recompute the UTC offset to account for DST changes. + // We also need to validate by converting back to a local time in case hours are skipped during forward DST transitions. + let plainDateTime = toIncompleteDateTime(dateTime); + let res = setTime(set(plainDateTime, fields), fields); + + // If the resulting plain date time values are equal, return the original time. + // We don't want to change the offset when setting the time to the same value. + if (res.compare(plainDateTime) === 0) { + return dateTime; + } + + let ms = toAbsolute(res, dateTime.timeZone, disambiguation); + return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar); +} + +/** + * Converts a date value to a `ZonedDateTime` in the provided time zone. The `disambiguation` option can be set + * to control how values that fall on daylight saving time changes are interpreted. + */ +export function toZoned(date: IncompleteDate | IncompleteDateTime | IncompleteZonedDateTime, timeZone: string, disambiguation?: Disambiguation): IncompleteZonedDateTime { + if (date instanceof IncompleteZonedDateTime) { + if (date.timeZone === timeZone) { + return date; + } + + return toTimeZone(date, timeZone); + } + + let ms = toAbsolute(date, timeZone, disambiguation); + return fromAbsolute(ms, timeZone); +} + + +export function cycleZoned(dateTime: IncompleteZonedDateTime, field: DateField | TimeField, amount: number, options?: CycleTimeOptions): IncompleteZonedDateTime { + // For date fields, we want the time to remain consistent and the UTC offset to potentially change to account for DST changes. + // For time fields, we want the time to change by the amount given. This may result in the hour field staying the same, but the UTC + // offset changing in the case of a backward DST transition, or skipping an hour in the case of a forward DST transition. + switch (field) { + case 'hour': { + let min = 0; + let max = 23; + if (options?.hourCycle === 12) { + let isPM = dateTime.hour >= 12; + min = isPM ? 12 : 0; + max = isPM ? 23 : 11; + } + + // The minimum and maximum hour may be affected by daylight saving time. + // For example, it might jump forward at midnight, and skip 1am. + // Or it might end at midnight and repeat the 11pm hour. To handle this, we get + // the possible absolute times for the min and max, and find the maximum range + // that is within the current day. + let plainDateTime = toIncompleteDateTime(dateTime); + let minDate = toCalendar(setTime(plainDateTime, {hour: min}), new GregorianCalendar()); + let minAbsolute = [toAbsolute(minDate, dateTime.timeZone, 'earlier'), toAbsolute(minDate, dateTime.timeZone, 'later')] + .filter(ms => fromAbsolute(ms, dateTime.timeZone).day === minDate.day)[0]; + + let maxDate = toCalendar(setTime(plainDateTime, {hour: max}), new GregorianCalendar()); + let maxAbsolute = [toAbsolute(maxDate, dateTime.timeZone, 'earlier'), toAbsolute(maxDate, dateTime.timeZone, 'later')] + .filter(ms => fromAbsolute(ms, dateTime.timeZone).day === maxDate.day).pop()!; + + // Since hours may repeat, we need to operate on the absolute time in milliseconds. + // This is done in hours from the Unix epoch so that cycleValue works correctly, + // and then converted back to milliseconds. + let ms = epochFromDate(dateTime) - dateTime.offset; + let hours = Math.floor(ms / ONE_HOUR); + let remainder = ms % ONE_HOUR; + ms = cycleValue( + hours, + amount, + Math.floor(minAbsolute / ONE_HOUR), + Math.floor(maxAbsolute / ONE_HOUR), + options?.round + ) * ONE_HOUR + remainder; + + // Now compute the new timezone offset, and convert the absolute time back to local time. + return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar); + } + case 'minute': + case 'second': + case 'millisecond': + // @ts-ignore + return cycleTime(dateTime, field, amount, options); + case 'era': + case 'year': + case 'month': + case 'day': { + let res = cycleDate(toIncompleteDateTime(dateTime), field, amount, options); + let ms = toAbsolute(res, dateTime.timeZone); + return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar); + } + default: + throw new Error('Unsupported field ' + field); + } +} diff --git a/packages/@react-stately/datepicker/src/useDateFieldState.ts b/packages/@react-stately/datepicker/src/useDateFieldState.ts index 2bf28053575..cce583d4fae 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -14,11 +14,15 @@ import {Calendar, CalendarIdentifier, DateFormatter, getMinimumDayInMonth, getMi 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 {fromCalendarToIncompleteDate} from './conversion'; import {getPlaceholder} from './placeholders'; +import {IncompleteDate, IncompleteDateTime, IncompleteZonedDateTime} from './IncompleteDate'; import {useControlledState} from '@react-stately/utils'; import {useEffect, useMemo, useRef, useState} from 'react'; import {ValidationState} from '@react-types/shared'; +type IncompleteValue = IncompleteDate | IncompleteDateTime | IncompleteZonedDateTime + export type SegmentType = 'era' | 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'dayPeriod' | 'literal' | 'timeZoneName'; export interface DateSegment { /** The type of segment. */ @@ -71,6 +75,9 @@ export interface DateFieldState extends FormValidationState { isReadOnly: boolean, /** Whether the field is required. */ isRequired: boolean, + /** Whether the field is changed. */ + shouldValidate: boolean, + setShouldValidate(value: boolean): void, /** Increments the given segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ increment(type: SegmentType): void, /** Decrements the given segment. Upon reaching the minimum or maximum value, the value wraps around to the opposite limit. */ @@ -90,6 +97,8 @@ export interface DateFieldState extends FormValidationState { /** Sets the value of the given segment. */ setSegment(type: 'era', value: string): void, setSegment(type: SegmentType, value: number): void, + /** Sets the value of the given segment by the maximum or the minimum, for example 31 days, 12 months, and 9999 years. */ + incrementToMinMax(type: SegmentType, value: number): void, /** Updates the remaining unfilled segments with the placeholder value. */ confirmPlaceholder(): void, /** Clears the value of the given segment, reverting it to the placeholder. */ @@ -167,6 +176,7 @@ export function useDateFieldState(props: DateFi let v: DateValue | null = props.value || props.defaultValue || props.placeholderValue || null; let [granularity, defaultTimeZone] = useDefaultProps(v, props.granularity); let timeZone = defaultTimeZone || 'UTC'; + const [shouldValidate, setShouldValidate] = useState(false); // props.granularity must actually exist in the value if one is provided. if (v && !(granularity in v)) { @@ -182,6 +192,9 @@ export function useDateFieldState(props: DateFi props.onChange ); + const [isValueConfirmed, setIsValueConfirmed] = useState(!!(value)); + const [previousValue, setPreviousValue] = useState(value); + let [initialValue] = useState(value); let calendarValue = useMemo(() => convertValue(value, calendar) ?? null, [value, calendar]); @@ -237,20 +250,24 @@ export function useDateFieldState(props: DateFi }, [calendar, granularity, validSegments, defaultTimeZone, props.placeholderValue]); // If there is a value prop, and some segments were previously placeholders, mark them all as valid. - if (value && Object.keys(validSegments).length < Object.keys(allSegments).length) { + if (value !== previousValue && value && Object.keys(validSegments).length <= Object.keys(allSegments).length) { validSegments = {...allSegments}; setValidSegments(validSegments); + setPreviousValue(value); + setIsValueConfirmed(true); } + // If the value is set to null and all segments are valid, reset the placeholder. - if (value == null && Object.keys(validSegments).length === Object.keys(allSegments).length) { + if (value !== previousValue && value == null && Object.keys(validSegments).length === Object.keys(allSegments).length) { validSegments = {}; setValidSegments(validSegments); setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); + setPreviousValue(value); + setIsValueConfirmed(true); } - // If all segments are valid, use the date from state, otherwise use the placeholder date. - let displayValue = calendarValue && Object.keys(validSegments).length >= Object.keys(allSegments).length ? calendarValue : placeholderDate; + let displayValue = isValueConfirmed && value ? fromCalendarToIncompleteDate(value) : placeholderDate; let setValue = (newValue: DateValue) => { if (props.isDisabled || props.isReadOnly) { return; @@ -261,6 +278,7 @@ export function useDateFieldState(props: DateFi // 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) { setDate(null); + setPreviousValue(null); setPlaceholderDate(createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone)); setValidSegments({}); } else if ( @@ -277,18 +295,26 @@ export function useDateFieldState(props: DateFi // The display calendar should not have any effect on the emitted value. // Emit dates in the same calendar as the original value, if any, otherwise gregorian. - newValue = toCalendar(newValue, v?.calendar || new GregorianCalendar()); - setDate(newValue); - } else { - setPlaceholderDate(newValue); + const value = toCalendar(newValue, v?.calendar || new GregorianCalendar()); + setDate(value); + setIsValueConfirmed(true); + setPreviousValue(value); } - clearedSegment.current = null; + }; + + + let updatePlaceholder = (newValue: IncompleteValue) => { + if (props.isDisabled || props.isReadOnly) { + return; + } + displayValue = newValue; + setPlaceholderDate(newValue); }; 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]); + let segments = useMemo(() => + processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isValueConfirmed), + [dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isValueConfirmed]); // 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. @@ -309,17 +335,27 @@ export function useDateFieldState(props: DateFi }; let adjustSegment = (type: Intl.DateTimeFormatPartTypes, amount: number) => { + setShouldValidate(true); + setIsValueConfirmed(false); + + let v = displayValue; 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)); + v = addSegment(displayValue, type, amount, resolvedOptions); + } + + let validKeys = Object.keys(validSegments); + let allKeys = Object.keys(allSegments); + if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod)) { + const constrained = v.toCalendar(); + displayValue = fromCalendarToIncompleteDate(constrained); + setValue(constrained); + } else { + updatePlaceholder(v); } }; + let builtinValidation = useMemo(() => getValidationResult( value, @@ -354,6 +390,8 @@ export function useDateFieldState(props: DateFi isDisabled, isReadOnly, isRequired, + shouldValidate, + setShouldValidate, increment(part) { adjustSegment(part, 1); }, @@ -367,24 +405,45 @@ export function useDateFieldState(props: DateFi adjustSegment(part, -(PAGE_STEP[part] || 1)); }, setSegment(part, v: string | number) { + setIsValueConfirmed(false); + setShouldValidate(true); markValid(part); - setValue(setSegment(displayValue, part, v, resolvedOptions)); + updatePlaceholder(setSegment(displayValue, part, v, resolvedOptions)); + }, + incrementToMinMax(part, v: string | number) { + setIsValueConfirmed(false); + setShouldValidate(true); + markValid(part); + let validKeys = Object.keys(validSegments); + let allKeys = Object.keys(allSegments); + const value = setSegment(displayValue, part, v, resolvedOptions); + if (validKeys.length >= allKeys.length || (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod)) { + setValue(value.toCalendar()); + } else { + updatePlaceholder(value); + } }, confirmPlaceholder() { if (props.isDisabled || props.isReadOnly) { 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) { + if (validKeys.length >= allKeys.length || + (validKeys.length === allKeys.length - 1 && allSegments.dayPeriod && !validSegments.dayPeriod && clearedSegment.current !== 'dayPeriod')) { validSegments = {...allSegments}; setValidSegments(validSegments); - setValue(displayValue.copy()); + setValue(displayValue.toCalendar()); + + } else { + setDate(null); + setPreviousValue(null); + setIsValueConfirmed(true); } }, clearSegment(part) { + setIsValueConfirmed(false); delete validSegments[part]; clearedSegment.current = part; setValidSegments({...validSegments}); @@ -406,9 +465,7 @@ export function useDateFieldState(props: DateFi } else if (part in displayValue) { value = displayValue.set({[part]: placeholder[part]}); } - - setDate(null); - setValue(value); + updatePlaceholder(value); }, formatValue(fieldOptions: FieldOptions) { if (!calendarValue) { @@ -427,7 +484,7 @@ export function useDateFieldState(props: DateFi }; } -function processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity) : DateSegment[] { +function processSegments(dateValue, validSegments, dateFormatter, resolvedOptions, displayValue, calendar, locale, granularity, isConfirmed: boolean) : DateSegment[] { let timeValue = ['hour', 'minute', 'second']; let segments = dateFormatter.formatToParts(dateValue); let processedSegments: DateSegment[] = []; @@ -441,9 +498,25 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption let isPlaceholder = EDITABLE_SEGMENTS[type] && !validSegments[type]; let placeholder = EDITABLE_SEGMENTS[type] ? getPlaceholder(type, segment.value, locale) : null; + let value = segment.value; + + if (!isConfirmed && ['day', 'month', 'year'].includes(segment.type)) { + let numberFormatter = new Intl.NumberFormat(locale, { + useGrouping: false + }); + + let twoDigitFormatter = new Intl.NumberFormat(locale, { + useGrouping: false, + minimumIntegerDigits: 2 + }); + + let f = dateFormatter.resolvedOptions()[segment.type] === '2-digit' ? twoDigitFormatter : numberFormatter; + value = f.format(displayValue[segment.type]); + } + let dateSegment = { type, - text: isPlaceholder ? placeholder : segment.value, + text: isPlaceholder ? placeholder : value, ...getSegmentLimits(displayValue, type, resolvedOptions), isPlaceholder, placeholder, @@ -495,33 +568,33 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption return processedSegments; } -function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedDateTimeFormatOptions) { +function getSegmentLimits(date: IncompleteValue, 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 + maxValue: eras.length - 1 }; } case 'year': 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) + maxValue: date.calendar.getMaxMonths() }; case 'day': return { value: date.day, minValue: getMinimumDayInMonth(date), - maxValue: date.calendar.getDaysInMonth(date) + maxValue: date.calendar.getMaxDays() }; } @@ -566,7 +639,7 @@ function getSegmentLimits(date: DateValue, type: string, options: Intl.ResolvedD return {}; } -function addSegment(value: DateValue, part: string, amount: number, options: Intl.ResolvedDateTimeFormatOptions) { +function addSegment(value: IncompleteValue, part: string, amount: number, options: Intl.ResolvedDateTimeFormatOptions) { switch (part) { case 'era': case 'year': @@ -595,7 +668,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: IncompleteValue, part: string, segmentValue: number | string, options: Intl.ResolvedDateTimeFormatOptions) { switch (part) { case 'day': case 'month': @@ -607,7 +680,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; let wasPM = hours >= 12; let isPM = segmentValue >= 12; if (isPM === wasPM) { diff --git a/packages/@react-stately/datepicker/src/utils.ts b/packages/@react-stately/datepicker/src/utils.ts index fda08dcd123..4a12741a9cb 100644 --- a/packages/@react-stately/datepicker/src/utils.ts +++ b/packages/@react-stately/datepicker/src/utils.ts @@ -10,16 +10,19 @@ * governing permissions and limitations under the License. */ -import {Calendar, DateFormatter, getLocalTimeZone, now, Time, toCalendar, toCalendarDate, toCalendarDateTime} from '@internationalized/date'; +import {Calendar, DateFormatter, getLocalTimeZone, now, Time} from '@internationalized/date'; import {DatePickerProps, DateValue, Granularity, TimeValue} from '@react-types/datepicker'; // @ts-ignore +import {fromCalendarToIncompleteDate, toCalendar, toIncompleteDate, toIncompleteDateTime, toIncompleteZonedDateTime} from './conversion'; import i18nMessages from '../intl/*.json'; +import {IncompleteDate, IncompleteDateTime, IncompleteZonedDateTime} from './IncompleteDate'; import {LocalizedStringDictionary, LocalizedStringFormatter} from '@internationalized/string'; import {mergeValidation, VALID_VALIDITY_STATE} from '@react-stately/form'; import {RangeValue, ValidationResult} from '@react-types/shared'; import {useState} from 'react'; const dictionary = new LocalizedStringDictionary(i18nMessages); +type IncompleteValue = IncompleteDate | IncompleteDateTime | IncompleteZonedDateTime function getLocale() { // Match browser language setting here, NOT react-aria's I18nProvider, so that we match other browser-provided @@ -223,9 +226,10 @@ export function convertValue(value: DateValue | null | undefined, calendar: Cale } -export function createPlaceholderDate(placeholderValue: DateValue | null | undefined, granularity: string, calendar: Calendar, timeZone: string | undefined): DateValue { +export function createPlaceholderDate(placeholderValue: DateValue | null | undefined, granularity: string, calendar: Calendar, timeZone: string | undefined): IncompleteValue { if (placeholderValue) { - return convertValue(placeholderValue, calendar)!; + const v = convertValue(placeholderValue, calendar) as DateValue; + return fromCalendarToIncompleteDate(v); } let date = toCalendar(now(timeZone ?? getLocalTimeZone()).set({ @@ -236,14 +240,14 @@ export function createPlaceholderDate(placeholderValue: DateValue | null | undef }), calendar); if (granularity === 'year' || granularity === 'month' || granularity === 'day') { - return toCalendarDate(date); + return toIncompleteDate(date); } if (!timeZone) { - return toCalendarDateTime(date); + return toIncompleteDateTime(date); } - return date; + return toIncompleteZonedDateTime(date); } export function useDefaultProps(v: DateValue | null, granularity: Granularity | undefined): [Granularity, string | undefined] {