diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5a020d0fa82f..d0a7b72cc15f 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -89,6 +89,9 @@ # Moment adapter package /src/material-moment-adapter/** @mmalerba +# Luxon adapter package +/src/material-luxon-adapter/** @mmalerba @crisbeto + # Material experimental package /src/material-experimental/* @jelbourn /src/material-experimental/column-resize/** @kseamon @andrewseguin diff --git a/package.json b/package.json index dd853752287f..e6f828b79e8c 100644 --- a/package.json +++ b/package.json @@ -91,6 +91,7 @@ "@types/gulp": "3.8.32", "@types/inquirer": "^0.0.43", "@types/jasmine": "^3.5.4", + "@types/luxon": "^1.4.1", "@types/marked": "^0.4.2", "@types/merge2": "^0.3.30", "@types/minimist": "^1.2.0", @@ -131,6 +132,7 @@ "karma-requirejs": "^1.1.0", "karma-sauce-launcher": "^2.0.2", "karma-sourcemap-loader": "^0.3.7", + "luxon": "^1.8.3", "madge": "^3.4.4", "marked": "^0.6.2", "merge2": "^1.2.3", diff --git a/scripts/deploy/publish-build-artifacts.sh b/scripts/deploy/publish-build-artifacts.sh index 9f101f6ddee0..10657d14ee8e 100755 --- a/scripts/deploy/publish-build-artifacts.sh +++ b/scripts/deploy/publish-build-artifacts.sh @@ -22,6 +22,7 @@ PACKAGES=( material material-experimental material-moment-adapter + material-luxon-adapter google-maps youtube-player ) diff --git a/src/dev-app/tsconfig.json b/src/dev-app/tsconfig.json index 970d538c1003..37a5b959abbc 100644 --- a/src/dev-app/tsconfig.json +++ b/src/dev-app/tsconfig.json @@ -14,6 +14,7 @@ "@angular/cdk-experimental/*": ["../cdk-experimental/*"], "@angular/cdk-experimental": ["../cdk-experimental"], "@angular/material-moment-adapter": ["../material-moment-adapter/public-api.ts"], + "@angular/material-luxon-adapter": ["../material-luxon-adapter/public-api.ts"], "@angular/google-maps": ["../google-maps"], "@angular/components-examples": ["../components-examples"], "@angular/components-examples/*": ["../components-examples/*"], diff --git a/src/e2e-app/tsconfig.json b/src/e2e-app/tsconfig.json index 4cef3e72c68a..f8ab016c2312 100644 --- a/src/e2e-app/tsconfig.json +++ b/src/e2e-app/tsconfig.json @@ -12,6 +12,7 @@ "@angular/cdk-experimental/*": ["../cdk-experimental/*"], "@angular/cdk-experimental": ["../cdk-experimental/"], "@angular/material-moment-adapter": ["../material-moment-adapter/"], + "@angular/material-luxon-adapter": ["../material-luxon-adapter/"], "@angular/components-examples": ["../components-examples/"], "@angular/components-examples/*": ["../components-examples/*"] } diff --git a/src/material-luxon-adapter/BUILD.bazel b/src/material-luxon-adapter/BUILD.bazel new file mode 100644 index 000000000000..eb9c1df2b3ef --- /dev/null +++ b/src/material-luxon-adapter/BUILD.bazel @@ -0,0 +1,62 @@ +load("@npm_bazel_typescript//:index.bzl", "ts_config") +load("//tools:defaults.bzl", "ng_module", "ng_package", "ng_test_library", "ng_web_test_suite") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "material-luxon-adapter", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + module_name = "@angular/material-luxon-adapter", + # We need a custom tsconfig that allows synthetic default imports. This is + # because "luxon" does not have a typed default import. + tsconfig = ":tsconfig", + deps = [ + "//src/material/core", + "@npm//@angular/core", + "@npm//luxon", + ], +) + +ts_config( + name = "tsconfig", + src = "tsconfig-build.json", + deps = ["//src:bazel-tsconfig-build.json"], +) + +ng_test_library( + name = "unit_test_sources", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":material-luxon-adapter", + "//src/material/core", + "//src/material/testing", + "@npm//luxon", + ], +) + +ng_web_test_suite( + name = "unit_tests", + # We need to load luxon statically since it is not a named AMD module and needs to + # be manually configured through "require.js" which is used by "karma_web_test_suite". + static_files = [ + "@npm//luxon", + ], + deps = [ + ":require-config.js", + ":unit_test_sources", + ], +) + +ng_package( + name = "npm_package", + srcs = ["package.json"], + entry_point = ":public-api.ts", + tags = ["release-package"], + deps = [":material-luxon-adapter"], +) diff --git a/src/material-luxon-adapter/adapter/index.ts b/src/material-luxon-adapter/adapter/index.ts new file mode 100644 index 000000000000..42111d67850e --- /dev/null +++ b/src/material-luxon-adapter/adapter/index.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE} from '@angular/material/core'; +import {MAT_LUXON_DATE_ADAPTER_OPTIONS, LuxonDateAdapter} from './luxon-date-adapter'; +import {MAT_LUXON_DATE_FORMATS} from './luxon-date-formats'; + +export * from './luxon-date-adapter'; +export * from './luxon-date-formats'; + +@NgModule({ + providers: [ + { + provide: DateAdapter, + useClass: LuxonDateAdapter, + deps: [MAT_DATE_LOCALE, MAT_LUXON_DATE_ADAPTER_OPTIONS] + } + ], +}) +export class LuxonDateModule {} + + +@NgModule({ + imports: [LuxonDateModule], + providers: [{provide: MAT_DATE_FORMATS, useValue: MAT_LUXON_DATE_FORMATS}], +}) +export class MatLuxonDateModule {} diff --git a/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts b/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts new file mode 100644 index 000000000000..00a5fa39fc9e --- /dev/null +++ b/src/material-luxon-adapter/adapter/luxon-date-adapter.spec.ts @@ -0,0 +1,498 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {LOCALE_ID} from '@angular/core'; +import {async, inject, TestBed} from '@angular/core/testing'; +import {DateAdapter, MAT_DATE_LOCALE} from '@angular/material/core'; +import {Platform} from '@angular/cdk/platform'; +import {DateTime, Info, Features} from 'luxon'; +import {LuxonDateModule} from './index'; +import {MAT_LUXON_DATE_ADAPTER_OPTIONS, LuxonDateAdapter} from './luxon-date-adapter'; + +// Month constants used for more readable tests. We can't use the +// ones from `material/testing`, because Luxon's months start from 1. +const JAN = 1, FEB = 2, MAR = 3, DEC = 12; + +describe('LuxonDateAdapter', () => { + let adapter: LuxonDateAdapter; + let assertValidDate: (d: DateTime | null, valid: boolean) => void; + let platform: Platform; + let features: Features; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [LuxonDateModule] + }).compileComponents(); + })); + + beforeEach(inject([DateAdapter, Platform], (dateAdapter: LuxonDateAdapter, p: Platform) => { + adapter = dateAdapter; + platform = p; + features = Info.features(); + + adapter.setLocale('en-US'); + assertValidDate = (d: DateTime | null, valid: boolean) => { + expect(adapter.isDateInstance(d)).not.toBeNull(`Expected ${d} to be a date instance`); + expect(adapter.isValid(d!)).toBe(valid, + `Expected ${d} to be ${valid ? 'valid' : 'invalid'},` + + ` but was ${valid ? 'invalid' : 'valid'}`); + }; + })); + + it('should get year', () => { + expect(adapter.getYear(DateTime.local(2017, JAN, 1))).toBe(2017); + }); + + it('should get month', () => { + expect(adapter.getMonth(DateTime.local(2017, JAN, 1))).toBe(0); + }); + + it('should get date', () => { + expect(adapter.getDate(DateTime.local(2017, JAN, 1))).toBe(1); + }); + + it('should get day of week', () => { + expect(adapter.getDayOfWeek(DateTime.local(2017, JAN, 1))).toBe(7); + }); + + it('should get long month names', () => { + expect(adapter.getMonthNames('long')).toEqual([ + 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', + 'October', 'November', 'December' + ]); + }); + + it('should get long month names', () => { + expect(adapter.getMonthNames('short')).toEqual([ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ]); + }); + + it('should get narrow month names', () => { + expect(adapter.getMonthNames('narrow')).toEqual([ + 'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D' + ]); + }); + + it('should get month names in a different locale', () => { + adapter.setLocale('da-DK'); + + if (features.intl && features.intlTokens) { + expect(adapter.getMonthNames('long')).toEqual([ + 'januar', 'februar', 'marts', 'april', 'maj', 'juni', 'juli', + 'august', 'september', 'oktober', 'november', 'december' + ]); + } else { + expect(adapter.getMonthNames('long')).toEqual([ + 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', + 'October', 'November', 'December' + ]); + } + }); + + it('should get date names', () => { + expect(adapter.getDateNames()).toEqual([ + '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', + '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31' + ]); + }); + + it('should get date names in a different locale', () => { + adapter.setLocale('da-DK'); + + // Edge and IE support Intl, but have different data for Danish. + if (features.intl && !(platform.EDGE || platform.TRIDENT)) { + expect(adapter.getDateNames()).toEqual([ + '1.', '2.', '3.', '4.', '5.', '6.', '7.', '8.', '9.', '10.', '11.', '12.', '13.', '14.', + '15.', '16.', '17.', '18.', '19.', '20.', '21.', '22.', '23.', '24.', '25.', '26.', '27.', + '28.', '29.', '30.', '31.' + ]); + } else { + expect(adapter.getDateNames()).toEqual([ + '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', + '18', '19', '20', '21', '22', '23', '24', '25', '26', '27', '28', '29', '30', '31' + ]); + } + }); + + it('should get long day of week names', () => { + expect(adapter.getDayOfWeekNames('long')).toEqual([ + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday' + ]); + }); + + it('should get short day of week names', () => { + expect(adapter.getDayOfWeekNames('short')).toEqual([ + 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun' + ]); + }); + + it('should get narrow day of week names', () => { + expect(adapter.getDayOfWeekNames('narrow')).toEqual([ + 'M', 'T', 'W', 'T', 'F', 'S', 'S' + ]); + }); + + it('should get day of week names in a different locale', () => { + adapter.setLocale('ja-JP'); + + if (features.intl && features.intlTokens) { + expect(adapter.getDayOfWeekNames('long')).toEqual([ + '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日', '日曜日' + ]); + } else { + expect(adapter.getDayOfWeekNames('long')).toEqual([ + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday' + ]); + } + }); + + it('should get year name', () => { + expect(adapter.getYearName(DateTime.local(2017, JAN, 1))).toBe('2017'); + }); + + it('should get year name in a different locale', () => { + adapter.setLocale('ja-JP'); + expect(adapter.getYearName(DateTime.local(2017, JAN, 1))).toBe('2017'); + }); + + it('should get first day of week', () => { + expect(adapter.getFirstDayOfWeek()).toBe(0); + }); + + it('should create Luxon date', () => { + expect(adapter.createDate(2017, JAN, 1) instanceof DateTime).toBe(true); + }); + + it('should not create Luxon date with month over/under-flow', () => { + expect(() => adapter.createDate(2017, 12, 1)).toThrow(); + expect(() => adapter.createDate(2017, -1, 1)).toThrow(); + }); + + it('should not create Luxon date with date over/under-flow', () => { + expect(() => adapter.createDate(2017, JAN, 32)).toThrow(); + expect(() => adapter.createDate(2017, JAN, 0)).toThrow(); + }); + + it("should get today's date", () => { + expect(adapter.sameDate(adapter.today(), DateTime.local())) + .toBe(true, "should be equal to today's date"); + }); + + it('should parse string according to given format', () => { + expect(adapter.parse('1/2/2017', 'L/d/yyyy')!.toISO()) + .toEqual(DateTime.local(2017, JAN, 2).toISO()); + expect(adapter.parse('1/2/2017', 'd/L/yyyy')!.toISO()) + .toEqual(DateTime.local(2017, FEB, 1).toISO()); + }); + + it('should parse number', () => { + let timestamp = new Date().getTime(); + expect(adapter.parse(timestamp, 'LL/dd/yyyy')!.toISO()) + .toEqual(DateTime.fromMillis(timestamp).toISO()); + }); + + it('should parse Date', () => { + let date = new Date(2017, JAN, 1); + expect(adapter.parse(date, 'LL/dd/yyyy')!.toISO()).toEqual(DateTime.fromJSDate(date).toISO()); + }); + + it('should parse DateTime', () => { + let date = DateTime.local(2017, JAN, 1); + expect(adapter.parse(date, 'LL/dd/yyyy')!.toISO()).toEqual(date.toISO()); + }); + + it('should parse empty string as null', () => { + expect(adapter.parse('', 'LL/dd/yyyy')).toBeNull(); + }); + + it('should parse invalid value as invalid', () => { + let d = adapter.parse('hello', 'LL/dd/yyyy'); + expect(d).not.toBeNull(); + expect(adapter.isDateInstance(d)).toBe(true); + expect(adapter.isValid(d as DateTime)) + .toBe(false, 'Expected to parse as "invalid date" object'); + }); + + it('should format date according to given format', () => { + expect(adapter.format(DateTime.local(2017, JAN, 2), 'LL/dd/yyyy')).toEqual('01/02/2017'); + }); + + it('should format with a different locale', () => { + let date = adapter.format(DateTime.local(2017, JAN, 2), 'DD'); + + expect(stripDirectionalityCharacters(date)).toEqual('Jan 2, 2017'); + adapter.setLocale('da-DK'); + + date = adapter.format(DateTime.local(2017, JAN, 2), 'DD'); + + if (platform.EDGE || platform.TRIDENT) { + expect(stripDirectionalityCharacters(date)).toEqual('2. jan 2017'); + } else { + expect(stripDirectionalityCharacters(date)).toEqual('2. jan. 2017'); + } + }); + + it('should throw when attempting to format invalid date', () => { + expect(() => adapter.format(DateTime.fromMillis(NaN), 'LL/dd/yyyy')) + .toThrowError(/LuxonDateAdapter: Cannot format invalid date\./); + }); + + it('should add years', () => { + expect(adapter.addCalendarYears(DateTime.local(2017, JAN, 1), 1).toISO()) + .toEqual(DateTime.local(2018, JAN, 1).toISO()); + expect(adapter.addCalendarYears(DateTime.local(2017, JAN, 1), -1).toISO()) + .toEqual(DateTime.local(2016, JAN, 1).toISO()); + }); + + it('should respect leap years when adding years', () => { + expect(adapter.addCalendarYears(DateTime.local(2016, FEB, 29), 1).toISO()) + .toEqual(DateTime.local(2017, FEB, 28).toISO()); + expect(adapter.addCalendarYears(DateTime.local(2016, FEB, 29), -1).toISO()) + .toEqual(DateTime.local(2015, FEB, 28).toISO()); + }); + + it('should add months', () => { + expect(adapter.addCalendarMonths(DateTime.local(2017, JAN, 1), 1).toISO()) + .toEqual(DateTime.local(2017, FEB, 1).toISO()); + expect(adapter.addCalendarMonths(DateTime.local(2017, JAN, 1), -1).toISO()) + .toEqual(DateTime.local(2016, DEC, 1).toISO()); + }); + + it('should respect month length differences when adding months', () => { + expect(adapter.addCalendarMonths(DateTime.local(2017, JAN, 31), 1).toISO()) + .toEqual(DateTime.local(2017, FEB, 28).toISO()); + expect(adapter.addCalendarMonths(DateTime.local(2017, MAR, 31), -1).toISO()) + .toEqual(DateTime.local(2017, FEB, 28).toISO()); + }); + + it('should add days', () => { + expect(adapter.addCalendarDays(DateTime.local(2017, JAN, 1), 1).toISO()) + .toEqual(DateTime.local(2017, JAN, 2).toISO()); + expect(adapter.addCalendarDays(DateTime.local(2017, JAN, 1), -1).toISO()) + .toEqual(DateTime.local(2016, DEC, 31).toISO()); + }); + + it('should clone', () => { + let date = DateTime.local(2017, JAN, 1); + let clone = adapter.clone(date); + + expect(clone).not.toBe(date); + expect(clone.toISO()).toEqual(date.toISO()); + }); + + it('should compare dates', () => { + expect(adapter.compareDate(DateTime.local(2017, JAN, 1), DateTime.local(2017, JAN, 2))) + .toBeLessThan(0); + + expect(adapter.compareDate(DateTime.local(2017, JAN, 1), DateTime.local(2017, FEB, 1))) + .toBeLessThan(0); + + expect(adapter.compareDate(DateTime.local(2017, JAN, 1), DateTime.local(2018, JAN, 1))) + .toBeLessThan(0); + + expect(adapter.compareDate(DateTime.local(2017, JAN, 1), DateTime.local(2017, JAN, 1))).toBe(0); + + expect(adapter.compareDate(DateTime.local(2018, JAN, 1), DateTime.local(2017, JAN, 1))) + .toBeGreaterThan(0); + + expect(adapter.compareDate(DateTime.local(2017, FEB, 1), DateTime.local(2017, JAN, 1))) + .toBeGreaterThan(0); + + expect(adapter.compareDate(DateTime.local(2017, JAN, 2), DateTime.local(2017, JAN, 1))) + .toBeGreaterThan(0); + }); + + it('should clamp date at lower bound', () => { + expect(adapter.clampDate( + DateTime.local(2017, JAN, 1), DateTime.local(2018, JAN, 1), DateTime.local(2019, JAN, 1))) + .toEqual(DateTime.local(2018, JAN, 1)); + }); + + it('should clamp date at upper bound', () => { + expect(adapter.clampDate( + DateTime.local(2020, JAN, 1), DateTime.local(2018, JAN, 1), DateTime.local(2019, JAN, 1))) + .toEqual(DateTime.local(2019, JAN, 1)); + }); + + it('should clamp date already within bounds', () => { + expect(adapter.clampDate( + DateTime.local(2018, FEB, 1), DateTime.local(2018, JAN, 1), DateTime.local(2019, JAN, 1))) + .toEqual(DateTime.local(2018, FEB, 1)); + }); + + it('should count today as a valid date instance', () => { + let d = DateTime.local(); + expect(adapter.isValid(d)).toBe(true); + expect(adapter.isDateInstance(d)).toBe(true); + }); + + it('should count an invalid date as an invalid date instance', () => { + let d = DateTime.fromMillis(NaN); + expect(adapter.isValid(d)).toBe(false); + expect(adapter.isDateInstance(d)).toBe(true); + }); + + it('should count a string as not a date instance', () => { + let d = '1/1/2017'; + expect(adapter.isDateInstance(d)).toBe(false); + }); + + it('should count a Date as not a date instance', () => { + let d = new Date(); + expect(adapter.isDateInstance(d)).toBe(false); + }); + + it('should create valid dates from valid ISO strings', () => { + assertValidDate(adapter.deserialize('1985-04-12T23:20:50.52Z'), true); + assertValidDate(adapter.deserialize('1996-12-19T16:39:57-08:00'), true); + assertValidDate(adapter.deserialize('1937-01-01T12:00:27.87+00:20'), true); + assertValidDate(adapter.deserialize('1990-13-31T23:59:00Z'), false); + assertValidDate(adapter.deserialize('1/1/2017'), false); + expect(adapter.deserialize('')).toBeNull(); + expect(adapter.deserialize(null)).toBeNull(); + assertValidDate(adapter.deserialize(new Date()), true); + assertValidDate(adapter.deserialize(new Date(NaN)), false); + assertValidDate(adapter.deserialize(DateTime.local()), true); + assertValidDate(adapter.deserialize(DateTime.invalid('Not valid')), false); + }); + + it('returned dates should have correct locale', () => { + adapter.setLocale('ja-JP'); + expect(adapter.createDate(2017, JAN, 1).locale).toBe('ja-JP'); + expect(adapter.today().locale).toBe('ja-JP'); + expect(adapter.parse('1/1/2017', 'L/d/yyyy')!.locale).toBe('ja-JP'); + expect(adapter.addCalendarDays(DateTime.local(), 1).locale).toBe('ja-JP'); + expect(adapter.addCalendarMonths(DateTime.local(), 1).locale).toBe('ja-JP'); + expect(adapter.addCalendarYears(DateTime.local(), 1).locale).toBe('ja-JP'); + }); + + it('should not change locale of DateTime passed as param', () => { + const date = DateTime.local(); + const initialLocale = date.locale; + expect(initialLocale).toBeTruthy(); + adapter.setLocale('ja-JP'); + adapter.getYear(date); + adapter.getMonth(date); + adapter.getDate(date); + adapter.getDayOfWeek(date); + adapter.getYearName(date); + adapter.getNumDaysInMonth(date); + adapter.clone(date); + adapter.parse(date, 'LL/dd/yyyy'); + adapter.format(date, 'LL/dd/yyyy'); + adapter.addCalendarDays(date, 1); + adapter.addCalendarMonths(date, 1); + adapter.addCalendarYears(date, 1); + adapter.toIso8601(date); + adapter.isDateInstance(date); + adapter.isValid(date); + expect(date.locale).toBe(initialLocale); + }); + + it('should create invalid date', () => { + assertValidDate(adapter.invalid(), false); + }); +}); + +describe('LuxonDateAdapter with MAT_DATE_LOCALE override', () => { + let adapter: LuxonDateAdapter; + let platform: Platform; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [LuxonDateModule], + providers: [{provide: MAT_DATE_LOCALE, useValue: 'da-DK'}] + }).compileComponents(); + })); + + beforeEach(inject([DateAdapter, Platform], (d: LuxonDateAdapter, p: Platform) => { + adapter = d; + platform = p; + })); + + it('should take the default locale id from the MAT_DATE_LOCALE injection token', () => { + const date = adapter.format(DateTime.local(2017, JAN, 2), 'DD'); + + // Some browsers add extra invisible characters that we should strip before asserting. + if (platform.EDGE || platform.TRIDENT) { + // IE and Edge's format for Danish is slightly different. + expect(stripDirectionalityCharacters(date)).toEqual('2. jan 2017'); + } else { + expect(stripDirectionalityCharacters(date)).toEqual('2. jan. 2017'); + } + }); +}); + +describe('LuxonDateAdapter with LOCALE_ID override', () => { + let adapter: LuxonDateAdapter; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [LuxonDateModule], + providers: [{provide: LOCALE_ID, useValue: 'fr-FR'}] + }).compileComponents(); + })); + + beforeEach(inject([DateAdapter], (d: LuxonDateAdapter) => { + adapter = d; + })); + + it('should take the default locale id from the LOCALE_ID injection token', () => { + const date = adapter.format(DateTime.local(2017, JAN, 2), 'DD'); + + // Some browsers add extra invisible characters that we should strip before asserting. + expect(stripDirectionalityCharacters(date)).toEqual('2 janv. 2017'); + }); +}); + +describe('LuxonDateAdapter with MAT_LUXON_DATE_ADAPTER_OPTIONS override', () => { + let adapter: LuxonDateAdapter; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [LuxonDateModule], + providers: [{ + provide: MAT_LUXON_DATE_ADAPTER_OPTIONS, + useValue: {useUtc: true} + }] + }).compileComponents(); + })); + + beforeEach(inject([DateAdapter], (d: LuxonDateAdapter) => { + adapter = d; + })); + + describe('use UTC', () => { + it('should create Luxon date in UTC', () => { + // Use 0 since createDate takes 0-indexed months. + expect(adapter.createDate(2017, 0, 5).toISO()) + .toBe(DateTime.utc(2017, JAN, 5).toISO()); + }); + + it('should create today in UTC', () => { + const today = adapter.today(); + expect(today.toISO()).toBe(today.toUTC().toISO()); + }); + + it('should parse dates to UTC', () => { + const date = adapter.parse('1/2/2017', 'LL/dd/yyyy')!; + expect(date.toISO()).toBe(date.toUTC().toISO()); + }); + + it('should return UTC date when deserializing', () => { + const date = adapter.deserialize('1985-04-12T23:20:50.52Z')!; + expect(date.toISO()).toBe(date.toUTC().toISO()); + }); + }); + +}); + +function stripDirectionalityCharacters(str: string) { + return str.replace(/[\u200e\u200f]/g, ''); +} diff --git a/src/material-luxon-adapter/adapter/luxon-date-adapter.ts b/src/material-luxon-adapter/adapter/luxon-date-adapter.ts new file mode 100644 index 000000000000..a0490252f915 --- /dev/null +++ b/src/material-luxon-adapter/adapter/luxon-date-adapter.ts @@ -0,0 +1,246 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Inject, Injectable, Optional, InjectionToken} from '@angular/core'; +import {DateAdapter, MAT_DATE_LOCALE} from '@angular/material/core'; +import {DateTime, Info, DateTimeOptions} from 'luxon'; + +/** Configurable options for {@see LuxonDateAdapter}. */ +export interface MatLuxonDateAdapterOptions { + /** + * Turns the use of utc dates on or off. + * Changing this will change how Angular Material components like DatePicker output dates. + * {@default false} + */ + useUtc: boolean; +} + +/** InjectionToken for LuxonDateAdapter to configure options. */ +export const MAT_LUXON_DATE_ADAPTER_OPTIONS = new InjectionToken( + 'MAT_LUXON_DATE_ADAPTER_OPTIONS', { + providedIn: 'root', + factory: MAT_LUXON_DATE_ADAPTER_OPTIONS_FACTORY +}); + + +/** @docs-private */ +export function MAT_LUXON_DATE_ADAPTER_OPTIONS_FACTORY(): MatLuxonDateAdapterOptions { + return { + useUtc: false + }; +} + +/** The default date names to use if Intl API is not available. */ +const DEFAULT_DATE_NAMES = range(31, i => String(i + 1)); + +/** Creates an array and fills it with values. */ +function range(length: number, valueFunction: (index: number) => T): T[] { + const valuesArray = Array(length); + for (let i = 0; i < length; i++) { + valuesArray[i] = valueFunction(i); + } + return valuesArray; +} + +/** Adapts Luxon Dates for use with Angular Material. */ +@Injectable() +export class LuxonDateAdapter extends DateAdapter { + private _useUTC: boolean; + + constructor(@Optional() @Inject(MAT_DATE_LOCALE) dateLocale: string, + @Optional() @Inject(MAT_LUXON_DATE_ADAPTER_OPTIONS) + options?: MatLuxonDateAdapterOptions) { + + super(); + this._useUTC = options ? !!options.useUtc : false; + this.setLocale(dateLocale || DateTime.local().locale); + } + + setLocale(locale: string) { + super.setLocale(locale); + } + + getYear(date: DateTime): number { + return date.year; + } + + getMonth(date: DateTime): number { + // Luxon works with 1-indexed months whereas our code expects 0-indexed. + return date.month - 1; + } + + getDate(date: DateTime): number { + return date.day; + } + + getDayOfWeek(date: DateTime): number { + return date.weekday; + } + + getMonthNames(style: 'long' | 'short' | 'narrow'): string[] { + return Info.months(style, {locale: this.locale}); + } + + getDateNames(): string[] { + if (Info.features().intl) { + // At the time of writing, Luxon doesn't offer similar + // functionality so we have to fall back to the Intl API. + const dtf = new Intl.DateTimeFormat(this.locale, {day: 'numeric', timeZone: 'utc'}); + + return range(31, i => { + // Format a UTC date in order to avoid DST issues. + const date = DateTime.utc(2017, 1, i + 1).toJSDate(); + + // Strip the directionality characters from the formatted date. + return dtf.format(date).replace(/[\u200e\u200f]/g, ''); + }); + } + return DEFAULT_DATE_NAMES; + } + + getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] { + return Info.weekdays(style, {locale: this.locale}); + } + + getYearName(date: DateTime): string { + return date.toFormat('yyyy'); + } + + getFirstDayOfWeek(): number { + // Luxon doesn't have support for getting the first day of the week. + return 0; + } + + getNumDaysInMonth(date: DateTime): number { + return date.daysInMonth; + } + + clone(date: DateTime): DateTime { + return DateTime.fromObject(date.toObject({includeConfig: true})); + } + + createDate(year: number, month: number, date: number): DateTime { + if (month < 0 || month > 11) { + throw Error(`Invalid month index "${month}". Month index has to be between 0 and 11.`); + } + + if (date < 1) { + throw Error(`Invalid date "${date}". Date has to be greater than 0.`); + } + + // Luxon uses 1-indexed months so we need to add one to the month. + const result = + this._useUTC ? DateTime.utc(year, month + 1, date) : DateTime.local(year, month + 1, date); + + if (!this.isValid(result)) { + throw Error(`Invalid date "${date}". Reason: "${result.invalidReason}".`); + } + + return result.setLocale(this.locale); + } + + today(): DateTime { + return (this._useUTC ? DateTime.utc() : DateTime.local()).setLocale(this.locale); + } + + parse(value: any, parseFormat: string): DateTime | null { + const options: DateTimeOptions = this._getOptions(); + + if (typeof value == 'string' && value.length > 0) { + const iso8601Date = DateTime.fromISO(value, options); + + if (this.isValid(iso8601Date)) { + return iso8601Date; + } + + const fromFormat = DateTime.fromFormat(value, parseFormat, options); + + if (this.isValid(fromFormat)) { + return fromFormat; + } + + return this.invalid(); + } else if (typeof value === 'number') { + return DateTime.fromMillis(value, options); + } else if (value instanceof Date) { + return DateTime.fromJSDate(value, options); + } else if (value instanceof DateTime) { + return DateTime.fromMillis(value.toMillis(), options); + } + + return null; + } + + format(date: DateTime, displayFormat: string): string { + if (!this.isValid(date)) { + throw Error('LuxonDateAdapter: Cannot format invalid date.'); + } + return date + .setLocale(this.locale) + .toFormat(displayFormat, {timeZone: this._useUTC ? 'utc' : undefined}); + } + + addCalendarYears(date: DateTime, years: number): DateTime { + return date.plus({years}).setLocale(this.locale); + } + + addCalendarMonths(date: DateTime, months: number): DateTime { + return date.plus({months}).setLocale(this.locale); + } + + addCalendarDays(date: DateTime, days: number): DateTime { + return date.plus({days}).setLocale(this.locale); + } + + toIso8601(date: DateTime): string { + return date.toISO(); + } + + /** + * Returns the given value if given a valid Luxon or null. Deserializes valid ISO 8601 strings + * (https://www.ietf.org/rfc/rfc3339.txt) and valid Date objects into valid DateTime and empty + * string into null. Returns an invalid date for all other values. + */ + deserialize(value: any): DateTime | null { + const options = this._getOptions(); + let date; + if (value instanceof Date) { + date = DateTime.fromJSDate(value, options); + } + if (typeof value === 'string') { + if (!value) { + return null; + } + date = DateTime.fromISO(value, options); + } + if (date && this.isValid(date)) { + return date; + } + return super.deserialize(value); + } + + isDateInstance(obj: any): boolean { + return obj instanceof DateTime; + } + + isValid(date: DateTime): boolean { + return date.isValid; + } + + invalid(): DateTime { + return DateTime.invalid('Invalid Luxon DateTime object.'); + } + + /** Gets the options that should be used when constructing a new `DateTime` object. */ + private _getOptions(): DateTimeOptions { + return { + zone: this._useUTC ? 'utc' : undefined, + locale: this.locale + }; + } +} diff --git a/src/material-luxon-adapter/adapter/luxon-date-formats.ts b/src/material-luxon-adapter/adapter/luxon-date-formats.ts new file mode 100644 index 000000000000..fdb094f15e30 --- /dev/null +++ b/src/material-luxon-adapter/adapter/luxon-date-formats.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {MatDateFormats} from '@angular/material/core'; + + +export const MAT_LUXON_DATE_FORMATS: MatDateFormats = { + parse: { + // Note: this isn't localized like `D`, however Luxon can't parse input against `D`. + dateInput: 'L/d/yyyy', + }, + display: { + dateInput: 'D', + monthYearLabel: 'LLL yyyy', + dateA11yLabel: 'DD', + monthYearA11yLabel: 'LLLL yyyy', + }, +}; diff --git a/src/material-luxon-adapter/index.ts b/src/material-luxon-adapter/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-luxon-adapter/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './public-api'; diff --git a/src/material-luxon-adapter/package.json b/src/material-luxon-adapter/package.json new file mode 100644 index 000000000000..e6cf99af4a1e --- /dev/null +++ b/src/material-luxon-adapter/package.json @@ -0,0 +1,31 @@ +{ + "name": "@angular/material-luxon-adapter", + "version": "0.0.0-PLACEHOLDER", + "description": "Angular Material Luxon Adapter", + "repository": { + "type": "git", + "url": "https://github.com/angular/components.git" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/angular/components/issues" + }, + "homepage": "https://github.com/angular/components#readme", + "peerDependencies": { + "@angular/material": "0.0.0-PLACEHOLDER", + "@angular/core": "0.0.0-NG", + "luxon": "^1.8.3", + "tslib": "0.0.0-TSLIB" + }, + "ng-update": { + "packageGroup": [ + "@angular/material", + "@angular/cdk", + "@angular/material-luxon-adapter" + ] + }, + "sideEffects": false, + "publishConfig":{ + "registry":"https://wombat-dressing-room.appspot.com" + } +} diff --git a/src/material-luxon-adapter/public-api.ts b/src/material-luxon-adapter/public-api.ts new file mode 100644 index 000000000000..c9246d3a07b8 --- /dev/null +++ b/src/material-luxon-adapter/public-api.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './adapter/index'; diff --git a/src/material-luxon-adapter/require-config.js b/src/material-luxon-adapter/require-config.js new file mode 100644 index 000000000000..8f76220b4664 --- /dev/null +++ b/src/material-luxon-adapter/require-config.js @@ -0,0 +1,7 @@ +// Require.js is being used by the karma bazel rules and needs to be configured to properly +// load AMD modules which are not explicitly named in their output bundle. +require.config({ + paths: { + 'luxon': '/base/npm/node_modules/luxon/build/amd/luxon' + } +}); diff --git a/src/material-luxon-adapter/tsconfig-build.json b/src/material-luxon-adapter/tsconfig-build.json new file mode 100644 index 000000000000..eab6d45859dd --- /dev/null +++ b/src/material-luxon-adapter/tsconfig-build.json @@ -0,0 +1,7 @@ +{ + "extends": "../bazel-tsconfig-build.json", + "compilerOptions": { + // Needed for Luxon since it doesn't have a default export. + "allowSyntheticDefaultImports": true + } +} diff --git a/src/material-luxon-adapter/tsconfig-tests.json b/src/material-luxon-adapter/tsconfig-tests.json new file mode 100644 index 000000000000..0e09039ce05e --- /dev/null +++ b/src/material-luxon-adapter/tsconfig-tests.json @@ -0,0 +1,28 @@ +{ + "extends": "./tsconfig-build.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "../../dist/packages/material-luxon-adapter", + "rootDir": ".", + "rootDirs": [ + ".", + "../../dist/packages/material-luxon-adapter" + ], + "importHelpers": false, + "module": "commonjs", + "target": "es5", + "types": ["jasmine"], + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "paths": { + "@angular/material/*": ["../../dist/packages/material/*"] + } + }, + "include": [ + "**/*.ts", + "**/*.spec.ts" + ], + "exclude": [ + "**/*.e2e.spec.ts" + ] +} diff --git a/src/material-luxon-adapter/tsconfig.json b/src/material-luxon-adapter/tsconfig.json new file mode 100644 index 000000000000..a5a9f4cce0c5 --- /dev/null +++ b/src/material-luxon-adapter/tsconfig.json @@ -0,0 +1,16 @@ +// Configuration for IDEs only. +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + // Needed for Moment.js since it doesn't have a default export. + "allowSyntheticDefaultImports": true, + "rootDir": "..", + "baseUrl": ".", + "paths": { + "@angular/cdk/*": ["../cdk/*"], + "@angular/material/*": ["../material/*"], + "@angular/material": ["../material/public-api.ts"] + } + }, + "include": ["./**/*.ts"] +} diff --git a/test/karma.conf.js b/test/karma.conf.js index bc8006e55515..94b10bbca7b6 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -44,6 +44,7 @@ module.exports = config => { included: false, watched: false }, + {pattern: 'node_modules/luxon/build/amd/luxon.js', included: false, watched: false}, {pattern: 'node_modules/@material/*/dist/*', included: false, watched: false}, {pattern: 'node_modules/kagekiri/**', included: false, watched: false}, diff --git a/tools/gulp/gulpfile.ts b/tools/gulp/gulpfile.ts index e68f381c1fec..0dba900a4481 100644 --- a/tools/gulp/gulpfile.ts +++ b/tools/gulp/gulpfile.ts @@ -6,7 +6,8 @@ import { materialExperimentalPackage, materialPackage, momentAdapterPackage, - youTubePlayerPackage + luxonAdapterPackage, + youTubePlayerPackage, } from './packages'; import './tasks/ci'; @@ -18,6 +19,7 @@ createPackageBuildTasks(cdkExperimentalPackage); createPackageBuildTasks(materialPackage); createPackageBuildTasks(materialExperimentalPackage); createPackageBuildTasks(momentAdapterPackage); +createPackageBuildTasks(luxonAdapterPackage); createPackageBuildTasks(youTubePlayerPackage); createPackageBuildTasks(googleMapsPackage); diff --git a/tools/gulp/packages.ts b/tools/gulp/packages.ts index bb38623decdc..1573c9d4a58e 100644 --- a/tools/gulp/packages.ts +++ b/tools/gulp/packages.ts @@ -8,3 +8,4 @@ export const cdkExperimentalPackage = new BuildPackage('cdk-experimental', [cdkP export const materialExperimentalPackage = new BuildPackage('material-experimental', [cdkPackage, cdkExperimentalPackage, materialPackage]); export const momentAdapterPackage = new BuildPackage('material-moment-adapter', [materialPackage]); +export const luxonAdapterPackage = new BuildPackage('material-luxon-adapter', [materialPackage]); diff --git a/tools/gulp/tasks/unit-test.ts b/tools/gulp/tasks/unit-test.ts index 8793f2daca4b..e365dbe360f2 100644 --- a/tools/gulp/tasks/unit-test.ts +++ b/tools/gulp/tasks/unit-test.ts @@ -14,6 +14,7 @@ task(':test:build', sequenceTask( 'material-experimental:build-no-bundles', 'youtube-player:build-no-bundles', 'material-moment-adapter:build-no-bundles', + 'material-luxon-adapter:build-no-bundles', 'google-maps:build-no-bundles', ':test:build-system-config' )); diff --git a/tools/release/changelog.ts b/tools/release/changelog.ts index 44e4b57d7a04..3b6ca39b5a78 100644 --- a/tools/release/changelog.ts +++ b/tools/release/changelog.ts @@ -32,6 +32,7 @@ const orderedChangelogPackages = [ 'google-maps', 'youtube-player', 'material-moment-adapter', + 'material-luxon-adapter', 'cdk-experimental', 'material-experimental', ]; diff --git a/tools/release/release-output/release-packages.ts b/tools/release/release-output/release-packages.ts index 0c8aa28898c2..43d2f65c6c9a 100644 --- a/tools/release/release-output/release-packages.ts +++ b/tools/release/release-output/release-packages.ts @@ -7,4 +7,5 @@ export const releasePackages = [ 'cdk-experimental', 'material-experimental', 'material-moment-adapter', + 'material-luxon-adapter', ]; diff --git a/yarn.lock b/yarn.lock index 231ac10e0a59..301de3aada13 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1323,6 +1323,11 @@ resolved "https://registry.yarnpkg.com/@types/long/-/long-4.0.0.tgz#719551d2352d301ac8b81db732acb6bdc28dbdef" integrity sha512-1w52Nyx4Gq47uuu0EVcsHBxZFJgurQ+rTKS3qMHxR1GY2T8c2AJYd6vZoZ9q1rupaDjU0yT+Jc2XTyXkjeMA+Q== +"@types/luxon@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-1.4.1.tgz#ece499ada215fb253e07c7ac05d30731412ba6aa" + integrity sha512-mYv/gbkOJ40CDgR8st5sosfFNrJncdlkpdzQSNRdU86UQg3oWWmll4AO/7B8F5FlBC6YrIXqXDSnkoCBqo+uMA== + "@types/marked@^0.4.2": version "0.4.2" resolved "https://registry.yarnpkg.com/@types/marked/-/marked-0.4.2.tgz#64a89e53ea37f61cc0f3ee1732c555c2dbf6452f" @@ -7648,6 +7653,11 @@ lru-queue@0.1: dependencies: es5-ext "~0.10.2" +luxon@^1.8.3: + version "1.8.3" + resolved "https://registry.yarnpkg.com/luxon/-/luxon-1.8.3.tgz#6c76b50319b3a91a357002ca2e96576bb3bfe26d" + integrity sha512-hICQcGmUl5e9VUnf5PtHAma+tpiwL/wMBV9/6/M/6iSpzWFHdEElK2Rj4IZx8kbk2/baJvYftgb+n/I2wJ7RXQ== + macos-release@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/macos-release/-/macos-release-2.3.0.tgz#eb1930b036c0800adebccd5f17bc4c12de8bb71f"