diff --git a/package.json b/package.json index c0410f53ab5a..d7be6be388fb 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "magic-string": "^0.21.3", "minimatch": "^3.0.4", "minimist": "^1.2.0", + "moment": "^2.18.1", "node-sass": "^4.5.3", "protractor": "^5.1.2", "request": "^2.81.0", diff --git a/scripts/closure-compiler/build-devapp-bundle.sh b/scripts/closure-compiler/build-devapp-bundle.sh index 3d3b1607cdf8..8e483922b3fc 100755 --- a/scripts/closure-compiler/build-devapp-bundle.sh +++ b/scripts/closure-compiler/build-devapp-bundle.sh @@ -8,9 +8,10 @@ set -e -o pipefail # Go to the project root directory cd $(dirname $0)/../.. -# Build a release of material and of the CDK package. +# Build a release of material, material-moment-adapter, and cdk packages. $(npm bin)/gulp material:build-release:clean $(npm bin)/gulp cdk:build-release +$(npm bin)/gulp material-moment-adapter:build-release # Build demo-app with ES2015 modules. Closure compiler is then able to parse imports. $(npm bin)/gulp :build:devapp:assets :build:devapp:scss @@ -38,6 +39,7 @@ OPTS=( "--js_module_root=dist/packages" "--js_module_root=dist/releases/material" "--js_module_root=dist/releases/cdk" + "--js_module_root=dist/releases/material-moment-adapter" "--js_module_root=node_modules/@angular/core" "--js_module_root=node_modules/@angular/common" "--js_module_root=node_modules/@angular/compiler" @@ -49,6 +51,7 @@ OPTS=( "--js_module_root=node_modules/@angular/platform-browser-dynamic" "--js_module_root=node_modules/@angular/animations" "--js_module_root=node_modules/@angular/animations/browser" + "--js_module_root=node_modules/moment" # Flags to simplify debugging. "--formatting=PRETTY_PRINT" @@ -57,6 +60,7 @@ OPTS=( # Include the Material and CDK FESM bundles dist/releases/material/@angular/material.js dist/releases/cdk/@angular/cdk.js + dist/releases/material-moment-adapter/@angular/material-moment-adapter.js # Include all Angular FESM bundles. node_modules/@angular/core/@angular/core.js @@ -71,8 +75,9 @@ OPTS=( node_modules/@angular/animations/@angular/animations.js node_modules/@angular/animations/@angular/animations/browser.js - # Include other dependencies like Zone.js and RxJS + # Include other dependencies like Zone.js, Moment.js, and RxJS node_modules/zone.js/dist/zone.js + node_modules/moment/moment.js $rxjsSourceFiles # Include all files from the demo-app package. diff --git a/src/demo-app/system-config.ts b/src/demo-app/system-config.ts index 38f8de2c2284..96b0a3d3e9e3 100644 --- a/src/demo-app/system-config.ts +++ b/src/demo-app/system-config.ts @@ -9,6 +9,7 @@ System.config({ map: { 'rxjs': 'node:rxjs', 'main': 'main.js', + 'moment': 'node:moment/min/moment-with-locales.min.js', // Angular specific mappings. '@angular/core': 'node:@angular/core/bundles/core.umd.js', @@ -26,6 +27,7 @@ System.config({ 'node:@angular/platform-browser-dynamic/bundles/platform-browser-dynamic.umd.js', '@angular/material': 'dist/bundles/material.umd.js', + '@angular/material-moment-adapter': 'dist/bundles/material-moment-adapter.umd.js', '@angular/cdk': 'dist/bundles/cdk.umd.js', '@angular/cdk/a11y': 'dist/bundles/cdk-a11y.umd.js', '@angular/cdk/bidi': 'dist/bundles/cdk-bidi.umd.js', diff --git a/src/demo-app/tsconfig-aot.json b/src/demo-app/tsconfig-aot.json index 2240063c7bce..f868fe1c2677 100644 --- a/src/demo-app/tsconfig-aot.json +++ b/src/demo-app/tsconfig-aot.json @@ -11,7 +11,8 @@ "outDir": ".", "paths": { "@angular/material": ["./material"], - "@angular/cdk/*": ["./cdk/*"] + "@angular/cdk/*": ["./cdk/*"], + "@angular/material-moment-adapter": ["./material-moment-adapter"] } }, "files": [ diff --git a/src/demo-app/tsconfig-build.json b/src/demo-app/tsconfig-build.json index ff3196c1261d..98e6b789b1d1 100644 --- a/src/demo-app/tsconfig-build.json +++ b/src/demo-app/tsconfig-build.json @@ -22,7 +22,8 @@ "baseUrl": ".", "paths": { "@angular/material": ["../../dist/packages/material/public_api"], - "@angular/cdk/*": ["../../dist/packages/cdk/*"] + "@angular/cdk/*": ["../../dist/packages/cdk/*"], + "@angular/material-moment-adapter": ["../../dist/packages/material-moment-adapter"] } }, "files": [ diff --git a/src/lib/core/datetime/date-adapter.ts b/src/lib/core/datetime/date-adapter.ts index 24172405d036..86369f8c85bf 100644 --- a/src/lib/core/datetime/date-adapter.ts +++ b/src/lib/core/datetime/date-adapter.ts @@ -7,6 +7,9 @@ */ import {InjectionToken, LOCALE_ID} from '@angular/core'; +import {Observable} from 'rxjs/Observable'; +import {Subject} from 'rxjs/Subject'; + /** InjectionToken for datepicker that can be used to override default locale code. */ export const MAT_DATE_LOCALE = new InjectionToken('MAT_DATE_LOCALE'); @@ -19,6 +22,10 @@ export abstract class DateAdapter { /** The locale to use for all dates. */ protected locale: any; + /** A stream that emits when the locale changes. */ + get localeChanges(): Observable { return this._localeChanges; } + protected _localeChanges= new Subject(); + /** * Gets the year component of the given date. * @param date The date to extract the year from. @@ -184,6 +191,7 @@ export abstract class DateAdapter { */ setLocale(locale: any) { this.locale = locale; + this._localeChanges.next(); } /** diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index 7847a51b0bb4..aa09ae366812 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -182,6 +182,8 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces private _datepickerSubscription = Subscription.EMPTY; + private _localeSubscription = Subscription.EMPTY; + /** The form control validator for whether the input parses. */ private _parseValidator: ValidatorFn = (): ValidationErrors | null => { return this._lastValueValid ? @@ -228,6 +230,11 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces if (!this._dateFormats) { throw createMissingDateImplError('MD_DATE_FORMATS'); } + + // Update the displayed date when the locale changes. + this._localeSubscription = _dateAdapter.localeChanges.subscribe(() => { + this.value = this.value; + }); } ngAfterContentInit() { @@ -245,6 +252,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces ngOnDestroy() { this._datepickerSubscription.unsubscribe(); + this._localeSubscription.unsubscribe(); this._valueChange.complete(); this._disabledChange.complete(); } diff --git a/src/material-moment-adapter/adapter/index.ts b/src/material-moment-adapter/adapter/index.ts new file mode 100644 index 000000000000..df84c7dabace --- /dev/null +++ b/src/material-moment-adapter/adapter/index.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google Inc. 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_LOCALE, + MAT_DATE_LOCALE_PROVIDER, + MD_DATE_FORMATS +} from '@angular/material'; +import {MomentDateAdapter} from './moment-date-adapter'; +import {MD_MOMENT_DATE_FORMATS} from './moment-date-formats'; + +export * from './moment-date-adapter'; +export * from './moment-date-formats'; + + +@NgModule({ + providers: [ + MAT_DATE_LOCALE_PROVIDER, + {provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE]} + ], +}) +export class MomentDateModule {} + + +@NgModule({ + imports: [MomentDateModule], + providers: [{provide: MD_DATE_FORMATS, useValue: MD_MOMENT_DATE_FORMATS}], +}) +export class MdMomentDateModule {} diff --git a/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts b/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts new file mode 100644 index 000000000000..c8330e8f1eaf --- /dev/null +++ b/src/material-moment-adapter/adapter/moment-date-adapter.spec.ts @@ -0,0 +1,388 @@ +/** + * @license + * Copyright Google Inc. 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 {MomentDateAdapter} from './moment-date-adapter'; +import {async, inject, TestBed} from '@angular/core/testing'; +import {MomentDateModule} from './index'; +import {DateAdapter, MAT_DATE_LOCALE} from '@angular/material'; +import {LOCALE_ID} from '@angular/core'; +import * as moment from 'moment'; + + +// Month constants for more readable tests. +const JAN = 0, FEB = 1, MAR = 2, DEC = 11; + + +describe('MomentDateAdapter', () => { + let adapter: MomentDateAdapter; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MomentDateModule] + }).compileComponents(); + })); + + beforeEach(inject([DateAdapter], (d: MomentDateAdapter) => { + moment.locale('en'); + adapter = d; + adapter.setLocale('en'); + })); + + it('should get year', () => { + expect(adapter.getYear(moment([2017, JAN, 1]))).toBe(2017); + }); + + it('should get month', () => { + expect(adapter.getMonth(moment([2017, JAN, 1]))).toBe(0); + }); + + it('should get date', () => { + expect(adapter.getDate(moment([2017, JAN, 1]))).toBe(1); + }); + + it('should get day of week', () => { + expect(adapter.getDayOfWeek(moment([2017, JAN, 1]))).toBe(0); + }); + + it('should get same day of week in a locale with a different first day of the week', () => { + adapter.setLocale('fr'); + expect(adapter.getDayOfWeek(moment([2017, JAN, 1]))).toBe(0); + }); + + 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([ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ]); + }); + + it('should get month names in a different locale', () => { + adapter.setLocale('ja-JP'); + expect(adapter.getMonthNames('long')).toEqual([ + '1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月' + ]); + }); + + 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('ja-JP'); + 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([ + 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' + ]); + }); + + it('should get short day of week names', () => { + expect(adapter.getDayOfWeekNames('short')).toEqual([ + 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' + ]); + }); + + it('should get narrow day of week names', () => { + expect(adapter.getDayOfWeekNames('narrow')).toEqual([ + 'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa' + ]); + }); + + it('should get day of week names in a different locale', () => { + adapter.setLocale('ja-JP'); + expect(adapter.getDayOfWeekNames('long')).toEqual([ + '日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日' + ]); + }); + + it('should get year name', () => { + expect(adapter.getYearName(moment([2017, JAN, 1]))).toBe('2017'); + }); + + it('should get year name in a different locale', () => { + adapter.setLocale('ja-JP'); + expect(adapter.getYearName(moment([2017, JAN, 1]))).toBe('2017'); + }); + + it('should get first day of week', () => { + expect(adapter.getFirstDayOfWeek()).toBe(0); + }); + + it('should get first day of week in a different locale', () => { + adapter.setLocale('fr'); + expect(adapter.getFirstDayOfWeek()).toBe(1); + }); + + it('should create Moment date', () => { + expect(adapter.createDate(2017, JAN, 1).format()).toEqual(moment([2017, JAN, 1]).format()); + }); + + it('should not create Moment date with month over/under-flow', () => { + expect(() => adapter.createDate(2017, DEC + 1, 1)).toThrow(); + expect(() => adapter.createDate(2017, JAN - 1, 1)).toThrow(); + }); + + it('should not create Moment date with date over/under-flow', () => { + expect(() => adapter.createDate(2017, JAN, 32)).toThrow(); + expect(() => adapter.createDate(2017, JAN, 0)).toThrow(); + }); + + it('should create Moment date with low year number', () => { + expect(adapter.createDate(-1, JAN, 1).year()).toBe(-1); + expect(adapter.createDate(0, JAN, 1).year()).toBe(0); + expect(adapter.createDate(50, JAN, 1).year()).toBe(50); + expect(adapter.createDate(99, JAN, 1).year()).toBe(99); + expect(adapter.createDate(100, JAN, 1).year()).toBe(100); + }); + + it("should get today's date", () => { + expect(adapter.sameDate(adapter.today(), moment())) + .toBe(true, "should be equal to today's date"); + }); + + it('should parse string according to given format', () => { + expect(adapter.parse('1/2/2017', 'MM/DD/YYYY')!.format()) + .toEqual(moment([2017, JAN, 2]).format()); + expect(adapter.parse('1/2/2017', 'DD/MM/YYYY')!.format()) + .toEqual(moment([2017, FEB, 1]).format()); + }); + + it('should parse number', () => { + let timestamp = new Date().getTime(); + expect(adapter.parse(timestamp, 'MM/DD/YYYY')!.format()).toEqual(moment(timestamp).format()); + }); + + it ('should parse Date', () => { + let date = new Date(2017, JAN, 1); + expect(adapter.parse(date, 'MM/DD/YYYY')!.format()).toEqual(moment(date).format()); + }); + + it ('should parse Moment date', () => { + let date = moment([2017, JAN, 1]); + let parsedDate = adapter.parse(date, 'MM/DD/YYYY'); + expect(parsedDate!.format()).toEqual(date.format()); + expect(parsedDate).not.toBe(date); + }); + + it('should parse empty string as null', () => { + expect(adapter.parse('', 'MM/DD/YYYY')).toBeNull(); + }); + + it('should parse invalid value as invalid', () => { + let d = adapter.parse('hello', 'MM/DD/YYYY'); + expect(d).not.toBeNull(); + expect(adapter.isDateInstance(d)) + .toBe(true, 'Expected string to have been fed through Date.parse'); + expect(adapter.isValid(d as moment.Moment)) + .toBe(false, 'Expected to parse as "invalid date" object'); + }); + + it('should format date according to given format', () => { + expect(adapter.format(moment([2017, JAN, 2]), 'MM/DD/YYYY')).toEqual('01/02/2017'); + expect(adapter.format(moment([2017, JAN, 2]), 'DD/MM/YYYY')).toEqual('02/01/2017'); + }); + + it('should format with a different locale', () => { + expect(adapter.format(moment([2017, JAN, 2]), 'll')).toEqual('Jan 2, 2017'); + adapter.setLocale('ja-JP'); + expect(adapter.format(moment([2017, JAN, 2]), 'll')).toEqual('2017年1月2日'); + }); + + it('should throw when attempting to format invalid date', () => { + expect(() => adapter.format(moment(NaN), 'MM/DD/YYYY')) + .toThrowError(/MomentDateAdapter: Cannot format invalid date\./); + }); + + it('should add years', () => { + expect(adapter.addCalendarYears(moment([2017, JAN, 1]), 1).format()) + .toEqual(moment([2018, JAN, 1]).format()); + expect(adapter.addCalendarYears(moment([2017, JAN, 1]), -1).format()) + .toEqual(moment([2016, JAN, 1]).format()); + }); + + it('should respect leap years when adding years', () => { + expect(adapter.addCalendarYears(moment([2016, FEB, 29]), 1).format()) + .toEqual(moment([2017, FEB, 28]).format()); + expect(adapter.addCalendarYears(moment([2016, FEB, 29]), -1).format()) + .toEqual(moment([2015, FEB, 28]).format()); + }); + + it('should add months', () => { + expect(adapter.addCalendarMonths(moment([2017, JAN, 1]), 1).format()) + .toEqual(moment([2017, FEB, 1]).format()); + expect(adapter.addCalendarMonths(moment([2017, JAN, 1]), -1).format()) + .toEqual(moment([2016, DEC, 1]).format()); + }); + + it('should respect month length differences when adding months', () => { + expect(adapter.addCalendarMonths(moment([2017, JAN, 31]), 1).format()) + .toEqual(moment([2017, FEB, 28]).format()); + expect(adapter.addCalendarMonths(moment([2017, MAR, 31]), -1).format()) + .toEqual(moment([2017, FEB, 28]).format()); + }); + + it('should add days', () => { + expect(adapter.addCalendarDays(moment([2017, JAN, 1]), 1).format()) + .toEqual(moment([2017, JAN, 2]).format()); + expect(adapter.addCalendarDays(moment([2017, JAN, 1]), -1).format()) + .toEqual(moment([2016, DEC, 31]).format()); + }); + + it('should clone', () => { + let date = moment([2017, JAN, 1]); + expect(adapter.clone(date).format()).toEqual(date.format()); + expect(adapter.clone(date)).not.toBe(date); + }); + + it('should compare dates', () => { + expect(adapter.compareDate(moment([2017, JAN, 1]), moment([2017, JAN, 2]))).toBeLessThan(0); + expect(adapter.compareDate(moment([2017, JAN, 1]), moment([2017, FEB, 1]))).toBeLessThan(0); + expect(adapter.compareDate(moment([2017, JAN, 1]), moment([2018, JAN, 1]))).toBeLessThan(0); + expect(adapter.compareDate(moment([2017, JAN, 1]), moment([2017, JAN, 1]))).toBe(0); + expect(adapter.compareDate(moment([2018, JAN, 1]), moment([2017, JAN, 1]))).toBeGreaterThan(0); + expect(adapter.compareDate(moment([2017, FEB, 1]), moment([2017, JAN, 1]))).toBeGreaterThan(0); + expect(adapter.compareDate(moment([2017, JAN, 2]), moment([2017, JAN, 1]))).toBeGreaterThan(0); + }); + + it('should clamp date at lower bound', () => { + expect(adapter.clampDate( + moment([2017, JAN, 1]), moment([2018, JAN, 1]), moment([2019, JAN, 1]))) + .toEqual(moment([2018, JAN, 1])); + }); + + it('should clamp date at upper bound', () => { + expect(adapter.clampDate( + moment([2020, JAN, 1]), moment([2018, JAN, 1]), moment([2019, JAN, 1]))) + .toEqual(moment([2019, JAN, 1])); + }); + + it('should clamp date already within bounds', () => { + expect(adapter.clampDate( + moment([2018, FEB, 1]), moment([2018, JAN, 1]), moment([2019, JAN, 1]))) + .toEqual(moment([2018, FEB, 1])); + }); + + it('should count today as a valid date instance', () => { + let d = moment(); + 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 = moment(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('setLocale should not modify global moment locale', () => { + expect(moment.locale()).toBe('en'); + adapter.setLocale('ja-JP'); + expect(moment.locale()).toBe('en'); + }); + + it('returned Moments should have correct locale', () => { + adapter.setLocale('ja-JP'); + expect(adapter.createDate(2017, JAN, 1).locale()).toBe('ja'); + expect(adapter.today().locale()).toBe('ja'); + expect(adapter.clone(moment()).locale()).toBe('ja'); + expect(adapter.parse('1/1/2017', 'MM/DD/YYYY')!.locale()).toBe('ja'); + expect(adapter.addCalendarDays(moment(), 1).locale()).toBe('ja'); + expect(adapter.addCalendarMonths(moment(), 1).locale()).toBe('ja'); + expect(adapter.addCalendarYears(moment(), 1).locale()).toBe('ja'); + }); + + it('should not change locale of Moments passed as params', () => { + let date = moment(); + expect(date.locale()).toBe('en'); + 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, 'MM/DD/YYYY'); + adapter.format(date, 'MM/DD/YYYY'); + adapter.addCalendarDays(date, 1); + adapter.addCalendarMonths(date, 1); + adapter.addCalendarYears(date, 1); + adapter.getISODateString(date); + adapter.isDateInstance(date); + adapter.isValid(date); + expect(date.locale()).toBe('en'); + }); +}); + +describe('MomentDateAdapter with MAT_DATE_LOCALE override', () => { + let adapter: MomentDateAdapter; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MomentDateModule], + providers: [{provide: MAT_DATE_LOCALE, useValue: 'ja-JP'}] + }).compileComponents(); + })); + + beforeEach(inject([DateAdapter], (d: MomentDateAdapter) => { + adapter = d; + })); + + 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日'); + }); +}); + +describe('MomentDateAdapter with LOCALE_ID override', () => { + let adapter: MomentDateAdapter; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MomentDateModule], + providers: [{provide: LOCALE_ID, useValue: 'fr'}] + }).compileComponents(); + })); + + beforeEach(inject([DateAdapter], (d: MomentDateAdapter) => { + adapter = d; + })); + + 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'); + }); +}); diff --git a/src/material-moment-adapter/adapter/moment-date-adapter.ts b/src/material-moment-adapter/adapter/moment-date-adapter.ts new file mode 100644 index 000000000000..a941eec54354 --- /dev/null +++ b/src/material-moment-adapter/adapter/moment-date-adapter.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright Google Inc. 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} from '@angular/core'; +import {DateAdapter, MAT_DATE_LOCALE} from '@angular/material'; + +// Depending on whether rollup is used, moment needs to be imported differently. +// Since Moment.js doesn't have a default export, we normally need to import using the `* as` +// syntax. However, rollup creates a synthetic default module and we thus need to import it using +// the `default as` syntax. +// TODO(mmalerba): See if we can clean this up at some point. +import {default as _rollupMoment, Moment} from 'moment'; +import * as _moment from 'moment'; +const moment = _rollupMoment || _moment; + + +/** 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 Moment.js Dates for use with Angular Material. */ +@Injectable() +export class MomentDateAdapter extends DateAdapter { + // Note: all of the methods that accept a `Moment` input parameter immediately call `this.clone` + // on it. This is to ensure that we're working with a `Moment` that has the correct locale setting + // while avoiding mutating the original object passed to us. Just calling `.locale(...)` on the + // input would mutate the object. + + private _localeData: { + firstDayOfWeek: number, + longMonths: string[], + shortMonths: string[], + dates: string[], + longDaysOfWeek: string[], + shortDaysOfWeek: string[], + narrowDaysOfWeek: string[] + }; + + constructor(@Optional() @Inject(MAT_DATE_LOCALE) dateLocale: string) { + super(); + this.setLocale(dateLocale || moment.locale()); + } + + setLocale(locale: string) { + super.setLocale(locale); + + let momentLocaleData = moment.localeData(locale); + this._localeData = { + firstDayOfWeek: momentLocaleData.firstDayOfWeek(), + longMonths: momentLocaleData.months(), + shortMonths: momentLocaleData.monthsShort(), + dates: range(31, (i) => this.createDate(2017, 0, i + 1).format('D')), + longDaysOfWeek: momentLocaleData.weekdays(), + shortDaysOfWeek: momentLocaleData.weekdaysShort(), + narrowDaysOfWeek: momentLocaleData.weekdaysMin(), + }; + } + + getYear(date: Moment): number { + return this.clone(date).year(); + } + + getMonth(date: Moment): number { + return this.clone(date).month(); + } + + getDate(date: Moment): number { + return this.clone(date).date(); + } + + getDayOfWeek(date: Moment): number { + return this.clone(date).day(); + } + + getMonthNames(style: 'long' | 'short' | 'narrow'): string[] { + // Moment.js doesn't support narrow month names, so we just use short if narrow is requested. + return style == 'long' ? this._localeData.longMonths : this._localeData.shortMonths; + } + + getDateNames(): string[] { + return this._localeData.dates; + } + + getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] { + if (style == 'long') { + return this._localeData.longDaysOfWeek; + } + if (style == 'short') { + return this._localeData.shortDaysOfWeek; + } + return this._localeData.narrowDaysOfWeek; + } + + getYearName(date: Moment): string { + return this.clone(date).format('YYYY'); + } + + getFirstDayOfWeek(): number { + return this._localeData.firstDayOfWeek; + } + + getNumDaysInMonth(date: Moment): number { + return this.clone(date).daysInMonth(); + } + + clone(date: Moment): Moment { + return date.clone().locale(this.locale); + } + + createDate(year: number, month: number, date: number): Moment { + // Moment.js will create an invalid date if any of the components are out of bounds, but we + // explicitly check each case so we can throw more descriptive errors. + 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.`); + } + + let result = moment({year, month, date}).locale(this.locale); + + // If the result isn't valid, the date must have been out of bounds for this month. + if (!result.isValid()) { + throw Error(`Invalid date "${date}" for month with index "${month}".`); + } + + return result; + } + + today(): Moment { + return moment().locale(this.locale); + } + + parse(value: any, parseFormat: string | string[]): Moment | null { + if (value && typeof value == 'string') { + return moment(value, parseFormat, this.locale); + } + return value ? moment(value).locale(this.locale) : null; + } + + format(date: Moment, displayFormat: string): string { + date = this.clone(date); + if (!this.isValid(date)) { + throw Error('MomentDateAdapter: Cannot format invalid date.'); + } + return date.format(displayFormat); + } + + addCalendarYears(date: Moment, years: number): Moment { + return this.clone(date).add({years}); + } + + addCalendarMonths(date: Moment, months: number): Moment { + return this.clone(date).add({months}); + } + + addCalendarDays(date: Moment, days: number): Moment { + return this.clone(date).add({days}); + } + + getISODateString(date: Moment): string { + return this.clone(date).format(); + } + + isDateInstance(obj: any): boolean { + return moment.isMoment(obj); + } + + isValid(date: Moment): boolean { + return this.clone(date).isValid(); + } +} diff --git a/src/material-moment-adapter/adapter/moment-date-formats.ts b/src/material-moment-adapter/adapter/moment-date-formats.ts new file mode 100644 index 000000000000..6f4747f0493c --- /dev/null +++ b/src/material-moment-adapter/adapter/moment-date-formats.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google Inc. 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 {MdDateFormats} from '@angular/material'; + + +export const MD_MOMENT_DATE_FORMATS: MdDateFormats = { + parse: { + dateInput: 'l', + }, + display: { + dateInput: 'l', + monthYearLabel: 'MMM YYYY', + dateA11yLabel: 'LL', + monthYearA11yLabel: 'MMMM YYYY', + }, +}; diff --git a/src/material-moment-adapter/index.spec.ts b/src/material-moment-adapter/index.spec.ts deleted file mode 100644 index a1ef3b6c1707..000000000000 --- a/src/material-moment-adapter/index.spec.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {_momentAdapter} from './public_api'; - -describe('moment-adapter', () => { - - it('should be tested when running the tests', () => { - expect(_momentAdapter).toBe(0); - }); - -}); diff --git a/src/material-moment-adapter/package.json b/src/material-moment-adapter/package.json index 116617becbc5..8e50eeac9aa7 100644 --- a/src/material-moment-adapter/package.json +++ b/src/material-moment-adapter/package.json @@ -17,7 +17,8 @@ "homepage": "https://github.com/angular/material2#readme", "peerDependencies": { "@angular/material": "0.0.0-PLACEHOLDER", - "@angular/core": "^4.0.0" + "@angular/core": "^4.0.0", + "moment": "^2.18.1" }, "dependencies": { "tslib": "^1.7.1" diff --git a/src/material-moment-adapter/public_api.ts b/src/material-moment-adapter/public_api.ts index 5f61ef654dcc..8d50b96839d7 100644 --- a/src/material-moment-adapter/public_api.ts +++ b/src/material-moment-adapter/public_api.ts @@ -6,4 +6,4 @@ * found in the LICENSE file at https://angular.io/license */ -export const _momentAdapter = 0; +export * from './adapter'; diff --git a/src/material-moment-adapter/tsconfig-build.json b/src/material-moment-adapter/tsconfig-build.json index de8b8dab6d0f..e964dd29752b 100644 --- a/src/material-moment-adapter/tsconfig-build.json +++ b/src/material-moment-adapter/tsconfig-build.json @@ -2,6 +2,8 @@ // needs to be ES2015 since the build process will create FESM bundles using rollup. { "compilerOptions": { + // Needed for Moment.js since it doesn't have a default export. + "allowSyntheticDefaultImports": true, "baseUrl": ".", "declaration": true, "stripInternal": false, diff --git a/test/karma-test-shim.js b/test/karma-test-shim.js index 3dfeae04facc..51fb7450022c 100644 --- a/test/karma-test-shim.js +++ b/test/karma-test-shim.js @@ -20,6 +20,7 @@ System.config({ map: { 'rxjs': 'node:rxjs', 'main': 'main.js', + 'moment': 'node:moment/min/moment-with-locales.min.js', // Angular specific mappings. '@angular/core': 'node:@angular/core/bundles/core.umd.js', diff --git a/test/karma.conf.js b/test/karma.conf.js index c120d8a918e9..b2b39d0c7185 100644 --- a/test/karma.conf.js +++ b/test/karma.conf.js @@ -26,6 +26,7 @@ module.exports = (config) => { {pattern: 'node_modules/zone.js/dist/fake-async-test.js', included: true, watched: false}, {pattern: 'node_modules/hammerjs/hammer.min.js', included: true, watched: false}, {pattern: 'node_modules/hammerjs/hammer.min.js.map', included: false, watched: false}, + {pattern: 'node_modules/moment/min/moment-with-locales.min.js', included: true, watched: false}, // Include all Angular dependencies {pattern: 'node_modules/@angular/**/*', included: false, watched: false}, diff --git a/tools/gulp/tasks/aot.ts b/tools/gulp/tasks/aot.ts index 9c7d35ba5baa..425e95bebccd 100644 --- a/tools/gulp/tasks/aot.ts +++ b/tools/gulp/tasks/aot.ts @@ -18,7 +18,7 @@ const tsconfigFile = join(demoAppOut, 'tsconfig-aot.json'); /** Builds the demo-app and material. To be able to run NGC, apply the metadata workaround. */ task('aot:deps', sequenceTask( 'build:devapp', - ['material:build-release', 'cdk:build-release'], + ['material:build-release', 'cdk:build-release', 'material-moment-adapter:build-release'], 'aot:copy-release' )); @@ -27,6 +27,8 @@ task('aot:deps', sequenceTask( task('aot:copy-release', () => { copySync(join(releasesDir, 'material'), join(demoAppOut, 'material')); copySync(join(releasesDir, 'cdk'), join(demoAppOut, 'cdk')); + copySync( + join(releasesDir, 'material-moment-adapter'), join(demoAppOut, 'material-moment-adapter')); }); /** Build the demo-app and a release to confirm that the library is AOT-compatible. */ diff --git a/tools/gulp/tasks/development.ts b/tools/gulp/tasks/development.ts index 5d8174a1c4d4..313a74239129 100644 --- a/tools/gulp/tasks/development.ts +++ b/tools/gulp/tasks/development.ts @@ -21,7 +21,7 @@ const materialOutPath = join(outputDir, 'packages', 'material'); /** Array of vendors that are required to serve the demo-app. */ const appVendors = [ - '@angular', 'systemjs', 'zone.js', 'rxjs', 'hammerjs', 'core-js', 'web-animations-js' + '@angular', 'systemjs', 'zone.js', 'rxjs', 'hammerjs', 'core-js', 'web-animations-js', 'moment', ]; /** Glob that matches all required vendors for the demo-app. */ diff --git a/tools/gulp/util/task_helpers.ts b/tools/gulp/util/task_helpers.ts index b9d6edbc5b79..8a109e8ae27b 100644 --- a/tools/gulp/util/task_helpers.ts +++ b/tools/gulp/util/task_helpers.ts @@ -130,7 +130,9 @@ export function buildAppTask(appName: string) { .filter(taskName => gulp.hasTask(taskName)); return sequenceTask( - 'material:clean-build', + // Build all required packages for serving the devapp by just building the moment-adapter + // package. All dependencies of that package (material, cdk) will be built automatically. + 'material-moment-adapter:clean-build', [...buildTasks] ); } diff --git a/tools/package-tools/rollup-globals.ts b/tools/package-tools/rollup-globals.ts index 55e513bb9537..7800d3530c51 100644 --- a/tools/package-tools/rollup-globals.ts +++ b/tools/package-tools/rollup-globals.ts @@ -17,6 +17,7 @@ const rollupCdkEntryPoints = cdkSecondaryEntryPoints.reduce((globals: any, entry /** Map of globals that are used inside of the different packages. */ export const rollupGlobals = { 'tslib': 'tslib', + 'moment': 'moment', '@angular/animations': 'ng.animations', '@angular/core': 'ng.core', diff --git a/tsconfig.json b/tsconfig.json index 6df0c68296f1..e06d166854ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,8 @@ // IDEs and TSLint. For IDEs it ensures that `experimentalDecorator` warnings are not showing up. { "compilerOptions": { + // Needed for Moment.js since it doesn't have a default export. + "allowSyntheticDefaultImports": true, "rootDir": ".", "experimentalDecorators": true, "module": "es2015", @@ -17,6 +19,7 @@ "paths": { "@angular/material": ["./src/lib/public_api.ts"], "@angular/cdk/*": ["./src/cdk/*"], + "@angular/material-moment-adapter": ["./src/material-moment-adapter"], "@angular/material-examples": ["./src/material-examples"], "material2-build-tools": ["./tools/package-tools"] }