Skip to content

Commit 67d6429

Browse files
devongovettLFDanLu
andauthored
Fix edge cases in DatePicker with min/max dates and eras (#3149)
* Constrain dates within valid calendar system defined range * Include era in gregorian dates in the BC era * Fix clicking on segments in Firefox * Work around Firefox bug with BC dates Co-authored-by: Daniel Lu <dl1644@gmail.com>
1 parent 5d6e7ed commit 67d6429

26 files changed

+422
-64
lines changed

packages/@internationalized/date/src/calendars/BuddhistCalendar.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export class BuddhistCalendar extends GregorianCalendar {
4949
getDaysInMonth(date: AnyCalendarDate): number {
5050
return super.getDaysInMonth(toGregorian(date));
5151
}
52+
53+
balanceDate() {}
5254
}
5355

5456
function toGregorian(date: AnyCalendarDate) {

packages/@internationalized/date/src/calendars/EthiopicCalendar.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,8 +173,15 @@ export class CopticCalendar extends EthiopicCalendar {
173173
return getDaysInMonth(year, date.month);
174174
}
175175

176-
getYearsToAdd(date: Mutable<AnyCalendarDate>, years: number) {
177-
return date.era === 'BCE' ? -years : years;
176+
isInverseEra(date: AnyCalendarDate): boolean {
177+
return date.era === 'BCE';
178+
}
179+
180+
balanceDate(date: Mutable<AnyCalendarDate>) {
181+
if (date.year <= 0) {
182+
date.era = date.era === 'BCE' ? 'CE' : 'BCE';
183+
date.year = 1 - date.year;
184+
}
178185
}
179186

180187
getEras() {

packages/@internationalized/date/src/calendars/GregorianCalendar.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ export class GregorianCalendar implements Calendar {
122122
return ['BC', 'AD'];
123123
}
124124

125-
getYearsToAdd(date: Mutable<AnyCalendarDate>, years: number) {
126-
return date.era === 'BC' ? -years : years;
125+
isInverseEra(date: AnyCalendarDate): boolean {
126+
return date.era === 'BC';
127127
}
128128

129129
balanceDate(date: Mutable<AnyCalendarDate>) {

packages/@internationalized/date/src/calendars/IndianCalendar.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,4 +122,6 @@ export class IndianCalendar extends GregorianCalendar {
122122
getEras() {
123123
return ['saka'];
124124
}
125+
126+
balanceDate() {}
125127
}

packages/@internationalized/date/src/calendars/TaiwanCalendar.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ export class TaiwanCalendar extends GregorianCalendar {
6464
date.year = year;
6565
}
6666

67-
getYearsToAdd(date: Mutable<AnyCalendarDate>, years: number) {
68-
return date.era === 'before_minguo' ? -years : years;
67+
isInverseEra(date: AnyCalendarDate): boolean {
68+
return date.era === 'before_minguo';
6969
}
7070

7171
getDaysInMonth(date: AnyCalendarDate): number {

packages/@internationalized/date/src/conversion.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,14 @@
1515

1616
import {AnyCalendarDate, AnyDateTime, AnyTime, Calendar, DateFields, Disambiguation, TimeFields} from './types';
1717
import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate';
18+
import {getExtendedYear, GregorianCalendar} from './calendars/GregorianCalendar';
1819
import {getLocalTimeZone} from './queries';
19-
import {GregorianCalendar} from './calendars/GregorianCalendar';
2020
import {Mutable} from './utils';
2121

2222
export function epochFromDate(date: AnyDateTime) {
2323
date = toCalendar(date, new GregorianCalendar());
24-
return epochFromParts(date.year, date.month, date.day, date.hour, date.minute, date.second, date.millisecond);
24+
let year = getExtendedYear(date.era, date.year);
25+
return epochFromParts(year, date.month, date.day, date.hour, date.minute, date.second, date.millisecond);
2526
}
2627

2728
function epochFromParts(year: number, month: number, day: number, hour: number, minute: number, second: number, millisecond: number) {
@@ -34,6 +35,11 @@ function epochFromParts(year: number, month: number, day: number, hour: number,
3435
}
3536

3637
export function getTimeZoneOffset(ms: number, timeZone: string) {
38+
// Fast path for UTC.
39+
if (timeZone === 'UTC') {
40+
return 0;
41+
}
42+
3743
// Fast path: for local timezone, use native Date.
3844
if (timeZone === getLocalTimeZone()) {
3945
return new Date(ms).getTimezoneOffset() * -60 * 1000;
@@ -72,8 +78,10 @@ function getTimeZoneParts(ms: number, timeZone: string) {
7278
}
7379
}
7480

81+
7582
return {
76-
year: namedParts.era === 'BC' ? -namedParts.year + 1 : +namedParts.year,
83+
// Firefox returns B instead of BC... https://bugzilla.mozilla.org/show_bug.cgi?id=1752253
84+
year: namedParts.era === 'BC' || namedParts.era === 'B' ? -namedParts.year + 1 : +namedParts.year,
7785
month: +namedParts.month,
7886
day: +namedParts.day,
7987
hour: namedParts.hour === '24' ? 0 : +namedParts.hour, // bugs.chromium.org/p/chromium/issues/detail?id=1045791
@@ -109,13 +117,19 @@ function isValidWallTime(date: CalendarDateTime, timeZone: string, absolute: num
109117
export function toAbsolute(date: CalendarDate | CalendarDateTime, timeZone: string, disambiguation: Disambiguation = 'compatible'): number {
110118
let dateTime = toCalendarDateTime(date);
111119

120+
// Fast path: if the time zone is UTC, use native Date.
121+
if (timeZone === 'UTC') {
122+
return epochFromDate(dateTime);
123+
}
124+
112125
// Fast path: if the time zone is the local timezone and disambiguation is compatible, use native Date.
113126
if (timeZone === getLocalTimeZone() && disambiguation === 'compatible') {
114127
dateTime = toCalendar(dateTime, new GregorianCalendar());
115128

116129
// Don't use Date constructor here because two-digit years are interpreted in the 20th century.
117130
let date = new Date();
118-
date.setFullYear(dateTime.year, dateTime.month - 1, dateTime.day);
131+
let year = getExtendedYear(dateTime.era, dateTime.year);
132+
date.setFullYear(year, dateTime.month - 1, dateTime.day);
119133
date.setHours(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond);
120134
return date.getTime();
121135
}

packages/@internationalized/date/src/manipulation.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,42 @@ export function add(date: CalendarDate | CalendarDateTime, duration: DateTimeDur
4545
mutableDate.calendar.balanceDate(mutableDate);
4646
}
4747

48+
// Constrain in case adding ended up with a date outside the valid range for the calendar system.
49+
// The behavior here is slightly different than when constraining in the `set` function in that
50+
// we adjust smaller fields to their minimum/maximum values rather than constraining each field
51+
// individually. This matches the general behavior of `add` vs `set` regarding how fields are balanced.
52+
if (mutableDate.year < 1) {
53+
mutableDate.year = 1;
54+
mutableDate.month = 1;
55+
mutableDate.day = 1;
56+
}
57+
58+
let maxYear = mutableDate.calendar.getYearsInEra(mutableDate);
59+
if (mutableDate.year > maxYear) {
60+
let isInverseEra = mutableDate.calendar.isInverseEra?.(mutableDate);
61+
mutableDate.year = maxYear;
62+
mutableDate.month = isInverseEra ? 1 : mutableDate.calendar.getMonthsInYear(mutableDate);
63+
mutableDate.day = isInverseEra ? 1 : mutableDate.calendar.getDaysInMonth(mutableDate);
64+
}
65+
66+
if (mutableDate.month < 1) {
67+
mutableDate.month = 1;
68+
mutableDate.day = 1;
69+
}
70+
71+
let maxMonth = mutableDate.calendar.getMonthsInYear(mutableDate);
72+
if (mutableDate.month > maxMonth) {
73+
mutableDate.month = maxMonth;
74+
mutableDate.day = mutableDate.calendar.getDaysInMonth(mutableDate);
75+
}
76+
77+
mutableDate.day = Math.max(1, Math.min(mutableDate.calendar.getDaysInMonth(mutableDate), mutableDate.day));
4878
return mutableDate;
4979
}
5080

5181
function addYears(date: Mutable<AnyCalendarDate>, years: number) {
52-
if (date.calendar.getYearsToAdd) {
53-
years = date.calendar.getYearsToAdd(date, years);
82+
if (date.calendar.isInverseEra?.(date)) {
83+
years = -years;
5484
}
5585

5686
date.year += years;
@@ -233,8 +263,8 @@ export function cycleDate(value: CalendarDate | CalendarDateTime, field: DateFie
233263
break;
234264
}
235265
case 'year': {
236-
if (mutable.calendar.getYearsToAdd) {
237-
amount = mutable.calendar.getYearsToAdd(mutable, amount);
266+
if (mutable.calendar.isInverseEra?.(mutable)) {
267+
amount = -amount;
238268
}
239269

240270
// The year field should not cycle within the era as that can cause weird behavior affecting other fields.

packages/@internationalized/date/src/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ export interface Calendar {
7575
/** @private */
7676
balanceYearMonth?(date: AnyCalendarDate, previousDate: AnyCalendarDate): void,
7777
/** @private */
78-
getYearsToAdd?(date: AnyCalendarDate, years: number): number,
78+
constrainDate?(date: AnyCalendarDate): void,
7979
/** @private */
80-
constrainDate?(date: AnyCalendarDate): void
80+
isInverseEra?(date: AnyCalendarDate): boolean
8181
}
8282

8383
/** Represents an amount of time in calendar-specific units, for use when performing arithmetic. */

packages/@internationalized/date/tests/manipulation.test.js

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {CalendarDate, HebrewCalendar, JapaneseCalendar, TaiwanCalendar} from '..';
13+
import {BuddhistCalendar, CalendarDate, CopticCalendar, HebrewCalendar, IndianCalendar, JapaneseCalendar, PersianCalendar, TaiwanCalendar} from '..';
1414

1515
describe('CalendarDate manipulation', function () {
1616
describe('add', function () {
@@ -106,6 +106,16 @@ describe('CalendarDate manipulation', function () {
106106
expect(date.add({years: 20})).toEqual(new CalendarDate(10, 9, 3));
107107
});
108108

109+
it('should constrain when hitting the maximum year', function () {
110+
let date = new CalendarDate(9999, 12, 1);
111+
expect(date.add({months: 1})).toEqual(new CalendarDate(9999, 12, 31));
112+
});
113+
114+
it('should constrain when hitting the minimum year', function () {
115+
let date = new CalendarDate('BC', 9999, 1, 12);
116+
expect(date.subtract({months: 1})).toEqual(new CalendarDate('BC', 9999, 1, 1));
117+
});
118+
109119
describe('Japanese calendar', function () {
110120
it('should add years and rebalance era', function () {
111121
let date = new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30);
@@ -121,6 +131,16 @@ describe('CalendarDate manipulation', function () {
121131
let date = new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30);
122132
expect(date.add({days: 1})).toEqual(new CalendarDate(new JapaneseCalendar(), 'reiwa', 1, 5, 1));
123133
});
134+
135+
it('should contstrain when reaching begining of meiji era', function () {
136+
let date = new CalendarDate(new JapaneseCalendar(), 'meiji', 1, 10, 1);
137+
expect(date.subtract({months: 1})).toEqual(new CalendarDate(new JapaneseCalendar(), 'meiji', 1, 9, 8));
138+
});
139+
140+
it('should contstrain when reaching 9999 reiwa', function () {
141+
let date = new CalendarDate(new JapaneseCalendar(), 'reiwa', 9999, 12, 5);
142+
expect(date.add({months: 1})).toEqual(new CalendarDate(new JapaneseCalendar(), 'reiwa', 9999, 12, 31));
143+
});
124144
});
125145

126146
describe('Taiwan calendar', function () {
@@ -143,6 +163,16 @@ describe('CalendarDate manipulation', function () {
143163
let date = new CalendarDate(new TaiwanCalendar(), 'before_minguo', 1, 12, 31);
144164
expect(date.add({days: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'minguo', 1, 1, 1));
145165
});
166+
167+
it('should constrain when reaching year 9999', function () {
168+
let date = new CalendarDate(new TaiwanCalendar(), 9999, 12, 10);
169+
expect(date.add({months: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 9999, 12, 31));
170+
});
171+
172+
it('should constrain when reaching year 9999 before minguo', function () {
173+
let date = new CalendarDate(new TaiwanCalendar(), 'before_minguo', 9999, 1, 10);
174+
expect(date.subtract({months: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'before_minguo', 9999, 1, 1));
175+
});
146176
});
147177

148178
describe('Hebrew calendar', function () {
@@ -166,6 +196,64 @@ describe('CalendarDate manipulation', function () {
166196
let date = new CalendarDate(new HebrewCalendar(), 5782, 13, 1);
167197
expect(date.add({years: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5783, 12, 1));
168198
});
199+
200+
it('should constrain when reaching year 1', function () {
201+
let date = new CalendarDate(new HebrewCalendar(), 1, 1, 10);
202+
expect(date.subtract({months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 1, 1, 1));
203+
});
204+
205+
it('should constrain when reaching year 9999', function () {
206+
let date = new CalendarDate(new HebrewCalendar(), 9999, 12, 10);
207+
expect(date.add({months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 9999, 12, 29));
208+
});
209+
});
210+
211+
describe('IndianCalendar', function () {
212+
it('should constrain when reaching year 1', function () {
213+
let date = new CalendarDate(new IndianCalendar(), 1, 1, 10);
214+
expect(date.subtract({months: 1})).toEqual(new CalendarDate(new IndianCalendar(), 1, 1, 1));
215+
});
216+
217+
it('should constrain when reaching year 9999', function () {
218+
let date = new CalendarDate(new PersianCalendar(), 9999, 12, 10);
219+
expect(date.add({months: 1})).toEqual(new CalendarDate(new PersianCalendar(), 9999, 12, 31));
220+
});
221+
});
222+
223+
describe('PersianCalendar', function () {
224+
it('should constrain when reaching year 1', function () {
225+
let date = new CalendarDate(new PersianCalendar(), 1, 1, 10);
226+
expect(date.subtract({months: 1})).toEqual(new CalendarDate(new PersianCalendar(), 1, 1, 1));
227+
});
228+
229+
it('should constrain when reaching year 9999', function () {
230+
let date = new CalendarDate(new PersianCalendar(), 9999, 12, 10);
231+
expect(date.add({months: 1})).toEqual(new CalendarDate(new PersianCalendar(), 9999, 12, 31));
232+
});
233+
});
234+
235+
describe('BuddhistCalendar', function () {
236+
it('should constrain when reaching year 1', function () {
237+
let date = new CalendarDate(new BuddhistCalendar(), 1, 1, 12);
238+
expect(date.subtract({months: 1})).toEqual(new CalendarDate(new BuddhistCalendar(), 1, 1, 1));
239+
});
240+
241+
it('should constrain when reaching year 9999', function () {
242+
let date = new CalendarDate(new BuddhistCalendar(), 9999, 12, 10);
243+
expect(date.add({months: 1})).toEqual(new CalendarDate(new BuddhistCalendar(), 9999, 12, 31));
244+
});
245+
});
246+
247+
describe('CopticCalendar', function () {
248+
it('should rebalance era when subtracting', function () {
249+
let date = new CalendarDate(new CopticCalendar(), 1, 1, 12);
250+
expect(date.subtract({months: 1})).toEqual(new CalendarDate(new CopticCalendar(), 'BCE', 1, 13, 5));
251+
});
252+
253+
it('should rebalance era when adding', function () {
254+
let date = new CalendarDate(new CopticCalendar(), 'BCE', 1, 13, 5);
255+
expect(date.add({months: 1})).toEqual(new CalendarDate(new CopticCalendar(), 1, 1, 5));
256+
});
169257
});
170258
});
171259

packages/@react-aria/calendar/src/useCalendarCell.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date';
1414
import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
1515
import {focusWithoutScrolling, useDescription} from '@react-aria/utils';
16+
import {getEraFormat, hookData} from './utils';
1617
import {getInteractionModality, usePress} from '@react-aria/interactions';
17-
import {hookData} from './utils';
1818
import {HTMLAttributes, RefObject, useEffect, useMemo, useRef} from 'react';
1919
// @ts-ignore
2020
import intlMessages from '../intl/*.json';
@@ -81,7 +81,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
8181
day: 'numeric',
8282
month: 'long',
8383
year: 'numeric',
84-
era: date.calendar.identifier !== 'gregory' ? 'long' : undefined,
84+
era: getEraFormat(date),
8585
timeZone: state.timeZone
8686
});
8787
let isSelected = state.isSelected(date);

0 commit comments

Comments
 (0)