diff --git a/packages/react-components/react-timepicker-preview/library/docs/Spec.md b/packages/react-components/react-timepicker-preview/library/docs/Spec.md index 9e763ba5af14c..ff47992c27e24 100644 --- a/packages/react-components/react-timepicker-preview/library/docs/Spec.md +++ b/packages/react-components/react-timepicker-preview/library/docs/Spec.md @@ -197,12 +197,12 @@ Formatting and the default parser share the resolved hour cycle: the `hourCycle` The default parser (`getDateFromTimeString`) accepts, after trimming leading and trailing whitespace (`:` stands for `:` or the separator the runtime locale puts after the hour, for example `.` in `fi-FI`): -| Resolved hour cycle | Accepted input | Notes | -| ------------------- | ---------------------------------------- | --------------------------------------------------------------------- | -| `h23` | `H:mm`, `HH:mm` with hour `0`–`23` | | -| `h24` | `H:mm`, `HH:mm` with hour `1`–`24` | `24:mm` is hour `0` of the same day, not a rollover to the next day. | -| `h12` | `h:mm AM`, `hh:mm AM` with hour `1`–`12` | Day period is case-insensitive and separated by a single ASCII space. | -| `h11` | `h:mm AM`, `hh:mm AM` with hour `0`–`11` | Same as `h12`. | +| Resolved hour cycle | Accepted input | Notes | +| ------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `h23` | `H:mm`, `HH:mm` with hour `0`–`23` | | +| `h24` | `H:mm`, `HH:mm` with hour `1`–`24` | `24:mm` is hour `0` of the same day, not a rollover to the next day. | +| `h12` | `h:mm AM`, `hh:mm AM` with hour `1`–`12` | Day period is case-insensitive and separated by a single space or U+202F (narrow no-break space). | +| `h11` | `h:mm AM`, `hh:mm AM` with hour `0`–`11` | Same as `h12`. | With `showSeconds`, `:ss` is required; without it, seconds are rejected. The same separator set applies between the minutes and the seconds. Any other separator, digits outside the cycle's range or a missing day period in a 12-hour cycle produce `invalid-input`. diff --git a/packages/react-components/react-timepicker-preview/library/src/utils/index.ts b/packages/react-components/react-timepicker-preview/library/src/utils/index.ts new file mode 100644 index 0000000000000..68f3945f853a1 --- /dev/null +++ b/packages/react-components/react-timepicker-preview/library/src/utils/index.ts @@ -0,0 +1,19 @@ +export type { + Hour, + HourCycle, + TimeFormatOptions, + TimePickerErrorType, + TimePickerOption, + TimeStringValidationResult, +} from './types'; +export type { TimeFormat } from './timeMath'; +export { + createTimeFormat, + dateToKey, + formatDateToTimeString, + getDateEndAnchor, + getDateFromTimeString, + getDateStartAnchor, + getTimesBetween, + keyToDate, +} from './timeMath'; diff --git a/packages/react-components/react-timepicker-preview/library/src/utils/timeMath.test.ts b/packages/react-components/react-timepicker-preview/library/src/utils/timeMath.test.ts new file mode 100644 index 0000000000000..75b238d8c93bc --- /dev/null +++ b/packages/react-components/react-timepicker-preview/library/src/utils/timeMath.test.ts @@ -0,0 +1,441 @@ +import { + dateToKey, + keyToDate, + formatDateToTimeString, + getDateEndAnchor, + getDateStartAnchor, + getTimesBetween, + getDateFromTimeString, + createTimeFormat, +} from './timeMath'; + +const { DateTimeFormat } = Intl; + +/** + * Simulates running in a runtime with the given default locale and time zone. + */ +function mockRuntimeLocale(locale: string, timeZone?: string) { + jest + .spyOn(Intl, 'DateTimeFormat') + .mockImplementation((_locales, options) => new DateTimeFormat(locale, { ...options, timeZone })); +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('Time Utilities', () => { + describe('dateToKey', () => { + it('should return empty string for null date', () => { + expect(dateToKey(null)).toBe(''); + }); + + it('should return "invalid" for invalid dates', () => { + const invalidDate = new Date('invalid-date'); + expect(dateToKey(invalidDate)).toBe('invalid'); + }); + + it('should return ISO string for valid dates', () => { + const date = new Date(2023, 9, 6); + expect(dateToKey(date)).toBe(date.toISOString()); + }); + }); + + describe('keyToDate', () => { + it('should return null for empty string', () => { + expect(keyToDate('')).toBeNull(); + }); + + it('should return null for "invalid" string', () => { + expect(keyToDate('invalid')).toBeNull(); + }); + + it('should return date for valid ISO string', () => { + const date = new Date(2023, 9, 6); + expect(keyToDate(date.toISOString())).toEqual(date); + }); + }); + + describe('dateToKey and keyToDate correspondence', () => { + it('should be inverses of each other for valid dates', () => { + const originalDate = new Date(2023, 9, 6, 23, 45, 12); + const key = dateToKey(originalDate); + const revertedDate = keyToDate(key); + + expect(revertedDate?.getTime()).toEqual(originalDate.getTime()); + }); + + it('should be inverses of each other for null date', () => { + const originalDate = null; + const key = dateToKey(originalDate); + const revertedDate = keyToDate(key); + + expect(revertedDate).toEqual(originalDate); + }); + + it('should be inverses of each other for invalid dates', () => { + const originalDate = new Date('invalid-date'); + const key = dateToKey(originalDate); + const revertedDate = keyToDate(key); + + expect(revertedDate).toBeNull(); + }); + }); + + describe('formatDateToTimeString', () => { + const testDate = new Date(2023, 9, 6, 23, 45, 12); + + it('should format time in 24-hour format without seconds', () => { + expect(formatDateToTimeString(testDate, { hourCycle: 'h23' })).toBe('23:45'); + }); + + it('should format time in 24-hour format with seconds', () => { + expect(formatDateToTimeString(testDate, { showSeconds: true, hourCycle: 'h23' })).toBe('23:45:12'); + }); + + it('should format time in 12-hour format with seconds', () => { + expect(formatDateToTimeString(testDate, { showSeconds: true, hourCycle: 'h11' })).toBe('11:45:12 PM'); + }); + + it('should format midnight correctly in 24-hour format', () => { + const midnight = new Date(2023, 9, 7, 0, 0, 0); + expect(formatDateToTimeString(midnight, { hourCycle: 'h23' })).toBe('00:00'); + }); + + it('should format time in Japanese locale', () => { + mockRuntimeLocale('ja-JP', 'Japan'); + expect( + formatDateToTimeString(new Date(Date.UTC(2023, 9, 6, 14, 45, 12)), { showSeconds: true, hourCycle: 'h11' }), + ).toBe('午後11:45:12'); + }); + + it('should format time without prefix 0 in US local', () => { + mockRuntimeLocale('en-US', 'America/Los_Angeles'); + expect(formatDateToTimeString(new Date(Date.UTC(2023, 9, 6, 15, 45, 12)))).toMatch(/^8:45[ \u202F]AM$/); + }); + }); + + describe('Anchor Date Calculations', () => { + it('should calculate the correct start anchor date', () => { + const date = new Date(2023, 9, 6); + const result = getDateStartAnchor(date, 5); + expect(result.getHours()).toBe(5); + }); + + it('should calculate the correct end anchor date (same day)', () => { + const date = new Date(2023, 9, 6); + const result = getDateEndAnchor(date, 5, 10); + expect(result.getHours()).toBe(10); + }); + + it('should calculate the correct end anchor date (next day)', () => { + const date = new Date(2023, 9, 6); + const result = getDateEndAnchor(date, 10, 5); + expect(result.getHours()).toBe(5); + expect(result.getDate()).toBe(7); + }); + + it('should handle the endHour being 24 correctly', () => { + const date = new Date(2023, 9, 6); + const result = getDateEndAnchor(date, 10, 24); + expect(result.getHours()).toBe(0); + expect(result.getDate()).toBe(7); + }); + }); + + describe('getTimesBetween', () => { + it('should return correct Date objects with 15-minute increment', () => { + const start = new Date('January 1, 2023 10:00:00'); + const end = new Date('January 1, 2023 11:00:00'); + const result = getTimesBetween(start, end, 15); + + expect(result.length).toBe(4); + result.forEach((date, i) => expect(date.getMinutes()).toBe(15 * i)); + }); + + it('should return correct Date objects spanning across midnight with 30-minute increment', () => { + const start = new Date('January 1, 2023 23:30:00'); + const end = new Date('January 2, 2023 00:30:00'); + const result = getTimesBetween(start, end, 30); + + expect(result.length).toBe(2); + expect([result[0].getHours(), result[0].getMinutes()]).toEqual([23, 30]); + expect([result[1].getHours(), result[1].getMinutes()]).toEqual([0, 0]); + }); + + it('should return correct Date objects for day light saving', () => { + const start = new Date('2023-11-05T01:00:00-07:00'); + const end = new Date('2023-11-05T03:00:00-08:00'); + const result = getTimesBetween(start, end, 60); + + expect(result.length).toBe(3); + expect( + result.map(date => date.toLocaleString('en-US', { timeZone: 'America/Los_Angeles', timeZoneName: 'short' })), + ).toMatchInlineSnapshot(` + Array [ + "11/5/2023, 1:00:00 AM PDT", + "11/5/2023, 1:00:00 AM PST", + "11/5/2023, 2:00:00 AM PST", + ] + `); + }); + + it.each([0, -30, 0.5, NaN, Infinity])('should return no dates and log an error for increment %p', increment => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined); + const start = new Date('January 1, 2023 10:00:00'); + const end = new Date('January 1, 2023 11:00:00'); + + expect(getTimesBetween(start, end, increment)).toEqual([]); + expect(consoleError).toHaveBeenCalledTimes(1); + + consoleError.mockRestore(); + }); + + it('should return only the start when the increment is larger than the range', () => { + const start = new Date('January 1, 2023 10:00:00'); + const end = new Date('January 1, 2023 11:00:00'); + + expect(getTimesBetween(start, end, 90)).toEqual([start]); + }); + }); + + describe('getDateFromTimeString', () => { + const dateStartAnchor = new Date('November 25, 2023 12:00:00'); + const dateEndAnchor = new Date('November 26, 2023 12:00:00'); + + it('returns a valid date when given a valid time string', () => { + const result = getDateFromTimeString('2:30 PM', dateStartAnchor, dateEndAnchor, { + hourCycle: 'h11', + showSeconds: false, + }); + expect(result.date?.getHours()).toBe(14); + expect(result.date?.getMinutes()).toBe(30); + expect(result.errorType).toBeUndefined(); + }); + + it('returns an errorType when no time string is provided', () => { + const result = getDateFromTimeString(undefined, dateStartAnchor, dateEndAnchor, {}); + expect(result.date).toBeNull(); + expect(result.errorType).toBe('required-input'); + }); + + it('returns an errorType for an invalid time string', () => { + const result = getDateFromTimeString('25:30', dateStartAnchor, dateEndAnchor, {}); + expect(result.date).toBeNull(); + expect(result.errorType).toBe('invalid-input'); + }); + + it('returns a date in the next day and an out-of-bounds errorType when the time is before the dateStartAnchor', () => { + const result = getDateFromTimeString('11:30 AM', dateStartAnchor, new Date('November 25, 2023 13:00:00'), { + hourCycle: 'h11', + showSeconds: false, + }); + expect(result.date?.getDate()).toBe(26); + expect(result.date?.getHours()).toBe(11); + expect(result.date?.getMinutes()).toBe(30); + expect(result.errorType).toBe('out-of-bounds'); + }); + + it('returns an out-of-bounds errorType when the time is same as the dateEndAnchor', () => { + const result = getDateFromTimeString('1:00 PM', dateStartAnchor, new Date('November 25, 2023 13:00:00'), { + hourCycle: 'h11', + showSeconds: false, + }); + expect(result.date?.getHours()).toBe(13); + expect(result.date?.getMinutes()).toBe(0); + expect(result.errorType).toBe('out-of-bounds'); + }); + + describe('resolved hour cycle', () => { + it('uses the hourCycle option when set', () => { + expect(createTimeFormat({ hourCycle: 'h24' }).hourCycle).toBe('h24'); + }); + + it('falls back to the locale hour cycle', () => { + mockRuntimeLocale('en-US'); + expect(createTimeFormat().hourCycle).toBe('h12'); + mockRuntimeLocale('de-DE'); + expect(createTimeFormat().hourCycle).toBe('h23'); + }); + + it('round-trips the default formatter output without an explicit hourCycle', () => { + const date = new Date('November 25, 2023 14:30:00'); + const text = formatDateToTimeString(date); + const result = getDateFromTimeString(text, dateStartAnchor, dateEndAnchor, {}); + expect(result.errorType).toBeUndefined(); + expect(result.date?.getTime()).toBe(date.getTime()); + }); + + it('round-trips the default formatter output with seconds', () => { + const date = new Date('November 25, 2023 14:30:45'); + const text = formatDateToTimeString(date, { showSeconds: true }); + const result = getDateFromTimeString(text, dateStartAnchor, dateEndAnchor, { showSeconds: true }); + expect(result.date?.getTime()).toBe(date.getTime()); + }); + }); + + describe('accepted input per hour cycle', () => { + const day = new Date('January 1, 2023 00:00:00'); + const nextDay = new Date('January 2, 2023 00:00:00'); + const parse = (time: string, hourCycle: 'h11' | 'h12' | 'h23' | 'h24', showSeconds = false) => + getDateFromTimeString(time, day, nextDay, { hourCycle, showSeconds }); + + it.each` + time | hourCycle | hours | minutes + ${'0:30'} | ${'h23'} | ${0} | ${30} + ${'23:30'} | ${'h23'} | ${23} | ${30} + ${'1:30'} | ${'h24'} | ${1} | ${30} + ${'24:30'} | ${'h24'} | ${0} | ${30} + ${'12:30 AM'} | ${'h12'} | ${0} | ${30} + ${'12:30 PM'} | ${'h12'} | ${12} | ${30} + ${'1:30 pm'} | ${'h12'} | ${13} | ${30} + ${'1:30\u202Fpm'} | ${'h12'} | ${13} | ${30} + ${'0:30 AM'} | ${'h11'} | ${0} | ${30} + ${'0:30 PM'} | ${'h11'} | ${12} | ${30} + ${'11:30 PM'} | ${'h11'} | ${23} | ${30} + ${' 9:15 '} | ${'h23'} | ${9} | ${15} + `('accepts $time in $hourCycle', ({ time, hourCycle, hours, minutes }) => { + const result = parse(time, hourCycle); + expect(result.errorType).toBeUndefined(); + expect(result.date?.getDate()).toBe(1); + expect([result.date?.getHours(), result.date?.getMinutes()]).toEqual([hours, minutes]); + }); + + it.each` + time | hourCycle + ${'24:00'} | ${'h23'} + ${'0:30'} | ${'h24'} + ${'0:30 AM'} | ${'h12'} + ${'13:30 PM'} | ${'h12'} + ${'12:30 PM'} | ${'h11'} + ${'2:30'} | ${'h12'} + ${'2:30\tPM'} | ${'h12'} + ${'2:30 PM'} | ${'h12'} + ${'2.30'} | ${'h23'} + ${'2:30:15'} | ${'h23'} + ${'2:30 PM'} | ${'h23'} + `('rejects $time in $hourCycle', ({ time, hourCycle }) => { + expect(parse(time, hourCycle)).toEqual({ date: null, errorType: 'invalid-input' }); + }); + + it('requires seconds only with showSeconds', () => { + expect(parse('2:30:15', 'h23', true).date?.getSeconds()).toBe(15); + expect(parse('2:30', 'h23', true).errorType).toBe('invalid-input'); + expect(parse('2:30:15 PM', 'h12', true).date?.getHours()).toBe(14); + }); + + it('keeps the full year of anchors in years 0–99', () => { + const start = new Date(0); + start.setFullYear(99, 0, 1); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(2); + const result = getDateFromTimeString('14:30', start, end, { hourCycle: 'h23' }); + expect(result.errorType).toBeUndefined(); + expect([result.date?.getFullYear(), result.date?.getDate(), result.date?.getHours()]).toEqual([99, 1, 14]); + }); + }); + + describe('locale separator', () => { + beforeEach(() => { + mockRuntimeLocale('fi-FI'); + }); + + it.each([false, true])('round-trips the formatter output (showSeconds: %s)', showSeconds => { + const date = new Date(2023, 10, 25, 14, 30, showSeconds ? 45 : 0); + const text = formatDateToTimeString(date, { showSeconds }); + expect(text).toBe(showSeconds ? '14.30.45' : '14.30'); + const result = getDateFromTimeString(text, dateStartAnchor, dateEndAnchor, { showSeconds }); + expect(result.errorType).toBeUndefined(); + expect(result.date?.getTime()).toBe(date.getTime()); + }); + + it('still accepts a colon', () => { + expect(getDateFromTimeString('14:30', dateStartAnchor, dateEndAnchor, {}).date?.getHours()).toBe(14); + }); + }); + + describe('localized formats', () => { + it.each` + locale | hourCycle | showSeconds | expected + ${'ja-JP'} | ${'h11'} | ${false} | ${'午後2:30'} + ${'ja-JP'} | ${'h11'} | ${true} | ${'午後2:30:45'} + ${'ko-KR'} | ${undefined} | ${false} | ${['오후 2:30', 'PM 2:30']} + ${'ar-EG'} | ${undefined} | ${false} | ${'٢:٣٠ م'} + ${'ar-EG'} | ${undefined} | ${true} | ${'٢:٣٠:٤٥ م'} + ${'fa-IR'} | ${undefined} | ${false} | ${'۱۴:۳۰'} + ${'fr-CA'} | ${undefined} | ${false} | ${'14 h 30'} + ${'fr-CA'} | ${undefined} | ${true} | ${'14 h 30 min 45 s'} + ${'es-US'} | ${undefined} | ${false} | ${'2:30 p.m.'} + `('round-trips the $locale format', ({ locale, hourCycle, showSeconds, expected }) => { + mockRuntimeLocale(locale); + const date = new Date(2023, 10, 25, 14, 30, showSeconds ? 45 : 0); + const text = formatDateToTimeString(date, { hourCycle, showSeconds }); + expect([expected].flat()).toContain(text.replace(/[\u200E\u200F\u061C]/g, '').replace(/[\u00A0\u202F]/g, ' ')); + const result = getDateFromTimeString(text, dateStartAnchor, dateEndAnchor, { hourCycle, showSeconds }); + expect(result.errorType).toBeUndefined(); + expect(result.date?.getTime()).toBe(date.getTime()); + }); + + it.each` + locale | time + ${'en-US'} | ${'2:30\u202FPM'} + ${'en-US'} | ${'2:30\u00A0PM'} + ${'ar-EG'} | ${'2:30 PM'} + ${'ar-EG'} | ${'٢:٣٠ م'} + ${'ja-JP'} | ${'午後2:30'} + ${'fr-CA'} | ${'14:30'} + `('accepts $time in $locale', ({ locale, time }) => { + mockRuntimeLocale(locale); + const result = getDateFromTimeString(time, dateStartAnchor, dateEndAnchor, { + hourCycle: locale === 'fr-CA' ? undefined : locale === 'ja-JP' ? 'h11' : 'h12', + }); + expect([result.date?.getHours(), result.date?.getMinutes()]).toEqual([14, 30]); + }); + }); + + describe('daylight saving transitions', () => { + const inLosAngeles = Intl.DateTimeFormat().resolvedOptions().timeZone === 'America/Los_Angeles'; + const itInLosAngeles = inLosAngeles ? it : it.skip; + + itInLosAngeles('keeps the clock time when it does not exist on the anchor day but does on the next', () => { + const start = new Date('2023-03-12T12:00:00-08:00'); + const end = new Date('2023-03-13T12:00:00-07:00'); + const result = getDateFromTimeString('2:30', start, end, { hourCycle: 'h23' }); + expect(result.errorType).toBeUndefined(); + expect(result.date?.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })).toBe('3/13/2023, 2:30:00 AM'); + }); + + itInLosAngeles('moves a clock time that does not exist on the anchor day to the next day', () => { + const start = new Date('2023-03-12T00:00:00-08:00'); + const end = new Date('2023-03-12T12:00:00-07:00'); + const result = getDateFromTimeString('2:30', start, end, { hourCycle: 'h23' }); + expect(result.errorType).toBe('out-of-bounds'); + expect(result.date?.toLocaleString('en-US', { timeZone: 'America/Los_Angeles' })).toBe('3/13/2023, 2:30:00 AM'); + }); + + itInLosAngeles('resolves a repeated clock time to the occurrence inside the range', () => { + const start = new Date('2023-11-05T01:30:00-08:00'); + const end = new Date('2023-11-05T03:00:00-08:00'); + const result = getDateFromTimeString('1:45', start, end, { hourCycle: 'h23' }); + expect(result.errorType).toBeUndefined(); + expect(result.date?.toLocaleString('en-US', { timeZone: 'America/Los_Angeles', timeZoneName: 'short' })).toBe( + '11/5/2023, 1:45:00 AM PST', + ); + }); + }); + + describe('daylight saving transitions shorter than an hour', () => { + const inLordHowe = Intl.DateTimeFormat().resolvedOptions().timeZone === 'Australia/Lord_Howe'; + const itInLordHowe = inLordHowe ? it : it.skip; + + itInLordHowe('resolves a repeated clock time to the occurrence inside the range', () => { + const start = new Date('2023-04-02T01:30:00+10:30'); + const end = new Date('2023-04-02T03:00:00+10:30'); + const result = getDateFromTimeString('1:45', start, end, { hourCycle: 'h23' }); + expect(result.errorType).toBeUndefined(); + expect(result.date?.toISOString()).toBe(new Date('2023-04-02T01:45:00+10:30').toISOString()); + }); + }); + }); +}); diff --git a/packages/react-components/react-timepicker-preview/library/src/utils/timeMath.ts b/packages/react-components/react-timepicker-preview/library/src/utils/timeMath.ts new file mode 100644 index 0000000000000..33c9afc6622e7 --- /dev/null +++ b/packages/react-components/react-timepicker-preview/library/src/utils/timeMath.ts @@ -0,0 +1,272 @@ +import type { HourCycle, TimeFormatOptions, TimeStringValidationResult } from './types'; + +const MS_PER_MINUTE = 60_000; +const MAX_DST_SHIFT_MS = 3 * 60 * MS_PER_MINUTE; +const HOUR_RANGES: Record = { h11: [0, 11], h12: [1, 12], h23: [0, 23], h24: [1, 24] }; +const BIDI_MARKS = /[\u200E\u200F\u061C]/g; +const NO_BREAK_SPACES = /[\u00A0\u202F]/g; + +type TimeParser = { + regex: RegExp; + normalize: (text: string) => string; + pmTexts: string[]; + hourRange: [number, number]; +}; + +function isValidDate(date: Date): boolean { + return !isNaN(date.getTime()); +} + +/** + * Returns the option key for a date: its ISO string, `''` for null, or `'invalid'` for an invalid date. + */ +export function dateToKey(date: Date | null): string { + if (!date) { + return ''; + } + return isValidDate(date) ? date.toISOString() : 'invalid'; +} + +/** + * Inverse of `dateToKey`. Returns null for empty or invalid keys. + */ +export function keyToDate(key: string): Date | null { + const date = new Date(key); + return isValidDate(date) ? date : null; +} + +/** + * Formats and parses times in the runtime locale with one shared `Intl.DateTimeFormat`. + */ +export type TimeFormat = { + /** The resolved hour cycle: `hourCycle` when set, otherwise the hour cycle of the runtime locale. */ + hourCycle: HourCycle; + + /** Formats the time of a date, see `formatDateToTimeString`. */ + format: (date: Date) => string; + + /** Parses a time string into a date between the anchors, see `getDateFromTimeString`. */ + parse: (time: string | undefined, dateStartAnchor: Date, dateEndAnchor: Date) => TimeStringValidationResult; +}; + +/** + * Creates the formatter and parser for a configuration. The parser accepts the formatter's own output in any locale. + */ +export function createTimeFormat({ hourCycle, showSeconds }: TimeFormatOptions = {}): TimeFormat { + const formatter = new Intl.DateTimeFormat(undefined, { + hour: 'numeric', + hourCycle, + minute: '2-digit', + second: showSeconds ? '2-digit' : undefined, + }); + const resolvedOptions = formatter.resolvedOptions() as Intl.ResolvedDateTimeFormatOptions & { hourCycle?: string }; + const resolvedHourCycle = toHourCycle(resolvedOptions.hourCycle); + let parser: TimeParser | undefined; + + return { + hourCycle: resolvedHourCycle, + format: date => formatter.format(date), + parse: (time, dateStartAnchor, dateEndAnchor) => { + parser ??= createTimeParser(formatter, resolvedOptions, resolvedHourCycle); + return parseTime(parser, time, dateStartAnchor, dateEndAnchor); + }, + }; +} + +/** + * Formats the time of a date in the user's locale. Use `createTimeFormat` to format many dates. + * + * @example + * const date = new Date(2023, 9, 6, 23, 45, 12); + * formatDateToTimeString(date); // "23:45" in CET + * formatDateToTimeString(date, \{ hourCycle: 'h12', showSeconds: true \}); // "11:45:12 PM" in CET + */ +export function formatDateToTimeString(date: Date, timeFormatOptions?: TimeFormatOptions): string { + return createTimeFormat(timeFormatOptions).format(date); +} + +/** + * Parses a time string (e.g. "2:30 PM", "15:45:20") into a date between the anchors. + * It accepts the output of `formatDateToTimeString` with the same options in the runtime locale, and `:` as a + * separator. A time before `dateStartAnchor` is moved to the next day. Use `createTimeFormat` to parse many strings. + * + * @returns the parsed date, and an `errorType` when the string is empty, invalid or out of bounds + */ +export function getDateFromTimeString( + time: string | undefined, + dateStartAnchor: Date, + dateEndAnchor: Date, + timeFormatOptions: TimeFormatOptions, +): TimeStringValidationResult { + return createTimeFormat(timeFormatOptions).parse(time, dateStartAnchor, dateEndAnchor); +} + +/** + * Returns `dateAnchor` with the time set to `startHour`:00. + */ +export function getDateStartAnchor(dateAnchor: Date, startHour: number): Date { + const startDate = new Date(dateAnchor); + startDate.setHours(startHour, 0, 0, 0); + return startDate; +} + +/** + * Returns `dateAnchor` with the time set to `endHour`:00. Rolls over to the next day when `endHour` is 24 + * or less than `startHour`. + */ +export function getDateEndAnchor(dateAnchor: Date, startHour: number, endHour: number): Date { + const endDate = new Date(dateAnchor); + if (startHour > endHour || endHour === 24) { + endDate.setDate(endDate.getDate() + 1); + } + endDate.setHours(endHour % 24, 0, 0, 0); + return endDate; +} + +/** + * Returns the dates from `dateStartAnchor` (inclusive) to `dateEndAnchor` (exclusive), `increment` minutes of + * elapsed time apart. The spacing is preserved across daylight saving transitions. + * + * `increment` must be a finite positive integer; for any other value no dates are returned. + * + * @example + * getTimesBetween(new Date(2023, 0, 1, 10), new Date(2023, 0, 1, 11), 15); // 10:00, 10:15, 10:30, 10:45 + */ +export function getTimesBetween(dateStartAnchor: Date, dateEndAnchor: Date, increment: number): Date[] { + if (!Number.isInteger(increment) || increment <= 0) { + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.error(`TimePicker: increment must be a finite positive integer, received ${increment}.`); + } + return []; + } + + const result: Date[] = []; + const end = dateEndAnchor.getTime(); + const step = increment * MS_PER_MINUTE; + for (let time = dateStartAnchor.getTime(); time < end; time += step) { + result.push(new Date(time)); + } + return result; +} + +function toHourCycle(hourCycle: string | undefined): HourCycle { + return hourCycle === 'h11' || hourCycle === 'h12' || hourCycle === 'h24' ? hourCycle : 'h23'; +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Builds a pattern from the parts the formatter produces, so the parser accepts the locale's field order, separators, + * day periods and digits. A separator also accepts `:`, and a day period also accepts `AM` or `PM`. + */ +function createTimeParser( + formatter: Intl.DateTimeFormat, + { locale, numberingSystem }: Intl.ResolvedDateTimeFormatOptions, + hourCycle: HourCycle, +): TimeParser { + const localDigits = new Intl.NumberFormat(locale, { numberingSystem, useGrouping: false }); + const digits = new Map(); + for (let digit = 0; digit <= 9; digit++) { + digits.set(localDigits.format(digit), `${digit}`); + } + const normalize = (text: string) => + text + .replace(BIDI_MARKS, '') + .replace(NO_BREAK_SPACES, ' ') + .replace(/./g, char => digits.get(char) ?? char); + + const formatParts = (hours: number) => + formatter + .formatToParts(new Date(2000, 0, 1, hours, 10, 10)) + .map(part => ({ ...part, value: normalize(part.value) })); + const pmParts = formatParts(13); + const getDayPeriod = (parts: Intl.DateTimeFormatPart[]) => parts.find(part => part.type === 'dayPeriod')?.value ?? ''; + const pmTexts = [getDayPeriod(pmParts), 'pm']; + const dayPeriods = [getDayPeriod(formatParts(1)), 'am', ...pmTexts].map(escapeRegExp).join('|'); + + const pattern = pmParts + .map(({ type, value }) => { + switch (type) { + case 'hour': + return '(?\\d{1,2})'; + case 'minute': + case 'second': + return `(?<${type}>\\d{2})`; + case 'dayPeriod': + return `(?${dayPeriods})`; + case 'literal': + return value.trim() ? `(?::|${escapeRegExp(value)})` : value; + default: + return ''; + } + }) + .join(''); + + return { + regex: new RegExp(`^${pattern}$`, 'i'), + normalize, + pmTexts: pmTexts.map(text => text.toLowerCase()), + hourRange: HOUR_RANGES[hourCycle], + }; +} + +function parseTime( + { regex, normalize, pmTexts, hourRange: [minHour, maxHour] }: TimeParser, + time: string | undefined, + dateStartAnchor: Date, + dateEndAnchor: Date, +): TimeStringValidationResult { + const normalizedTime = normalize(time ?? '').trim(); + if (!normalizedTime) { + return { date: null, errorType: 'required-input' }; + } + + const fields = regex.exec(normalizedTime)?.groups; + const hours = Number(fields?.hour); + const minutes = Number(fields?.minute); + const seconds = Number(fields?.second ?? 0); + if (!fields || hours < minHour || hours > maxHour || minutes > 59 || seconds > 59) { + return { date: null, errorType: 'invalid-input' }; + } + + const { dayPeriod } = fields; + const hours24 = dayPeriod ? (hours % 12) + (pmTexts.includes(dayPeriod.toLowerCase()) ? 12 : 0) : hours % 24; + const date = getDateInRange(dateStartAnchor, hours24, minutes, seconds); + if (!date) { + return { date: null, errorType: 'invalid-input' }; + } + + return date >= dateEndAnchor ? { date, errorType: 'out-of-bounds' } : { date }; +} + +/** + * Returns the first instant on the day of `dateStartAnchor` (or the next day) with the given clock fields that is not + * before `dateStartAnchor`. A clock time that occurs twice because of a daylight saving transition resolves to the + * occurrence inside the range; a clock time that does not exist on a day is skipped. + */ +function getDateInRange(dateStartAnchor: Date, hours: number, minutes: number, seconds: number): Date | null { + for (let dayOffset = 0; dayOffset <= 1; dayOffset++) { + const candidate = new Date(dateStartAnchor); + candidate.setDate(candidate.getDate() + dayOffset); + candidate.setHours(hours, minutes, seconds, 0); + + const shift = + (new Date(candidate.getTime() + MAX_DST_SHIFT_MS).getTimezoneOffset() - candidate.getTimezoneOffset()) * + MS_PER_MINUTE; + const occurrences = shift > 0 ? [candidate, new Date(candidate.getTime() + shift)] : [candidate]; + const inRange = occurrences.find( + date => + date >= dateStartAnchor && + date.getHours() === hours && + date.getMinutes() === minutes && + date.getSeconds() === seconds, + ); + if (inRange) { + return inRange; + } + } + return null; +} diff --git a/packages/react-components/react-timepicker-preview/library/src/utils/types.ts b/packages/react-components/react-timepicker-preview/library/src/utils/types.ts new file mode 100644 index 0000000000000..85bcf74eb2969 --- /dev/null +++ b/packages/react-components/react-timepicker-preview/library/src/utils/types.ts @@ -0,0 +1,75 @@ +/** + * Hour of the day. `24` is midnight of the next day. + */ +export type Hour = + | 0 + | 1 + | 2 + | 3 + | 4 + | 5 + | 6 + | 7 + | 8 + | 9 + | 10 + | 11 + | 12 + | 13 + | 14 + | 15 + | 16 + | 17 + | 18 + | 19 + | 20 + | 21 + | 22 + | 23 + | 24; + +/** + * An option rendered in the TimePicker listbox. + */ +export type TimePickerOption = { + /** Time of the option. */ + date: Date; + + /** Unique option value. */ + key: string; + + /** Text shown in the listbox. */ + text: string; +}; + +/** + * Why a time string could not be used. + */ +export type TimePickerErrorType = 'invalid-input' | 'out-of-bounds' | 'required-input'; + +/** + * Result of parsing a time string. + */ +export type TimeStringValidationResult = { + date: Date | null; + errorType?: TimePickerErrorType; +}; + +/** + * 12-hour (`h11`: 0–11, `h12`: 1–12) or 24-hour (`h23`: 0–23, `h24`: 1–24) format. + */ +export type HourCycle = 'h11' | 'h12' | 'h23' | 'h24'; + +export type TimeFormatOptions = { + /** + * 12-hour (`h11`: 0–11, `h12`: 1–12) or 24-hour (`h23`: 0–23, `h24`: 1–24) format. + * Defaults to the locale's format, see `createTimeFormat`. + */ + hourCycle?: HourCycle | undefined; + + /** + * Show seconds in the options and require them when parsing. + * @default false + */ + showSeconds?: boolean; +}; diff --git a/packages/react-components/react-timepicker-preview/library/tsconfig.lib.json b/packages/react-components/react-timepicker-preview/library/tsconfig.lib.json index 53066fdd11fff..195ed38cc2cd7 100644 --- a/packages/react-components/react-timepicker-preview/library/tsconfig.lib.json +++ b/packages/react-components/react-timepicker-preview/library/tsconfig.lib.json @@ -2,7 +2,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, - "lib": ["ES2019", "dom"], + "lib": ["ES2019", "dom", "ES2020.Intl"], "declaration": true, "declarationDir": "../../../../dist/out-tsc/types", "outDir": "../../../../dist/out-tsc",