diff --git a/package.json b/package.json index bcf6427197d..1b409e1afb8 100644 --- a/package.json +++ b/package.json @@ -105,6 +105,7 @@ "@parcel/reporter-bundle-analyzer": "^2.16.3", "@parcel/reporter-cli": "^2.16.3", "@parcel/resolver-glob": "^2.16.3", + "@parcel/rust": "^2.16.3", "@parcel/transformer-inline": "^2.16.3", "@parcel/transformer-inline-string": "^2.16.3", "@parcel/transformer-react-static": "^2.16.3", @@ -172,6 +173,7 @@ "jest-environment-jsdom": "^29.5.0", "jest-junit": "^15.0.0", "jest-matchmedia-mock": "^1.1.0", + "json5": "^2.2.3", "lerna": "^3.13.2", "lucide-react": "^0.517.0", "md5": "^2.2.1", 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 328ff68c6e1..9e77f840f51 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, @@ -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 !== undefined && 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 3b931ba8152..ae8dcc86720 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, '); @@ -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, '); @@ -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,10 +1346,14 @@ 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); + 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(); @@ -1360,7 +1387,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); } @@ -1406,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) { @@ -1419,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(); } @@ -1429,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 () { @@ -1438,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 () { @@ -1455,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); @@ -1463,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 () { @@ -1514,6 +1541,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 +1601,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 +1618,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 +1697,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); + }); }); }); @@ -1972,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 cf027ab73ad..3c85574f68c 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, '); @@ -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, '); @@ -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-spectrum/s2/chromatic/Popover.stories.tsx b/packages/@react-spectrum/s2/chromatic/Popover.stories.tsx new file mode 100644 index 00000000000..3db3f78251f --- /dev/null +++ b/packages/@react-spectrum/s2/chromatic/Popover.stories.tsx @@ -0,0 +1,66 @@ +/* + * 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 {AccountMenu, AutocompletePopover, CustomTrigger, HelpCenter, MenuTrigger} from '../stories/Popover.stories'; +import type {Meta, StoryObj} from '@storybook/react'; +import {Popover} from '../src'; +import {userEvent} from '@storybook/test'; + +const meta: Meta = { + component: Popover, + parameters: { + chromaticProvider: {colorSchemes: ['light'], backgrounds: ['base'], locales: ['en-US'], disableAnimations: true}, + chromatic: {ignoreSelectors: ['[role="progressbar"]']} + }, + tags: ['autodocs'], + title: 'S2 Chromatic/Popover' +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + ...HelpCenter, + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{Enter}'); + } +}; + +export const AccountMenuExample: Story = { + ...AccountMenu, + name: 'Account Menu', + play: Default.play +}; + +export const Autocomplete: Story = { + ...AutocompletePopover, + name: 'Autocomplete Popover', + play: Default.play +}; + +export const Custom: Story = { + ...CustomTrigger, + name: 'Custom Trigger', + play: Default.play +}; + +export const MenuTriggerExample: Story = { + ...MenuTrigger, + name: 'MenuTrigger', + play: async () => { + await userEvent.tab(); + await userEvent.keyboard('{Enter}'); + await userEvent.tab(); + await userEvent.keyboard('{Enter}'); + } +}; diff --git a/packages/@react-spectrum/s2/src/Menu.tsx b/packages/@react-spectrum/s2/src/Menu.tsx index d442bece48b..aaef84b9203 100644 --- a/packages/@react-spectrum/s2/src/Menu.tsx +++ b/packages/@react-spectrum/s2/src/Menu.tsx @@ -581,11 +581,13 @@ function MenuTrigger(props: MenuTriggerProps): ReactNode { shouldFlip: props.shouldFlip }}> - - - {props.children} - - + + + + {props.children} + + + ); diff --git a/packages/@react-spectrum/s2/stories/Popover.stories.tsx b/packages/@react-spectrum/s2/stories/Popover.stories.tsx index 38971db7490..b6071eec89f 100644 --- a/packages/@react-spectrum/s2/stories/Popover.stories.tsx +++ b/packages/@react-spectrum/s2/stories/Popover.stories.tsx @@ -10,18 +10,23 @@ * governing permissions and limitations under the License. */ -import {ActionButton, Avatar, Button, Card, CardPreview, Content, DialogTrigger, Divider, Form, Image, Menu, MenuItem, MenuSection, Popover, SearchField, SubmenuTrigger, Switch, Tab, TabList, TabPanel, Tabs, Text, TextField} from '../src'; +import {ActionButton, ActionMenu, Avatar, Button, Card, CardPreview, Content, DialogTrigger, Divider, Form, Image, Menu, MenuItem, MenuSection, Popover, SearchField, SubmenuTrigger, Switch, Tab, TabList, TabPanel, Tabs, Text, TextField} from '../src'; import Cloud from '../s2wf-icons/S2_Icon_Cloud_20_N.svg'; +import Comment from '../s2wf-icons/S2_Icon_Comment_20_N.svg'; +import CommentText from '../s2wf-icons/S2_Icon_CommentText_20_N.svg'; +import Copy from '../s2wf-icons/S2_Icon_Copy_20_N.svg'; import Education from '../s2wf-icons/S2_Icon_Education_20_N.svg'; import File from '../s2wf-icons/S2_Icon_File_20_N.svg'; import Help from '../s2wf-icons/S2_Icon_HelpCircle_20_N.svg'; import Lightbulb from '../s2wf-icons/S2_Icon_Lightbulb_20_N.svg'; import type {Meta, StoryObj} from '@storybook/react'; import Org from '../s2wf-icons/S2_Icon_Buildings_20_N.svg'; +import Paste from '../s2wf-icons/S2_Icon_Paste_20_N.svg'; import {Autocomplete as RACAutocomplete, useFilter} from 'react-aria-components'; import {ReactElement, useRef, useState} from 'react'; import Settings from '../s2wf-icons/S2_Icon_Settings_20_N.svg'; import {style} from '../style/spectrum-theme' with {type: 'macro'}; +import ThumbUp from '../s2wf-icons/S2_Icon_ThumbUp_20_N.svg'; import User from '../s2wf-icons/S2_Icon_User_20_N.svg'; import Users from '../s2wf-icons/S2_Icon_UserGroup_20_N.svg'; @@ -227,3 +232,36 @@ const CustomTriggerRender = (): ReactElement => { export const CustomTrigger: StoryObj = { render: () => }; + +export const MenuTrigger: Story = { + render: (args) => ( + + + + + +
+
+
+ +

Author Name

+
+ + Copy body text + Paste + +
+

The experience is smooth and well thought out, though there’s room to refine some details for clarity and efficiency.

+
+ Like + Reply +
+
+
+
+ ), + argTypes: { + hideArrow: {table: {disable: true}}, + placement: {table: {disable: true}} + } +}; 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/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 2bf28053575..0554481c33a 100644 --- a/packages/@react-stately/datepicker/src/useDateFieldState.ts +++ b/packages/@react-stately/datepicker/src/useDateFieldState.ts @@ -10,13 +10,15 @@ * governing permissions and limitations under the License. */ -import {Calendar, CalendarIdentifier, DateFormatter, getMinimumDayInMonth, getMinimumMonthInYear, GregorianCalendar, isEqualCalendar, 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 {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 +28,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. */ @@ -87,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, @@ -173,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, @@ -184,17 +199,11 @@ 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, hourCycle, calendarValue) ); - let val = calendarValue || placeholderDate; - let showEra = calendar.identifier === 'gregory' && val.era === 'BC'; + let showEra = calendar.identifier === 'gregory' && displayValue.era === 'BC'; let formatOpts = useMemo(() => ({ granularity, maxGranularity: props.maxGranularity ?? 'year', @@ -208,117 +217,73 @@ 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)); + let placeholder = useMemo(() => createPlaceholderDate(props.placeholderValue, granularity, calendar, defaultTimeZone), [props.placeholderValue, granularity, calendar, defaultTimeZone]); + let displaySegments = useMemo(() => { + 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, 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); } - // 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 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, hourCycle, 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, hourCycle, 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, hourCycle, 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)); - } + let adjustSegment = (type: SegmentType, amount: number) => { + setValue(displayValue.cycle(type, amount, placeholder)); }; let builtinValidation = useMemo(() => getValidationResult( @@ -366,48 +331,43 @@ 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) { - markValid(part); - setValue(setSegment(displayValue, part, v, resolvedOptions)); + setValue(displayValue.set(part, v, placeholder)); }, 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) { - 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(newDateValue); + } + setDisplayValue(new IncompleteDate(calendar, hourCycle, 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}); - } - } 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]}); + if (part !== 'timeZoneName' && part !== 'literal') { + value = displayValue.clear(part); } - setDate(null); setValue(value); }, formatValue(fieldOptions: FieldOptions) { @@ -427,9 +387,34 @@ 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 (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,13 +423,13 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption isEditable = false; } - let isPlaceholder = EDITABLE_SEGMENTS[type] && !validSegments[type]; + 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 @@ -458,7 +443,6 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption processedSegments.push({ type: 'literal', text: '\u2066', - ...getSegmentLimits(displayValue, 'literal', resolvedOptions), isPlaceholder: false, placeholder: '', isEditable: false @@ -469,7 +453,6 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption processedSegments.push({ type: 'literal', text: '\u2069', - ...getSegmentLimits(displayValue, 'literal', resolvedOptions), isPlaceholder: false, placeholder: '', isEditable: false @@ -481,7 +464,6 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption processedSegments.push({ type: 'literal', text: '\u2069', - ...getSegmentLimits(displayValue, 'literal', resolvedOptions), isPlaceholder: false, placeholder: '', isEditable: false @@ -494,145 +476,3 @@ function processSegments(dateValue, validSegments, dateFormatter, resolvedOption return processedSegments; } - -function getSegmentLimits(date: DateValue, 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: date.calendar.getYearsInEra(date) - }; - case 'month': - return { - value: date.month, - minValue: getMinimumMonthInYear(date), - maxValue: date.calendar.getMonthsInYear(date) - }; - case 'day': - return { - value: date.day, - minValue: getMinimumDayInMonth(date), - maxValue: date.calendar.getDaysInMonth(date) - }; - } - - 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 - }; - } - - 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 - }; - } - } - - return {}; -} - -function addSegment(value: DateValue, part: string, amount: number, options: Intl.ResolvedDateTimeFormatOptions) { - switch (part) { - case 'era': - case 'year': - case 'month': - case 'day': - return value.cycle(part, amount, {round: part === 'year'}); - } - - if ('hour' in value) { - switch (part) { - case 'dayPeriod': { - let hours = value.hour; - let isPM = hours >= 12; - return value.set({hour: isPM ? hours - 12 : hours + 12}); - } - case 'hour': - case 'minute': - case 'second': - return value.cycle(part, amount, { - round: part !== 'hour', - hourCycle: options.hour12 ? 12 : 24 - }); - } - } - - throw new Error('Unknown segment: ' + part); -} - -function setSegment(value: DateValue, 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; - 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; - 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); -} diff --git a/packages/dev/s2-docs/src/StackBlitz.tsx b/packages/dev/s2-docs/src/StackBlitz.tsx index eef2ecc5fdc..7383e95fc65 100644 --- a/packages/dev/s2-docs/src/StackBlitz.tsx +++ b/packages/dev/s2-docs/src/StackBlitz.tsx @@ -111,6 +111,7 @@ createRoot(document.getElementById('root')!).render(${type === 's2' ? `\n