From 7e24807fa871d52664da6d187bb601fb372189ef Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 19 Sep 2024 12:21:21 +0200 Subject: [PATCH 1/6] refactor(material/core): add methods to date adapter Expands the date adapter to include some methods that will be used in the future. --- src/material/core/datetime/date-adapter.ts | 90 ++++++++ src/material/core/datetime/date-formats.ts | 3 + .../core/datetime/native-date-adapter.spec.ts | 204 ++++++++++++++++-- .../core/datetime/native-date-adapter.ts | 128 +++++++++++ .../core/datetime/native-date-formats.ts | 3 + tools/public_api_guard/material/core.md | 23 ++ 6 files changed, 431 insertions(+), 20 deletions(-) diff --git a/src/material/core/datetime/date-adapter.ts b/src/material/core/datetime/date-adapter.ts index 3539e7c095d2..e3ad332e1807 100644 --- a/src/material/core/datetime/date-adapter.ts +++ b/src/material/core/datetime/date-adapter.ts @@ -20,6 +20,8 @@ export function MAT_DATE_LOCALE_FACTORY(): {} { return inject(LOCALE_ID); } +const NOT_IMPLEMENTED = 'Method not implemented'; + /** Adapts type `D` to be usable as a date by cdk-based components that work with dates. */ export abstract class DateAdapter { /** The locale to use for all dates. */ @@ -195,6 +197,60 @@ export abstract class DateAdapter { */ abstract invalid(): D; + /** + * Sets the time of one date to the time of another. + * @param target Date whose time will be set. + * @param hours New hours to set on the date object. + * @param minutes New minutes to set on the date object. + * @param seconds New seconds to set on the date object. + */ + setTime(target: D, hours: number, minutes: number, seconds: number): D { + throw new Error(NOT_IMPLEMENTED); + } + + /** + * Gets the hours component of the given date. + * @param date The date to extract the hours from. + */ + getHours(date: D): number { + throw new Error(NOT_IMPLEMENTED); + } + + /** + * Gets the minutes component of the given date. + * @param date The date to extract the minutes from. + */ + getMinutes(date: D): number { + throw new Error(NOT_IMPLEMENTED); + } + + /** + * Gets the seconds component of the given date. + * @param date The date to extract the seconds from. + */ + getSeconds(date: D): number { + throw new Error(NOT_IMPLEMENTED); + } + + /** + * Parses a date with a specific time from a user-provided value. + * @param value The value to parse. + * @param parseFormat The expected format of the value being parsed + * (type is implementation-dependent). + */ + parseTime(value: any, parseFormat: any): D | null { + throw new Error(NOT_IMPLEMENTED); + } + + /** + * Adds an amount of milliseconds to the specified date. + * @param date Date to which to add the milliseconds. + * @param amount Amount of milliseconds to add to the date. + */ + addMilliseconds(date: D, amount: number): D { + throw new Error(NOT_IMPLEMENTED); + } + /** * Given a potential date object, returns that same date object if it is * a valid date, or `null` if it's not a valid date. @@ -248,6 +304,21 @@ export abstract class DateAdapter { ); } + /** + * Compares the time values of two dates. + * @param first First date to compare. + * @param second Second date to compare. + * @returns 0 if the times are equal, a number less than 0 if the first time is earlier, + * a number greater than 0 if the first time is later. + */ + compareTime(first: D, second: D): number { + return ( + this.getHours(first) - this.getHours(second) || + this.getMinutes(first) - this.getMinutes(second) || + this.getSeconds(first) - this.getSeconds(second) + ); + } + /** * Checks if two dates are equal. * @param first The first date to check. @@ -267,6 +338,25 @@ export abstract class DateAdapter { return first == second; } + /** + * Checks if the times of two dates are equal. + * @param first The first date to check. + * @param second The second date to check. + * @returns Whether the times of the two dates are equal. + * Null dates are considered equal to other null dates. + */ + sameTime(first: D | null, second: D | null): boolean { + if (first && second) { + const firstValid = this.isValid(first); + const secondValid = this.isValid(second); + if (firstValid && secondValid) { + return !this.compareTime(first, second); + } + return firstValid == secondValid; + } + return first == second; + } + /** * Clamp the given date between min and max dates. * @param date The date to clamp. diff --git a/src/material/core/datetime/date-formats.ts b/src/material/core/datetime/date-formats.ts index 6118f0e380a5..25dbd15fb386 100644 --- a/src/material/core/datetime/date-formats.ts +++ b/src/material/core/datetime/date-formats.ts @@ -11,6 +11,7 @@ import {InjectionToken} from '@angular/core'; export type MatDateFormats = { parse: { dateInput: any; + timeInput?: any; }; display: { dateInput: any; @@ -18,6 +19,8 @@ export type MatDateFormats = { monthYearLabel: any; dateA11yLabel: any; monthYearA11yLabel: any; + timeInput?: any; + timeOptionLabel?: any; }; }; diff --git a/src/material/core/datetime/native-date-adapter.spec.ts b/src/material/core/datetime/native-date-adapter.spec.ts index ed3c997f00c1..a5112359b79f 100644 --- a/src/material/core/datetime/native-date-adapter.spec.ts +++ b/src/material/core/datetime/native-date-adapter.spec.ts @@ -1,20 +1,18 @@ import {LOCALE_ID} from '@angular/core'; -import {waitForAsync, inject, TestBed} from '@angular/core/testing'; +import {TestBed} from '@angular/core/testing'; +import {Platform} from '@angular/cdk/platform'; import {DEC, FEB, JAN, MAR} from '../../testing'; import {DateAdapter, MAT_DATE_LOCALE, NativeDateAdapter, NativeDateModule} from './index'; describe('NativeDateAdapter', () => { let adapter: NativeDateAdapter; let assertValidDate: (d: Date | null, valid: boolean) => void; + let platform: Platform; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [NativeDateModule], - }); - })); - - beforeEach(inject([DateAdapter], (dateAdapter: NativeDateAdapter) => { - adapter = dateAdapter; + beforeEach(() => { + TestBed.configureTestingModule({imports: [NativeDateModule]}); + adapter = TestBed.inject(DateAdapter) as NativeDateAdapter; + platform = TestBed.inject(Platform); assertValidDate = (d: Date | null, valid: boolean) => { expect(adapter.isDateInstance(d)) @@ -27,7 +25,7 @@ describe('NativeDateAdapter', () => { ) .toBe(valid); }; - })); + }); it('should get year', () => { expect(adapter.getYear(new Date(2017, JAN, 1))).toBe(2017); @@ -464,21 +462,189 @@ describe('NativeDateAdapter', () => { it('should not throw when attempting to format a date with a year greater than 9999', () => { expect(() => adapter.format(new Date(10000, 1, 1), {})).not.toThrow(); }); + + it('should get hours', () => { + expect(adapter.getHours(new Date(2024, JAN, 1, 14))).toBe(14); + }); + + it('should get minutes', () => { + expect(adapter.getMinutes(new Date(2024, JAN, 1, 14, 53))).toBe(53); + }); + + it('should get seconds', () => { + expect(adapter.getSeconds(new Date(2024, JAN, 1, 14, 53, 42))).toBe(42); + }); + + it('should set the time of a date', () => { + const target = new Date(2024, JAN, 1, 0, 0, 0); + const result = adapter.setTime(target, 14, 53, 42); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(53); + expect(adapter.getSeconds(result)).toBe(42); + }); + + it('should throw when passing in invalid hours to setTime', () => { + expect(() => adapter.setTime(adapter.today(), -1, 0, 0)).toThrowError( + 'Invalid hours "-1". Hours value must be between 0 and 23.', + ); + expect(() => adapter.setTime(adapter.today(), 51, 0, 0)).toThrowError( + 'Invalid hours "51". Hours value must be between 0 and 23.', + ); + }); + + it('should throw when passing in invalid minutes to setTime', () => { + expect(() => adapter.setTime(adapter.today(), 0, -1, 0)).toThrowError( + 'Invalid minutes "-1". Minutes value must be between 0 and 59.', + ); + expect(() => adapter.setTime(adapter.today(), 0, 65, 0)).toThrowError( + 'Invalid minutes "65". Minutes value must be between 0 and 59.', + ); + }); + + it('should throw when passing in invalid seconds to setTime', () => { + expect(() => adapter.setTime(adapter.today(), 0, 0, -1)).toThrowError( + 'Invalid seconds "-1". Seconds value must be between 0 and 59.', + ); + expect(() => adapter.setTime(adapter.today(), 0, 0, 65)).toThrowError( + 'Invalid seconds "65". Seconds value must be between 0 and 59.', + ); + }); + + it('should parse a 24-hour time string', () => { + const result = adapter.parseTime('14:52')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a 12-hour time string', () => { + const result = adapter.parseTime('2:52 PM')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a 12-hour time string with seconds', () => { + const result = adapter.parseTime('2:52:46 PM')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(46); + }); + + it('should parse a padded 12-hour time string', () => { + const result = adapter.parseTime('02:52 PM')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a padded time string', () => { + const result = adapter.parseTime('03:04:05')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(3); + expect(adapter.getMinutes(result)).toBe(4); + expect(adapter.getSeconds(result)).toBe(5); + }); + + it('should parse a time string that uses dot as a separator', () => { + const result = adapter.parseTime('14.52')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a time string with characters around the time', () => { + const result = adapter.parseTime('14:52 ч.')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a 12-hour time string using a dot separator', () => { + const result = adapter.parseTime('2.52.46 PM')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(46); + }); + + it('should return an invalid date when parsing invalid time string', () => { + expect(adapter.isValid(adapter.parseTime('abc')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('123')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('14:52 PM')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('24:05')!)).toBe(false); + + // Firefox is a bit more forgiving of invalid times than other browsers. + // E.g. these just roll over instead of producing an invalid object. + if (!platform.FIREFOX) { + expect(adapter.isValid(adapter.parseTime('00:61:05')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('14:52:78')!)).toBe(false); + } + }); + + it('should return null when parsing unsupported time values', () => { + expect(adapter.parseTime(321)).toBeNull(); + expect(adapter.parseTime('')).toBeNull(); + expect(adapter.parseTime(' ')).toBeNull(); + expect(adapter.parseTime(true)).toBeNull(); + expect(adapter.parseTime(undefined)).toBeNull(); + }); + + it('should compare times', () => { + const base = [2024, JAN, 1] as const; + + expect( + adapter.compareTime(new Date(...base, 12, 0, 0), new Date(...base, 13, 0, 0)), + ).toBeLessThan(0); + expect( + adapter.compareTime(new Date(...base, 12, 50, 0), new Date(...base, 12, 51, 0)), + ).toBeLessThan(0); + expect(adapter.compareTime(new Date(...base, 1, 2, 3), new Date(...base, 1, 2, 3))).toBe(0); + expect( + adapter.compareTime(new Date(...base, 13, 0, 0), new Date(...base, 12, 0, 0)), + ).toBeGreaterThan(0); + expect( + adapter.compareTime(new Date(...base, 12, 50, 11), new Date(...base, 12, 50, 10)), + ).toBeGreaterThan(0); + expect( + adapter.compareTime(new Date(...base, 13, 0, 0), new Date(...base, 10, 59, 59)), + ).toBeGreaterThan(0); + }); + + it('should add milliseconds to a date', () => { + const amount = 1234567; + const initial = new Date(2024, JAN, 1, 12, 34, 56); + const result = adapter.addMilliseconds(initial, amount); + expect(result).not.toBe(initial); + expect(result.getTime() - initial.getTime()).toBe(amount); + }); }); describe('NativeDateAdapter with MAT_DATE_LOCALE override', () => { let adapter: NativeDateAdapter; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [NativeDateModule], providers: [{provide: MAT_DATE_LOCALE, useValue: 'da-DK'}], }); - })); - beforeEach(inject([DateAdapter], (d: NativeDateAdapter) => { - adapter = d; - })); + adapter = TestBed.inject(DateAdapter) as NativeDateAdapter; + }); it('should take the default locale id from the MAT_DATE_LOCALE injection token', () => { const expectedValue = ['søndag', 'mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag']; @@ -489,16 +655,14 @@ describe('NativeDateAdapter with MAT_DATE_LOCALE override', () => { describe('NativeDateAdapter with LOCALE_ID override', () => { let adapter: NativeDateAdapter; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [NativeDateModule], providers: [{provide: LOCALE_ID, useValue: 'da-DK'}], }); - })); - beforeEach(inject([DateAdapter], (d: NativeDateAdapter) => { - adapter = d; - })); + adapter = TestBed.inject(DateAdapter) as NativeDateAdapter; + }); it('should cascade locale id from the LOCALE_ID injection token to MAT_DATE_LOCALE', () => { const expectedValue = ['søndag', 'mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag']; diff --git a/src/material/core/datetime/native-date-adapter.ts b/src/material/core/datetime/native-date-adapter.ts index 73d953c7298c..62ccdae31fa4 100644 --- a/src/material/core/datetime/native-date-adapter.ts +++ b/src/material/core/datetime/native-date-adapter.ts @@ -17,6 +17,19 @@ import {DateAdapter, MAT_DATE_LOCALE} from './date-adapter'; const ISO_8601_REGEX = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|(?:(?:\+|-)\d{2}:\d{2}))?)?$/; +/** + * Matches a time string. Supported formats: + * - {{hours}}:{{minutes}} + * - {{hours}}:{{minutes}}:{{seconds}} + * - {{hours}}:{{minutes}} AM/PM + * - {{hours}}:{{minutes}}:{{seconds}} AM/PM + * - {{hours}}.{{minutes}} + * - {{hours}}.{{minutes}}.{{seconds}} + * - {{hours}}.{{minutes}} AM/PM + * - {{hours}}.{{minutes}}.{{seconds}} AM/PM + */ +const TIME_REGEX = /(\d?\d)[:.](\d?\d)(?:[:.](\d?\d))?\s*(AM|PM)?/i; + /** Creates an array and fills it with values. */ function range(length: number, valueFunction: (index: number) => T): T[] { const valuesArray = Array(length); @@ -219,6 +232,116 @@ export class NativeDateAdapter extends DateAdapter { return new Date(NaN); } + override setTime(target: Date, hours: number, minutes: number, seconds: number): Date { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (!inRange(hours, 0, 23)) { + throw Error(`Invalid hours "${hours}". Hours value must be between 0 and 23.`); + } + + if (!inRange(minutes, 0, 59)) { + throw Error(`Invalid minutes "${minutes}". Minutes value must be between 0 and 59.`); + } + + if (!inRange(seconds, 0, 59)) { + throw Error(`Invalid seconds "${seconds}". Seconds value must be between 0 and 59.`); + } + } + + const clone = this.clone(target); + clone.setHours(hours, minutes, seconds, 0); + return clone; + } + + override getHours(date: Date): number { + return date.getHours(); + } + + override getMinutes(date: Date): number { + return date.getMinutes(); + } + + override getSeconds(date: Date): number { + return date.getSeconds(); + } + + override parseTime(userValue: any, parseFormat?: any): Date | null { + if (typeof userValue !== 'string') { + return userValue instanceof Date ? new Date(userValue.getTime()) : null; + } + + const value = userValue.trim(); + + if (value.length === 0) { + return null; + } + + const today = this.today(); + const base = this.toIso8601(today); + + // JS is able to parse colon-separated times (including AM/PM) by + // appending it to a valid date string. Generate one from today's date. + let result = Date.parse(`${base} ${value}`); + + // Some locales use a dot instead of a colon as a separator, try replacing it before parsing. + if (!result && value.includes('.')) { + result = Date.parse(`${base} ${value.replace(/\./g, ':')}`); + } + + // Other locales add extra characters around the time, but are otherwise parseable + // (e.g. `00:05 ч.` in bg-BG). Try replacing all non-number and non-colon characters. + if (!result) { + const withoutExtras = value.replace(/[^0-9:(AM|PM)]/gi, '').trim(); + + if (withoutExtras.length > 0) { + result = Date.parse(`${base} ${withoutExtras}`); + } + } + + // Some browser implementations of Date aren't very flexible with the time formats. + // E.g. Safari doesn't support AM/PM or padded numbers. As a final resort, we try + // parsing some of the more common time formats ourselves. + if (!result) { + const parsed = value.toUpperCase().match(TIME_REGEX); + + if (parsed) { + let hours = parseInt(parsed[1]); + const minutes = parseInt(parsed[2]); + let seconds: number | undefined = parsed[3] == null ? undefined : parseInt(parsed[3]); + const amPm = parsed[4] as 'AM' | 'PM' | undefined; + + if (hours === 12) { + hours = amPm === 'AM' ? 0 : hours; + } else if (amPm === 'PM') { + hours += 12; + } + + if ( + inRange(hours, 0, 23) && + inRange(minutes, 0, 59) && + (seconds == null || inRange(seconds, 0, 59)) + ) { + return this.setTime(today, hours, minutes, seconds || 0); + } + } + } + + if (result) { + const date = new Date(result); + + // Firefox allows overflows in the time string, e.g. 25:00 gets parsed as the next day. + // Other browsers return invalid date objects in such cases so try to normalize it. + if (this.sameDate(today, date)) { + return date; + } + } + + return this.invalid(); + } + + override addMilliseconds(date: Date, amount: number): Date { + return new Date(date.getTime() + amount); + } + /** Creates a date but allows the month and date to overflow. */ private _createDateWithOverflow(year: number, month: number, date: number) { // Passing the year to the constructor causes year numbers <100 to be converted to 19xx. @@ -258,3 +381,8 @@ export class NativeDateAdapter extends DateAdapter { return dtf.format(d); } } + +/** Checks whether a number is within a certain range. */ +function inRange(value: number, min: number, max: number): boolean { + return !isNaN(value) && value >= min && value <= max; +} diff --git a/src/material/core/datetime/native-date-formats.ts b/src/material/core/datetime/native-date-formats.ts index b233e92f499d..792c3fded8e5 100644 --- a/src/material/core/datetime/native-date-formats.ts +++ b/src/material/core/datetime/native-date-formats.ts @@ -11,11 +11,14 @@ import {MatDateFormats} from './date-formats'; export const MAT_NATIVE_DATE_FORMATS: MatDateFormats = { parse: { dateInput: null, + timeInput: null, }, display: { dateInput: {year: 'numeric', month: 'numeric', day: 'numeric'}, + timeInput: {hour: 'numeric', minute: 'numeric'}, monthYearLabel: {year: 'numeric', month: 'short'}, dateA11yLabel: {year: 'numeric', month: 'long', day: 'numeric'}, monthYearA11yLabel: {year: 'numeric', month: 'long'}, + timeOptionLabel: {hour: 'numeric', minute: 'numeric'}, }, }; diff --git a/tools/public_api_guard/material/core.md b/tools/public_api_guard/material/core.md index d1e887327553..0246d052438b 100644 --- a/tools/public_api_guard/material/core.md +++ b/tools/public_api_guard/material/core.md @@ -58,9 +58,11 @@ export abstract class DateAdapter { abstract addCalendarDays(date: D, days: number): D; abstract addCalendarMonths(date: D, months: number): D; abstract addCalendarYears(date: D, years: number): D; + addMilliseconds(date: D, amount: number): D; clampDate(date: D, min?: D | null, max?: D | null): D; abstract clone(date: D): D; compareDate(first: D, second: D): number; + compareTime(first: D, second: D): number; abstract createDate(year: number, month: number, date: number): D; deserialize(value: any): D | null; abstract format(date: D, displayFormat: any): string; @@ -69,9 +71,12 @@ export abstract class DateAdapter { abstract getDayOfWeek(date: D): number; abstract getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[]; abstract getFirstDayOfWeek(): number; + getHours(date: D): number; + getMinutes(date: D): number; abstract getMonth(date: D): number; abstract getMonthNames(style: 'long' | 'short' | 'narrow'): string[]; abstract getNumDaysInMonth(date: D): number; + getSeconds(date: D): number; getValidDateOrNull(obj: unknown): D | null; abstract getYear(date: D): number; abstract getYearName(date: D): string; @@ -83,8 +88,11 @@ export abstract class DateAdapter { // (undocumented) protected readonly _localeChanges: Subject; abstract parse(value: any, parseFormat: any): D | null; + parseTime(value: any, parseFormat: any): D | null; sameDate(first: D | null, second: D | null): boolean; + sameTime(first: D | null, second: D | null): boolean; setLocale(locale: L): void; + setTime(target: D, hours: number, minutes: number, seconds: number): D; abstract today(): D; abstract toIso8601(date: D): string; } @@ -164,6 +172,7 @@ export class MatCommonModule { export type MatDateFormats = { parse: { dateInput: any; + timeInput?: any; }; display: { dateInput: any; @@ -171,6 +180,8 @@ export type MatDateFormats = { monthYearLabel: any; dateA11yLabel: any; monthYearA11yLabel: any; + timeInput?: any; + timeOptionLabel?: any; }; }; @@ -397,6 +408,8 @@ export class NativeDateAdapter extends DateAdapter { // (undocumented) addCalendarYears(date: Date, years: number): Date; // (undocumented) + addMilliseconds(date: Date, amount: number): Date; + // (undocumented) clone(date: Date): Date; // (undocumented) createDate(year: number, month: number, date: number): Date; @@ -414,12 +427,18 @@ export class NativeDateAdapter extends DateAdapter { // (undocumented) getFirstDayOfWeek(): number; // (undocumented) + getHours(date: Date): number; + // (undocumented) + getMinutes(date: Date): number; + // (undocumented) getMonth(date: Date): number; // (undocumented) getMonthNames(style: 'long' | 'short' | 'narrow'): string[]; // (undocumented) getNumDaysInMonth(date: Date): number; // (undocumented) + getSeconds(date: Date): number; + // (undocumented) getYear(date: Date): number; // (undocumented) getYearName(date: Date): string; @@ -432,6 +451,10 @@ export class NativeDateAdapter extends DateAdapter { // (undocumented) parse(value: any, parseFormat?: any): Date | null; // (undocumented) + parseTime(userValue: any, parseFormat?: any): Date | null; + // (undocumented) + setTime(target: Date, hours: number, minutes: number, seconds: number): Date; + // (undocumented) today(): Date; // (undocumented) toIso8601(date: Date): string; From fc2bb764b671b07aadce148122dd5490be254992 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 19 Sep 2024 13:34:20 +0200 Subject: [PATCH 2/6] refactor(material-moment-adapter): implement new methods Implements the new methods in the Moment adapter. --- .../adapter/moment-date-adapter.spec.ts | 184 +++++++++++++++--- .../adapter/moment-date-adapter.ts | 38 ++++ .../adapter/moment-date-formats.ts | 3 + 3 files changed, 195 insertions(+), 30 deletions(-) diff --git a/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts b/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts index 4da5de992b7d..2238723d8efc 100644 --- a/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts +++ b/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts @@ -7,7 +7,7 @@ */ import {LOCALE_ID} from '@angular/core'; -import {waitForAsync, inject, TestBed} from '@angular/core/testing'; +import {TestBed} from '@angular/core/testing'; import {DateAdapter, MAT_DATE_LOCALE} from '@angular/material/core'; import {DEC, FEB, JAN, MAR} from '../../material/testing'; import {MomentDateModule} from './index'; @@ -22,15 +22,10 @@ describe('MomentDateAdapter', () => { let adapter: MomentDateAdapter; let assertValidDate: (d: moment.Moment | null, valid: boolean) => void; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [MomentDateModule], - }); - })); - - beforeEach(inject([DateAdapter], (dateAdapter: MomentDateAdapter) => { + beforeEach(() => { + TestBed.configureTestingModule({imports: [MomentDateModule]}); moment.locale('en'); - adapter = dateAdapter; + adapter = TestBed.inject(DateAdapter) as MomentDateAdapter; adapter.setLocale('en'); assertValidDate = (d: moment.Moment | null, valid: boolean) => { @@ -44,7 +39,7 @@ describe('MomentDateAdapter', () => { ) .toBe(valid); }; - })); + }); it('should get year', () => { expect(adapter.getYear(moment([2017, JAN, 1]))).toBe(2017); @@ -534,21 +529,156 @@ describe('MomentDateAdapter', () => { it('should create invalid date', () => { assertValidDate(adapter.invalid(), false); }); + + it('should get hours', () => { + expect(adapter.getHours(moment([2024, JAN, 1, 14]))).toBe(14); + }); + + it('should get minutes', () => { + expect(adapter.getMinutes(moment([2024, JAN, 1, 14, 53]))).toBe(53); + }); + + it('should get seconds', () => { + expect(adapter.getSeconds(moment([2024, JAN, 1, 14, 53, 42]))).toBe(42); + }); + + it('should set the time of a date', () => { + const target = moment([2024, JAN, 1, 0, 0, 0]); + const result = adapter.setTime(target, 14, 53, 42); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(53); + expect(adapter.getSeconds(result)).toBe(42); + }); + + it('should throw when passing in invalid hours to setTime', () => { + expect(() => adapter.setTime(adapter.today(), -1, 0, 0)).toThrowError( + 'Invalid hours "-1". Hours value must be between 0 and 23.', + ); + expect(() => adapter.setTime(adapter.today(), 51, 0, 0)).toThrowError( + 'Invalid hours "51". Hours value must be between 0 and 23.', + ); + }); + + it('should throw when passing in invalid minutes to setTime', () => { + expect(() => adapter.setTime(adapter.today(), 0, -1, 0)).toThrowError( + 'Invalid minutes "-1". Minutes value must be between 0 and 59.', + ); + expect(() => adapter.setTime(adapter.today(), 0, 65, 0)).toThrowError( + 'Invalid minutes "65". Minutes value must be between 0 and 59.', + ); + }); + + it('should throw when passing in invalid seconds to setTime', () => { + expect(() => adapter.setTime(adapter.today(), 0, 0, -1)).toThrowError( + 'Invalid seconds "-1". Seconds value must be between 0 and 59.', + ); + expect(() => adapter.setTime(adapter.today(), 0, 0, 65)).toThrowError( + 'Invalid seconds "65". Seconds value must be between 0 and 59.', + ); + }); + + it('should parse a 24-hour time string', () => { + const result = adapter.parseTime('14:52', 'LT')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a 12-hour time string', () => { + const result = adapter.parseTime('2:52 PM', 'LT')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a padded time string', () => { + const result = adapter.parseTime('03:04:05', 'LTS')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(3); + expect(adapter.getMinutes(result)).toBe(4); + expect(adapter.getSeconds(result)).toBe(5); + }); + + it('should parse a time string that uses dot as a separator', () => { + adapter.setLocale('fi-FI'); + const result = adapter.parseTime('14.52', 'LT')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a time string with characters around the time', () => { + adapter.setLocale('bg-BG'); + const result = adapter.parseTime('14:52 ч.', 'LT')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should return an invalid date when parsing invalid time string', () => { + expect(adapter.isValid(adapter.parseTime('abc', 'LT')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime(' ', 'LT')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime(true, 'LT')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime('24:05', 'LT')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime('00:61:05', 'LTS')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime('14:52:78', 'LTS')!)).toBeFalse(); + }); + + it('should return null when parsing unsupported time values', () => { + expect(adapter.parseTime(undefined, 'LT')).toBeNull(); + expect(adapter.parseTime('', 'LT')).toBeNull(); + }); + + it('should compare times', () => { + const base = [2024, JAN, 1] as const; + + expect( + adapter.compareTime(moment([...base, 12, 0, 0]), moment([...base, 13, 0, 0])), + ).toBeLessThan(0); + expect( + adapter.compareTime(moment([...base, 12, 50, 0]), moment([...base, 12, 51, 0])), + ).toBeLessThan(0); + expect(adapter.compareTime(moment([...base, 1, 2, 3]), moment([...base, 1, 2, 3]))).toBe(0); + expect( + adapter.compareTime(moment([...base, 13, 0, 0]), moment([...base, 12, 0, 0])), + ).toBeGreaterThan(0); + expect( + adapter.compareTime(moment([...base, 12, 50, 11]), moment([...base, 12, 50, 10])), + ).toBeGreaterThan(0); + expect( + adapter.compareTime(moment([...base, 13, 0, 0]), moment([...base, 10, 59, 59])), + ).toBeGreaterThan(0); + }); + + it('should add milliseconds to a date', () => { + const amount = 1234567; + const initial = moment([2024, JAN, 1, 12, 34, 56]); + const result = adapter.addMilliseconds(initial, amount); + expect(result).not.toBe(initial); + expect(result.valueOf() - initial.valueOf()).toBe(amount); + }); }); describe('MomentDateAdapter with MAT_DATE_LOCALE override', () => { let adapter: MomentDateAdapter; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [MomentDateModule], providers: [{provide: MAT_DATE_LOCALE, useValue: 'ja-JP'}], }); - })); - beforeEach(inject([DateAdapter], (d: MomentDateAdapter) => { - adapter = d; - })); + adapter = TestBed.inject(DateAdapter) as MomentDateAdapter; + }); it('should take the default locale id from the MAT_DATE_LOCALE injection token', () => { expect(adapter.format(moment([2017, JAN, 2]), 'll')).toEqual('2017年1月2日'); @@ -558,16 +688,14 @@ describe('MomentDateAdapter with MAT_DATE_LOCALE override', () => { describe('MomentDateAdapter with LOCALE_ID override', () => { let adapter: MomentDateAdapter; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [MomentDateModule], providers: [{provide: LOCALE_ID, useValue: 'fr'}], }); - })); - beforeEach(inject([DateAdapter], (d: MomentDateAdapter) => { - adapter = d; - })); + adapter = TestBed.inject(DateAdapter) as MomentDateAdapter; + }); it('should take the default locale id from the LOCALE_ID injection token', () => { expect(adapter.format(moment([2017, JAN, 2]), 'll')).toEqual('2 janv. 2017'); @@ -577,7 +705,7 @@ describe('MomentDateAdapter with LOCALE_ID override', () => { describe('MomentDateAdapter with MAT_MOMENT_DATE_ADAPTER_OPTIONS override', () => { let adapter: MomentDateAdapter; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [MomentDateModule], providers: [ @@ -587,11 +715,9 @@ describe('MomentDateAdapter with MAT_MOMENT_DATE_ADAPTER_OPTIONS override', () = }, ], }); - })); - beforeEach(inject([DateAdapter], (d: MomentDateAdapter) => { - adapter = d; - })); + adapter = TestBed.inject(DateAdapter) as MomentDateAdapter; + }); describe('use UTC', () => { it('should create Moment date in UTC', () => { @@ -612,7 +738,7 @@ describe('MomentDateAdapter with MAT_MOMENT_DATE_ADAPTER_OPTIONS override', () = }); describe('strict mode', () => { - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [MomentDateModule], @@ -625,11 +751,9 @@ describe('MomentDateAdapter with MAT_MOMENT_DATE_ADAPTER_OPTIONS override', () = }, ], }); - })); - beforeEach(inject([DateAdapter], (d: MomentDateAdapter) => { - adapter = d; - })); + adapter = TestBed.inject(DateAdapter) as MomentDateAdapter; + }); it('should detect valid strings according to given format', () => { expect(adapter.parse('1/2/2017', 'D/M/YYYY')!.format('l')).toEqual( diff --git a/src/material-moment-adapter/adapter/moment-date-adapter.ts b/src/material-moment-adapter/adapter/moment-date-adapter.ts index 1a0fce907a46..c4fe1e6d72ae 100644 --- a/src/material-moment-adapter/adapter/moment-date-adapter.ts +++ b/src/material-moment-adapter/adapter/moment-date-adapter.ts @@ -251,6 +251,44 @@ export class MomentDateAdapter extends DateAdapter { return moment.invalid(); } + override setTime(target: Moment, hours: number, minutes: number, seconds: number): Moment { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (hours < 0 || hours > 23) { + throw Error(`Invalid hours "${hours}". Hours value must be between 0 and 23.`); + } + + if (minutes < 0 || minutes > 59) { + throw Error(`Invalid minutes "${minutes}". Minutes value must be between 0 and 59.`); + } + + if (seconds < 0 || seconds > 59) { + throw Error(`Invalid seconds "${seconds}". Seconds value must be between 0 and 59.`); + } + } + + return this.clone(target).set({hours, minutes, seconds}); + } + + override getHours(date: Moment): number { + return date.hours(); + } + + override getMinutes(date: Moment): number { + return date.minutes(); + } + + override getSeconds(date: Moment): number { + return date.seconds(); + } + + override parseTime(value: any, parseFormat: string | string[]): Moment | null { + return this.parse(value, parseFormat); + } + + override addMilliseconds(date: Moment, amount: number): Moment { + return this.clone(date).add({milliseconds: amount}); + } + /** Creates a Moment instance while respecting the current UTC settings. */ private _createMoment( date?: MomentInput, diff --git a/src/material-moment-adapter/adapter/moment-date-formats.ts b/src/material-moment-adapter/adapter/moment-date-formats.ts index 806f2c76cfe2..c81ae1b64875 100644 --- a/src/material-moment-adapter/adapter/moment-date-formats.ts +++ b/src/material-moment-adapter/adapter/moment-date-formats.ts @@ -11,11 +11,14 @@ import {MatDateFormats} from '@angular/material/core'; export const MAT_MOMENT_DATE_FORMATS: MatDateFormats = { parse: { dateInput: 'l', + timeInput: 'LT', }, display: { dateInput: 'l', + timeInput: 'LT', monthYearLabel: 'MMM YYYY', dateA11yLabel: 'LL', monthYearA11yLabel: 'MMMM YYYY', + timeOptionLabel: 'LT', }, }; From 0b8a1606eff31a46888ad6dbf24073d171a88f4a Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 19 Sep 2024 14:28:31 +0200 Subject: [PATCH 3/6] refactor(material-luxon-adapter): implement new methods Implements the new methods in the Luxon adapter. --- .../adapter/luxon-date-adapter.spec.ts | 166 ++++++++++++++++-- .../adapter/luxon-date-adapter.ts | 55 ++++++ .../adapter/luxon-date-formats.ts | 3 + 3 files changed, 209 insertions(+), 15 deletions(-) diff --git a/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts b/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts index 34ad4ec797e0..1b6f90e1c5ac 100644 --- a/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts +++ b/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts @@ -7,7 +7,7 @@ */ import {LOCALE_ID} from '@angular/core'; -import {TestBed, waitForAsync} from '@angular/core/testing'; +import {TestBed} from '@angular/core/testing'; import {DateAdapter, MAT_DATE_LOCALE} from '@angular/material/core'; import {CalendarSystem, DateTime, FixedOffsetZone, Settings} from 'luxon'; import {LuxonDateModule} from './index'; @@ -21,14 +21,11 @@ const JAN = 1, describe('LuxonDateAdapter', () => { let adapter: DateAdapter; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [LuxonDateModule], - }); - + beforeEach(() => { + TestBed.configureTestingModule({imports: [LuxonDateModule]}); adapter = TestBed.inject(DateAdapter); adapter.setLocale('en-US'); - })); + }); it('should get year', () => { expect(adapter.getYear(DateTime.local(2017, JAN, 1))).toBe(2017); @@ -550,19 +547,158 @@ describe('LuxonDateAdapter', () => { it('should create invalid date', () => { assertValidDate(adapter, adapter.invalid(), false); }); + + it('should get hours', () => { + expect(adapter.getHours(DateTime.local(2024, JAN, 1, 14))).toBe(14); + }); + + it('should get minutes', () => { + expect(adapter.getMinutes(DateTime.local(2024, JAN, 1, 14, 53))).toBe(53); + }); + + it('should get seconds', () => { + expect(adapter.getSeconds(DateTime.local(2024, JAN, 1, 14, 53, 42))).toBe(42); + }); + + it('should set the time of a date', () => { + const target = DateTime.local(2024, JAN, 1, 0, 0, 0); + const result = adapter.setTime(target, 14, 53, 42); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(53); + expect(adapter.getSeconds(result)).toBe(42); + }); + + it('should throw when passing in invalid hours to setTime', () => { + expect(() => adapter.setTime(adapter.today(), -1, 0, 0)).toThrowError( + 'Invalid hours "-1". Hours value must be between 0 and 23.', + ); + expect(() => adapter.setTime(adapter.today(), 51, 0, 0)).toThrowError( + 'Invalid hours "51". Hours value must be between 0 and 23.', + ); + }); + + it('should throw when passing in invalid minutes to setTime', () => { + expect(() => adapter.setTime(adapter.today(), 0, -1, 0)).toThrowError( + 'Invalid minutes "-1". Minutes value must be between 0 and 59.', + ); + expect(() => adapter.setTime(adapter.today(), 0, 65, 0)).toThrowError( + 'Invalid minutes "65". Minutes value must be between 0 and 59.', + ); + }); + + it('should throw when passing in invalid seconds to setTime', () => { + expect(() => adapter.setTime(adapter.today(), 0, 0, -1)).toThrowError( + 'Invalid seconds "-1". Seconds value must be between 0 and 59.', + ); + expect(() => adapter.setTime(adapter.today(), 0, 0, 65)).toThrowError( + 'Invalid seconds "65". Seconds value must be between 0 and 59.', + ); + }); + + it('should parse a 24-hour time string', () => { + const result = adapter.parseTime('14:52', 't')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a 12-hour time string', () => { + const result = adapter.parseTime('2:52 PM', 't')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a padded time string', () => { + const result = adapter.parseTime('03:04:05', 'tt')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(3); + expect(adapter.getMinutes(result)).toBe(4); + expect(adapter.getSeconds(result)).toBe(5); + }); + + it('should parse a time string that uses dot as a separator', () => { + adapter.setLocale('fi-FI'); + const result = adapter.parseTime('14.52', 't')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a time string with characters around the time', () => { + adapter.setLocale('bg-BG'); + const result = adapter.parseTime('14:52 ч.', 't')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should return an invalid date when parsing invalid time string', () => { + expect(adapter.isValid(adapter.parseTime('abc', 't')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime(' ', 't')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime('24:05', 't')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime('00:61:05', 'tt')!)).toBeFalse(); + expect(adapter.isValid(adapter.parseTime('14:52:78', 'tt')!)).toBeFalse(); + }); + + it('should return null when parsing unsupported time values', () => { + expect(adapter.parseTime(true, 't')).toBeNull(); + expect(adapter.parseTime(undefined, 't')).toBeNull(); + expect(adapter.parseTime('', 't')).toBeNull(); + }); + + it('should compare times', () => { + const base = [2024, JAN, 1] as const; + + expect( + adapter.compareTime(DateTime.local(...base, 12, 0, 0), DateTime.local(...base, 13, 0, 0)), + ).toBeLessThan(0); + expect( + adapter.compareTime(DateTime.local(...base, 12, 50, 0), DateTime.local(...base, 12, 51, 0)), + ).toBeLessThan(0); + expect( + adapter.compareTime(DateTime.local(...base, 1, 2, 3), DateTime.local(...base, 1, 2, 3)), + ).toBe(0); + expect( + adapter.compareTime(DateTime.local(...base, 13, 0, 0), DateTime.local(...base, 12, 0, 0)), + ).toBeGreaterThan(0); + expect( + adapter.compareTime(DateTime.local(...base, 12, 50, 11), DateTime.local(...base, 12, 50, 10)), + ).toBeGreaterThan(0); + expect( + adapter.compareTime(DateTime.local(...base, 13, 0, 0), DateTime.local(...base, 10, 59, 59)), + ).toBeGreaterThan(0); + }); + + it('should add milliseconds to a date', () => { + const amount = 1234567; + const initial = DateTime.local(2024, JAN, 1, 12, 34, 56); + const result = adapter.addMilliseconds(initial, amount); + expect(result).not.toBe(initial); + expect(result.toMillis() - initial.toMillis()).toBe(amount); + }); }); describe('LuxonDateAdapter with MAT_DATE_LOCALE override', () => { let adapter: DateAdapter; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [LuxonDateModule], providers: [{provide: MAT_DATE_LOCALE, useValue: 'da-DK'}], }); adapter = TestBed.inject(DateAdapter); - })); + }); it('should take the default locale id from the MAT_DATE_LOCALE injection token', () => { const date = adapter.format(DateTime.local(2017, JAN, 2), 'DD'); @@ -573,14 +709,14 @@ describe('LuxonDateAdapter with MAT_DATE_LOCALE override', () => { describe('LuxonDateAdapter with LOCALE_ID override', () => { let adapter: DateAdapter; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [LuxonDateModule], providers: [{provide: LOCALE_ID, useValue: 'fr-FR'}], }); adapter = TestBed.inject(DateAdapter); - })); + }); it('should take the default locale id from the LOCALE_ID injection token', () => { const date = adapter.format(DateTime.local(2017, JAN, 2), 'DD'); @@ -591,7 +727,7 @@ describe('LuxonDateAdapter with LOCALE_ID override', () => { describe('LuxonDateAdapter with MAT_LUXON_DATE_ADAPTER_OPTIONS override', () => { let adapter: DateAdapter; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [LuxonDateModule], providers: [ @@ -603,7 +739,7 @@ describe('LuxonDateAdapter with MAT_LUXON_DATE_ADAPTER_OPTIONS override', () => }); adapter = TestBed.inject(DateAdapter); - })); + }); describe('use UTC', () => { it('should create Luxon date in UTC', () => { @@ -637,7 +773,7 @@ describe('LuxonDateAdapter with MAT_LUXON_DATE_ADAPTER_OPTIONS override for defa const calendarExample: CalendarSystem = 'islamic'; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [LuxonDateModule], providers: [ @@ -649,7 +785,7 @@ describe('LuxonDateAdapter with MAT_LUXON_DATE_ADAPTER_OPTIONS override for defa }); adapter = TestBed.inject(DateAdapter); - })); + }); describe(`use ${calendarExample} calendar`, () => { it(`should create Luxon date in ${calendarExample} calendar`, () => { diff --git a/src/material-luxon-adapter/adapter/luxon-date-adapter.ts b/src/material-luxon-adapter/adapter/luxon-date-adapter.ts index e089d14c75cd..c647a825d92b 100644 --- a/src/material-luxon-adapter/adapter/luxon-date-adapter.ts +++ b/src/material-luxon-adapter/adapter/luxon-date-adapter.ts @@ -272,6 +272,61 @@ export class LuxonDateAdapter extends DateAdapter { return LuxonDateTime.invalid('Invalid Luxon DateTime object.'); } + override setTime( + target: LuxonDateTime, + hours: number, + minutes: number, + seconds: number, + ): LuxonDateTime { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (hours < 0 || hours > 23) { + throw Error(`Invalid hours "${hours}". Hours value must be between 0 and 23.`); + } + + if (minutes < 0 || minutes > 59) { + throw Error(`Invalid minutes "${minutes}". Minutes value must be between 0 and 59.`); + } + + if (seconds < 0 || seconds > 59) { + throw Error(`Invalid seconds "${seconds}". Seconds value must be between 0 and 59.`); + } + } + + return this.clone(target).set({ + hour: hours, + minute: minutes, + second: seconds, + }); + } + + override getHours(date: LuxonDateTime): number { + return date.hour; + } + + override getMinutes(date: LuxonDateTime): number { + return date.minute; + } + + override getSeconds(date: LuxonDateTime): number { + return date.second; + } + + override parseTime(value: any, parseFormat: string | string[]): LuxonDateTime | null { + const result = this.parse(value, parseFormat); + + if ((!result || !this.isValid(result)) && typeof value === 'string') { + // It seems like Luxon doesn't work well cross-browser for strings that have + // additional characters around the time. Try parsing without those characters. + return this.parse(value.replace(/[^0-9:(AM|PM)]/gi, ''), parseFormat) || result; + } + + return result; + } + + override addMilliseconds(date: LuxonDateTime, amount: number): LuxonDateTime { + return date.reconfigure(this._getOptions()).plus({milliseconds: amount}); + } + /** Gets the options that should be used when constructing a new `DateTime` object. */ private _getOptions(): LuxonDateTimeOptions { return { diff --git a/src/material-luxon-adapter/adapter/luxon-date-formats.ts b/src/material-luxon-adapter/adapter/luxon-date-formats.ts index 5e4489756a00..5b6b88ffec90 100644 --- a/src/material-luxon-adapter/adapter/luxon-date-formats.ts +++ b/src/material-luxon-adapter/adapter/luxon-date-formats.ts @@ -11,11 +11,14 @@ import {MatDateFormats} from '@angular/material/core'; export const MAT_LUXON_DATE_FORMATS: MatDateFormats = { parse: { dateInput: 'D', + timeInput: 't', }, display: { dateInput: 'D', + timeInput: 't', monthYearLabel: 'LLL yyyy', dateA11yLabel: 'DD', monthYearA11yLabel: 'LLLL yyyy', + timeOptionLabel: 't', }, }; From 446cc6df9f55cef014aa519c99a37de5f42bbd1d Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 19 Sep 2024 14:28:44 +0200 Subject: [PATCH 4/6] refactor(material-date-fns-adapter): implement new methods Implements the new methods in the `date-fns` adapter. --- .../adapter/date-fns-adapter.spec.ts | 144 ++++++++++++++++-- .../adapter/date-fns-adapter.ts | 43 ++++++ .../adapter/date-fns-formats.ts | 3 + 3 files changed, 180 insertions(+), 10 deletions(-) diff --git a/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts b/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts index f3fa6fc48b83..dcdc466b2bf2 100644 --- a/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts +++ b/src/material-date-fns-adapter/adapter/date-fns-adapter.spec.ts @@ -6,10 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import {TestBed, waitForAsync} from '@angular/core/testing'; +import {TestBed} from '@angular/core/testing'; import {DateAdapter, MAT_DATE_LOCALE} from '@angular/material/core'; import {Locale} from 'date-fns'; -import {ja, enUS, da, de} from 'date-fns/locale'; +import {ja, enUS, da, de, fi} from 'date-fns/locale'; import {DateFnsModule} from './index'; const JAN = 0, @@ -20,14 +20,11 @@ const JAN = 0, describe('DateFnsAdapter', () => { let adapter: DateAdapter; - beforeEach(waitForAsync(() => { - TestBed.configureTestingModule({ - imports: [DateFnsModule], - }); - + beforeEach(() => { + TestBed.configureTestingModule({imports: [DateFnsModule]}); adapter = TestBed.inject(DateAdapter); adapter.setLocale(enUS); - })); + }); it('should get year', () => { expect(adapter.getYear(new Date(2017, JAN, 1))).toBe(2017); @@ -452,19 +449,146 @@ describe('DateFnsAdapter', () => { it('should create invalid date', () => { assertValidDate(adapter, adapter.invalid(), false); }); + + it('should get hours', () => { + expect(adapter.getHours(new Date(2024, JAN, 1, 14))).toBe(14); + }); + + it('should get minutes', () => { + expect(adapter.getMinutes(new Date(2024, JAN, 1, 14, 53))).toBe(53); + }); + + it('should get seconds', () => { + expect(adapter.getSeconds(new Date(2024, JAN, 1, 14, 53, 42))).toBe(42); + }); + + it('should set the time of a date', () => { + const target = new Date(2024, JAN, 1, 0, 0, 0); + const result = adapter.setTime(target, 14, 53, 42); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(53); + expect(adapter.getSeconds(result)).toBe(42); + }); + + it('should throw when passing in invalid hours to setTime', () => { + expect(() => adapter.setTime(adapter.today(), -1, 0, 0)).toThrowError( + 'Invalid hours "-1". Hours value must be between 0 and 23.', + ); + expect(() => adapter.setTime(adapter.today(), 51, 0, 0)).toThrowError( + 'Invalid hours "51". Hours value must be between 0 and 23.', + ); + }); + + it('should throw when passing in invalid minutes to setTime', () => { + expect(() => adapter.setTime(adapter.today(), 0, -1, 0)).toThrowError( + 'Invalid minutes "-1". Minutes value must be between 0 and 59.', + ); + expect(() => adapter.setTime(adapter.today(), 0, 65, 0)).toThrowError( + 'Invalid minutes "65". Minutes value must be between 0 and 59.', + ); + }); + + it('should throw when passing in invalid seconds to setTime', () => { + expect(() => adapter.setTime(adapter.today(), 0, 0, -1)).toThrowError( + 'Invalid seconds "-1". Seconds value must be between 0 and 59.', + ); + expect(() => adapter.setTime(adapter.today(), 0, 0, 65)).toThrowError( + 'Invalid seconds "65". Seconds value must be between 0 and 59.', + ); + }); + + it('should parse a 24-hour time string', () => { + adapter.setLocale(da); + const result = adapter.parseTime('14:52', 'p')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a 12-hour time string', () => { + const result = adapter.parseTime('2:52 PM', 'p')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should parse a padded time string', () => { + const result = adapter.parseTime('03:04:05 AM', 'pp')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(3); + expect(adapter.getMinutes(result)).toBe(4); + expect(adapter.getSeconds(result)).toBe(5); + }); + + it('should parse a time string that uses dot as a separator', () => { + adapter.setLocale(fi); + const result = adapter.parseTime('14.52', 'p')!; + expect(result).toBeTruthy(); + expect(adapter.isValid(result)).toBe(true); + expect(adapter.getHours(result)).toBe(14); + expect(adapter.getMinutes(result)).toBe(52); + expect(adapter.getSeconds(result)).toBe(0); + }); + + it('should return an invalid date when parsing invalid time string', () => { + expect(adapter.isValid(adapter.parseTime('abc', 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('123', 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('', 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime(' ', 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime(true, 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime(undefined, 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('14:52 PM', 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('24:05', 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('00:61:05', 'p')!)).toBe(false); + expect(adapter.isValid(adapter.parseTime('14:52:78', 'p')!)).toBe(false); + }); + + it('should compare times', () => { + const base = [2024, JAN, 1] as const; + + expect( + adapter.compareTime(new Date(...base, 12, 0, 0), new Date(...base, 13, 0, 0)), + ).toBeLessThan(0); + expect( + adapter.compareTime(new Date(...base, 12, 50, 0), new Date(...base, 12, 51, 0)), + ).toBeLessThan(0); + expect(adapter.compareTime(new Date(...base, 1, 2, 3), new Date(...base, 1, 2, 3))).toBe(0); + expect( + adapter.compareTime(new Date(...base, 13, 0, 0), new Date(...base, 12, 0, 0)), + ).toBeGreaterThan(0); + expect( + adapter.compareTime(new Date(...base, 12, 50, 11), new Date(...base, 12, 50, 10)), + ).toBeGreaterThan(0); + expect( + adapter.compareTime(new Date(...base, 13, 0, 0), new Date(...base, 10, 59, 59)), + ).toBeGreaterThan(0); + }); + + it('should add milliseconds to a date', () => { + const amount = 1234567; + const initial = new Date(2024, JAN, 1, 12, 34, 56); + const result = adapter.addMilliseconds(initial, amount); + expect(result).not.toBe(initial); + expect(result.getTime() - initial.getTime()).toBe(amount); + }); }); describe('DateFnsAdapter with MAT_DATE_LOCALE override', () => { let adapter: DateAdapter; - beforeEach(waitForAsync(() => { + beforeEach(() => { TestBed.configureTestingModule({ imports: [DateFnsModule], providers: [{provide: MAT_DATE_LOCALE, useValue: da}], }); adapter = TestBed.inject(DateAdapter); - })); + }); it('should take the default locale id from the MAT_DATE_LOCALE injection token', () => { const date = adapter.format(new Date(2017, JAN, 2), 'PP'); diff --git a/src/material-date-fns-adapter/adapter/date-fns-adapter.ts b/src/material-date-fns-adapter/adapter/date-fns-adapter.ts index d9306454aa2d..0cef3728d8fc 100644 --- a/src/material-date-fns-adapter/adapter/date-fns-adapter.ts +++ b/src/material-date-fns-adapter/adapter/date-fns-adapter.ts @@ -14,11 +14,16 @@ import { getYear, getDate, getDay, + getHours, + getMinutes, + getSeconds, + set, getDaysInMonth, formatISO, addYears, addMonths, addDays, + addMilliseconds, isValid, isDate, format, @@ -241,4 +246,42 @@ export class DateFnsAdapter extends DateAdapter { invalid(): Date { return new Date(NaN); } + + override setTime(target: Date, hours: number, minutes: number, seconds: number): Date { + if (typeof ngDevMode === 'undefined' || ngDevMode) { + if (hours < 0 || hours > 23) { + throw Error(`Invalid hours "${hours}". Hours value must be between 0 and 23.`); + } + + if (minutes < 0 || minutes > 59) { + throw Error(`Invalid minutes "${minutes}". Minutes value must be between 0 and 59.`); + } + + if (seconds < 0 || seconds > 59) { + throw Error(`Invalid seconds "${seconds}". Seconds value must be between 0 and 59.`); + } + } + + return set(this.clone(target), {hours, minutes, seconds}); + } + + override getHours(date: Date): number { + return getHours(date); + } + + override getMinutes(date: Date): number { + return getMinutes(date); + } + + override getSeconds(date: Date): number { + return getSeconds(date); + } + + override parseTime(value: any, parseFormat: string | string[]): Date | null { + return this.parse(value, parseFormat); + } + + override addMilliseconds(date: Date, amount: number): Date { + return addMilliseconds(date, amount); + } } diff --git a/src/material-date-fns-adapter/adapter/date-fns-formats.ts b/src/material-date-fns-adapter/adapter/date-fns-formats.ts index b1ffeac2885c..fa99a6d4823d 100644 --- a/src/material-date-fns-adapter/adapter/date-fns-formats.ts +++ b/src/material-date-fns-adapter/adapter/date-fns-formats.ts @@ -11,11 +11,14 @@ import {MatDateFormats} from '@angular/material/core'; export const MAT_DATE_FNS_FORMATS: MatDateFormats = { parse: { dateInput: 'P', + timeInput: 'p', }, display: { dateInput: 'P', + timeInput: 'p', monthYearLabel: 'LLL uuuu', dateA11yLabel: 'PP', monthYearA11yLabel: 'LLLL uuuu', + timeOptionLabel: 'p', }, }; From 167f5c4347c8aefe15e9413ec428fd6ca1ae4ab7 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 20 Sep 2024 10:32:38 +0200 Subject: [PATCH 5/6] refactor(material/input): support signal-based input accessor Expands the `MAT_INPUT_VALUE_ACCESSOR` to support a signal-based accessor. --- src/material/input/input-value-accessor.ts | 4 +-- src/material/input/input.ts | 39 ++++++++++++++++++---- tools/public_api_guard/material/input.md | 3 +- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/src/material/input/input-value-accessor.ts b/src/material/input/input-value-accessor.ts index e010053e51b5..4d00b96a48ec 100644 --- a/src/material/input/input-value-accessor.ts +++ b/src/material/input/input-value-accessor.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {InjectionToken} from '@angular/core'; +import {InjectionToken, WritableSignal} from '@angular/core'; /** * This token is used to inject the object whose value should be set into `MatInput`. If none is @@ -14,6 +14,6 @@ import {InjectionToken} from '@angular/core'; * themselves for this token, in order to make `MatInput` delegate the getting and setting of the * value to them. */ -export const MAT_INPUT_VALUE_ACCESSOR = new InjectionToken<{value: any}>( +export const MAT_INPUT_VALUE_ACCESSOR = new InjectionToken<{value: any | WritableSignal}>( 'MAT_INPUT_VALUE_ACCESSOR', ); diff --git a/src/material/input/input.ts b/src/material/input/input.ts index c12eac1b537f..9b334064e90a 100644 --- a/src/material/input/input.ts +++ b/src/material/input/input.ts @@ -14,13 +14,16 @@ import { booleanAttribute, Directive, DoCheck, + effect, ElementRef, inject, InjectionToken, Input, + isSignal, NgZone, OnChanges, OnDestroy, + WritableSignal, } from '@angular/core'; import {FormGroupDirective, NgControl, NgForm, Validators} from '@angular/forms'; import {ErrorStateMatcher, _ErrorStateTracker} from '@angular/material/core'; @@ -104,6 +107,7 @@ export class MatInput protected _uid = `mat-input-${nextUniqueId++}`; protected _previousNativeValue: any; private _inputValueAccessor: {value: any}; + private _signalBasedValueAccessor?: {value: WritableSignal}; private _previousPlaceholder: string | null; private _errorStateTracker: _ErrorStateTracker; private _webkitBlinkWheelListenerAttached = false; @@ -244,11 +248,18 @@ export class MatInput */ @Input() get value(): string { - return this._inputValueAccessor.value; + return this._signalBasedValueAccessor + ? this._signalBasedValueAccessor.value() + : this._inputValueAccessor.value; } set value(value: any) { if (value !== this.value) { - this._inputValueAccessor.value = value; + if (this._signalBasedValueAccessor) { + this._signalBasedValueAccessor.value.set(value); + } else { + this._inputValueAccessor.value = value; + } + this.stateChanges.next(); } } @@ -290,14 +301,22 @@ export class MatInput const parentForm = inject(NgForm, {optional: true}); const parentFormGroup = inject(FormGroupDirective, {optional: true}); const defaultErrorStateMatcher = inject(ErrorStateMatcher); - const inputValueAccessor = inject(MAT_INPUT_VALUE_ACCESSOR, {optional: true, self: true}); + const accessor = inject(MAT_INPUT_VALUE_ACCESSOR, {optional: true, self: true}); const element = this._elementRef.nativeElement; const nodeName = element.nodeName.toLowerCase(); - // If no input value accessor was explicitly specified, use the element as the input value - // accessor. - this._inputValueAccessor = inputValueAccessor || element; + if (accessor) { + if (isSignal(accessor.value)) { + this._signalBasedValueAccessor = accessor; + } else { + this._inputValueAccessor = accessor; + } + } else { + // If no input value accessor was explicitly specified, use the element as the input value + // accessor. + this._inputValueAccessor = element; + } this._previousNativeValue = this.value; @@ -331,6 +350,14 @@ export class MatInput ? 'mat-native-select-multiple' : 'mat-native-select'; } + + if (this._signalBasedValueAccessor) { + effect(() => { + // Read the value so the effect can register the dependency. + this._signalBasedValueAccessor!.value(); + this.stateChanges.next(); + }); + } } ngAfterViewInit() { diff --git a/tools/public_api_guard/material/input.md b/tools/public_api_guard/material/input.md index 7ca0e414552a..98fc6612dd51 100644 --- a/tools/public_api_guard/material/input.md +++ b/tools/public_api_guard/material/input.md @@ -26,6 +26,7 @@ import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { Platform } from '@angular/cdk/platform'; import { Subject } from 'rxjs'; +import { WritableSignal } from '@angular/core'; // @public export function getMatInputUnsupportedTypeError(type: string): Error; @@ -35,7 +36,7 @@ export const MAT_INPUT_CONFIG: InjectionToken; // @public export const MAT_INPUT_VALUE_ACCESSOR: InjectionToken<{ - value: any; + value: any | WritableSignal; }>; export { MatError } From db0f61faff71ab5765b2dc998a63355e26d22dde Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Sat, 28 Sep 2024 09:08:00 +0200 Subject: [PATCH 6/6] refactor(material/core): allow option parent's disableRipple to be a signal Expands the `MatOptionParentComponent` to allow the `disableRipple` to be a signal. This will be relevant in the time picker. --- src/material/core/option/option-parent.ts | 4 ++-- src/material/core/option/option.ts | 11 +++++++++-- tools/public_api_guard/material/core.md | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/material/core/option/option-parent.ts b/src/material/core/option/option-parent.ts index decd8e50e268..8df8908f35a5 100644 --- a/src/material/core/option/option-parent.ts +++ b/src/material/core/option/option-parent.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {InjectionToken} from '@angular/core'; +import {InjectionToken, Signal} from '@angular/core'; /** * Describes a parent component that manages a list of options. @@ -14,7 +14,7 @@ import {InjectionToken} from '@angular/core'; * @docs-private */ export interface MatOptionParentComponent { - disableRipple?: boolean; + disableRipple?: boolean | Signal; multiple?: boolean; inertGroups?: boolean; hideSingleSelectionIndicator?: boolean; diff --git a/src/material/core/option/option.ts b/src/material/core/option/option.ts index f086d36bd965..77ee6fbdb061 100644 --- a/src/material/core/option/option.ts +++ b/src/material/core/option/option.ts @@ -23,6 +23,8 @@ import { ViewChild, booleanAttribute, inject, + isSignal, + Signal, } from '@angular/core'; import {Subject} from 'rxjs'; import {MAT_OPTGROUP, MatOptgroup} from './optgroup'; @@ -87,6 +89,7 @@ export class MatOption implements FocusableOption, AfterViewChecked, On private _parent = inject(MAT_OPTION_PARENT_COMPONENT, {optional: true}); group = inject(MAT_OPTGROUP, {optional: true}); + private _signalDisableRipple = false; private _selected = false; private _active = false; private _disabled = false; @@ -119,7 +122,9 @@ export class MatOption implements FocusableOption, AfterViewChecked, On /** Whether ripples for the option are disabled. */ get disableRipple(): boolean { - return !!(this._parent && this._parent.disableRipple); + return this._signalDisableRipple + ? (this._parent!.disableRipple as Signal)() + : !!this._parent?.disableRipple; } /** Whether to display checkmark for single-selection. */ @@ -138,7 +143,9 @@ export class MatOption implements FocusableOption, AfterViewChecked, On readonly _stateChanges = new Subject(); constructor(...args: unknown[]); - constructor() {} + constructor() { + this._signalDisableRipple = !!this._parent && isSignal(this._parent.disableRipple); + } /** * Whether or not the option is currently active and ready to be selected. diff --git a/tools/public_api_guard/material/core.md b/tools/public_api_guard/material/core.md index 0246d052438b..abdc0028e14e 100644 --- a/tools/public_api_guard/material/core.md +++ b/tools/public_api_guard/material/core.md @@ -25,6 +25,7 @@ import { OnInit } from '@angular/core'; import { Platform } from '@angular/cdk/platform'; import { Provider } from '@angular/core'; import { QueryList } from '@angular/core'; +import { Signal } from '@angular/core'; import { Subject } from 'rxjs'; import { Version } from '@angular/core'; @@ -295,7 +296,7 @@ export class MatOptionModule { // @public export interface MatOptionParentComponent { // (undocumented) - disableRipple?: boolean; + disableRipple?: boolean | Signal; // (undocumented) hideSingleSelectionIndicator?: boolean; // (undocumented)