Skip to content

Commit e766084

Browse files
Calendar and TimeField integration into DatePicker (#2242)
* Be explicit about which functions accept which date/time types * Enforce that onChange is sent the same type as is given to value/defaultValue with TS * Don't change offset if setting the same field values in ZonedDateTime * Time field integration into date picker calendar popover * Calendar improvements * Remove overflow balancing and always constrain instead * Add DateField export * Ignore time when adding or subtracting from a CalendarDate * Improve constraining behavior of Japanese calendar * Work around browser bugs with hour cycles * Add tests for queries * Add tests for selecting date + time * Update Calendar tests * Remove support for year and month granularity * Fix test * TypeScript is dumb * Fix comment * lint * Update packages/@react-stately/datepicker/src/useDateRangePickerState.ts Co-authored-by: Robert Snow <[email protected]> Co-authored-by: Robert Snow <[email protected]>
1 parent 562b400 commit e766084

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+2297
-902
lines changed

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

Lines changed: 76 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {add, addTime, addZoned, cycleDate, cycleTime, cycleZoned, set, setTime, setZoned, subtract, subtractTime, subtractZoned} from './manipulation';
14-
import {Calendar, CycleOptions, CycleTimeOptions, DateField, DateFields, Disambiguation, Duration, OverflowBehavior, TimeField, TimeFields} from './types';
14+
import {AnyCalendarDate, AnyTime, Calendar, CycleOptions, CycleTimeOptions, DateField, DateFields, Disambiguation, Duration, TimeField, TimeFields} from './types';
1515
import {compareDate, compareTime} from './queries';
1616
import {dateTimeToString, dateToString, timeToString, zonedDateTimeToString} from './string';
1717
import {GregorianCalendar} from './calendars/GregorianCalendar';
@@ -38,6 +38,10 @@ function shiftArgs(args: any[]) {
3838
}
3939

4040
export class CalendarDate {
41+
// This prevents TypeScript from allowing other types with the same fields to match.
42+
// i.e. a ZonedDateTime should not be be passable to a parameter that expects CalendarDate.
43+
// If that behavior is desired, use the AnyCalendarDate interface instead.
44+
#type;
4145
public readonly calendar: Calendar;
4246
public readonly era: string;
4347
public readonly year: number;
@@ -76,8 +80,8 @@ export class CalendarDate {
7680
return subtract(this, duration);
7781
}
7882

79-
set(fields: DateFields, behavior?: OverflowBehavior) {
80-
return set(this, fields, behavior);
83+
set(fields: DateFields) {
84+
return set(this, fields);
8185
}
8286

8387
cycle(field: DateField, amount: number, options?: CycleOptions) {
@@ -92,12 +96,15 @@ export class CalendarDate {
9296
return dateToString(this);
9397
}
9498

95-
compare(b: CalendarDate) {
99+
compare(b: AnyCalendarDate) {
96100
return compareDate(this, b);
97101
}
98102
}
99103

100104
export class Time {
105+
// This prevents TypeScript from allowing other types with the same fields to match.
106+
#type;
107+
101108
constructor(
102109
public readonly hour: number = 0,
103110
public readonly minute: number = 0,
@@ -117,8 +124,8 @@ export class Time {
117124
return subtractTime(this, duration);
118125
}
119126

120-
set(fields: TimeFields, behavior?: OverflowBehavior) {
121-
return setTime(this, fields, behavior);
127+
set(fields: TimeFields) {
128+
return setTime(this, fields);
122129
}
123130

124131
cycle(field: TimeField, amount: number, options?: CycleTimeOptions) {
@@ -129,12 +136,19 @@ export class Time {
129136
return timeToString(this);
130137
}
131138

132-
compare(b: Time) {
139+
compare(b: AnyTime) {
133140
return compareTime(this, b);
134141
}
135142
}
136143

137-
export class CalendarDateTime extends CalendarDate {
144+
export class CalendarDateTime {
145+
// This prevents TypeScript from allowing other types with the same fields to match.
146+
#type;
147+
public readonly calendar: Calendar;
148+
public readonly era: string;
149+
public readonly year: number;
150+
public readonly month: number;
151+
public readonly day: number;
138152
public readonly hour: number;
139153
public readonly minute: number;
140154
public readonly second: number;
@@ -145,7 +159,16 @@ export class CalendarDateTime extends CalendarDate {
145159
constructor(calendar: Calendar, era: string, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number);
146160
constructor(...args: any[]) {
147161
let [calendar, era, year, month, day] = shiftArgs(args);
148-
super(calendar, era, year, month, day);
162+
this.calendar = calendar;
163+
this.era = era;
164+
this.year = year;
165+
this.month = month;
166+
this.day = day;
167+
168+
if (this.calendar.balanceDate) {
169+
this.calendar.balanceDate(this);
170+
}
171+
149172
this.hour = args.shift() || 0;
150173
this.minute = args.shift() || 0;
151174
this.second = args.shift() || 0;
@@ -160,8 +183,16 @@ export class CalendarDateTime extends CalendarDate {
160183
}
161184
}
162185

163-
set(fields: DateFields & TimeFields, behavior?: OverflowBehavior) {
164-
return set(setTime(this, fields, behavior), fields, behavior);
186+
add(duration: Duration) {
187+
return add(this, duration);
188+
}
189+
190+
subtract(duration: Duration) {
191+
return subtract(this, duration);
192+
}
193+
194+
set(fields: DateFields & TimeFields) {
195+
return set(setTime(this, fields), fields);
165196
}
166197

167198
cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions) {
@@ -176,11 +207,15 @@ export class CalendarDateTime extends CalendarDate {
176207
}
177208
}
178209

210+
toDate(timeZone: string) {
211+
return toDate(this, timeZone);
212+
}
213+
179214
toString() {
180215
return dateTimeToString(this);
181216
}
182217

183-
compare(b: CalendarDate | CalendarDateTime) {
218+
compare(b: CalendarDate | CalendarDateTime | ZonedDateTime) {
184219
let res = compareDate(this, b);
185220
if (res === 0) {
186221
return compareTime(this, toCalendarDateTime(b));
@@ -190,7 +225,18 @@ export class CalendarDateTime extends CalendarDate {
190225
}
191226
}
192227

193-
export class ZonedDateTime extends CalendarDateTime {
228+
export class ZonedDateTime {
229+
// This prevents TypeScript from allowing other types with the same fields to match.
230+
#type;
231+
public readonly calendar: Calendar;
232+
public readonly era: string;
233+
public readonly year: number;
234+
public readonly month: number;
235+
public readonly day: number;
236+
public readonly hour: number;
237+
public readonly minute: number;
238+
public readonly second: number;
239+
public readonly millisecond: number;
194240
public readonly timeZone: string;
195241
public readonly offset: number;
196242

@@ -201,9 +247,22 @@ export class ZonedDateTime extends CalendarDateTime {
201247
let [calendar, era, year, month, day] = shiftArgs(args);
202248
let timeZone = args.shift();
203249
let offset = args.shift();
204-
super(calendar, era, year, month, day, ...args);
250+
this.calendar = calendar;
251+
this.era = era;
252+
this.year = year;
253+
this.month = month;
254+
this.day = day;
255+
256+
if (this.calendar.balanceDate) {
257+
this.calendar.balanceDate(this);
258+
}
259+
205260
this.timeZone = timeZone;
206261
this.offset = offset;
262+
this.hour = args.shift() || 0;
263+
this.minute = args.shift() || 0;
264+
this.second = args.shift() || 0;
265+
this.millisecond = args.shift() || 0;
207266
}
208267

209268
copy(): ZonedDateTime {
@@ -222,8 +281,8 @@ export class ZonedDateTime extends CalendarDateTime {
222281
return subtractZoned(this, duration);
223282
}
224283

225-
set(fields: DateFields & TimeFields, behavior?: OverflowBehavior, disambiguation?: Disambiguation) {
226-
return setZoned(this, fields, behavior, disambiguation);
284+
set(fields: DateFields & TimeFields, disambiguation?: Disambiguation) {
285+
return setZoned(this, fields, disambiguation);
227286
}
228287

229288
cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions) {
@@ -244,6 +303,6 @@ export class ZonedDateTime extends CalendarDateTime {
244303

245304
compare(b: CalendarDate | CalendarDateTime | ZonedDateTime) {
246305
// TODO: Is this a bad idea??
247-
return this.toDate() - toZoned(b, this.timeZone).toDate();
306+
return this.toDate().getTime() - toZoned(b, this.timeZone).toDate().getTime();
248307
}
249308
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
let formatterCache = new Map<string, Intl.DateTimeFormat>();
14+
15+
interface ResolvedDateTimeFormatOptions extends Intl.ResolvedDateTimeFormatOptions {
16+
hourCycle?: Intl.DateTimeFormatOptions['hourCycle']
17+
}
18+
19+
export class DateFormatter implements Intl.DateTimeFormat {
20+
private formatter: Intl.DateTimeFormat;
21+
private options: Intl.DateTimeFormatOptions;
22+
private resolvedHourCycle: Intl.DateTimeFormatOptions['hourCycle'];
23+
24+
constructor(locale: string, options: Intl.DateTimeFormatOptions = {}) {
25+
this.formatter = getCachedDateFormatter(locale, options);
26+
this.options = options;
27+
}
28+
29+
format(value: Date): string {
30+
return this.formatter.format(value);
31+
}
32+
33+
formatToParts(value: Date): Intl.DateTimeFormatPart[] {
34+
return this.formatter.formatToParts(value);
35+
}
36+
37+
formatRange(start: Date, end: Date) {
38+
// @ts-ignore
39+
if (typeof this.formatter.formatRange === 'function') {
40+
// @ts-ignore
41+
return this.formatter.formatRange(start, end);
42+
}
43+
44+
if (end < start) {
45+
throw new RangeError('End date must be >= start date');
46+
}
47+
48+
// Very basic fallback for old browsers.
49+
return `${this.formatter.format(start)}${this.formatter.format(end)}`;
50+
}
51+
52+
formatRangeToParts(start: Date, end: Date) {
53+
// @ts-ignore
54+
if (typeof this.formatter.formatRangeToParts === 'function') {
55+
// @ts-ignore
56+
return this.formatter.formatRangeToParts(start, end);
57+
}
58+
59+
if (end < start) {
60+
throw new RangeError('End date must be >= start date');
61+
}
62+
63+
let startParts = this.formatter.formatToParts(start);
64+
let endParts = this.formatter.formatToParts(end);
65+
return [
66+
...startParts.map(p => ({...p, source: 'startRange'})),
67+
{type: 'literal', value: ' – ', source: 'shared'},
68+
...endParts.map(p => ({...p, source: 'endRange'}))
69+
];
70+
}
71+
72+
resolvedOptions(): ResolvedDateTimeFormatOptions {
73+
let resolvedOptions = this.formatter.resolvedOptions() as ResolvedDateTimeFormatOptions;
74+
if (hasBuggyResolvedHourCycle()) {
75+
if (!this.resolvedHourCycle) {
76+
this.resolvedHourCycle = getResolvedHourCycle(resolvedOptions.locale, this.options);
77+
}
78+
resolvedOptions.hourCycle = this.resolvedHourCycle;
79+
resolvedOptions.hour12 = this.resolvedHourCycle === 'h11' || this.resolvedHourCycle === 'h12';
80+
}
81+
82+
return resolvedOptions;
83+
}
84+
}
85+
86+
// There are multiple bugs involving the hour12 and hourCycle options in various browser engines.
87+
// - Chrome [1] (and the ECMA 402 spec [2]) resolve hour12: false in English and other locales to h24 (24:00 - 23:59)
88+
// rather than h23 (00:00 - 23:59). Same can happen with hour12: true in French, which Chrome resolves to h11 (00:00 - 11:59)
89+
// rather than h12 (12:00 - 11:59).
90+
// - WebKit returns an incorrect hourCycle resolved option in the French locale due to incorrect parsing of 'h' literal
91+
// in the resolved pattern. It also formats incorrectly when specifying the hourCycle option for the same reason. [3]
92+
// [1] https://bugs.chromium.org/p/chromium/issues/detail?id=1045791
93+
// [2] https://github.com/tc39/ecma402/issues/402
94+
// [3] https://bugs.webkit.org/show_bug.cgi?id=229313
95+
96+
// https://github.com/unicode-org/cldr/blob/018b55eff7ceb389c7e3fc44e2f657eae3b10b38/common/supplemental/supplementalData.xml#L4774-L4802
97+
const hour12Preferences = {
98+
true: {
99+
// Only Japanese uses the h11 style for 12 hour time. All others use h12.
100+
ja: 'h11'
101+
},
102+
false: {
103+
// All locales use h23 for 24 hour time. None use h24.
104+
}
105+
};
106+
107+
function getCachedDateFormatter(locale: string, options: Intl.DateTimeFormatOptions = {}): Intl.DateTimeFormat {
108+
// Work around buggy hour12 behavior in Chrome / ECMA 402 spec by using hourCycle instead.
109+
// Only apply the workaround if the issue is detected, because the hourCycle option is buggy in Safari.
110+
if (typeof options.hour12 === 'boolean' && hasBuggyHour12Behavior()) {
111+
options = {...options};
112+
let pref = hour12Preferences[String(options.hour12)][locale.split('-')[0]];
113+
let defaultHourCycle = options.hour12 ? 'h12' : 'h23';
114+
options.hourCycle = pref ?? defaultHourCycle;
115+
delete options.hour12;
116+
}
117+
118+
let cacheKey = locale + (options ? Object.entries(options).sort((a, b) => a[0] < b[0] ? -1 : 1).join() : '');
119+
if (formatterCache.has(cacheKey)) {
120+
return formatterCache.get(cacheKey);
121+
}
122+
123+
let numberFormatter = new Intl.DateTimeFormat(locale, options);
124+
formatterCache.set(cacheKey, numberFormatter);
125+
return numberFormatter;
126+
}
127+
128+
let _hasBuggyHour12Behavior: boolean = null;
129+
function hasBuggyHour12Behavior() {
130+
if (_hasBuggyHour12Behavior == null) {
131+
_hasBuggyHour12Behavior = new Intl.DateTimeFormat('en-US', {
132+
hour: 'numeric',
133+
hour12: false
134+
}).format(new Date(2020, 2, 3, 0)) === '24';
135+
}
136+
137+
return _hasBuggyHour12Behavior;
138+
}
139+
140+
let _hasBuggyResolvedHourCycle: boolean = null;
141+
function hasBuggyResolvedHourCycle() {
142+
if (_hasBuggyResolvedHourCycle == null) {
143+
_hasBuggyResolvedHourCycle = (new Intl.DateTimeFormat('fr', {
144+
hour: 'numeric',
145+
hour12: false
146+
}).resolvedOptions() as ResolvedDateTimeFormatOptions).hourCycle === 'h12';
147+
}
148+
149+
return _hasBuggyResolvedHourCycle;
150+
}
151+
152+
function getResolvedHourCycle(locale: string, options: Intl.DateTimeFormatOptions) {
153+
// Work around buggy results in resolved hourCycle and hour12 options in WebKit.
154+
// Format the minimum possible hour and maximum possible hour in a day and parse the results.
155+
locale = locale.replace(/(-u-)?-nu-[a-zA-Z0-9]+/, '');
156+
locale += (locale.includes('-u-') ? '' : '-u') + '-nu-latn';
157+
let formatter = getCachedDateFormatter(locale, options);
158+
159+
let min = parseInt(formatter.formatToParts(new Date(2020, 2, 3, 0)).find(p => p.type === 'hour').value, 10);
160+
let max = parseInt(formatter.formatToParts(new Date(2020, 2, 3, 23)).find(p => p.type === 'hour').value, 10);
161+
162+
if (min === 0 && max === 23) {
163+
return 'h23';
164+
}
165+
166+
if (min === 24 && max === 23) {
167+
return 'h24';
168+
}
169+
170+
if (min === 0 && max === 11) {
171+
return 'h11';
172+
}
173+
174+
if (min === 12 && max === 11) {
175+
return 'h12';
176+
}
177+
178+
throw new Error('Unexpected hour cycle result');
179+
}

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// Portions of the code in this file are based on code from ICU.
1414
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
1515

16+
import {AnyCalendarDate} from '../types';
1617
import {CalendarDate} from '../CalendarDate';
1718
import {GregorianCalendar} from './GregorianCalendar';
1819
import {Mutable} from '../utils';
@@ -25,10 +26,10 @@ export class BuddhistCalendar extends GregorianCalendar {
2526
fromJulianDay(jd: number): CalendarDate {
2627
let date = super.fromJulianDay(jd) as Mutable<CalendarDate>;
2728
date.year -= BUDDHIST_ERA_START;
28-
return date;
29+
return date as CalendarDate;
2930
}
3031

31-
toJulianDay(date: CalendarDate) {
32+
toJulianDay(date: AnyCalendarDate) {
3233
return super.toJulianDay(
3334
new CalendarDate(
3435
date.year + BUDDHIST_ERA_START,

0 commit comments

Comments
 (0)