diff --git a/src/material/core/datetime/native-date-adapter.spec.ts b/src/material/core/datetime/native-date-adapter.spec.ts index a1264175bf02..e17bbbf84e3f 100644 --- a/src/material/core/datetime/native-date-adapter.spec.ts +++ b/src/material/core/datetime/native-date-adapter.spec.ts @@ -1,6 +1,6 @@ import {LOCALE_ID} from '@angular/core'; import {TestBed} from '@angular/core/testing'; -import {DEC, FEB, JAN, MAR} from '../../testing'; +import {DEC, FEB, JAN, JUL, JUN, MAR} from '../../testing'; import {DateAdapter, MAT_DATE_LOCALE, NativeDateAdapter, NativeDateModule} from './index'; describe('NativeDateAdapter', () => { @@ -627,6 +627,24 @@ describe('NativeDateAdapter', () => { expect(result).not.toBe(initial); expect(result.getTime() - initial.getTime()).toBe(amount * 1000); }); + + it('should parse strings taking locale date formats into account', () => { + adapter.setLocale('fr-BE'); + expect(adapter.parse('6/7/2023')).toEqual(new Date(2023, JUL, 6)); + expect(adapter.parse('7/6/2023')).toEqual(new Date(2023, JUN, 7)); + adapter.setLocale('en-US'); + expect(adapter.parse('7/6/2023')).toEqual(new Date(2023, JUN, 7)); + expect(adapter.parse('7/6/2023')).toEqual(new Date(2023, JUL, 6)); + }); + + it('should infer current year if not supplied when parsing', () => { + expect(adapter.parse('01-01')).toEqual(new Date(new Date().getFullYear(), JAN, 1)); + }); + + it('should not return dates with a non zero local time component when parsing', () => { + expect(adapter.parse('2023-01-01')).toEqual(new Date(2023, JAN, 1, 0, 0, 0)); + expect(adapter.parse('1-1-2023')).toEqual(new Date(2023, JAN, 1, 0, 0, 0)); + }); }); describe('NativeDateAdapter with MAT_DATE_LOCALE override', () => { diff --git a/src/material/core/datetime/native-date-adapter.ts b/src/material/core/datetime/native-date-adapter.ts index b4663da4dcef..f5bc61b71597 100644 --- a/src/material/core/datetime/native-date-adapter.ts +++ b/src/material/core/datetime/native-date-adapter.ts @@ -30,6 +30,8 @@ const ISO_8601_REGEX = */ const TIME_REGEX = /^(\d?\d)[:.](\d?\d)(?:[:.](\d?\d))?\s*(AM|PM)?$/i; +const DATE_COMPONENT_SEPARATOR_REGEX = /[ \/.:,'"|\\_-]+/; + /** Creates an array and fills it with values. */ function range(length: number, valueFunction: (index: number) => T): T[] { const valuesArray = Array(length); @@ -148,7 +150,7 @@ export class NativeDateAdapter extends DateAdapter { let result = this._createDateWithOverflow(year, month, date); // Check that the date wasn't above the upper bound for the month, causing the month to overflow - if (result.getMonth() != month && (typeof ngDevMode === 'undefined' || ngDevMode)) { + if (result.getMonth() !== month && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw Error(`Invalid date "${date}" for month with index "${month}".`); } @@ -165,7 +167,71 @@ export class NativeDateAdapter extends DateAdapter { if (typeof value == 'number') { return new Date(value); } - return value ? new Date(Date.parse(value)) : null; + + if (!value) { + return null; + } + + if (typeof value !== 'string') { + return new Date(value); + } + + const dateParts = value + .trim() + .split(DATE_COMPONENT_SEPARATOR_REGEX) + .map(part => Number(part)) + .filter(part => !isNaN(part)); + + if (dateParts.length < 2) { + return this.invalid(); + } + + const localeFormatParts = Intl.DateTimeFormat(this.locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(); + + let year: number | null = null; + let month: number | null = null; + let day: number | null = null; + + const valueHasYear = dateParts.length > 2; + + if (!valueHasYear) { + // Year is implied to be current year if only 2 date components are given. + year = new Date().getFullYear(); + } + + let parsedPartIndex = 0; + + for (const part of localeFormatParts) { + switch (part.type) { + case 'year': + if (valueHasYear) { + year = dateParts[parsedPartIndex++]; + } + break; + case 'month': + month = dateParts[parsedPartIndex++] - 1; + break; + case 'day': + day = dateParts[parsedPartIndex++]; + break; + } + } + + if (year !== null && month !== null && day !== null) { + const date = this.createDate(year, month, day); + + if (date.getFullYear() === year && date.getMonth() === month && date.getDate() === day) { + return date; + } + + return this.invalid(); + } + + return this._nativeParseFallback(value); } format(date: Date, displayFormat: Object): string { @@ -351,6 +417,32 @@ export class NativeDateAdapter extends DateAdapter { return dtf.format(d); } + private _nativeParseFallback(value: string): Date { + const date = new Date(Date.parse(value)); + if (!this.isValid(date)) { + return date; + } + + // Native parsing sometimes assumes UTC, sometimes does not. + // We have to remove the difference between the two in order to get the date as a local date. + + const compareDate = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0); + const difference = date.getTime() - compareDate.getTime(); + if (difference === 0) { + return date; + } + + return new Date( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + date.getUTCMinutes(), + date.getUTCSeconds(), + date.getUTCMilliseconds(), + ); + } + /** * Attempts to parse a time string into a date object. Returns null if it cannot be parsed. * @param value Time string to parse.