Skip to content

Commit 0fb4247

Browse files
committed
fix(material/core): avoid browser inconsistencies when parsing time
Previously we were trying to rely on the browser to parse time, but we couldn't rely on it fully because browsers are inconsistent in how the handle time strings. We had some fallback logic to try and patch over it, but it led to some bug. These changes switch to relying fully on our own logic for parsing times.
1 parent 490bcfe commit 0fb4247

File tree

4 files changed

+50
-64
lines changed

4 files changed

+50
-64
lines changed

src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ describe('DateFnsAdapter', () => {
546546
expect(adapter.isValid(adapter.parseTime('24:05', 'p')!)).toBe(false);
547547
expect(adapter.isValid(adapter.parseTime('00:61:05', 'p')!)).toBe(false);
548548
expect(adapter.isValid(adapter.parseTime('14:52:78', 'p')!)).toBe(false);
549+
expect(adapter.isValid(adapter.parseTime('12:10 PM11:10 PM', 'p')!)).toBe(false);
549550
});
550551

551552
it('should compare times', () => {

src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,7 @@ describe('LuxonDateAdapter', () => {
648648
expect(adapter.isValid(adapter.parseTime('24:05', 't')!)).toBeFalse();
649649
expect(adapter.isValid(adapter.parseTime('00:61:05', 'tt')!)).toBeFalse();
650650
expect(adapter.isValid(adapter.parseTime('14:52:78', 'tt')!)).toBeFalse();
651+
expect(adapter.isValid(adapter.parseTime('12:10 PM11:10 PM', 'tt')!)).toBeFalse();
651652
});
652653

653654
it('should return null when parsing unsupported time values', () => {

src/material/core/datetime/native-date-adapter.spec.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
import {LOCALE_ID} from '@angular/core';
22
import {TestBed} from '@angular/core/testing';
3-
import {Platform} from '@angular/cdk/platform';
43
import {DEC, FEB, JAN, MAR} from '../../testing';
54
import {DateAdapter, MAT_DATE_LOCALE, NativeDateAdapter, NativeDateModule} from './index';
65

76
describe('NativeDateAdapter', () => {
87
let adapter: NativeDateAdapter;
98
let assertValidDate: (d: Date | null, valid: boolean) => void;
10-
let platform: Platform;
119

1210
beforeEach(() => {
1311
TestBed.configureTestingModule({imports: [NativeDateModule]});
1412
adapter = TestBed.inject(DateAdapter) as NativeDateAdapter;
15-
platform = TestBed.inject(Platform);
1613

1714
assertValidDate = (d: Date | null, valid: boolean) => {
1815
expect(adapter.isDateInstance(d))
@@ -587,13 +584,9 @@ describe('NativeDateAdapter', () => {
587584
expect(adapter.isValid(adapter.parseTime('123')!)).toBe(false);
588585
expect(adapter.isValid(adapter.parseTime('14:52 PM')!)).toBe(false);
589586
expect(adapter.isValid(adapter.parseTime('24:05')!)).toBe(false);
590-
591-
// Firefox is a bit more forgiving of invalid times than other browsers.
592-
// E.g. these just roll over instead of producing an invalid object.
593-
if (!platform.FIREFOX) {
594-
expect(adapter.isValid(adapter.parseTime('00:61:05')!)).toBe(false);
595-
expect(adapter.isValid(adapter.parseTime('14:52:78')!)).toBe(false);
596-
}
587+
expect(adapter.isValid(adapter.parseTime('00:61:05')!)).toBe(false);
588+
expect(adapter.isValid(adapter.parseTime('14:52:78')!)).toBe(false);
589+
expect(adapter.isValid(adapter.parseTime('12:10 PM11:10 PM')!)).toBe(false);
597590
});
598591

599592
it('should return null when parsing unsupported time values', () => {

src/material/core/datetime/native-date-adapter.ts

Lines changed: 45 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const ISO_8601_REGEX =
2828
* - {{hours}}.{{minutes}} AM/PM
2929
* - {{hours}}.{{minutes}}.{{seconds}} AM/PM
3030
*/
31-
const TIME_REGEX = /(\d?\d)[:.](\d?\d)(?:[:.](\d?\d))?\s*(AM|PM)?/i;
31+
const TIME_REGEX = /^(\d?\d)[:.](\d?\d)(?:[:.](\d?\d))?\s*(AM|PM)?$/i;
3232

3333
/** Creates an array and fills it with values. */
3434
function range<T>(length: number, valueFunction: (index: number) => T): T[] {
@@ -292,67 +292,20 @@ export class NativeDateAdapter extends DateAdapter<Date> {
292292
return null;
293293
}
294294

295-
const today = this.today();
296-
const base = this.toIso8601(today);
295+
// Attempt to parse the value directly.
296+
let result = this._parseTimeString(value);
297297

298-
// JS is able to parse colon-separated times (including AM/PM) by
299-
// appending it to a valid date string. Generate one from today's date.
300-
let result = Date.parse(`${base} ${value}`);
301-
302-
// Some locales use a dot instead of a colon as a separator, try replacing it before parsing.
303-
if (!result && value.includes('.')) {
304-
result = Date.parse(`${base} ${value.replace(/\./g, ':')}`);
305-
}
306-
307-
// Other locales add extra characters around the time, but are otherwise parseable
298+
// Some locales add extra characters around the time, but are otherwise parseable
308299
// (e.g. `00:05 ч.` in bg-BG). Try replacing all non-number and non-colon characters.
309-
if (!result) {
300+
if (result === null) {
310301
const withoutExtras = value.replace(/[^0-9:(AM|PM)]/gi, '').trim();
311302

312303
if (withoutExtras.length > 0) {
313-
result = Date.parse(`${base} ${withoutExtras}`);
314-
}
315-
}
316-
317-
// Some browser implementations of Date aren't very flexible with the time formats.
318-
// E.g. Safari doesn't support AM/PM or padded numbers. As a final resort, we try
319-
// parsing some of the more common time formats ourselves.
320-
if (!result) {
321-
const parsed = value.toUpperCase().match(TIME_REGEX);
322-
323-
if (parsed) {
324-
let hours = parseInt(parsed[1]);
325-
const minutes = parseInt(parsed[2]);
326-
let seconds: number | undefined = parsed[3] == null ? undefined : parseInt(parsed[3]);
327-
const amPm = parsed[4] as 'AM' | 'PM' | undefined;
328-
329-
if (hours === 12) {
330-
hours = amPm === 'AM' ? 0 : hours;
331-
} else if (amPm === 'PM') {
332-
hours += 12;
333-
}
334-
335-
if (
336-
inRange(hours, 0, 23) &&
337-
inRange(minutes, 0, 59) &&
338-
(seconds == null || inRange(seconds, 0, 59))
339-
) {
340-
return this.setTime(today, hours, minutes, seconds || 0);
341-
}
342-
}
343-
}
344-
345-
if (result) {
346-
const date = new Date(result);
347-
348-
// Firefox allows overflows in the time string, e.g. 25:00 gets parsed as the next day.
349-
// Other browsers return invalid date objects in such cases so try to normalize it.
350-
if (this.sameDate(today, date)) {
351-
return date;
304+
result = this._parseTimeString(withoutExtras);
352305
}
353306
}
354307

355-
return this.invalid();
308+
return result || this.invalid();
356309
}
357310

358311
override addSeconds(date: Date, amount: number): Date {
@@ -397,6 +350,44 @@ export class NativeDateAdapter extends DateAdapter<Date> {
397350
d.setUTCHours(date.getHours(), date.getMinutes(), date.getSeconds(), date.getMilliseconds());
398351
return dtf.format(d);
399352
}
353+
354+
/**
355+
* Attempts to parse a time string into a date object. Returns null if it cannot be parsed.
356+
* @param value Time string to parse.
357+
*/
358+
private _parseTimeString(value: string): Date | null {
359+
// Note: we can technically rely on the browser for the time parsing by generating
360+
// an ISO string and appending the string to the end of it. We don't do it, because
361+
// browsers aren't consistent in what they support. Some examples:
362+
// - Safari doesn't support AM/PM.
363+
// - Firefox produces a valid date object if the time string has overflows (e.g. 12:75) while
364+
// other browsers produce an invalid date.
365+
// - Safari doesn't allow padded numbers.
366+
const parsed = value.toUpperCase().match(TIME_REGEX);
367+
368+
if (parsed) {
369+
let hours = parseInt(parsed[1]);
370+
const minutes = parseInt(parsed[2]);
371+
let seconds: number | undefined = parsed[3] == null ? undefined : parseInt(parsed[3]);
372+
const amPm = parsed[4] as 'AM' | 'PM' | undefined;
373+
374+
if (hours === 12) {
375+
hours = amPm === 'AM' ? 0 : hours;
376+
} else if (amPm === 'PM') {
377+
hours += 12;
378+
}
379+
380+
if (
381+
inRange(hours, 0, 23) &&
382+
inRange(minutes, 0, 59) &&
383+
(seconds == null || inRange(seconds, 0, 59))
384+
) {
385+
return this.setTime(this.today(), hours, minutes, seconds || 0);
386+
}
387+
}
388+
389+
return null;
390+
}
400391
}
401392

402393
/** Checks whether a number is within a certain range. */

0 commit comments

Comments
 (0)