From e766084682147c9d46840a8986e7da6bdd7a1e52 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Fri, 27 Aug 2021 15:02:02 -0700 Subject: [PATCH 1/2] 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 Co-authored-by: Robert Snow --- .../date/src/CalendarDate.ts | 93 +++++- .../date/src/DateFormatter.ts | 179 +++++++++++ .../date/src/calendars/BuddhistCalendar.ts | 5 +- .../date/src/calendars/EthiopicCalendar.ts | 20 +- .../date/src/calendars/GregorianCalendar.ts | 13 +- .../date/src/calendars/HebrewCalendar.ts | 12 +- .../date/src/calendars/IndianCalendar.ts | 5 +- .../date/src/calendars/IslamicCalendar.ts | 16 +- .../date/src/calendars/JapaneseCalendar.ts | 102 +++++- .../date/src/calendars/PersianCalendar.ts | 6 +- .../date/src/calendars/TaiwanCalendar.ts | 15 +- .../@internationalized/date/src/conversion.ts | 64 ++-- packages/@internationalized/date/src/index.ts | 2 +- .../date/src/manipulation.ts | 165 +++++----- .../@internationalized/date/src/queries.ts | 49 ++- .../@internationalized/date/src/string.ts | 20 +- packages/@internationalized/date/src/types.ts | 38 ++- .../date/tests/DateFormatter.test.js | 90 ++++++ .../date/tests/ZonedDateTime.test.js | 5 + .../date/tests/manipulation.test.js | 185 +++++++++-- .../date/tests/queries.test.js | 111 +++++++ .../@react-aria/calendar/src/useCalendar.ts | 7 +- .../calendar/src/useCalendarBase.ts | 3 +- .../calendar/src/useCalendarCell.ts | 32 +- .../calendar/src/useRangeCalendar.ts | 10 +- .../datepicker/src/useDateField.ts | 4 +- .../datepicker/src/useDatePicker.ts | 10 +- .../datepicker/src/useDateRangePicker.ts | 10 +- .../datepicker/src/useDateSegment.ts | 27 +- .../@react-spectrum/calendar/package.json | 1 + .../@react-spectrum/calendar/src/Calendar.tsx | 4 +- .../calendar/src/CalendarCell.tsx | 8 +- .../calendar/src/RangeCalendar.tsx | 4 +- .../calendar/stories/Calendar.stories.tsx | 57 +++- .../stories/RangeCalendar.stories.tsx | 56 +++- .../calendar/test/Calendar.test.js | 92 ++---- .../calendar/test/CalendarBase.test.js | 174 ++++------- .../calendar/test/RangeCalendar.test.js | 136 +++----- .../@react-spectrum/datepicker/package.json | 1 + .../datepicker/src/DateField.tsx | 30 ++ .../datepicker/src/DatePicker.tsx | 33 +- .../datepicker/src/DatePickerField.tsx | 11 +- .../datepicker/src/DatePickerSegment.tsx | 4 +- .../datepicker/src/DateRangePicker.tsx | 70 +++-- .../datepicker/src/TimeField.tsx | 10 +- .../@react-spectrum/datepicker/src/index.ts | 1 + .../datepicker/stories/DateField.stories.tsx | 253 +++++++++++++++ .../datepicker/stories/DatePicker.stories.tsx | 8 +- .../stories/DateRangePicker.stories.tsx | 4 + .../datepicker/stories/TimeField.stories.tsx | 12 +- .../datepicker/test/DatePicker.test.js | 146 ++++++++- .../datepicker/test/DateRangePicker.test.js | 165 +++++++++- packages/@react-stately/calendar/src/types.ts | 7 +- .../calendar/src/useCalendarState.ts | 47 +-- .../calendar/src/useRangeCalendarState.ts | 43 +-- .../@react-stately/datepicker/package.json | 1 - .../datepicker/src/useDatePickerFieldState.ts | 291 ++++++++++-------- .../datepicker/src/useDatePickerState.ts | 71 ++++- .../datepicker/src/useDateRangePickerState.ts | 95 ++++-- .../@react-stately/datepicker/src/utils.ts | 4 +- packages/@react-types/calendar/package.json | 1 + packages/@react-types/calendar/src/index.d.ts | 17 +- .../@react-types/datepicker/src/index.d.ts | 40 ++- packages/@react-types/shared/src/inputs.d.ts | 4 +- 64 files changed, 2297 insertions(+), 902 deletions(-) create mode 100644 packages/@internationalized/date/src/DateFormatter.ts create mode 100644 packages/@internationalized/date/tests/DateFormatter.test.js create mode 100644 packages/@internationalized/date/tests/queries.test.js create mode 100644 packages/@react-spectrum/datepicker/src/DateField.tsx create mode 100644 packages/@react-spectrum/datepicker/stories/DateField.stories.tsx diff --git a/packages/@internationalized/date/src/CalendarDate.ts b/packages/@internationalized/date/src/CalendarDate.ts index 1ed27d3b4b1..6fc20d77d1e 100644 --- a/packages/@internationalized/date/src/CalendarDate.ts +++ b/packages/@internationalized/date/src/CalendarDate.ts @@ -11,7 +11,7 @@ */ import {add, addTime, addZoned, cycleDate, cycleTime, cycleZoned, set, setTime, setZoned, subtract, subtractTime, subtractZoned} from './manipulation'; -import {Calendar, CycleOptions, CycleTimeOptions, DateField, DateFields, Disambiguation, Duration, OverflowBehavior, TimeField, TimeFields} from './types'; +import {AnyCalendarDate, AnyTime, Calendar, CycleOptions, CycleTimeOptions, DateField, DateFields, Disambiguation, Duration, TimeField, TimeFields} from './types'; import {compareDate, compareTime} from './queries'; import {dateTimeToString, dateToString, timeToString, zonedDateTimeToString} from './string'; import {GregorianCalendar} from './calendars/GregorianCalendar'; @@ -38,6 +38,10 @@ function shiftArgs(args: any[]) { } export class CalendarDate { + // This prevents TypeScript from allowing other types with the same fields to match. + // i.e. a ZonedDateTime should not be be passable to a parameter that expects CalendarDate. + // If that behavior is desired, use the AnyCalendarDate interface instead. + #type; public readonly calendar: Calendar; public readonly era: string; public readonly year: number; @@ -76,8 +80,8 @@ export class CalendarDate { return subtract(this, duration); } - set(fields: DateFields, behavior?: OverflowBehavior) { - return set(this, fields, behavior); + set(fields: DateFields) { + return set(this, fields); } cycle(field: DateField, amount: number, options?: CycleOptions) { @@ -92,12 +96,15 @@ export class CalendarDate { return dateToString(this); } - compare(b: CalendarDate) { + compare(b: AnyCalendarDate) { return compareDate(this, b); } } export class Time { + // This prevents TypeScript from allowing other types with the same fields to match. + #type; + constructor( public readonly hour: number = 0, public readonly minute: number = 0, @@ -117,8 +124,8 @@ export class Time { return subtractTime(this, duration); } - set(fields: TimeFields, behavior?: OverflowBehavior) { - return setTime(this, fields, behavior); + set(fields: TimeFields) { + return setTime(this, fields); } cycle(field: TimeField, amount: number, options?: CycleTimeOptions) { @@ -129,12 +136,19 @@ export class Time { return timeToString(this); } - compare(b: Time) { + compare(b: AnyTime) { return compareTime(this, b); } } -export class CalendarDateTime extends CalendarDate { +export class CalendarDateTime { + // This prevents TypeScript from allowing other types with the same fields to match. + #type; + public readonly calendar: Calendar; + public readonly era: string; + public readonly year: number; + public readonly month: number; + public readonly day: number; public readonly hour: number; public readonly minute: number; public readonly second: number; @@ -145,7 +159,16 @@ export class CalendarDateTime extends CalendarDate { constructor(calendar: Calendar, era: string, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number); constructor(...args: any[]) { let [calendar, era, year, month, day] = shiftArgs(args); - super(calendar, era, year, month, day); + this.calendar = calendar; + this.era = era; + this.year = year; + this.month = month; + this.day = day; + + if (this.calendar.balanceDate) { + this.calendar.balanceDate(this); + } + this.hour = args.shift() || 0; this.minute = args.shift() || 0; this.second = args.shift() || 0; @@ -160,8 +183,16 @@ export class CalendarDateTime extends CalendarDate { } } - set(fields: DateFields & TimeFields, behavior?: OverflowBehavior) { - return set(setTime(this, fields, behavior), fields, behavior); + add(duration: Duration) { + return add(this, duration); + } + + subtract(duration: Duration) { + return subtract(this, duration); + } + + set(fields: DateFields & TimeFields) { + return set(setTime(this, fields), fields); } cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions) { @@ -176,11 +207,15 @@ export class CalendarDateTime extends CalendarDate { } } + toDate(timeZone: string) { + return toDate(this, timeZone); + } + toString() { return dateTimeToString(this); } - compare(b: CalendarDate | CalendarDateTime) { + compare(b: CalendarDate | CalendarDateTime | ZonedDateTime) { let res = compareDate(this, b); if (res === 0) { return compareTime(this, toCalendarDateTime(b)); @@ -190,7 +225,18 @@ export class CalendarDateTime extends CalendarDate { } } -export class ZonedDateTime extends CalendarDateTime { +export class ZonedDateTime { + // This prevents TypeScript from allowing other types with the same fields to match. + #type; + public readonly calendar: Calendar; + public readonly era: string; + public readonly year: number; + public readonly month: number; + public readonly day: number; + public readonly hour: number; + public readonly minute: number; + public readonly second: number; + public readonly millisecond: number; public readonly timeZone: string; public readonly offset: number; @@ -201,9 +247,22 @@ export class ZonedDateTime extends CalendarDateTime { let [calendar, era, year, month, day] = shiftArgs(args); let timeZone = args.shift(); let offset = args.shift(); - super(calendar, era, year, month, day, ...args); + this.calendar = calendar; + this.era = era; + this.year = year; + this.month = month; + this.day = day; + + if (this.calendar.balanceDate) { + this.calendar.balanceDate(this); + } + this.timeZone = timeZone; this.offset = offset; + this.hour = args.shift() || 0; + this.minute = args.shift() || 0; + this.second = args.shift() || 0; + this.millisecond = args.shift() || 0; } copy(): ZonedDateTime { @@ -222,8 +281,8 @@ export class ZonedDateTime extends CalendarDateTime { return subtractZoned(this, duration); } - set(fields: DateFields & TimeFields, behavior?: OverflowBehavior, disambiguation?: Disambiguation) { - return setZoned(this, fields, behavior, disambiguation); + set(fields: DateFields & TimeFields, disambiguation?: Disambiguation) { + return setZoned(this, fields, disambiguation); } cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions) { @@ -244,6 +303,6 @@ export class ZonedDateTime extends CalendarDateTime { compare(b: CalendarDate | CalendarDateTime | ZonedDateTime) { // TODO: Is this a bad idea?? - return this.toDate() - toZoned(b, this.timeZone).toDate(); + return this.toDate().getTime() - toZoned(b, this.timeZone).toDate().getTime(); } } diff --git a/packages/@internationalized/date/src/DateFormatter.ts b/packages/@internationalized/date/src/DateFormatter.ts new file mode 100644 index 00000000000..3ab8ac7aafe --- /dev/null +++ b/packages/@internationalized/date/src/DateFormatter.ts @@ -0,0 +1,179 @@ +/* + * Copyright 2020 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. + */ + +let formatterCache = new Map(); + +interface ResolvedDateTimeFormatOptions extends Intl.ResolvedDateTimeFormatOptions { + hourCycle?: Intl.DateTimeFormatOptions['hourCycle'] +} + +export class DateFormatter implements Intl.DateTimeFormat { + private formatter: Intl.DateTimeFormat; + private options: Intl.DateTimeFormatOptions; + private resolvedHourCycle: Intl.DateTimeFormatOptions['hourCycle']; + + constructor(locale: string, options: Intl.DateTimeFormatOptions = {}) { + this.formatter = getCachedDateFormatter(locale, options); + this.options = options; + } + + format(value: Date): string { + return this.formatter.format(value); + } + + formatToParts(value: Date): Intl.DateTimeFormatPart[] { + return this.formatter.formatToParts(value); + } + + formatRange(start: Date, end: Date) { + // @ts-ignore + if (typeof this.formatter.formatRange === 'function') { + // @ts-ignore + return this.formatter.formatRange(start, end); + } + + if (end < start) { + throw new RangeError('End date must be >= start date'); + } + + // Very basic fallback for old browsers. + return `${this.formatter.format(start)} – ${this.formatter.format(end)}`; + } + + formatRangeToParts(start: Date, end: Date) { + // @ts-ignore + if (typeof this.formatter.formatRangeToParts === 'function') { + // @ts-ignore + return this.formatter.formatRangeToParts(start, end); + } + + if (end < start) { + throw new RangeError('End date must be >= start date'); + } + + let startParts = this.formatter.formatToParts(start); + let endParts = this.formatter.formatToParts(end); + return [ + ...startParts.map(p => ({...p, source: 'startRange'})), + {type: 'literal', value: ' – ', source: 'shared'}, + ...endParts.map(p => ({...p, source: 'endRange'})) + ]; + } + + resolvedOptions(): ResolvedDateTimeFormatOptions { + let resolvedOptions = this.formatter.resolvedOptions() as ResolvedDateTimeFormatOptions; + if (hasBuggyResolvedHourCycle()) { + if (!this.resolvedHourCycle) { + this.resolvedHourCycle = getResolvedHourCycle(resolvedOptions.locale, this.options); + } + resolvedOptions.hourCycle = this.resolvedHourCycle; + resolvedOptions.hour12 = this.resolvedHourCycle === 'h11' || this.resolvedHourCycle === 'h12'; + } + + return resolvedOptions; + } +} + +// There are multiple bugs involving the hour12 and hourCycle options in various browser engines. +// - Chrome [1] (and the ECMA 402 spec [2]) resolve hour12: false in English and other locales to h24 (24:00 - 23:59) +// rather than h23 (00:00 - 23:59). Same can happen with hour12: true in French, which Chrome resolves to h11 (00:00 - 11:59) +// rather than h12 (12:00 - 11:59). +// - WebKit returns an incorrect hourCycle resolved option in the French locale due to incorrect parsing of 'h' literal +// in the resolved pattern. It also formats incorrectly when specifying the hourCycle option for the same reason. [3] +// [1] https://bugs.chromium.org/p/chromium/issues/detail?id=1045791 +// [2] https://github.com/tc39/ecma402/issues/402 +// [3] https://bugs.webkit.org/show_bug.cgi?id=229313 + +// https://github.com/unicode-org/cldr/blob/018b55eff7ceb389c7e3fc44e2f657eae3b10b38/common/supplemental/supplementalData.xml#L4774-L4802 +const hour12Preferences = { + true: { + // Only Japanese uses the h11 style for 12 hour time. All others use h12. + ja: 'h11' + }, + false: { + // All locales use h23 for 24 hour time. None use h24. + } +}; + +function getCachedDateFormatter(locale: string, options: Intl.DateTimeFormatOptions = {}): Intl.DateTimeFormat { + // Work around buggy hour12 behavior in Chrome / ECMA 402 spec by using hourCycle instead. + // Only apply the workaround if the issue is detected, because the hourCycle option is buggy in Safari. + if (typeof options.hour12 === 'boolean' && hasBuggyHour12Behavior()) { + options = {...options}; + let pref = hour12Preferences[String(options.hour12)][locale.split('-')[0]]; + let defaultHourCycle = options.hour12 ? 'h12' : 'h23'; + options.hourCycle = pref ?? defaultHourCycle; + delete options.hour12; + } + + let cacheKey = locale + (options ? Object.entries(options).sort((a, b) => a[0] < b[0] ? -1 : 1).join() : ''); + if (formatterCache.has(cacheKey)) { + return formatterCache.get(cacheKey); + } + + let numberFormatter = new Intl.DateTimeFormat(locale, options); + formatterCache.set(cacheKey, numberFormatter); + return numberFormatter; +} + +let _hasBuggyHour12Behavior: boolean = null; +function hasBuggyHour12Behavior() { + if (_hasBuggyHour12Behavior == null) { + _hasBuggyHour12Behavior = new Intl.DateTimeFormat('en-US', { + hour: 'numeric', + hour12: false + }).format(new Date(2020, 2, 3, 0)) === '24'; + } + + return _hasBuggyHour12Behavior; +} + +let _hasBuggyResolvedHourCycle: boolean = null; +function hasBuggyResolvedHourCycle() { + if (_hasBuggyResolvedHourCycle == null) { + _hasBuggyResolvedHourCycle = (new Intl.DateTimeFormat('fr', { + hour: 'numeric', + hour12: false + }).resolvedOptions() as ResolvedDateTimeFormatOptions).hourCycle === 'h12'; + } + + return _hasBuggyResolvedHourCycle; +} + +function getResolvedHourCycle(locale: string, options: Intl.DateTimeFormatOptions) { + // Work around buggy results in resolved hourCycle and hour12 options in WebKit. + // Format the minimum possible hour and maximum possible hour in a day and parse the results. + locale = locale.replace(/(-u-)?-nu-[a-zA-Z0-9]+/, ''); + locale += (locale.includes('-u-') ? '' : '-u') + '-nu-latn'; + let formatter = getCachedDateFormatter(locale, options); + + let min = parseInt(formatter.formatToParts(new Date(2020, 2, 3, 0)).find(p => p.type === 'hour').value, 10); + let max = parseInt(formatter.formatToParts(new Date(2020, 2, 3, 23)).find(p => p.type === 'hour').value, 10); + + if (min === 0 && max === 23) { + return 'h23'; + } + + if (min === 24 && max === 23) { + return 'h24'; + } + + if (min === 0 && max === 11) { + return 'h11'; + } + + if (min === 12 && max === 11) { + return 'h12'; + } + + throw new Error('Unexpected hour cycle result'); +} diff --git a/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts index 90edd49cdf5..84176d214d4 100644 --- a/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts +++ b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts @@ -13,6 +13,7 @@ // Portions of the code in this file are based on code from ICU. // Original licensing can be found in the NOTICE file in the root directory of this source tree. +import {AnyCalendarDate} from '../types'; import {CalendarDate} from '../CalendarDate'; import {GregorianCalendar} from './GregorianCalendar'; import {Mutable} from '../utils'; @@ -25,10 +26,10 @@ export class BuddhistCalendar extends GregorianCalendar { fromJulianDay(jd: number): CalendarDate { let date = super.fromJulianDay(jd) as Mutable; date.year -= BUDDHIST_ERA_START; - return date; + return date as CalendarDate; } - toJulianDay(date: CalendarDate) { + toJulianDay(date: AnyCalendarDate) { return super.toJulianDay( new CalendarDate( date.year + BUDDHIST_ERA_START, diff --git a/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts index 3e3a004fdfb..768e83a5f4b 100644 --- a/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts +++ b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts @@ -13,7 +13,7 @@ // Portions of the code in this file are based on code from ICU. // Original licensing can be found in the NOTICE file in the root directory of this source tree. -import {Calendar} from '../types'; +import {AnyCalendarDate, Calendar} from '../types'; import {CalendarDate} from '../CalendarDate'; import {Mutable} from '../utils'; @@ -73,10 +73,10 @@ export class EthiopicCalendar implements Calendar { date.year += AMETE_MIHRET_DELTA; } - return date; + return date as CalendarDate; } - toJulianDay(date: CalendarDate) { + toJulianDay(date: AnyCalendarDate) { let year = date.year; if (date.era === 'AA') { year -= AMETE_MIHRET_DELTA; @@ -85,7 +85,7 @@ export class EthiopicCalendar implements Calendar { return ceToJulianDay(ETHIOPIC_EPOCH, year, date.month, date.day); } - getDaysInMonth(date: CalendarDate): number { + getDaysInMonth(date: AnyCalendarDate): number { let year = date.year; if (date.era === 'AA') { year -= AMETE_MIHRET_DELTA; @@ -98,7 +98,7 @@ export class EthiopicCalendar implements Calendar { return 13; } - getDaysInYear(date: CalendarDate): number { + getDaysInYear(date: AnyCalendarDate): number { return 365 + getLeapDay(date.year); } @@ -118,7 +118,7 @@ export class EthiopicAmeteAlemCalendar extends EthiopicCalendar { let date = julianDayToCE(this, ETHIOPIC_EPOCH, jd); date.era = 'AA'; date.year += AMETE_MIHRET_DELTA; - return date; + return date as CalendarDate; } getEras() { @@ -138,10 +138,10 @@ export class CopticCalendar extends EthiopicCalendar { date.era = 'CE'; } - return date; + return date as CalendarDate; } - toJulianDay(date: CalendarDate) { + toJulianDay(date: AnyCalendarDate) { let year = date.year; if (date.era === 'BCE') { year = 1 - year; @@ -150,7 +150,7 @@ export class CopticCalendar extends EthiopicCalendar { return ceToJulianDay(COPTIC_EPOCH, year, date.month, date.day); } - getDaysInMonth(date: CalendarDate): number { + getDaysInMonth(date: AnyCalendarDate): number { let year = date.year; if (date.era === 'BCE') { year = 1 - year; @@ -159,7 +159,7 @@ export class CopticCalendar extends EthiopicCalendar { return getDaysInMonth(year, date.month); } - addYears(date: Mutable, years: number) { + addYears(date: Mutable, years: number) { if (date.era === 'BCE') { years = -years; } diff --git a/packages/@internationalized/date/src/calendars/GregorianCalendar.ts b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts index ae24a924aa1..da73f217e8a 100644 --- a/packages/@internationalized/date/src/calendars/GregorianCalendar.ts +++ b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts @@ -13,7 +13,7 @@ // Portions of the code in this file are based on code from ICU. // Original licensing can be found in the NOTICE file in the root directory of this source tree. -import {Calendar} from '../types'; +import {AnyCalendarDate, Calendar} from '../types'; import {CalendarDate} from '../CalendarDate'; import {mod} from '../utils'; @@ -75,24 +75,25 @@ export class GregorianCalendar implements Calendar { return new CalendarDate(this, year, month, day); } - toJulianDay(date: CalendarDate): number { + toJulianDay(date: AnyCalendarDate): number { return gregorianToJulianDay(date.year, date.month, date.day); } - getDaysInMonth(date: CalendarDate): number { + getDaysInMonth(date: AnyCalendarDate): number { return daysInMonth[isLeapYear(date.year) ? 'leapyear' : 'standard'][date.month - 1]; } - getMonthsInYear(): number { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + getMonthsInYear(date: AnyCalendarDate): number { return 12; } - getDaysInYear(date: CalendarDate): number { + getDaysInYear(date: AnyCalendarDate): number { return isLeapYear(date.year) ? 366 : 365; } // eslint-disable-next-line @typescript-eslint/no-unused-vars - getYearsInEra(date: CalendarDate): number { + 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 0d3122ea96f..d7db658f623 100644 --- a/packages/@internationalized/date/src/calendars/HebrewCalendar.ts +++ b/packages/@internationalized/date/src/calendars/HebrewCalendar.ts @@ -13,7 +13,7 @@ // Portions of the code in this file are based on code from ICU. // Original licensing can be found in the NOTICE file in the root directory of this source tree. -import {Calendar} from '../types'; +import {AnyCalendarDate, Calendar} from '../types'; import {CalendarDate} from '../CalendarDate'; import {mod, Mutable} from '../utils'; @@ -154,7 +154,7 @@ export class HebrewCalendar implements Calendar { return new CalendarDate(this, year, month, day); } - toJulianDay(date: CalendarDate) { + toJulianDay(date: AnyCalendarDate) { let jd = startOfYear(date.year); for (let month = 1; month < date.month; month++) { jd += getDaysInMonth(date.year, month); @@ -163,15 +163,15 @@ export class HebrewCalendar implements Calendar { return jd + date.day + HEBREW_EPOCH; } - getDaysInMonth(date: CalendarDate): number { + getDaysInMonth(date: AnyCalendarDate): number { return getDaysInMonth(date.year, date.month); } - getMonthsInYear(date: CalendarDate): number { + getMonthsInYear(date: AnyCalendarDate): number { return isLeapYear(date.year) ? 13 : 12; } - getDaysInYear(date: CalendarDate): number { + getDaysInYear(date: AnyCalendarDate): number { return getDaysInYear(date.year); } @@ -183,7 +183,7 @@ export class HebrewCalendar implements Calendar { return ['AM']; } - addYears(date: Mutable, years: number) { + addYears(date: Mutable, years: number) { // Keep date in the same month when switching between leap years and non leap years let nextYear = date.year + years; if (isLeapYear(date.year) && !isLeapYear(nextYear) && date.month > 6) { diff --git a/packages/@internationalized/date/src/calendars/IndianCalendar.ts b/packages/@internationalized/date/src/calendars/IndianCalendar.ts index db38a16cfe2..e17d4f39d1b 100644 --- a/packages/@internationalized/date/src/calendars/IndianCalendar.ts +++ b/packages/@internationalized/date/src/calendars/IndianCalendar.ts @@ -13,6 +13,7 @@ // Portions of the code in this file are based on code from ICU. // Original licensing can be found in the NOTICE file in the root directory of this source tree. +import {AnyCalendarDate} from '../types'; import {CalendarDate} from '../CalendarDate'; import {GregorianCalendar, gregorianToJulianDay, isLeapYear} from './GregorianCalendar'; import {Mutable} from '../utils'; @@ -70,7 +71,7 @@ export class IndianCalendar extends GregorianCalendar { return new CalendarDate(this, indianYear, indianMonth, indianDay); } - toJulianDay(date: CalendarDate) { + toJulianDay(date: AnyCalendarDate) { let year = date.year + INDIAN_ERA_START; let leapMonth: number; @@ -97,7 +98,7 @@ export class IndianCalendar extends GregorianCalendar { return jd; } - getDaysInMonth(date: CalendarDate): number { + getDaysInMonth(date: AnyCalendarDate): number { if (date.month === 1 && isLeapYear(date.year + INDIAN_ERA_START)) { return 31; } diff --git a/packages/@internationalized/date/src/calendars/IslamicCalendar.ts b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts index 0891f61c5af..69f498e474f 100644 --- a/packages/@internationalized/date/src/calendars/IslamicCalendar.ts +++ b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts @@ -13,7 +13,7 @@ // Portions of the code in this file are based on code from ICU. // Original licensing can be found in the NOTICE file in the root directory of this source tree. -import {Calendar} from '../types'; +import {AnyCalendarDate, Calendar} from '../types'; import {CalendarDate} from '../CalendarDate'; const CIVIL_EPOC = 1948440; // CE 622 July 16 Friday (Julian calendar) / CE 622 July 19 (Gregorian calendar) @@ -49,11 +49,11 @@ export class IslamicCivilCalendar implements Calendar { return julianDayToIslamic(this, CIVIL_EPOC, jd); } - toJulianDay(date: CalendarDate) { + toJulianDay(date: AnyCalendarDate) { return islamicToJulianDay(CIVIL_EPOC, date.year, date.month, date.day); } - getDaysInMonth(date: CalendarDate): number { + getDaysInMonth(date: AnyCalendarDate): number { let length = 29 + date.month % 2; if (date.month === 12 && isLeapYear(date.year)) { length++; @@ -66,7 +66,7 @@ export class IslamicCivilCalendar implements Calendar { return 12; } - getDaysInYear(date: CalendarDate): number { + getDaysInYear(date: AnyCalendarDate): number { return isLeapYear(date.year) ? 355 : 354; } @@ -86,7 +86,7 @@ export class IslamicTabularCalendar extends IslamicCivilCalendar { return julianDayToIslamic(this, ASTRONOMICAL_EPOC, jd); } - toJulianDay(date: CalendarDate) { + toJulianDay(date: AnyCalendarDate) { return islamicToJulianDay(ASTRONOMICAL_EPOC, date.year, date.month, date.day); } } @@ -177,7 +177,7 @@ export class IslamicUmalquraCalendar extends IslamicCivilCalendar { } } - toJulianDay(date: CalendarDate): number { + toJulianDay(date: AnyCalendarDate): number { if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) { return super.toJulianDay(date); } @@ -185,7 +185,7 @@ export class IslamicUmalquraCalendar extends IslamicCivilCalendar { return CIVIL_EPOC + umalquraMonthStart(date.year, date.month) + (date.day - 1); } - getDaysInMonth(date: CalendarDate): number { + getDaysInMonth(date: AnyCalendarDate): number { if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) { return super.getDaysInMonth(date); } @@ -193,7 +193,7 @@ export class IslamicUmalquraCalendar extends IslamicCivilCalendar { return umalquraMonthLength(date.year, date.month); } - getDaysInYear(date: CalendarDate): number { + getDaysInYear(date: AnyCalendarDate): number { if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) { return super.getDaysInYear(date); } diff --git a/packages/@internationalized/date/src/calendars/JapaneseCalendar.ts b/packages/@internationalized/date/src/calendars/JapaneseCalendar.ts index c2829453540..dc78f74da49 100644 --- a/packages/@internationalized/date/src/calendars/JapaneseCalendar.ts +++ b/packages/@internationalized/date/src/calendars/JapaneseCalendar.ts @@ -13,15 +13,18 @@ // Portions of the code in this file are based on code from the TC39 Temporal proposal. // Original licensing can be found in the NOTICE file in the root directory of this source tree. +import {AnyCalendarDate, Duration} from '../types'; import {CalendarDate} from '../CalendarDate'; import {GregorianCalendar} from './GregorianCalendar'; import {Mutable} from '../utils'; +import {toCalendar} from '../conversion'; const ERA_START_DATES = [[1868, 9, 8], [1912, 7, 30], [1926, 12, 25], [1989, 1, 8], [2019, 5, 1]]; +const ERA_END_DATES = [[1912, 7, 29], [1926, 12, 24], [1989, 1, 7], [2019, 4, 30]]; const ERA_ADDENDS = [1867, 1911, 1925, 1988, 2018]; const ERA_NAMES = ['meiji', 'taisho', 'showa', 'heisei', 'reiwa']; -function findEraFromGregorianDate(date: CalendarDate) { +function findEraFromGregorianDate(date: AnyCalendarDate) { const idx = ERA_START_DATES.findIndex(([year, month, day]) => { if (date.year < year) { return true; @@ -49,7 +52,7 @@ function findEraFromGregorianDate(date: CalendarDate) { return idx - 1; } -function toGregorian(date: CalendarDate) { +function toGregorian(date: AnyCalendarDate) { let eraAddend = ERA_ADDENDS[ERA_NAMES.indexOf(date.era)]; if (!eraAddend) { throw new Error('Unknown era: ' + date.era); @@ -71,14 +74,14 @@ export class JapaneseCalendar extends GregorianCalendar { let era = findEraFromGregorianDate(date); date.era = ERA_NAMES[era]; date.year -= ERA_ADDENDS[era]; - return date; + return date as CalendarDate; } - toJulianDay(date: CalendarDate) { + toJulianDay(date: AnyCalendarDate) { return super.toJulianDay(toGregorian(date)); } - balanceDate(date: Mutable) { + balanceDate(date: Mutable) { let gregorianDate = toGregorian(date); let era = findEraFromGregorianDate(gregorianDate); @@ -88,13 +91,48 @@ export class JapaneseCalendar extends GregorianCalendar { } } + add(date: AnyCalendarDate, duration: Duration) { + // Always do addition in the gregorian calendar to avoid issues with eras. + // For example, Heisei 31/4/30 + 1 day is Reiwa 1/5/1. Reiwa 1/1/1 does not exist. + return toCalendar(toGregorian(date).add(duration), this); + } + + constrainDate(date: Mutable) { + let idx = ERA_NAMES.indexOf(date.era); + let end = ERA_END_DATES[idx]; + if (end != null) { + let [, endMonth, endDay] = end; + + // Constrain the year to the maximum possible value in the era. + // Then constrain the month and day fields within that. + let maxYear = getMaxYear(idx); + date.year = Math.min(maxYear, date.year); + if (date.year === maxYear) { + date.month = Math.min(endMonth, date.month); + + if (date.month === endMonth) { + date.day = Math.min(endDay, date.day); + } + } + + if (date.year === 1) { + let [, startMonth, startDay] = ERA_START_DATES[idx]; + date.month = Math.max(startMonth, date.month); + + if (date.month === startMonth) { + date.day = Math.max(startDay, date.day); + } + } + } + } + getEras() { return ERA_NAMES; } - getYearsInEra(date: CalendarDate): number { - let gregorianDate = toGregorian(date); - let era = findEraFromGregorianDate(gregorianDate); + getYearsInEra(date: AnyCalendarDate): number { + // Get the number of years in the era, taking into account the date's month and day fields. + let era = ERA_NAMES.indexOf(date.era); let next = ERA_START_DATES[era + 1]; if (next == null) { return 9999; @@ -109,4 +147,52 @@ export class JapaneseCalendar extends GregorianCalendar { return years; } + + getMonthsInYear(date: AnyCalendarDate): number { + let idx = ERA_NAMES.indexOf(date.era); + let end = ERA_END_DATES[idx]; + if (end && date.year === getMaxYear(idx)) { + return end[1]; + } + + return super.getMonthsInYear(date); + } + + getDaysInMonth(date: AnyCalendarDate): number { + let idx = ERA_NAMES.indexOf(date.era); + let end = ERA_END_DATES[idx]; + if (end && date.year === getMaxYear(idx) && date.month === end[1]) { + return end[2]; + } + + return super.getDaysInMonth(date); + } + + getMinimumMonthInYear(date: AnyCalendarDate): number { + let start = getMinimums(date); + return start ? start[1] : 1; + } + + getMinimumDayInMonth(date: AnyCalendarDate): number { + let start = getMinimums(date); + return start && date.month === start[1] ? start[2] : 1; + } +} + +function getMinimums(date: AnyCalendarDate) { + if (date.year === 1) { + let idx = ERA_NAMES.indexOf(date.era); + return ERA_START_DATES[idx]; + } +} + +function getMaxYear(era: number) { + let [endYear, endMonth, endDay] = ERA_END_DATES[era]; + let [startYear, startMonth, startDay] = ERA_START_DATES[era]; + let maxYear = endYear - startYear; + if (startMonth < endMonth || (startMonth === endMonth && startDay < endDay)) { + maxYear++; + } + + return maxYear; } diff --git a/packages/@internationalized/date/src/calendars/PersianCalendar.ts b/packages/@internationalized/date/src/calendars/PersianCalendar.ts index c8622e56ccb..ae2216a355b 100644 --- a/packages/@internationalized/date/src/calendars/PersianCalendar.ts +++ b/packages/@internationalized/date/src/calendars/PersianCalendar.ts @@ -13,7 +13,7 @@ // Portions of the code in this file are based on code from ICU. // Original licensing can be found in the NOTICE file in the root directory of this source tree. -import {Calendar} from '../types'; +import {AnyCalendarDate, Calendar} from '../types'; import {CalendarDate} from '../CalendarDate'; import {mod} from '../utils'; @@ -62,7 +62,7 @@ export class PersianCalendar implements Calendar { return new CalendarDate(this, year, month, day); } - toJulianDay(date: CalendarDate): number { + toJulianDay(date: AnyCalendarDate): number { return persianToJulianDay(date.year, date.month, date.day); } @@ -70,7 +70,7 @@ export class PersianCalendar implements Calendar { return 12; } - getDaysInMonth(date: CalendarDate): number { + getDaysInMonth(date: AnyCalendarDate): number { if (date.month <= 6) { return 31; } diff --git a/packages/@internationalized/date/src/calendars/TaiwanCalendar.ts b/packages/@internationalized/date/src/calendars/TaiwanCalendar.ts index c10d293e2b2..858acc8cdfb 100644 --- a/packages/@internationalized/date/src/calendars/TaiwanCalendar.ts +++ b/packages/@internationalized/date/src/calendars/TaiwanCalendar.ts @@ -13,19 +13,20 @@ // Portions of the code in this file are based on code from ICU. // Original licensing can be found in the NOTICE file in the root directory of this source tree. +import {AnyCalendarDate} from '../types'; import {CalendarDate} from '../CalendarDate'; import {GregorianCalendar} from './GregorianCalendar'; import {Mutable} from '../utils'; const TAIWAN_ERA_START = 1911; -function gregorianYear(date: CalendarDate) { +function gregorianYear(date: AnyCalendarDate) { return date.era === 'minguo' ? date.year + TAIWAN_ERA_START : 1 - date.year + TAIWAN_ERA_START; } -function gregorianToTaiwan(year: number, date: Mutable) { +function gregorianToTaiwan(year: number, date: Mutable) { let y = year - TAIWAN_ERA_START; if (y > 0) { date.era = 'minguo'; @@ -40,12 +41,12 @@ export class TaiwanCalendar extends GregorianCalendar { identifier = 'roc'; // Republic of China fromJulianDay(jd: number): CalendarDate { - let date = super.fromJulianDay(jd) as Mutable; + let date: Mutable = super.fromJulianDay(jd); gregorianToTaiwan(date.year, date); - return date; + return date as CalendarDate; } - toJulianDay(date: CalendarDate) { + toJulianDay(date: AnyCalendarDate) { return super.toJulianDay( new CalendarDate( gregorianYear(date), @@ -59,11 +60,11 @@ export class TaiwanCalendar extends GregorianCalendar { return ['before_minguo', 'minguo']; } - balanceDate(date: Mutable) { + balanceDate(date: Mutable) { gregorianToTaiwan(gregorianYear(date), date); } - addYears(date: Mutable, years: number) { + addYears(date: Mutable, years: number) { if (date.era === 'before_minguo') { years = -years; } diff --git a/packages/@internationalized/date/src/conversion.ts b/packages/@internationalized/date/src/conversion.ts index 0e31853c970..74e53bb7ece 100644 --- a/packages/@internationalized/date/src/conversion.ts +++ b/packages/@internationalized/date/src/conversion.ts @@ -13,13 +13,13 @@ // Portions of the code in this file are based on code from the TC39 Temporal proposal. // Original licensing can be found in the NOTICE file in the root directory of this source tree. -import {Calendar, Disambiguation} from './types'; +import {AnyCalendarDate, AnyDateTime, AnyTime, Calendar, DateFields, Disambiguation, TimeFields} from './types'; import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate'; import {getLocalTimeZone} from './queries'; import {GregorianCalendar} from './calendars/GregorianCalendar'; import {Mutable} from './utils'; -export function epochFromDate(date: CalendarDateTime) { +export function epochFromDate(date: AnyDateTime) { date = toCalendar(date, new GregorianCalendar()); return epochFromParts(date.year, date.month, date.day, date.hour, date.minute, date.second, date.millisecond); } @@ -101,7 +101,7 @@ function isValidWallTime(date: CalendarDateTime, timeZone: string, absolute: num && date.second === parts.second; } -export function toAbsolute(date: CalendarDate, timeZone: string, disambiguation: Disambiguation = 'compatible'): number { +export function toAbsolute(date: CalendarDate | CalendarDateTime, timeZone: string, disambiguation: Disambiguation = 'compatible'): number { let dateTime = toCalendarDateTime(date); let ms = epochFromDate(dateTime); let offsetBefore = getTimeZoneOffset(ms - DAYMILLIS, timeZone); @@ -137,7 +137,7 @@ export function toAbsolute(date: CalendarDate, timeZone: string, disambiguation: } } -export function toDate(dateTime: CalendarDate, timeZone: string, disambiguation: Disambiguation = 'compatible'): Date { +export function toDate(dateTime: CalendarDate | CalendarDateTime, timeZone: string, disambiguation: Disambiguation = 'compatible'): Date { return new Date(toAbsolute(dateTime, timeZone, disambiguation)); } @@ -163,18 +163,31 @@ export function fromDateToLocal(date: Date): ZonedDateTime { return fromDate(date, getLocalTimeZone()); } -export function toCalendarDate(dateTime: CalendarDateTime): CalendarDate { +export function toCalendarDate(dateTime: AnyCalendarDate): CalendarDate { return new CalendarDate(dateTime.calendar, dateTime.era, dateTime.year, dateTime.month, dateTime.day); } -/* eslint-disable no-redeclare */ -export function toCalendarDateTime(date: ZonedDateTime, time?: Time): CalendarDateTime; -export function toCalendarDateTime(date: CalendarDateTime, time?: Time): CalendarDateTime; -export function toCalendarDateTime(date: CalendarDate, time?: Time): CalendarDateTime; -export function toCalendarDateTime(date: CalendarDate | CalendarDateTime | ZonedDateTime, time?: Time) { -/* eslint-enable no-redeclare */ +export function toDateFields(date: AnyCalendarDate): DateFields { + return { + era: date.era, + year: date.year, + month: date.month, + day: date.day + }; +} + +export function toTimeFields(date: AnyTime): TimeFields { + return { + hour: date.hour, + minute: date.minute, + second: date.second, + millisecond: date.millisecond + }; +} + +export function toCalendarDateTime(date: CalendarDate | CalendarDateTime | ZonedDateTime, time?: AnyTime): CalendarDateTime { let hour = 0, minute = 0, second = 0, millisecond = 0; - if ('timeZone' in date && !time) { + if ('timeZone' in date) { ({hour, minute, second, millisecond} = date); } else if ('hour' in date && !time) { return date; @@ -201,31 +214,22 @@ export function toTime(dateTime: CalendarDateTime): Time { return new Time(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond); } -/* eslint-disable no-redeclare */ -export function toCalendar(date: ZonedDateTime, calendar: Calendar): ZonedDateTime; -export function toCalendar(date: CalendarDateTime, calendar: Calendar): CalendarDateTime; -export function toCalendar(date: CalendarDate, calendar: Calendar): CalendarDate; -export function toCalendar(date: CalendarDate | CalendarDateTime | ZonedDateTime, calendar: Calendar) { -/* eslint-enable no-redeclare */ +export function toCalendar(date: T, calendar: Calendar): T { if (date.calendar.identifier === calendar.identifier) { return date; } let calendarDate = calendar.fromJulianDay(date.calendar.toJulianDay(date)); - if ('hour' in date) { - let copy: Mutable = date.copy(); - copy.calendar = calendar; - copy.era = calendarDate.era; - copy.year = calendarDate.year; - copy.month = calendarDate.month; - copy.day = calendarDate.day; - return copy; - } - - return calendarDate; + let copy: Mutable = date.copy(); + copy.calendar = calendar; + copy.era = calendarDate.era; + copy.year = calendarDate.year; + copy.month = calendarDate.month; + copy.day = calendarDate.day; + return copy; } -export function toZoned(date: CalendarDate | CalendarDateTime, timeZone: string, disambiguation?: Disambiguation) { +export function toZoned(date: CalendarDate | CalendarDateTime | ZonedDateTime, timeZone: string, disambiguation?: Disambiguation) { if (date instanceof ZonedDateTime) { if (date.timeZone === timeZone) { return date; diff --git a/packages/@internationalized/date/src/index.ts b/packages/@internationalized/date/src/index.ts index 1136c464171..621cb9a9fde 100644 --- a/packages/@internationalized/date/src/index.ts +++ b/packages/@internationalized/date/src/index.ts @@ -21,8 +21,8 @@ export {IslamicCivilCalendar, IslamicTabularCalendar, IslamicUmalquraCalendar} f export {HebrewCalendar} from './calendars/HebrewCalendar'; export {EthiopicCalendar, EthiopicAmeteAlemCalendar, CopticCalendar} from './calendars/EthiopicCalendar'; export {createCalendar} from './createCalendar'; -export * from './manipulation'; export * from './conversion'; export * from './queries'; export * from './types'; export * from './string'; +export * from './DateFormatter'; diff --git a/packages/@internationalized/date/src/manipulation.ts b/packages/@internationalized/date/src/manipulation.ts index 5ce5f36a765..0541b30cec4 100644 --- a/packages/@internationalized/date/src/manipulation.ts +++ b/packages/@internationalized/date/src/manipulation.ts @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ +import {AnyCalendarDate, AnyTime, CycleOptions, CycleTimeOptions, DateField, DateFields, Disambiguation, Duration, TimeField, TimeFields} from './types'; import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate'; -import {CycleOptions, CycleTimeOptions, DateField, DateFields, Disambiguation, Duration, OverflowBehavior, TimeField, TimeFields} from './types'; import {epochFromDate, fromAbsolute, toAbsolute, toCalendar, toCalendarDateTime} from './conversion'; +import {getMinimumDayInMonth, getMinimumMonthInYear} from './queries'; import {GregorianCalendar} from './calendars/GregorianCalendar'; import {Mutable} from './utils'; @@ -21,31 +22,40 @@ const ONE_HOUR = 3600000; /* eslint-disable no-redeclare */ export function add(date: CalendarDateTime, duration: Duration): CalendarDateTime; export function add(date: CalendarDate, duration: Duration): CalendarDate; -export function add(date: CalendarDate | CalendarDateTime, duration: Duration): CalendarDate | CalendarDateTime { +export function add(date: CalendarDate | CalendarDateTime, duration: Duration): CalendarDate | CalendarDateTime; +export function add(date: CalendarDate | CalendarDateTime, duration: Duration) { /* eslint-enable no-redeclare */ - let mutableDate: Mutable = date.copy(); - let days = addTimeFields(toCalendarDateTime(date), duration); - - addYears(mutableDate, duration.years || 0); - mutableDate.month += duration.months || 0; + let mutableDate: Mutable = date.copy(); + let days = 'hour' in date ? addTimeFields(date, duration) : 0; + + if (date.calendar.add) { + let res = date.calendar.add(date, duration); + mutableDate.era = res.era; + mutableDate.year = res.year; + mutableDate.month = res.month; + mutableDate.day = res.day; + } else { + addYears(mutableDate, duration.years || 0); + mutableDate.month += duration.months || 0; - balanceYearMonth(mutableDate); - constrain(mutableDate); + balanceYearMonth(mutableDate); + constrainMonthDay(mutableDate); - mutableDate.day += (duration.weeks || 0) * 7; - mutableDate.day += duration.days || 0; - mutableDate.day += days; + mutableDate.day += (duration.weeks || 0) * 7; + mutableDate.day += duration.days || 0; + mutableDate.day += days; - balanceDay(mutableDate); + balanceDay(mutableDate); - if (mutableDate.calendar.balanceDate) { - mutableDate.calendar.balanceDate(mutableDate); + if (mutableDate.calendar.balanceDate) { + mutableDate.calendar.balanceDate(mutableDate); + } } return mutableDate; } -function addYears(date: Mutable, years: number) { +function addYears(date: Mutable, years: number) { if (date.calendar.addYears) { date.calendar.addYears(date, years); } else { @@ -53,7 +63,7 @@ function addYears(date: Mutable, years: number) { } } -function balanceYearMonth(date: Mutable) { +function balanceYearMonth(date: Mutable) { while (date.month < 1) { date.month += date.calendar.getMonthsInYear(date); addYears(date, -1); @@ -66,7 +76,7 @@ function balanceYearMonth(date: Mutable) { } } -function balanceDay(date: Mutable) { +function balanceDay(date: Mutable) { while (date.day < 1) { date.month--; balanceYearMonth(date); @@ -80,18 +90,18 @@ function balanceDay(date: Mutable) { } } -function balance(date: Mutable) { - balanceYearMonth(date); - balanceDay(date); +function constrainMonthDay(date: Mutable) { + date.month = Math.max(getMinimumMonthInYear(date), Math.min(date.calendar.getMonthsInYear(date), date.month)); + date.day = Math.max(getMinimumDayInMonth(date), Math.min(date.calendar.getDaysInMonth(date), date.day)); +} - if (date.calendar.balanceDate) { - date.calendar.balanceDate(date); +function constrain(date: Mutable) { + if (date.calendar.constrainDate) { + date.calendar.constrainDate(date); } -} -function constrain(date: Mutable) { - date.month = Math.max(1, Math.min(date.calendar.getMonthsInYear(date), date.month)); - date.day = Math.max(1, Math.min(date.calendar.getDaysInMonth(date), date.day)); + date.year = Math.max(1, Math.min(date.calendar.getYearsInEra(date), date.year)); + constrainMonthDay(date); } export function invertDuration(duration: Duration): Duration { @@ -114,11 +124,11 @@ export function subtract(date: CalendarDate | CalendarDateTime, duration: Durati } /* eslint-disable no-redeclare */ -export function set(date: CalendarDateTime, fields: DateFields, behavior?: OverflowBehavior): CalendarDateTime; -export function set(date: CalendarDate, fields: DateFields, behavior: OverflowBehavior): CalendarDate; -export function set(date: CalendarDate, fields: DateFields, behavior: OverflowBehavior = 'balance'): CalendarDate { +export function set(date: CalendarDateTime, fields: DateFields): CalendarDateTime; +export function set(date: CalendarDate, fields: DateFields): CalendarDate; +export function set(date: CalendarDate | CalendarDateTime, fields: DateFields) { /* eslint-enable no-redeclare */ - let mutableDate: Mutable = date.copy(); + let mutableDate: Mutable = date.copy(); if (fields.era != null) { mutableDate.era = fields.era; @@ -126,7 +136,6 @@ export function set(date: CalendarDate, fields: DateFields, behavior: OverflowBe if (fields.year != null) { mutableDate.year = fields.year; - // addYears(mutableDate, fields.year - mutableDate.year); } if (fields.month != null) { @@ -137,24 +146,14 @@ export function set(date: CalendarDate, fields: DateFields, behavior: OverflowBe mutableDate.day = fields.day; } - switch (behavior) { - case 'balance': - balance(mutableDate); - break; - case 'constrain': - constrain(mutableDate); - break; - default: - throw new Error(`Invalid behavior: ${behavior}. Must be either 'balance' or 'constrain'.`); - } - + constrain(mutableDate); return mutableDate; } /* eslint-disable no-redeclare */ -export function setTime(value: CalendarDateTime, fields: TimeFields, behavior?: OverflowBehavior): CalendarDateTime; -export function setTime(value: Time, fields: TimeFields, behavior: OverflowBehavior): Time; -export function setTime(value: Time | CalendarDateTime, fields: TimeFields, behavior: OverflowBehavior = 'balance'): Time | CalendarDateTime { +export function setTime(value: CalendarDateTime, fields: TimeFields): CalendarDateTime; +export function setTime(value: Time, fields: TimeFields): Time; +export function setTime(value: Time | CalendarDateTime, fields: TimeFields) { /* eslint-enable no-redeclare */ let mutableValue: Mutable