From 869d0f275e06697c11a0ded24e09cceb67d3c6fa Mon Sep 17 00:00:00 2001 From: mmalerba Date: Wed, 1 Feb 2017 18:09:20 -0800 Subject: [PATCH 01/37] create SimpleDate and CalendarLocale objects (#2839) * create SimpleDate and CalendarLocale objects * tests * addressed comments * make parseDate more robust * simplify createFormatFunction --- src/lib/core/core.ts | 5 + src/lib/core/datetime/calendar-locale.spec.ts | 105 +++++++++++ src/lib/core/datetime/calendar-locale.ts | 172 ++++++++++++++++++ src/lib/core/datetime/index.ts | 12 ++ src/lib/core/datetime/simple-date.spec.ts | 12 ++ src/lib/core/datetime/simple-date.ts | 15 ++ src/lib/module.ts | 4 +- 7 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 src/lib/core/datetime/calendar-locale.spec.ts create mode 100644 src/lib/core/datetime/calendar-locale.ts create mode 100644 src/lib/core/datetime/index.ts create mode 100644 src/lib/core/datetime/simple-date.spec.ts create mode 100644 src/lib/core/datetime/simple-date.ts diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 302bdff76340..cc992c11679d 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -8,6 +8,7 @@ import {OverlayModule} from './overlay/overlay-directives'; import {A11yModule} from './a11y/index'; import {MdSelectionModule} from './selection/index'; import {MdRippleModule} from './ripple/index'; +import {DatetimeModule} from './datetime/index'; // RTL @@ -120,6 +121,8 @@ export {CompatibilityModule, NoConflictStyleCompatibilityMode} from './compatibi // Common material module export {MdCommonModule} from './common-behaviors/common-module'; +// Datetime +export * from './datetime/index'; @NgModule({ imports: [ @@ -132,6 +135,7 @@ export {MdCommonModule} from './common-behaviors/common-module'; A11yModule, MdOptionModule, MdSelectionModule, + DatetimeModule, ], exports: [ MdLineModule, @@ -143,6 +147,7 @@ export {MdCommonModule} from './common-behaviors/common-module'; A11yModule, MdOptionModule, MdSelectionModule, + DatetimeModule, ], }) export class MdCoreModule {} diff --git a/src/lib/core/datetime/calendar-locale.spec.ts b/src/lib/core/datetime/calendar-locale.spec.ts new file mode 100644 index 000000000000..455e2a68cae0 --- /dev/null +++ b/src/lib/core/datetime/calendar-locale.spec.ts @@ -0,0 +1,105 @@ +import {inject, TestBed, async} from '@angular/core/testing'; +import {CalendarLocale} from './calendar-locale'; +import {DatetimeModule} from './index'; +import {SimpleDate} from './simple-date'; + + +describe('DefaultCalendarLocale', () => { + let calendarLocale: CalendarLocale; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [DatetimeModule], + }); + + TestBed.compileComponents(); + })); + + beforeEach(inject([CalendarLocale], (cl: CalendarLocale) => { + calendarLocale = cl; + })); + + it('lists months', () => { + expect(calendarLocale.months).toEqual([ + 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', + 'October', 'November', 'December' + ]); + }); + + it('lists short months', () => { + expect(calendarLocale.shortMonths).toEqual([ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ]); + }); + + it('lists narrow months', () => { + expect(calendarLocale.narrowMonths).toEqual([ + 'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D' + ]); + }); + + it('lists days', () => { + expect(calendarLocale.days).toEqual([ + 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' + ]); + }); + + it('lists short days', () => { + expect(calendarLocale.shortDays).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + }); + + it('lists narrow days', () => { + expect(calendarLocale.narrowDays).toEqual(['S', 'M', 'T', 'W', 'T', 'F', 'S']); + }); + + it('lists dates', () => { + expect(calendarLocale.dates).toEqual([ + null, '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('has first day of the week', () => { + expect(calendarLocale.firstDayOfWeek).toBe(0); + }); + + it('has calendar label', () => { + expect(calendarLocale.calendarLabel).toBe('Calendar'); + }); + + it('has open calendar label', () => { + expect(calendarLocale.openCalendarLabel).toBe('Open calendar'); + }); + + it('parses SimpleDate from string', () => { + expect(calendarLocale.parseDate('1/1/2017')).toEqual(new SimpleDate(2017, 0, 1)); + }); + + it('parses SimpleDate from number', () => { + let timestamp = new Date().getTime(); + expect(calendarLocale.parseDate(timestamp)) + .toEqual(SimpleDate.fromNativeDate(new Date(timestamp))); + }); + + it ('parses SimpleDate from SimpleDate by copying', () => { + let originalSimpleDate = new SimpleDate(2017, 0, 1); + expect(calendarLocale.parseDate(originalSimpleDate)).toEqual(originalSimpleDate); + }); + + it('parses null for invalid dates', () => { + expect(calendarLocale.parseDate('hello')).toBeNull(); + }); + + it('formats SimpleDates', () => { + expect(calendarLocale.formatDate(new SimpleDate(2017, 0, 1))).toEqual('1/1/2017'); + }); + + it('gets header label for calendar month', () => { + expect(calendarLocale.getCalendarMonthHeaderLabel(new SimpleDate(2017, 0, 1))) + .toEqual('Jan 2017'); + }); + + it('gets header label for calendar year', () => { + expect(calendarLocale.getCalendarYearHeaderLabel(new SimpleDate(2017, 0, 1))).toBe('2017'); + }) +}); diff --git a/src/lib/core/datetime/calendar-locale.ts b/src/lib/core/datetime/calendar-locale.ts new file mode 100644 index 000000000000..7179253c76a1 --- /dev/null +++ b/src/lib/core/datetime/calendar-locale.ts @@ -0,0 +1,172 @@ +import {SimpleDate} from './simple-date'; +import {Injectable} from '@angular/core'; + + +/** Whether the browser supports the Intl API. */ +const SUPPORTS_INTL_API = !!Intl; + + +/** Creates an array and fills it with values. */ +function range(length: number, valueFunction: (index: number) => T): T[] { + return Array.apply(null, Array(length)).map((v: undefined, i: number) => valueFunction(i)); +} + + +/** + * This class encapsulates the details of how to localize all information needed for displaying a + * calendar. It is used by md-datepicker to render a properly localized calendar. Unless otherwise + * specified by the user DefaultCalendarLocale will be provided as the CalendarLocale. + */ +@Injectable() +export abstract class CalendarLocale { + /** Labels to use for the long form of the month. (e.g. 'January') */ + months: string[]; + + /** Labels to use for the short form of the month. (e.g. 'Jan') */ + shortMonths: string[]; + + /** Labels to use for the narrow form of the month. (e.g. 'J') */ + narrowMonths: string[]; + + /** Labels to use for the long form of the week days. (e.g. 'Sunday') */ + days: string[]; + + /** Labels to use for the short form of the week days. (e.g. 'Sun') */ + shortDays: string[]; + + /** Labels to use for the narrow form of the week days. (e.g. 'S') */ + narrowDays: string[]; + + /** + * Labels to use for the dates of the month. (e.g. null, '1', '2', ..., '31'). + * Note that the 0th index is null, since there is no January 0th. + */ + dates: string[]; + + /** The first day of the week. (e.g. 0 = Sunday, 6 = Saturday). */ + firstDayOfWeek: number; + + /** A label for the calendar popup (used by screen readers). */ + calendarLabel: string; + + /** A label for the button used to open the calendar popup (used by screen readers). */ + openCalendarLabel: string; + + /** + * Parses a SimpleDate from a value. + * @param value The value to parse. + */ + parseDate: (value: any) => SimpleDate; + + /** + * Formats a SimpleDate to a string. + * @param date The date to format. + */ + formatDate: (date: SimpleDate) => string; + + /** + * Gets a label to display as the heading for the specified calendar month. + * @param date A date that falls within the month to be labeled. + */ + getCalendarMonthHeaderLabel: (date: SimpleDate) => string; + + /** + * Gets a label to display as the heading for the specified calendar year. + * @param date A date that falls within the year to be labeled. + */ + getCalendarYearHeaderLabel: (date: SimpleDate) => string; +} + + +/** + * The default implementation of CalendarLocale. This implementation is a best attempt at + * localization using only the functionality natively available in JS. If more robust localization + * is needed, an alternate class can be provided as the CalendarLocale for the app. + */ +export class DefaultCalendarLocale implements CalendarLocale { + months = SUPPORTS_INTL_API ? this._createMonthsArray('long') : + [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' + ]; + + shortMonths = SUPPORTS_INTL_API ? this._createMonthsArray('short') : + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + + narrowMonths = SUPPORTS_INTL_API ? this._createMonthsArray('narrow') : + ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; + + days = SUPPORTS_INTL_API ? this._createDaysArray('long') : + ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + + shortDays = SUPPORTS_INTL_API ? this._createDaysArray('short') : + ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + narrowDays = SUPPORTS_INTL_API ? this._createDaysArray('narrow') : + ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + + dates = [null].concat( + SUPPORTS_INTL_API ? this._createDatesArray('numeric') : range(31, i => String(i + 1))); + + firstDayOfWeek = 0; + + calendarLabel = 'Calendar'; + + openCalendarLabel = 'Open calendar'; + + parseDate(value: any) { + if (value instanceof SimpleDate) { + return value; + } + let timestamp = typeof value == 'number' ? value : Date.parse(value); + return isNaN(timestamp) ? null : SimpleDate.fromNativeDate(new Date(timestamp)); + } + + formatDate = this._createFormatFunction(undefined) || + ((date: SimpleDate) => date.toNativeDate().toDateString()); + + getCalendarMonthHeaderLabel = this._createFormatFunction({month: 'short', year: 'numeric'}) || + ((date: SimpleDate) => this.shortMonths[date.month] + ' ' + date.year); + + getCalendarYearHeaderLabel = this._createFormatFunction({year: 'numeric'}) || + ((date: SimpleDate) => String(date.year)); + + private _createMonthsArray(format: string) { + let dtf = new Intl.DateTimeFormat(undefined, {month: format}); + return range(12, i => dtf.format(new Date(2017, i, 1))); + } + + private _createDaysArray(format: string) { + let dtf = new Intl.DateTimeFormat(undefined, {weekday: format}); + return range(7, i => dtf.format(new Date(2017, 0, i + 1))); + } + + private _createDatesArray(format: string) { + let dtf = new Intl.DateTimeFormat(undefined, {day: format}); + return range(31, i => dtf.format(new Date(2017, 0, i + 1))); + } + + /** + * Creates a function to format SimpleDates as strings using Intl.DateTimeFormat. + * @param options The options to use for Intl.DateTimeFormat. + * @returns The newly created format function, or null if the Intl API is not available. + * @private + */ + private _createFormatFunction(options: Object): (date: SimpleDate) => string { + if (SUPPORTS_INTL_API) { + let dtf = new Intl.DateTimeFormat(undefined, options); + return (date: SimpleDate) => dtf.format(date.toNativeDate()); + } + return null; + } +} diff --git a/src/lib/core/datetime/index.ts b/src/lib/core/datetime/index.ts new file mode 100644 index 000000000000..12c859a57f23 --- /dev/null +++ b/src/lib/core/datetime/index.ts @@ -0,0 +1,12 @@ +import {NgModule} from '@angular/core'; +import {DefaultCalendarLocale, CalendarLocale} from './calendar-locale'; + + +export * from './calendar-locale'; +export * from './simple-date'; + + +@NgModule({ + providers: [{provide: CalendarLocale, useClass: DefaultCalendarLocale}], +}) +export class DatetimeModule {} diff --git a/src/lib/core/datetime/simple-date.spec.ts b/src/lib/core/datetime/simple-date.spec.ts new file mode 100644 index 000000000000..bbb69e00fc40 --- /dev/null +++ b/src/lib/core/datetime/simple-date.spec.ts @@ -0,0 +1,12 @@ +import {SimpleDate} from './simple-date'; + + +describe('SimpleDate', () => { + it('can be created from native Date', () => { + expect(SimpleDate.fromNativeDate(new Date(2017, 0, 1))).toEqual(new SimpleDate(2017, 0, 1)); + }); + + it('can be converted to native Date', () => { + expect(new SimpleDate(2017, 0, 1).toNativeDate()).toEqual(new Date(2017, 0, 1)); + }); +}); diff --git a/src/lib/core/datetime/simple-date.ts b/src/lib/core/datetime/simple-date.ts new file mode 100644 index 000000000000..6fbe28a2a4fd --- /dev/null +++ b/src/lib/core/datetime/simple-date.ts @@ -0,0 +1,15 @@ +/** + * A replacement for the native JS Date class that allows us to avoid dealing with time zone + * details and the time component of the native Date. + */ +export class SimpleDate { + static fromNativeDate(nativeDate: Date) { + return new SimpleDate(nativeDate.getFullYear(), nativeDate.getMonth(), nativeDate.getDate()); + } + + constructor(public year: number, public month: number, public date: number) {} + + toNativeDate() { + return new Date(this.year, this.month, this.date); + } +} diff --git a/src/lib/module.ts b/src/lib/module.ts index 3e93c3e4db19..52d1bf66d047 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -8,6 +8,7 @@ import { OverlayModule, A11yModule, MdCommonModule, + DatetimeModule, } from './core/index'; import {MdButtonToggleModule} from './button-toggle/index'; @@ -68,7 +69,8 @@ const MATERIAL_MODULES = [ A11yModule, PlatformModule, MdCommonModule, - ObserveContentModule + ObserveContentModule, + DatetimeModule, ]; /** @deprecated */ From 4347689e09dad4a2ad9c180ff72c84df84364fa9 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 7 Feb 2017 13:59:25 -0800 Subject: [PATCH 02/37] feat(datepicker): add month & year view (#2904) * date picker initial commit * month view * added month view functionality * more month view tweaking * started extracting common stuff to calendar-table. * base month view on calendar table * added year view * add disclaimers * addressed comments * fix lint * fixed aot and added comment * started on tests * calendar table tests * tests for month and year view * rebase on top of CalendarLocale & SimpleDate * add some additional functionality to SimpleDate * fix lint * addressed comments * add comment --- src/demo-app/datepicker/datepicker-demo.html | 7 ++ src/demo-app/datepicker/datepicker-demo.ts | 13 ++ src/demo-app/demo-app-module.ts | 2 + src/demo-app/demo-app/demo-app.ts | 1 + src/demo-app/demo-app/routes.ts | 2 + src/lib/core/datetime/calendar-locale.spec.ts | 2 +- src/lib/core/datetime/simple-date.spec.ts | 28 +++++ src/lib/core/datetime/simple-date.ts | 53 +++++++- src/lib/core/theming/_all-theme.scss | 2 + src/lib/datepicker/README.md | 3 + src/lib/datepicker/_datepicker-theme.scss | 30 +++++ src/lib/datepicker/calendar-table.html | 24 ++++ src/lib/datepicker/calendar-table.scss | 39 ++++++ src/lib/datepicker/calendar-table.spec.ts | 116 +++++++++++++++++ src/lib/datepicker/calendar-table.ts | 66 ++++++++++ src/lib/datepicker/index.ts | 19 +++ src/lib/datepicker/month-view.html | 7 ++ src/lib/datepicker/month-view.spec.ts | 76 +++++++++++ src/lib/datepicker/month-view.ts | 119 ++++++++++++++++++ src/lib/datepicker/year-view.html | 7 ++ src/lib/datepicker/year-view.spec.ts | 76 +++++++++++ src/lib/datepicker/year-view.ts | 100 +++++++++++++++ src/lib/module.ts | 2 + src/lib/public_api.ts | 1 + 24 files changed, 793 insertions(+), 2 deletions(-) create mode 100644 src/demo-app/datepicker/datepicker-demo.html create mode 100644 src/demo-app/datepicker/datepicker-demo.ts create mode 100644 src/lib/datepicker/README.md create mode 100644 src/lib/datepicker/_datepicker-theme.scss create mode 100644 src/lib/datepicker/calendar-table.html create mode 100644 src/lib/datepicker/calendar-table.scss create mode 100644 src/lib/datepicker/calendar-table.spec.ts create mode 100644 src/lib/datepicker/calendar-table.ts create mode 100644 src/lib/datepicker/index.ts create mode 100644 src/lib/datepicker/month-view.html create mode 100644 src/lib/datepicker/month-view.spec.ts create mode 100644 src/lib/datepicker/month-view.ts create mode 100644 src/lib/datepicker/year-view.html create mode 100644 src/lib/datepicker/year-view.spec.ts create mode 100644 src/lib/datepicker/year-view.ts diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html new file mode 100644 index 000000000000..65903b322f5b --- /dev/null +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -0,0 +1,7 @@ +

Work in progress, not ready for use.

+ + + + +
+
{{selected?.toNativeDate()}}
diff --git a/src/demo-app/datepicker/datepicker-demo.ts b/src/demo-app/datepicker/datepicker-demo.ts new file mode 100644 index 000000000000..38c4e8247c3c --- /dev/null +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -0,0 +1,13 @@ +import {Component} from '@angular/core'; +import {SimpleDate} from '@angular/material'; + + +@Component({ + moduleId: module.id, + selector: 'datepicker-demo', + templateUrl: 'datepicker-demo.html' +}) +export class DatepickerDemo { + date = SimpleDate.today(); + selected: SimpleDate; +} diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index c18519702c1e..673d27703e83 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -43,6 +43,7 @@ import {PlatformDemo} from './platform/platform-demo'; import {AutocompleteDemo} from './autocomplete/autocomplete-demo'; import {InputDemo} from './input/input-demo'; import {StyleDemo} from './style/style-demo'; +import {DatepickerDemo} from './datepicker/datepicker-demo'; @NgModule({ @@ -64,6 +65,7 @@ import {StyleDemo} from './style/style-demo'; CardDemo, ChipsDemo, CheckboxDemo, + DatepickerDemo, DemoApp, DialogDemo, GesturesDemo, diff --git a/src/demo-app/demo-app/demo-app.ts b/src/demo-app/demo-app/demo-app.ts index c6efa81c67af..baaf7f568771 100644 --- a/src/demo-app/demo-app/demo-app.ts +++ b/src/demo-app/demo-app/demo-app.ts @@ -25,6 +25,7 @@ export class DemoApp { {name: 'Card', route: 'card'}, {name: 'Chips', route: 'chips'}, {name: 'Checkbox', route: 'checkbox'}, + {name: 'Datepicker', route: 'datepicker'}, {name: 'Dialog', route: 'dialog'}, {name: 'Gestures', route: 'gestures'}, {name: 'Grid List', route: 'grid-list'}, diff --git a/src/demo-app/demo-app/routes.ts b/src/demo-app/demo-app/routes.ts index fa01702d1e7d..b25605399e3f 100644 --- a/src/demo-app/demo-app/routes.ts +++ b/src/demo-app/demo-app/routes.ts @@ -32,6 +32,7 @@ import {PlatformDemo} from '../platform/platform-demo'; import {AutocompleteDemo} from '../autocomplete/autocomplete-demo'; import {InputDemo} from '../input/input-demo'; import {StyleDemo} from '../style/style-demo'; +import {DatepickerDemo} from '../datepicker/datepicker-demo'; export const DEMO_APP_ROUTES: Routes = [ {path: '', component: Home}, @@ -39,6 +40,7 @@ export const DEMO_APP_ROUTES: Routes = [ {path: 'button', component: ButtonDemo}, {path: 'card', component: CardDemo}, {path: 'chips', component: ChipsDemo}, + {path: 'datepicker', component: DatepickerDemo}, {path: 'radio', component: RadioDemo}, {path: 'select', component: SelectDemo}, {path: 'sidenav', component: SidenavDemo}, diff --git a/src/lib/core/datetime/calendar-locale.spec.ts b/src/lib/core/datetime/calendar-locale.spec.ts index 455e2a68cae0..d37b0e072e9b 100644 --- a/src/lib/core/datetime/calendar-locale.spec.ts +++ b/src/lib/core/datetime/calendar-locale.spec.ts @@ -101,5 +101,5 @@ describe('DefaultCalendarLocale', () => { it('gets header label for calendar year', () => { expect(calendarLocale.getCalendarYearHeaderLabel(new SimpleDate(2017, 0, 1))).toBe('2017'); - }) + }); }); diff --git a/src/lib/core/datetime/simple-date.spec.ts b/src/lib/core/datetime/simple-date.spec.ts index bbb69e00fc40..e47dead290bc 100644 --- a/src/lib/core/datetime/simple-date.spec.ts +++ b/src/lib/core/datetime/simple-date.spec.ts @@ -9,4 +9,32 @@ describe('SimpleDate', () => { it('can be converted to native Date', () => { expect(new SimpleDate(2017, 0, 1).toNativeDate()).toEqual(new Date(2017, 0, 1)); }); + + it('handles month and date overflow', () => { + expect(new SimpleDate(2017, 12, 32)).toEqual(new SimpleDate(2018, 1, 1)); + }); + + it('handles month and date underflow', () => { + expect(new SimpleDate(2017, -1, 0)).toEqual(new SimpleDate(2016, 10, 30)); + }); + + it('handles low year numbers', () => { + expect(new SimpleDate(-1, 0, 1).year).toBe(-1); + expect(new SimpleDate(0, 0, 1).year).toBe(0); + expect(new SimpleDate(50, 0, 1).year).toBe(50); + expect(new SimpleDate(99, 0, 1).year).toBe(99); + expect(new SimpleDate(100, 0, 1).year).toBe(100); + }); + + it('handles low year number with over/under-flow', () => { + expect(new SimpleDate(50, 12 * 51, 1).year).toBe(101); + expect(new SimpleDate(50, 12, 1).year).toBe(51); + expect(new SimpleDate(50, -12, 1).year).toBe(49); + expect(new SimpleDate(50, -12 * 51, 1).year).toBe(-1); + }); + + it('adds years, months, and days', () => { + expect(new SimpleDate(2017, 0, 1).add({years: 1, months: 1, days: 1})) + .toEqual(new SimpleDate(2018, 1, 2)); + }); }); diff --git a/src/lib/core/datetime/simple-date.ts b/src/lib/core/datetime/simple-date.ts index 6fbe28a2a4fd..29e19338162d 100644 --- a/src/lib/core/datetime/simple-date.ts +++ b/src/lib/core/datetime/simple-date.ts @@ -3,12 +3,63 @@ * details and the time component of the native Date. */ export class SimpleDate { + /** + * Create a SimpleDate from a native JS Date object. + * @param nativeDate The native JS Date object to convert. + */ static fromNativeDate(nativeDate: Date) { return new SimpleDate(nativeDate.getFullYear(), nativeDate.getMonth(), nativeDate.getDate()); } - constructor(public year: number, public month: number, public date: number) {} + /** Creates a SimpleDate object representing today. */ + static today() { + return SimpleDate.fromNativeDate(new Date()); + } + + /** The native JS Date. */ + private _date: Date; + + constructor(year: number, month: number, date: number) { + this._date = new Date(year, month, date); + // We need to correct for the fact that JS native Date treats years in range [0, 99] as + // abbreviations for 19xx. + if (year >= 0 && year < 100) { + this._date = new Date(this._date.setFullYear(this.year - 1900)); + } + } + + /** The year component of this date. */ + get year() { + return this._date.getFullYear(); + } + + /** The month component of this date. (0-indexed, 0 = January). */ + get month() { + return this._date.getMonth(); + } + + /** The date component of this date. (1-indexed, 1 = 1st of month). */ + get date() { + return this._date.getDate(); + } + + /** The day component of this date. (0-indexed, 0 = Sunday) */ + get day() { + return this._date.getDay(); + } + + /** + * Adds an amount of time (in days, months, and years) to the date. + * @param amount The amount of time to add. + */ + add(amount: {days: number, months: number, years: number}) { + return new SimpleDate( + this.year + amount.years || 0, + this.month + amount.months || 0, + this.date + amount.days || 0); + } + /** Converts the SimpleDate to a native JS Date object. */ toNativeDate() { return new Date(this.year, this.month, this.date); } diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index 555a8687119f..6dbfae92684c 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -6,6 +6,7 @@ @import '../../card/card-theme'; @import '../../checkbox/checkbox-theme'; @import '../../chips/chips-theme'; +@import '../../datepicker/datepicker-theme'; @import '../../dialog/dialog-theme'; @import '../../grid-list/grid-list-theme'; @import '../../icon/icon-theme'; @@ -33,6 +34,7 @@ @include mat-card-theme($theme); @include mat-checkbox-theme($theme); @include mat-chips-theme($theme); + @include mat-datepicker-theme($theme); @include mat-dialog-theme($theme); @include mat-grid-list-theme($theme); @include mat-icon-theme($theme); diff --git a/src/lib/datepicker/README.md b/src/lib/datepicker/README.md new file mode 100644 index 000000000000..f206ff3f0b46 --- /dev/null +++ b/src/lib/datepicker/README.md @@ -0,0 +1,3 @@ +# md-datepicker + +Work in progress, not ready for use. diff --git a/src/lib/datepicker/_datepicker-theme.scss b/src/lib/datepicker/_datepicker-theme.scss new file mode 100644 index 000000000000..af66d645fa60 --- /dev/null +++ b/src/lib/datepicker/_datepicker-theme.scss @@ -0,0 +1,30 @@ +@import '../core/theming/palette'; +@import '../core/theming/theming'; + + +@mixin mat-datepicker-theme($theme) { + $primary: map-get($theme, primary); + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + .mat-calendar-table-label { + color: md-color($foreground, secondary-text); + } + + .mat-calendar-table-cell-content { + color: md-color($foreground, text); + + .mat-calendar-table-cell:hover & { + background: md-color($background, hover); + } + + .mat-calendar-table-cell &.mat-calendar-table-selected { + background: md-color($primary); + color: md-color($primary, default-contrast); + } + + &.mat-calendar-table-today { + border-color: md-color($foreground, divider); + } + } +} diff --git a/src/lib/datepicker/calendar-table.html b/src/lib/datepicker/calendar-table.html new file mode 100644 index 000000000000..6df7a677b4e4 --- /dev/null +++ b/src/lib/datepicker/calendar-table.html @@ -0,0 +1,24 @@ + + + + + + + + + + + +
{{label}}
+ {{_firstRowOffset >= labelMinRequiredCells ? label : ''}} + +
+ {{item.displayValue}} +
+
diff --git a/src/lib/datepicker/calendar-table.scss b/src/lib/datepicker/calendar-table.scss new file mode 100644 index 000000000000..f6c99a7df15e --- /dev/null +++ b/src/lib/datepicker/calendar-table.scss @@ -0,0 +1,39 @@ +$mat-calendar-table-font-size: 12px !default; +$mat-calendar-table-cell-padding: 1px !default; +$mat-calendar-table-cell-content-size: 32px !default; +$mat-calendar-table-cell-content-border-width: 1px !default; + + +.mat-calendar-table-table { + border-spacing: 0; + font-size: $mat-calendar-table-font-size; +} + +.mat-calendar-table-label { + height: $mat-calendar-table-cell-content-size; + padding: 0 0 0 10px; + text-align: left; + font-weight: normal; +} + +.mat-calendar-table-cell { + padding: $mat-calendar-table-cell-padding; +} + +.mat-calendar-table-cell-content { + display: table-cell; + box-sizing: border-box; + width: $mat-calendar-table-cell-content-size; + height: $mat-calendar-table-cell-content-size; + border: $mat-calendar-table-cell-content-border-width solid transparent; + border-radius: 50%; + text-align: center; + vertical-align: middle; +} + +[dir='rtl'] { + .mat-calendar-table-label { + padding: 0 10px 0 0; + text-align: right; + } +} diff --git a/src/lib/datepicker/calendar-table.spec.ts b/src/lib/datepicker/calendar-table.spec.ts new file mode 100644 index 000000000000..a7f12e786d3f --- /dev/null +++ b/src/lib/datepicker/calendar-table.spec.ts @@ -0,0 +1,116 @@ +import {async, TestBed, ComponentFixture} from '@angular/core/testing'; +import {MdDatepickerModule} from './index'; +import {Component} from '@angular/core'; +import {MdCalendarTable, MdCalendarCell} from './calendar-table'; +import {By} from '@angular/platform-browser'; + + +describe('MdCalendarTable', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdDatepickerModule], + declarations: [ + StandardCalendarTable, + ], + }); + + TestBed.compileComponents(); + })); + + describe('standard calendar table', () => { + let fixture: ComponentFixture; + let testComponent: StandardCalendarTable; + let calendarTableNativeElement: Element; + let rowEls: NodeListOf; + let labelEls: NodeListOf; + let cellEls: NodeListOf; + + let refreshElementLists = () => { + rowEls = calendarTableNativeElement.querySelectorAll('tr'); + labelEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-table-label'); + cellEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-table-cell'); + }; + + beforeEach(() => { + fixture = TestBed.createComponent(StandardCalendarTable); + fixture.detectChanges(); + + let calendarTableDebugElement = fixture.debugElement.query(By.directive(MdCalendarTable)); + calendarTableNativeElement = calendarTableDebugElement.nativeElement; + testComponent = fixture.componentInstance; + + refreshElementLists(); + }); + + it('creates table', () => { + expect(rowEls.length).toBe(3); + expect(labelEls.length).toBe(1); + expect(cellEls.length).toBe(14); + }); + + it('highlights today', () => { + let todayCell = calendarTableNativeElement.querySelector('.mat-calendar-table-today'); + expect(todayCell).not.toBeNull(); + expect(todayCell.innerHTML.trim()).toBe('3'); + }); + + it('highlights selected', () => { + let selectedCell = calendarTableNativeElement.querySelector('.mat-calendar-table-selected'); + expect(selectedCell).not.toBeNull(); + expect(selectedCell.innerHTML.trim()).toBe('4'); + }); + + it('places label in first row if space is available', () => { + testComponent.rows[0] = testComponent.rows[0].slice(3); + testComponent.rows = testComponent.rows.slice(); + fixture.detectChanges(); + refreshElementLists(); + + expect(rowEls.length).toBe(2); + expect(labelEls.length).toBe(1); + expect(cellEls.length).toBe(11); + expect(rowEls[0].firstElementChild.classList.contains('mat-calendar-table-label')).toBe( + true, 'first cell should be the label'); + expect(labelEls[0].getAttribute('colspan')).toBe('3'); + }); + + it('cell should be selected on click', () => { + let todayElement = + calendarTableNativeElement.querySelector('.mat-calendar-table-today') as HTMLElement; + todayElement.click(); + fixture.detectChanges(); + + expect(todayElement.classList.contains('mat-calendar-table-selected')).toBe( + true, 'today should be selected'); + }); + }); +}); + + +@Component({ + template: ` + `, +}) +class StandardCalendarTable { + label = 'Jan 2017'; + rows = [[1, 2, 3, 4, 5, 6, 7], [8, 9, 10, 11, 12, 13, 14]].map(r => r.map(createCell)); + todayValue = 3; + selectedValue = 4; + labelMinRequiredCells = 3; + numCols = 7; + + onSelect(value: number) { + this.selectedValue = value; + } +} + + +function createCell(value: number) { + return new MdCalendarCell(value, `${value}`); +} diff --git a/src/lib/datepicker/calendar-table.ts b/src/lib/datepicker/calendar-table.ts new file mode 100644 index 000000000000..70999010cbd5 --- /dev/null +++ b/src/lib/datepicker/calendar-table.ts @@ -0,0 +1,66 @@ +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + Input, + EventEmitter, + Output +} from '@angular/core'; + + +/** + * An internal class that represents the data corresponding to a single calendar cell. + * @docs-private + */ +export class MdCalendarCell { + constructor(public value: number, public displayValue: string) {} +} + + +/** + * An internal component used to display calendar data in a table. + * @docs-private + */ +@Component({ + moduleId: module.id, + selector: 'md-calendar-table', + templateUrl: 'calendar-table.html', + styleUrls: ['calendar-table.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdCalendarTable { + /** The label for the table. (e.g. "Jan 2017"). */ + @Input() label: string; + + /** The cells to display in the table. */ + @Input() rows: MdCalendarCell[][]; + + /** The value in the table that corresponds to today. */ + @Input() todayValue: number; + + /** The value in the table that is currently selected. */ + @Input() selectedValue: number; + + /** The minimum number of free cells needed to fit the label in the first row. */ + @Input() labelMinRequiredCells: number; + + /** The number of columns in the table. */ + @Input() numCols = 7; + + /** Emits when a new value is selected. */ + @Output() selectedValueChange = new EventEmitter(); + + _cellClicked(value: number) { + if (this.selectedValue && this.selectedValue === value) { + return; + } + this.selectedValueChange.emit(value); + } + + /** The number of blank cells to put at the beginning for the first row. */ + get _firstRowOffset() { + return this.rows && this.rows.length && this.rows[0].length ? + this.numCols - this.rows[0].length : 0; + } +} diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts new file mode 100644 index 000000000000..05eafc0cf9bf --- /dev/null +++ b/src/lib/datepicker/index.ts @@ -0,0 +1,19 @@ +import {NgModule} from '@angular/core'; +import {MdMonthView} from './month-view'; +import {CommonModule} from '@angular/common'; +import {MdCalendarTable} from './calendar-table'; +import {MdYearView} from './year-view'; +import {DatetimeModule} from '../core/datetime/index'; + + +export * from './calendar-table'; +export * from './month-view'; +export * from './year-view'; + + +@NgModule({ + imports: [CommonModule, DatetimeModule], + exports: [MdCalendarTable, MdMonthView, MdYearView], + declarations: [MdCalendarTable, MdMonthView, MdYearView], +}) +export class MdDatepickerModule {} diff --git a/src/lib/datepicker/month-view.html b/src/lib/datepicker/month-view.html new file mode 100644 index 000000000000..e0e0559cfc9c --- /dev/null +++ b/src/lib/datepicker/month-view.html @@ -0,0 +1,7 @@ + + diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts new file mode 100644 index 000000000000..c9a7badd5f92 --- /dev/null +++ b/src/lib/datepicker/month-view.spec.ts @@ -0,0 +1,76 @@ +import {async, TestBed, ComponentFixture} from '@angular/core/testing'; +import {MdDatepickerModule} from './index'; +import {Component} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {MdMonthView} from './month-view'; +import {SimpleDate} from '../core/datetime/simple-date'; + + +describe('MdMonthView', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdDatepickerModule], + declarations: [ + StandardMonthView, + ], + }); + + TestBed.compileComponents(); + })); + + describe('standard month view', () => { + let fixture: ComponentFixture; + let testComponent: StandardMonthView; + let monthViewNativeElement: Element; + + beforeEach(() => { + fixture = TestBed.createComponent(StandardMonthView); + fixture.detectChanges(); + + let monthViewDebugElement = fixture.debugElement.query(By.directive(MdMonthView)); + monthViewNativeElement = monthViewDebugElement.nativeElement; + testComponent = fixture.componentInstance; + }); + + it('has correct year label', () => { + let labelEl = monthViewNativeElement.querySelector('.mat-calendar-table-label'); + expect(labelEl.innerHTML.trim()).toBe('Jan 2017'); + }); + + it('has 31 days', () => { + let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + expect(cellEls.length).toBe(31); + }); + + it('shows selected date if in same month', () => { + let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-table-selected'); + expect(selectedEl.innerHTML.trim()).toBe('10'); + }); + + it('does not show selected date if in different month', () => { + testComponent.selected = new SimpleDate(2017, 2, 10); + fixture.detectChanges(); + + let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-table-selected'); + expect(selectedEl).toBeNull(); + }); + + it('fires selected change event on cell clicked', () => { + let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + (cellEls[cellEls.length - 1] as HTMLElement).click(); + fixture.detectChanges(); + + let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-table-selected'); + expect(selectedEl.innerHTML.trim()).toBe('31'); + }); + }); +}); + + +@Component({ + template: ``, +}) +class StandardMonthView { + date = new SimpleDate(2017, 0, 5); + selected = new SimpleDate(2017, 0, 10); +} diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts new file mode 100644 index 000000000000..fbfc0c0d7d8c --- /dev/null +++ b/src/lib/datepicker/month-view.ts @@ -0,0 +1,119 @@ +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + Input, + EventEmitter, + Output, + AfterContentInit +} from '@angular/core'; +import {MdCalendarCell} from './calendar-table'; +import {CalendarLocale} from '../core/datetime/calendar-locale'; +import {SimpleDate} from '../core/datetime/simple-date'; + + +const DAYS_PER_WEEK = 7; + + +/** + * An internal component used to display a single month in the datepicker. + * @docs-private + */ +@Component({ + moduleId: module.id, + selector: 'md-month-view', + templateUrl: 'month-view.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdMonthView implements AfterContentInit { + /** + * The date to display in this month view (everything other than the month and year is ignored). + */ + @Input() + get date() { return this._date; } + set date(value) { + this._date = this._locale.parseDate(value) || SimpleDate.today(); + this._init(); + } + private _date = SimpleDate.today(); + + /** The currently selected date. */ + @Input() + get selected() { return this._selected; } + set selected(value) { + this._selected = this._locale.parseDate(value); + this._selectedDate = this._getDateInCurrentMonth(this.selected); + } + private _selected: SimpleDate; + + /** Emits when a new date is selected. */ + @Output() selectedChange = new EventEmitter(); + + /** The label for this month (e.g. "January 2017"). */ + _monthLabel: string; + + /** Grid of calendar cells representing the dates of the month. */ + _weeks: MdCalendarCell[][]; + + /** The number of blank cells in the first row before the 1st of the month. */ + _firstWeekOffset: number; + + /** + * The date of the month that the currently selected Date falls on. + * Null if the currently selected Date is in another month. + */ + _selectedDate: number; + + /** The date of the month that today falls on. Null if today is in another month. */ + _todayDate: number; + + constructor(private _locale: CalendarLocale) {} + + ngAfterContentInit(): void { + this._init(); + } + + /** Handles when a new date is selected. */ + _dateSelected(date: number) { + if (this.selected && this.selected.date == date) { + return; + } + this.selectedChange.emit(new SimpleDate(this.date.year, this.date.month, date)); + } + + /** Initializes this month view. */ + private _init() { + this._selectedDate = this._getDateInCurrentMonth(this.selected); + this._todayDate = this._getDateInCurrentMonth(SimpleDate.today()); + this._monthLabel = this._locale.getCalendarMonthHeaderLabel(this.date); + + let firstOfMonth = new SimpleDate(this.date.year, this.date.month, 1); + this._firstWeekOffset = + (DAYS_PER_WEEK + firstOfMonth.day - this._locale.firstDayOfWeek) % DAYS_PER_WEEK; + + this._createWeekCells(); + } + + /** Creates MdCalendarCells for the dates in this month. */ + private _createWeekCells() { + let daysInMonth = new SimpleDate(this.date.year, this.date.month + 1, 0).date; + this._weeks = [[]]; + for (let i = 0, cell = this._firstWeekOffset; i < daysInMonth; i++, cell++) { + if (cell == DAYS_PER_WEEK) { + this._weeks.push([]); + cell = 0; + } + this._weeks[this._weeks.length - 1] + .push(new MdCalendarCell(i + 1, this._locale.dates[i + 1])); + } + } + + /** + * Gets the date in this month that the given Date falls on. + * Returns null if the given Date is in another month. + */ + private _getDateInCurrentMonth(date: SimpleDate) { + return date && date.month == this.date.month ? date.date : null; + } +} diff --git a/src/lib/datepicker/year-view.html b/src/lib/datepicker/year-view.html new file mode 100644 index 000000000000..c2aa74d8e08c --- /dev/null +++ b/src/lib/datepicker/year-view.html @@ -0,0 +1,7 @@ + + diff --git a/src/lib/datepicker/year-view.spec.ts b/src/lib/datepicker/year-view.spec.ts new file mode 100644 index 000000000000..a60febe29320 --- /dev/null +++ b/src/lib/datepicker/year-view.spec.ts @@ -0,0 +1,76 @@ +import {async, TestBed, ComponentFixture} from '@angular/core/testing'; +import {MdDatepickerModule} from './index'; +import {Component} from '@angular/core'; +import {By} from '@angular/platform-browser'; +import {MdYearView} from './year-view'; +import {SimpleDate} from '../core/datetime/simple-date'; + + +describe('MdYearView', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdDatepickerModule], + declarations: [ + StandardYearView, + ], + }); + + TestBed.compileComponents(); + })); + + describe('standard year view', () => { + let fixture: ComponentFixture; + let testComponent: StandardYearView; + let yearViewNativeElement: Element; + + beforeEach(() => { + fixture = TestBed.createComponent(StandardYearView); + fixture.detectChanges(); + + let yearViewDebugElement = fixture.debugElement.query(By.directive(MdYearView)); + yearViewNativeElement = yearViewDebugElement.nativeElement; + testComponent = fixture.componentInstance; + }); + + it('has correct year label', () => { + let labelEl = yearViewNativeElement.querySelector('.mat-calendar-table-label'); + expect(labelEl.innerHTML.trim()).toBe('2017'); + }); + + it('has 12 months', () => { + let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + expect(cellEls.length).toBe(12); + }); + + it('shows selected month if in same year', () => { + let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); + expect(selectedEl.innerHTML.trim()).toBe('Mar'); + }); + + it('does not show selected month if in different year', () => { + testComponent.selected = new SimpleDate(2016, 2, 10); + fixture.detectChanges(); + + let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); + expect(selectedEl).toBeNull(); + }); + + it('fires selected change event on cell clicked', () => { + let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + (cellEls[cellEls.length - 1] as HTMLElement).click(); + fixture.detectChanges(); + + let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); + expect(selectedEl.innerHTML.trim()).toBe('Dec'); + }); + }); +}); + + +@Component({ + template: ``, +}) +class StandardYearView { + date = new SimpleDate(2017, 0, 5); + selected = new SimpleDate(2017, 2, 10); +} diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts new file mode 100644 index 000000000000..7521103f73fa --- /dev/null +++ b/src/lib/datepicker/year-view.ts @@ -0,0 +1,100 @@ +import { + Component, + ViewEncapsulation, + ChangeDetectionStrategy, + Input, + AfterContentInit, + Output, + EventEmitter +} from '@angular/core'; +import {MdCalendarCell} from './calendar-table'; +import {CalendarLocale} from '../core/datetime/calendar-locale'; +import {SimpleDate} from '../core/datetime/simple-date'; + + +/** + * An internal component used to display a single year in the datepicker. + * @docs-private + */ +@Component({ + moduleId: module.id, + selector: 'md-year-view', + templateUrl: 'year-view.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdYearView implements AfterContentInit { + /** The date to display in this year view (everything other than the year is ignored). */ + @Input() + get date() { return this._date; } + set date(value) { + this._date = this._locale.parseDate(value) || SimpleDate.today(); + this._init(); + } + private _date = SimpleDate.today(); + + /** The currently selected date. */ + @Input() + get selected() { return this._selected; } + set selected(value) { + this._selected = this._locale.parseDate(value); + this._selectedMonth = this._getMonthInCurrentYear(this.selected); + } + private _selected: SimpleDate; + + /** Emits when a new month is selected. */ + @Output() selectedChange = new EventEmitter(); + + /** Grid of calendar cells representing the months of the year. */ + _months: MdCalendarCell[][]; + + /** The label for this year (e.g. "2017"). */ + _yearLabel: string; + + /** The month in this year that today falls on. Null if today is in a different year. */ + _todayMonth: number; + + /** + * The month in this year that the selected Date falls on. + * Null if the selected Date is in a different year. + */ + _selectedMonth: number; + + constructor(private _locale: CalendarLocale) { + // First row of months only contains 5 elements so we can fit the year label on the same row. + this._months = [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11]].map(row => row.map( + month => this._createCellForMonth(month))); + } + + ngAfterContentInit() { + this._init(); + } + + /** Handles when a new month is selected. */ + _monthSelected(month: number) { + if (this.selected && this.selected.month == month) { + return; + } + this.selectedChange.emit(new SimpleDate(this.date.year, month, 1)); + } + + /** Initializes this month view. */ + private _init() { + this._selectedMonth = this._getMonthInCurrentYear(this.selected); + this._todayMonth = this._getMonthInCurrentYear(SimpleDate.today()); + this._yearLabel = this._locale.getCalendarYearHeaderLabel(this._date); + } + + /** + * Gets the month in this year that the given Date falls on. + * Returns null if the given Date is in another year. + */ + private _getMonthInCurrentYear(date: SimpleDate) { + return date && date.year == this.date.year ? date.month : null; + } + + /** Creates an MdCalendarCell for the given month. */ + private _createCellForMonth(month: number) { + return new MdCalendarCell(month, this._locale.shortMonths[month]); + } +} diff --git a/src/lib/module.ts b/src/lib/module.ts index 52d1bf66d047..442521de14ed 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -36,6 +36,7 @@ import {MdDialogModule} from './dialog/index'; import {PlatformModule} from './core/platform/index'; import {MdAutocompleteModule} from './autocomplete/index'; import {StyleModule} from './core/style/index'; +import {MdDatepickerModule} from './datepicker/index'; const MATERIAL_MODULES = [ MdAutocompleteModule, @@ -44,6 +45,7 @@ const MATERIAL_MODULES = [ MdCardModule, MdChipsModule, MdCheckboxModule, + MdDatepickerModule, MdDialogModule, MdGridListModule, MdIconModule, diff --git a/src/lib/public_api.ts b/src/lib/public_api.ts index cb2f324e643f..ac3d936034b1 100644 --- a/src/lib/public_api.ts +++ b/src/lib/public_api.ts @@ -13,6 +13,7 @@ export * from './button-toggle/index'; export * from './card/index'; export * from './chips/index'; export * from './checkbox/index'; +export * from './datepicker/index'; export * from './dialog/index'; export * from './grid-list/index'; export * from './icon/index'; From fc34721c144c4369b0c0d90f26568afffedc880d Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 7 Feb 2017 17:04:18 -0800 Subject: [PATCH 03/37] fixes i forgot to push before merging #2904 (#2978) --- src/lib/core/datetime/simple-date.ts | 16 ++++++++-------- src/lib/datepicker/calendar-table.scss | 5 +++-- src/lib/datepicker/calendar-table.spec.ts | 8 ++++---- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/lib/core/datetime/simple-date.ts b/src/lib/core/datetime/simple-date.ts index 29e19338162d..75a498425d57 100644 --- a/src/lib/core/datetime/simple-date.ts +++ b/src/lib/core/datetime/simple-date.ts @@ -7,12 +7,12 @@ export class SimpleDate { * Create a SimpleDate from a native JS Date object. * @param nativeDate The native JS Date object to convert. */ - static fromNativeDate(nativeDate: Date) { + static fromNativeDate(nativeDate: Date): SimpleDate { return new SimpleDate(nativeDate.getFullYear(), nativeDate.getMonth(), nativeDate.getDate()); } /** Creates a SimpleDate object representing today. */ - static today() { + static today(): SimpleDate { return SimpleDate.fromNativeDate(new Date()); } @@ -29,22 +29,22 @@ export class SimpleDate { } /** The year component of this date. */ - get year() { + get year(): number { return this._date.getFullYear(); } /** The month component of this date. (0-indexed, 0 = January). */ - get month() { + get month(): number { return this._date.getMonth(); } /** The date component of this date. (1-indexed, 1 = 1st of month). */ - get date() { + get date(): number { return this._date.getDate(); } /** The day component of this date. (0-indexed, 0 = Sunday) */ - get day() { + get day(): number { return this._date.getDay(); } @@ -52,7 +52,7 @@ export class SimpleDate { * Adds an amount of time (in days, months, and years) to the date. * @param amount The amount of time to add. */ - add(amount: {days: number, months: number, years: number}) { + add(amount: {days: number, months: number, years: number}): SimpleDate { return new SimpleDate( this.year + amount.years || 0, this.month + amount.months || 0, @@ -60,7 +60,7 @@ export class SimpleDate { } /** Converts the SimpleDate to a native JS Date object. */ - toNativeDate() { + toNativeDate(): Date { return new Date(this.year, this.month, this.date); } } diff --git a/src/lib/datepicker/calendar-table.scss b/src/lib/datepicker/calendar-table.scss index f6c99a7df15e..2b3503ef0227 100644 --- a/src/lib/datepicker/calendar-table.scss +++ b/src/lib/datepicker/calendar-table.scss @@ -2,6 +2,7 @@ $mat-calendar-table-font-size: 12px !default; $mat-calendar-table-cell-padding: 1px !default; $mat-calendar-table-cell-content-size: 32px !default; $mat-calendar-table-cell-content-border-width: 1px !default; +$mat-calendar-table-label-padding-start: 10px !default; .mat-calendar-table-table { @@ -11,7 +12,7 @@ $mat-calendar-table-cell-content-border-width: 1px !default; .mat-calendar-table-label { height: $mat-calendar-table-cell-content-size; - padding: 0 0 0 10px; + padding: 0 0 0 $mat-calendar-table-label-padding-start; text-align: left; font-weight: normal; } @@ -33,7 +34,7 @@ $mat-calendar-table-cell-content-border-width: 1px !default; [dir='rtl'] { .mat-calendar-table-label { - padding: 0 10px 0 0; + padding: 0 $mat-calendar-table-label-padding-start 0 0; text-align: right; } } diff --git a/src/lib/datepicker/calendar-table.spec.ts b/src/lib/datepicker/calendar-table.spec.ts index a7f12e786d3f..fb9869bda99a 100644 --- a/src/lib/datepicker/calendar-table.spec.ts +++ b/src/lib/datepicker/calendar-table.spec.ts @@ -69,8 +69,8 @@ describe('MdCalendarTable', () => { expect(rowEls.length).toBe(2); expect(labelEls.length).toBe(1); expect(cellEls.length).toBe(11); - expect(rowEls[0].firstElementChild.classList.contains('mat-calendar-table-label')).toBe( - true, 'first cell should be the label'); + expect(rowEls[0].firstElementChild.classList) + .toContain('mat-calendar-table-label', 'first cell should be the label'); expect(labelEls[0].getAttribute('colspan')).toBe('3'); }); @@ -80,8 +80,8 @@ describe('MdCalendarTable', () => { todayElement.click(); fixture.detectChanges(); - expect(todayElement.classList.contains('mat-calendar-table-selected')).toBe( - true, 'today should be selected'); + expect(todayElement.classList) + .toContain('mat-calendar-table-selected', 'today should be selected'); }); }); }); From e24f0ce2bee4e1ca74de307af2a337d294ff5379 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 10 Feb 2017 10:26:06 -0800 Subject: [PATCH 04/37] feat(datepicker): create the md-datepicker component (#3024) * date picker initial commit * month view * added month view functionality * more month view tweaking * started extracting common stuff to calendar-table. * base month view on calendar table * added year view * add disclaimers * addressed comments * fix lint * fixed aot and added comment * started on tests * calendar table tests * tests for month and year view * rebase on top of CalendarLocale & SimpleDate * add some additional functionality to SimpleDate * add md-datepicker and input[mdDatepicker] * Add touch UI support * fix some stuff that got messed up in rebasing * addressed comments * move position strategy to separate method * added tests for error cases --- src/demo-app/datepicker/datepicker-demo.html | 5 + src/lib/datepicker/datepicker-input.ts | 23 +++ src/lib/datepicker/datepicker.html | 3 + src/lib/datepicker/datepicker.spec.ts | 124 +++++++++++++++ src/lib/datepicker/datepicker.ts | 159 +++++++++++++++++++ src/lib/datepicker/index.ts | 12 +- 6 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 src/lib/datepicker/datepicker-input.ts create mode 100644 src/lib/datepicker/datepicker.html create mode 100644 src/lib/datepicker/datepicker.spec.ts create mode 100644 src/lib/datepicker/datepicker.ts diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index 65903b322f5b..331b77e28c2d 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -5,3 +5,8 @@

Work in progress, not ready for use.


{{selected?.toNativeDate()}}
+ + + + + diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts new file mode 100644 index 000000000000..84290461e160 --- /dev/null +++ b/src/lib/datepicker/datepicker-input.ts @@ -0,0 +1,23 @@ +import {Directive, ElementRef, Input} from '@angular/core'; +import {MdDatepicker} from './datepicker'; + + +/** Directive used to connect an input to a MdDatepicker. */ +@Directive({ + selector: 'input[mdDatepicker], input[matDatepicker]', +}) +export class MdDatepickerInput { + @Input() + set mdDatepicker(value: MdDatepicker) { + if (value) { + this._datepicker = value; + this._datepicker._registerInput(this._elementRef); + } + } + private _datepicker: MdDatepicker; + + @Input() + set matDatepicker(value: MdDatepicker) { this.mdDatepicker = value; } + + constructor(private _elementRef: ElementRef) {} +} diff --git a/src/lib/datepicker/datepicker.html b/src/lib/datepicker/datepicker.html new file mode 100644 index 000000000000..3fcc1f13bc5a --- /dev/null +++ b/src/lib/datepicker/datepicker.html @@ -0,0 +1,3 @@ + diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts new file mode 100644 index 000000000000..b6dbfab95629 --- /dev/null +++ b/src/lib/datepicker/datepicker.spec.ts @@ -0,0 +1,124 @@ +import {TestBed, async, ComponentFixture} from '@angular/core/testing'; +import {MdDatepickerModule} from './index'; +import {Component, ViewChild} from '@angular/core'; +import {MdDatepicker} from './datepicker'; + +describe('MdDatepicker', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdDatepickerModule], + declarations: [ + StandardDatepicker, + MultiInputDatepicker, + NoInputDatepicker, + ], + }); + + TestBed.compileComponents(); + })); + + describe('standard datepicker', () => { + let fixture: ComponentFixture; + let testComponent: StandardDatepicker; + + beforeEach(() => { + fixture = TestBed.createComponent(StandardDatepicker); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + }); + + it('openStandardUi should open popup', () => { + expect(document.querySelector('.cdk-overlay-pane')).toBeNull(); + + testComponent.datepicker.openStandardUi(); + fixture.detectChanges(); + + expect(document.querySelector('.cdk-overlay-pane')).not.toBeNull(); + }); + + it('openTouchUi should open dialog', () => { + expect(document.querySelector('md-dialog-container')).toBeNull(); + + testComponent.datepicker.openTouchUi(); + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + }); + + it('close should close popup', () => { + testComponent.datepicker.openStandardUi(); + fixture.detectChanges(); + + let popup = document.querySelector('.cdk-overlay-pane'); + expect(popup).not.toBeNull(); + expect(parseInt(getComputedStyle(popup).height)).not.toBe(0); + + testComponent.datepicker.close(); + fixture.detectChanges(); + + expect(parseInt(getComputedStyle(popup).height)).toBe(0); + }); + + it('close should close dialog', () => { + testComponent.datepicker.openTouchUi(); + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + + testComponent.datepicker.close(); + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).toBeNull(); + }); + }); + + describe('datepicker with too many inputs', () => { + it('should throw when multiple inputs registered', () => { + let fixture = TestBed.createComponent(MultiInputDatepicker); + expect(() => fixture.detectChanges()).toThrow(); + }); + }); + + describe('datepicker with no inputs', () => { + let fixture: ComponentFixture; + let testComponent: NoInputDatepicker; + + beforeEach(() => { + fixture = TestBed.createComponent(NoInputDatepicker); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + }); + + it('should throw when opened with no registered inputs', () => { + expect(() => testComponent.datepicker.openStandardUi()).toThrow(); + }); + }); +}); + + +@Component({ + template: ``, +}) +class StandardDatepicker { + @ViewChild('d') datepicker: MdDatepicker; +} + + +@Component({ + template: ` + + `, +}) +class MultiInputDatepicker { + @ViewChild('d') datepicker: MdDatepicker; +} + + +@Component({ + template: ``, +}) +class NoInputDatepicker { + @ViewChild('d') datepicker: MdDatepicker; +} diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts new file mode 100644 index 000000000000..fabe16441acb --- /dev/null +++ b/src/lib/datepicker/datepicker.ts @@ -0,0 +1,159 @@ +import { + Component, + TemplateRef, + ViewChild, + ViewEncapsulation, + ChangeDetectionStrategy, + ViewContainerRef, + Optional, + ElementRef, + OnDestroy +} from '@angular/core'; +import {Overlay} from '../core/overlay/overlay'; +import {OverlayRef} from '../core/overlay/overlay-ref'; +import {TemplatePortal} from '../core/portal/portal'; +import {OverlayState} from '../core/overlay/overlay-state'; +import {Dir} from '../core/rtl/dir'; +import {MdError} from '../core/errors/error'; +import {MdDialog} from '../dialog/dialog'; +import {MdDialogRef} from '../dialog/dialog-ref'; +import {PositionStrategy} from '../core/overlay/position/position-strategy'; +import { + OriginConnectionPosition, + OverlayConnectionPosition +} from '../core/overlay/position/connected-position'; + + +// TODO(mmalerba): Figure out what the real width should be. +const CALENDAR_POPUP_WIDTH = 300; + + +/** Component responsible for managing the datepicker popup/dialog. */ +@Component({ + moduleId: module.id, + selector: 'md-datepicker, mat-datepicker', + templateUrl: 'datepicker.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdDatepicker implements OnDestroy { + /** + * Whether the calendar UI is in touch mode. In touch mode the calendar opens in a dialog rather + * than a popup and elements have more padding to allow for bigger touch targets. + */ + touchUi: boolean; + + /** The calendar template. */ + @ViewChild(TemplateRef) calendarTemplate: TemplateRef; + + /** A reference to the overlay when the calendar is opened as a popup. */ + private _popupRef: OverlayRef; + + /** A reference to the dialog when the calendar is opened as a dialog. */ + private _dialogRef: MdDialogRef; + + /** A portal containing the calendar for this datepicker. */ + private _calendarPortal: TemplatePortal; + + /** The input element this datepicker is associated with. */ + private _inputElementRef: ElementRef; + + constructor(private _dialog: MdDialog, private _overlay: Overlay, + private _viewContainerRef: ViewContainerRef, @Optional() private _dir: Dir) {} + + ngOnDestroy() { + this.close(); + if (this._popupRef) { + this._popupRef.dispose(); + } + } + + /** + * Register an input with this datepicker. + * @param inputElementRef An ElementRef for the input. + */ + _registerInput(inputElementRef: ElementRef): void { + if (this._inputElementRef) { + throw new MdError('An MdDatepicker can only be associated with a single input.'); + } + this._inputElementRef = inputElementRef; + } + + /** Opens the calendar in standard UI mode. */ + openStandardUi(): void { + this._open(); + } + + /** Opens the calendar in touch UI mode. */ + openTouchUi(): void { + this._open(true); + } + + /** + * Open the calendar. + * @param touchUi Whether to use the touch UI. + */ + private _open(touchUi = false): void { + if (!this._inputElementRef) { + throw new MdError('Attempted to open an MdDatepicker with no associated input.'); + } + + if (!this._calendarPortal) { + this._calendarPortal = new TemplatePortal(this.calendarTemplate, this._viewContainerRef); + } + + this.touchUi = touchUi; + touchUi ? this._openAsDialog() : this._openAsPopup(); + } + + /** Close the calendar. */ + close(): void { + if (this._popupRef && this._popupRef.hasAttached()) { + this._popupRef.detach(); + } + if (this._dialogRef) { + this._dialogRef.close(); + this._dialogRef = null; + } + if (this._calendarPortal && this._calendarPortal.isAttached) { + this._calendarPortal.detach(); + } + } + + /** Open the calendar as a dialog. */ + private _openAsDialog(): void { + this._dialogRef = this._dialog.open(this.calendarTemplate); + } + + /** Open the calendar as a popup. */ + private _openAsPopup(): void { + if (!this._popupRef) { + this._createPopup(); + } + + if (!this._popupRef.hasAttached()) { + this._popupRef.attach(this._calendarPortal); + } + + this._popupRef.backdropClick().first().subscribe(() => this.close()); + } + + /** Create the popup. */ + private _createPopup(): void { + const overlayState = new OverlayState(); + overlayState.positionStrategy = this._createPopupPositionStrategy(); + overlayState.width = CALENDAR_POPUP_WIDTH; + overlayState.hasBackdrop = true; + overlayState.backdropClass = 'md-overlay-transparent-backdrop'; + overlayState.direction = this._dir ? this._dir.value : 'ltr'; + + this._popupRef = this._overlay.create(overlayState); + } + + /** Create the popup PositionStrategy. */ + private _createPopupPositionStrategy(): PositionStrategy { + let origin = {originX: 'start', originY: 'bottom'} as OriginConnectionPosition; + let overlay = {overlayX: 'start', overlayY: 'top'} as OverlayConnectionPosition; + return this._overlay.position().connectedTo(this._inputElementRef, origin, overlay); + } +} diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index 05eafc0cf9bf..9bea0410509a 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -4,16 +4,22 @@ import {CommonModule} from '@angular/common'; import {MdCalendarTable} from './calendar-table'; import {MdYearView} from './year-view'; import {DatetimeModule} from '../core/datetime/index'; +import {OverlayModule} from '../core/overlay/overlay-directives'; +import {MdDatepicker} from './datepicker'; +import {MdDatepickerInput} from './datepicker-input'; +import {MdDialogModule} from '../dialog/index'; export * from './calendar-table'; +export * from './datepicker'; +export * from './datepicker-input'; export * from './month-view'; export * from './year-view'; @NgModule({ - imports: [CommonModule, DatetimeModule], - exports: [MdCalendarTable, MdMonthView, MdYearView], - declarations: [MdCalendarTable, MdMonthView, MdYearView], + imports: [CommonModule, DatetimeModule, MdDialogModule, OverlayModule], + exports: [MdCalendarTable, MdDatepicker, MdDatepickerInput, MdMonthView, MdYearView], + declarations: [MdCalendarTable, MdDatepicker, MdDatepickerInput, MdMonthView, MdYearView], }) export class MdDatepickerModule {} From 16a16c69dd7278de274b0913f3e7c5de22909c53 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 14 Feb 2017 14:17:06 -0800 Subject: [PATCH 05/37] feat(datepicker): make calendar responsive (#3086) * started working on responsive calendar table * fix today, selected, hover styles * fix label positioning * replace em with px * fix today cell styling --- src/lib/datepicker/_datepicker-theme.scss | 26 +++++++------- src/lib/datepicker/calendar-table.scss | 44 +++++++++++++++++------ 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/lib/datepicker/_datepicker-theme.scss b/src/lib/datepicker/_datepicker-theme.scss index af66d645fa60..693eb77337fd 100644 --- a/src/lib/datepicker/_datepicker-theme.scss +++ b/src/lib/datepicker/_datepicker-theme.scss @@ -8,23 +8,25 @@ $background: map-get($theme, background); .mat-calendar-table-label { - color: md-color($foreground, secondary-text); + color: mat-color($foreground, secondary-text); } .mat-calendar-table-cell-content { - color: md-color($foreground, text); + color: mat-color($foreground, text); + border-color: transparent; + } - .mat-calendar-table-cell:hover & { - background: md-color($background, hover); - } + .mat-calendar-table-cell-content:hover { + background: mat-color($background, hover); + } - .mat-calendar-table-cell &.mat-calendar-table-selected { - background: md-color($primary); - color: md-color($primary, default-contrast); - } + .mat-calendar-table-selected, + .mat-calendar-table-selected:hover { + background: mat-color($primary); + color: mat-color($primary, default-contrast); + } - &.mat-calendar-table-today { - border-color: md-color($foreground, divider); - } + .mat-calendar-table-today { + border-color: mat-color($foreground, divider); } } diff --git a/src/lib/datepicker/calendar-table.scss b/src/lib/datepicker/calendar-table.scss index 2b3503ef0227..16466a7dac90 100644 --- a/src/lib/datepicker/calendar-table.scss +++ b/src/lib/datepicker/calendar-table.scss @@ -1,40 +1,62 @@ $mat-calendar-table-font-size: 12px !default; -$mat-calendar-table-cell-padding: 1px !default; -$mat-calendar-table-cell-content-size: 32px !default; +$mat-calendar-table-label-padding-start: 5% !default; +$mat-calendar-table-label-translation: -6px !default; +$mat-calendar-table-cell-min-size: 32px !default; +$mat-calendar-table-cell-size: 100% / 7 !default; +$mat-calendar-table-cell-content-margin: 5% !default; $mat-calendar-table-cell-content-border-width: 1px !default; -$mat-calendar-table-label-padding-start: 10px !default; + +$mat-calendar-table-min-size: 7 * $mat-calendar-table-cell-min-size !default; +$mat-calendar-table-cell-padding: $mat-calendar-table-cell-size / 2 !default; +$mat-calendar-table-cell-content-size: 100% - $mat-calendar-table-cell-content-margin * 2 !default; .mat-calendar-table-table { border-spacing: 0; + border-collapse: collapse; font-size: $mat-calendar-table-font-size; + min-width: $mat-calendar-table-min-size; + width: 100%; } .mat-calendar-table-label { - height: $mat-calendar-table-cell-content-size; - padding: 0 0 0 $mat-calendar-table-label-padding-start; + padding: 0 0 0 $mat-calendar-table-cell-padding; + transform: translateX($mat-calendar-table-label-translation); text-align: left; font-weight: normal; } .mat-calendar-table-cell { - padding: $mat-calendar-table-cell-padding; + position: relative; + width: $mat-calendar-table-cell-size; + height: 0; + line-height: 0; + padding: $mat-calendar-table-cell-padding 0; + text-align: center; } .mat-calendar-table-cell-content { - display: table-cell; + position: absolute; + top: $mat-calendar-table-cell-content-margin; + left: $mat-calendar-table-cell-content-margin; + + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; width: $mat-calendar-table-cell-content-size; height: $mat-calendar-table-cell-content-size; - border: $mat-calendar-table-cell-content-border-width solid transparent; + + border-width: $mat-calendar-table-cell-content-border-width; + border-style: solid; border-radius: 50%; - text-align: center; - vertical-align: middle; } [dir='rtl'] { .mat-calendar-table-label { - padding: 0 $mat-calendar-table-label-padding-start 0 0; + padding: 0 $mat-calendar-table-cell-padding 0 0; + transform: translateX(-$mat-calendar-table-label-translation); text-align: right; } } From bcff6be769aadd1a4fc3a06d1aa5242da81fe841 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 14 Feb 2017 14:20:07 -0800 Subject: [PATCH 06/37] feat(datepicker): add calendar component that pulls together month and year views (#2994) * create a calendar component that pulls together month and year views styling still needs some love * fix typo --- src/demo-app/datepicker/datepicker-demo.html | 3 +- src/demo-app/datepicker/datepicker-demo.scss | 3 + src/demo-app/datepicker/datepicker-demo.ts | 4 +- src/lib/core/datetime/simple-date.ts | 18 ++- src/lib/datepicker/calendar.html | 39 ++++++ src/lib/datepicker/calendar.scss | 36 ++++++ src/lib/datepicker/calendar.spec.ts | 128 +++++++++++++++++++ src/lib/datepicker/calendar.ts | 108 ++++++++++++++++ src/lib/datepicker/index.ts | 6 +- 9 files changed, 336 insertions(+), 9 deletions(-) create mode 100644 src/demo-app/datepicker/datepicker-demo.scss create mode 100644 src/lib/datepicker/calendar.html create mode 100644 src/lib/datepicker/calendar.scss create mode 100644 src/lib/datepicker/calendar.spec.ts create mode 100644 src/lib/datepicker/calendar.ts diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index 331b77e28c2d..70a7cc1e6556 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -1,7 +1,6 @@

Work in progress, not ready for use.

- - +
{{selected?.toNativeDate()}}
diff --git a/src/demo-app/datepicker/datepicker-demo.scss b/src/demo-app/datepicker/datepicker-demo.scss new file mode 100644 index 000000000000..c98cba687f8c --- /dev/null +++ b/src/demo-app/datepicker/datepicker-demo.scss @@ -0,0 +1,3 @@ +md-calendar { + width: 300px; +} diff --git a/src/demo-app/datepicker/datepicker-demo.ts b/src/demo-app/datepicker/datepicker-demo.ts index 38c4e8247c3c..240e721a5ef9 100644 --- a/src/demo-app/datepicker/datepicker-demo.ts +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -5,9 +5,11 @@ import {SimpleDate} from '@angular/material'; @Component({ moduleId: module.id, selector: 'datepicker-demo', - templateUrl: 'datepicker-demo.html' + templateUrl: 'datepicker-demo.html', + styleUrls: ['datepicker-demo.css'], }) export class DatepickerDemo { + startAt = new SimpleDate(2017, 0, 1); date = SimpleDate.today(); selected: SimpleDate; } diff --git a/src/lib/core/datetime/simple-date.ts b/src/lib/core/datetime/simple-date.ts index 75a498425d57..abb904fbab7a 100644 --- a/src/lib/core/datetime/simple-date.ts +++ b/src/lib/core/datetime/simple-date.ts @@ -52,11 +52,21 @@ export class SimpleDate { * Adds an amount of time (in days, months, and years) to the date. * @param amount The amount of time to add. */ - add(amount: {days: number, months: number, years: number}): SimpleDate { + add(amount: {days?: number, months?: number, years?: number}): SimpleDate { return new SimpleDate( - this.year + amount.years || 0, - this.month + amount.months || 0, - this.date + amount.days || 0); + this.year + (amount.years || 0), + this.month + (amount.months || 0), + this.date + (amount.days || 0)); + } + + /** + * Compares this SimpleDate with another SimpleDate. + * @param other The other SimpleDate + * @returns 0 if the dates are equal, a number less than 0 if this date is earlier, + * a number greater than 0 if this date is greater. + */ + compare(other: SimpleDate): number { + return this.year - other.year || this.month - other.month || this.date - other.date; } /** Converts the SimpleDate to a native JS Date object. */ diff --git a/src/lib/datepicker/calendar.html b/src/lib/datepicker/calendar.html new file mode 100644 index 000000000000..4c30a6bd9037 --- /dev/null +++ b/src/lib/datepicker/calendar.html @@ -0,0 +1,39 @@ +
+ +
+ + +
+ + + +
{{day}}
+ + + + + + diff --git a/src/lib/datepicker/calendar.scss b/src/lib/datepicker/calendar.scss new file mode 100644 index 000000000000..8192360fad9b --- /dev/null +++ b/src/lib/datepicker/calendar.scss @@ -0,0 +1,36 @@ +$mat-calendar-arrow-size: 5px !default; + +.mat-calendar { + display: block; +} + +.mat-calendar-header { + display: flex; +} + +.mat-calendar-spacer { + flex: 1 1 auto; +} + +.mat-calendar-button { + background: transparent; + padding: 0; + margin: 0; + border: none; + outline: none; +} + +.mat-calendar-button > svg { + vertical-align: middle; +} + +.mat-calendar-arrow { + display: inline-block; + width: 0; + height: 0; + border-left: $mat-calendar-arrow-size solid transparent; + border-right: $mat-calendar-arrow-size solid transparent; + border-top: $mat-calendar-arrow-size solid; + margin: 0 $mat-calendar-arrow-size; + vertical-align: middle; +} diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts new file mode 100644 index 000000000000..27608c2b655b --- /dev/null +++ b/src/lib/datepicker/calendar.spec.ts @@ -0,0 +1,128 @@ +import {async, TestBed, ComponentFixture} from '@angular/core/testing'; +import {MdDatepickerModule} from './index'; +import {Component} from '@angular/core'; +import {SimpleDate} from '../core/datetime/simple-date'; +import {MdCalendar} from './calendar'; +import {By} from '@angular/platform-browser'; + + +describe('MdCalendar', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [MdDatepickerModule], + declarations: [ + StandardCalendar, + ], + }); + + TestBed.compileComponents(); + })); + + describe('standard calendar', () => { + let fixture: ComponentFixture; + let testComponent: StandardCalendar; + let calendarElement: HTMLElement; + let periodButton: HTMLElement; + let prevButton: HTMLElement; + let nextButton: HTMLElement; + let calendarInstance: MdCalendar; + + beforeEach(() => { + fixture = TestBed.createComponent(StandardCalendar); + fixture.detectChanges(); + + let calendarDebugElement = fixture.debugElement.query(By.directive(MdCalendar)); + calendarElement = calendarDebugElement.nativeElement; + periodButton = calendarElement.querySelector('.mat-calendar-period-button') as HTMLElement; + prevButton = calendarElement.querySelector('.mat-calendar-previous-button') as HTMLElement; + nextButton = calendarElement.querySelector('.mat-calendar-next-button') as HTMLElement; + + calendarInstance = calendarDebugElement.componentInstance; + testComponent = fixture.componentInstance; + }); + + it('should be in month view with specified month visible', () => { + expect(calendarInstance._monthView).toBe(true, 'should be in month view'); + expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + }); + + it('should toggle view when period clicked', () => { + expect(calendarInstance._monthView).toBe(true, 'should be in month view'); + + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(false, 'should be in year view'); + + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(true, 'should be in month view'); + }); + + it('should go to next and previous month', () => { + expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + + nextButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 1, 1)); + + prevButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + }); + + it('should go to previous and next year', () => { + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(false, 'should be in year view'); + expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + + nextButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2018, 0, 1)); + + prevButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + }); + + it('should go back to month view after selecting month in year view', () => { + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(false, 'should be in year view'); + expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + + let monthCells = calendarElement.querySelectorAll('.mat-calendar-table-cell'); + (monthCells[monthCells.length - 1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(true, 'should be in month view'); + expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 11, 1)); + expect(testComponent.selected).toBeFalsy('no date should be selected yet'); + }); + + it('should select date in month view', () => { + let monthCells = calendarElement.querySelectorAll('.mat-calendar-table-cell'); + (monthCells[monthCells.length - 1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(true, 'should be in month view'); + expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 31)); + }); + }); +}); + + +@Component({ + template: `` +}) +class StandardCalendar { + selected: SimpleDate; +} diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts new file mode 100644 index 000000000000..cd840713e05f --- /dev/null +++ b/src/lib/datepicker/calendar.ts @@ -0,0 +1,108 @@ +import { + ChangeDetectionStrategy, + ViewEncapsulation, + Component, + Input, + AfterContentInit, Output, EventEmitter +} from '@angular/core'; +import {SimpleDate} from '../core/datetime/simple-date'; +import {CalendarLocale} from '../core/datetime/calendar-locale'; + + +/** + * A calendar that is used as part of the datepicker. + * @docs-private + */ +@Component({ + moduleId: module.id, + selector: 'md-calendar', + templateUrl: 'calendar.html', + styleUrls: ['calendar.css'], + host: { + '[class.mat-calendar]': 'true', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdCalendar implements AfterContentInit { + /** A date representing the period (month or year) to start the calendar in. */ + @Input() + get startAt() {return this._startAt; } + set startAt(value: any) { this._startAt = this._locale.parseDate(value); } + private _startAt: SimpleDate; + + /** Whether the calendar should be started in month or year view. */ + @Input() startView: 'month' | 'year' = 'month'; + + /** The currently selected date. */ + @Input() + get selected() { return this._selected; } + set selected(value: any) { this._selected = this._locale.parseDate(value); } + private _selected: SimpleDate; + + /** Emits when the currently selected date changes. */ + @Output() selectedChange = new EventEmitter(); + + /** + * A date representing the current period shown in the calendar. The current period is always + * normalized to the 1st of a month, this prevents date overflow issues (e.g. adding a month to + * January 31st and overflowing into March). + */ + get _currentPeriod() { return this._normalizedCurrentPeriod; } + set _currentPeriod(value: SimpleDate) { + this._normalizedCurrentPeriod = new SimpleDate(value.year, value.month, 1); + } + private _normalizedCurrentPeriod: SimpleDate; + + /** Whether the calendar is in month view. */ + _monthView: boolean; + + /** The names of the weekdays. */ + _weekdays: string[]; + + /** The label for the current calendar view. */ + get _label(): string { + return this._monthView ? this._locale.getCalendarMonthHeaderLabel(this._currentPeriod) : + this._locale.getCalendarYearHeaderLabel(this._currentPeriod); + } + + constructor(private _locale: CalendarLocale) { + this._weekdays = this._locale.narrowDays.slice(this._locale.firstDayOfWeek) + .concat(this._locale.narrowDays.slice(0, this._locale.firstDayOfWeek)); + } + + ngAfterContentInit() { + this._currentPeriod = this.startAt || SimpleDate.today(); + this._monthView = this.startView != 'year'; + } + + /** Handles date selection in the month view. */ + _dateSelected(date: SimpleDate) { + if ((!date || !this.selected) && date != this.selected || date.compare(this.selected)) { + this.selectedChange.emit(date); + } + } + + /** Handles month selection in the year view. */ + _monthSelected(month: SimpleDate) { + this._currentPeriod = month; + this._monthView = true; + } + + /** Handles user clicks on the period label. */ + _currentPeriodClicked() { + this._monthView = !this._monthView; + } + + /** Handles user clicks on the previous button. */ + _previousClicked() { + let amount = this._monthView ? {months: -1} : {years: -1}; + this._currentPeriod = this._currentPeriod.add(amount); + } + + /** Handles user clicks on the next button. */ + _nextClicked() { + let amount = this._monthView ? {months: 1} : {years: 1}; + this._currentPeriod = this._currentPeriod.add(amount); + } +} diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index 9bea0410509a..5f6c2f471738 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -8,8 +8,10 @@ import {OverlayModule} from '../core/overlay/overlay-directives'; import {MdDatepicker} from './datepicker'; import {MdDatepickerInput} from './datepicker-input'; import {MdDialogModule} from '../dialog/index'; +import {MdCalendar} from './calendar'; +export * from './calendar'; export * from './calendar-table'; export * from './datepicker'; export * from './datepicker-input'; @@ -19,7 +21,7 @@ export * from './year-view'; @NgModule({ imports: [CommonModule, DatetimeModule, MdDialogModule, OverlayModule], - exports: [MdCalendarTable, MdDatepicker, MdDatepickerInput, MdMonthView, MdYearView], - declarations: [MdCalendarTable, MdDatepicker, MdDatepickerInput, MdMonthView, MdYearView], + exports: [MdCalendar, MdCalendarTable, MdDatepicker, MdDatepickerInput, MdMonthView, MdYearView], + declarations: [MdCalendar, MdCalendarTable, MdDatepicker, MdDatepickerInput, MdMonthView, MdYearView], }) export class MdDatepickerModule {} From 58456ea0bae1be8e1cc9be5aa14d70519df3a8f1 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 17 Feb 2017 12:23:16 -0800 Subject: [PATCH 07/37] fix(datepicker): make calendar look more like mocks (#3138) * style changes * more styles * addressed comments --- src/lib/datepicker/_datepicker-theme.scss | 44 +++++++++++-- src/lib/datepicker/calendar-table.scss | 11 +++- src/lib/datepicker/calendar.html | 75 ++++++++++++----------- src/lib/datepicker/calendar.scss | 42 ++++++++++++- src/lib/datepicker/calendar.ts | 3 +- src/lib/datepicker/month-view.ts | 4 +- src/lib/datepicker/year-view.ts | 2 +- 7 files changed, 132 insertions(+), 49 deletions(-) diff --git a/src/lib/datepicker/_datepicker-theme.scss b/src/lib/datepicker/_datepicker-theme.scss index 693eb77337fd..5cf64b335323 100644 --- a/src/lib/datepicker/_datepicker-theme.scss +++ b/src/lib/datepicker/_datepicker-theme.scss @@ -3,10 +3,32 @@ @mixin mat-datepicker-theme($theme) { + $mat-datepicker-selected-today-box-shadow-width: 1px; $primary: map-get($theme, primary); $foreground: map-get($theme, foreground); $background: map-get($theme, background); + .mat-calendar { + background-color: mat-color($background, card); + } + + .mat-calendar-header { + border-color: mat-color($foreground, divider); + } + + .mat-calendar-arrow { + border-top-color: mat-color($foreground, icon); + } + + .mat-calendar-next-button, + .mat-calendar-previous-button { + color: mat-color($foreground, icon); + } + + .mat-calendar-weekday-table { + color: mat-color($foreground, hint-text); + } + .mat-calendar-table-label { color: mat-color($foreground, secondary-text); } @@ -16,17 +38,27 @@ border-color: transparent; } - .mat-calendar-table-cell-content:hover { - background: mat-color($background, hover); + .mat-calendar-table-cell:hover { + .mat-calendar-table-cell-content:not(.mat-calendar-table-selected) { + background-color: mat-color($background, hover); + } } - .mat-calendar-table-selected, - .mat-calendar-table-selected:hover { - background: mat-color($primary); + .mat-calendar-table-selected { + background-color: mat-color($primary); color: mat-color($primary, default-contrast); } .mat-calendar-table-today { - border-color: mat-color($foreground, divider); + &:not(.mat-calendar-table-selected) { + // Note: though it's not text, the border is a hint about the fact that this is today's date, + // so we use the hint color. + border-color: mat-color($foreground, hint-text); + } + + &.mat-calendar-table-selected { + box-shadow: inset 0 0 0 $mat-datepicker-selected-today-box-shadow-width + mat-color($primary, default-contrast); + } } } diff --git a/src/lib/datepicker/calendar-table.scss b/src/lib/datepicker/calendar-table.scss index 16466a7dac90..c96189cd84ac 100644 --- a/src/lib/datepicker/calendar-table.scss +++ b/src/lib/datepicker/calendar-table.scss @@ -1,4 +1,5 @@ -$mat-calendar-table-font-size: 12px !default; +$mat-calendar-table-font-size: 13px !default; +$mat-calendar-table-header-font-size: 14px !default; $mat-calendar-table-label-padding-start: 5% !default; $mat-calendar-table-label-translation: -6px !default; $mat-calendar-table-cell-min-size: 32px !default; @@ -20,10 +21,14 @@ $mat-calendar-table-cell-content-size: 100% - $mat-calendar-table-cell-content-m } .mat-calendar-table-label { - padding: 0 0 0 $mat-calendar-table-cell-padding; + padding: $mat-calendar-table-cell-padding 0 + $mat-calendar-table-cell-padding $mat-calendar-table-cell-padding; + height: 0; + line-height: 0; transform: translateX($mat-calendar-table-label-translation); text-align: left; - font-weight: normal; + font-size: $mat-calendar-table-header-font-size; + font-weight: bold; } .mat-calendar-table-cell { diff --git a/src/lib/datepicker/calendar.html b/src/lib/datepicker/calendar.html index 4c30a6bd9037..1c4921e792fb 100644 --- a/src/lib/datepicker/calendar.html +++ b/src/lib/datepicker/calendar.html @@ -1,39 +1,44 @@
- -
- - -
+
+ +
+ + +
- - -
{{day}}
+ + +
{{day}}
+ - - +
+ + - - + + +
diff --git a/src/lib/datepicker/calendar.scss b/src/lib/datepicker/calendar.scss index 8192360fad9b..ab0761a96cf5 100644 --- a/src/lib/datepicker/calendar.scss +++ b/src/lib/datepicker/calendar.scss @@ -1,11 +1,34 @@ +$mat-calendar-padding: 8px !default; +$mat-calendar-header-divider-width: 1px !default; +$mat-calendar-controls-vertical-padding: 5%; +// We want to indent to the middle of the first tile. There are 7 tiles, so 100% / 7 / 2. +// Then we back up a little bit since the text in the cells is center-aligned. +$mat-calendar-controls-start-padding: calc(100% / 14 - 6px); +// Same as above, but we back up a little more since the arrow buttons have more padding. +$mat-calendar-controls-end-padding: calc(100% / 14 - 12px); +$mat-calendar-period-font-size: 14px; $mat-calendar-arrow-size: 5px !default; +$mat-calendar-weekday-table-font-size: 11px !default; + .mat-calendar { display: block; } .mat-calendar-header { + border-bottom-width: $mat-calendar-header-divider-width; + border-bottom-style: solid; + padding: $mat-calendar-padding; +} + +.mat-calendar-body { + padding: $mat-calendar-padding; +} + +.mat-calendar-controls { display: flex; + padding: $mat-calendar-controls-vertical-padding $mat-calendar-controls-end-padding + $mat-calendar-controls-vertical-padding $mat-calendar-controls-start-padding; } .mat-calendar-spacer { @@ -20,6 +43,12 @@ $mat-calendar-arrow-size: 5px !default; outline: none; } +.mat-calendar-period-button { + font: inherit; + font-size: $mat-calendar-period-font-size; + font-weight: bold; +} + .mat-calendar-button > svg { vertical-align: middle; } @@ -30,7 +59,18 @@ $mat-calendar-arrow-size: 5px !default; height: 0; border-left: $mat-calendar-arrow-size solid transparent; border-right: $mat-calendar-arrow-size solid transparent; - border-top: $mat-calendar-arrow-size solid; + border-top-width: $mat-calendar-arrow-size; + border-top-style: solid; margin: 0 $mat-calendar-arrow-size; vertical-align: middle; + + .mat-calendar-invert { + transform: rotate(180deg); + } +} + +.mat-calendar-weekday-table { + width: 100%; + text-align: center; + font-size: $mat-calendar-weekday-table-font-size; } diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index cd840713e05f..cca226f0de45 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -62,7 +62,8 @@ export class MdCalendar implements AfterContentInit { /** The label for the current calendar view. */ get _label(): string { - return this._monthView ? this._locale.getCalendarMonthHeaderLabel(this._currentPeriod) : + return this._monthView ? + this._locale.getCalendarMonthHeaderLabel(this._currentPeriod).toLocaleUpperCase() : this._locale.getCalendarYearHeaderLabel(this._currentPeriod); } diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts index fbfc0c0d7d8c..8fb387b9e358 100644 --- a/src/lib/datepicker/month-view.ts +++ b/src/lib/datepicker/month-view.ts @@ -86,7 +86,7 @@ export class MdMonthView implements AfterContentInit { private _init() { this._selectedDate = this._getDateInCurrentMonth(this.selected); this._todayDate = this._getDateInCurrentMonth(SimpleDate.today()); - this._monthLabel = this._locale.getCalendarMonthHeaderLabel(this.date); + this._monthLabel = this._locale.shortMonths[this.date.month].toLocaleUpperCase(); let firstOfMonth = new SimpleDate(this.date.year, this.date.month, 1); this._firstWeekOffset = @@ -114,6 +114,6 @@ export class MdMonthView implements AfterContentInit { * Returns null if the given Date is in another month. */ private _getDateInCurrentMonth(date: SimpleDate) { - return date && date.month == this.date.month ? date.date : null; + return date && date.month == this.date.month && date.year == this.date.year ? date.date : null; } } diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts index 7521103f73fa..e49ea0b6b4b8 100644 --- a/src/lib/datepicker/year-view.ts +++ b/src/lib/datepicker/year-view.ts @@ -95,6 +95,6 @@ export class MdYearView implements AfterContentInit { /** Creates an MdCalendarCell for the given month. */ private _createCellForMonth(month: number) { - return new MdCalendarCell(month, this._locale.shortMonths[month]); + return new MdCalendarCell(month, this._locale.shortMonths[month].toLocaleUpperCase()); } } From bc4b168f8c68ab1ce6473c8b54f0c1123c06f9b2 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Sat, 18 Feb 2017 17:57:40 -0800 Subject: [PATCH 08/37] add the calendar to the datepicker popup (#3178) --- src/lib/datepicker/calendar.scss | 2 +- src/lib/datepicker/datepicker.html | 5 ++++- src/lib/datepicker/datepicker.scss | 21 +++++++++++++++++++++ src/lib/datepicker/datepicker.ts | 16 ++++++---------- 4 files changed, 32 insertions(+), 12 deletions(-) create mode 100644 src/lib/datepicker/datepicker.scss diff --git a/src/lib/datepicker/calendar.scss b/src/lib/datepicker/calendar.scss index ab0761a96cf5..2dabff7e0c7b 100644 --- a/src/lib/datepicker/calendar.scss +++ b/src/lib/datepicker/calendar.scss @@ -64,7 +64,7 @@ $mat-calendar-weekday-table-font-size: 11px !default; margin: 0 $mat-calendar-arrow-size; vertical-align: middle; - .mat-calendar-invert { + &.mat-calendar-invert { transform: rotate(180deg); } } diff --git a/src/lib/datepicker/datepicker.html b/src/lib/datepicker/datepicker.html index 3fcc1f13bc5a..389890cfa5e2 100644 --- a/src/lib/datepicker/datepicker.html +++ b/src/lib/datepicker/datepicker.html @@ -1,3 +1,6 @@ diff --git a/src/lib/datepicker/datepicker.scss b/src/lib/datepicker/datepicker.scss new file mode 100644 index 000000000000..097546c2298a --- /dev/null +++ b/src/lib/datepicker/datepicker.scss @@ -0,0 +1,21 @@ +@import '../core/style/elevation'; + + +$md-datepicker-touch-calendar-cell-size: 60px; +$md-datepicker-non-touch-calendar-cell-size: 40px; +$md-datepicker-calendar-padding: 8px; + +$md-datepicker-touch-calendar-width: + $md-datepicker-touch-calendar-cell-size * 7 + $md-datepicker-calendar-padding * 2; +$md-datepicker-non-touch-calendar-width: + $md-datepicker-non-touch-calendar-cell-size * 7 + $md-datepicker-calendar-padding * 2; + + +.mat-datepicker-touch { + width: $md-datepicker-touch-calendar-width; +} + +.mat-datepicker-non-touch { + width: $md-datepicker-non-touch-calendar-width; + @include mat-elevation(8); +} diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index fabe16441acb..417fe8e514ad 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -1,13 +1,13 @@ import { + ChangeDetectionStrategy, Component, + ElementRef, + OnDestroy, + Optional, TemplateRef, ViewChild, - ViewEncapsulation, - ChangeDetectionStrategy, ViewContainerRef, - Optional, - ElementRef, - OnDestroy + ViewEncapsulation } from '@angular/core'; import {Overlay} from '../core/overlay/overlay'; import {OverlayRef} from '../core/overlay/overlay-ref'; @@ -24,15 +24,12 @@ import { } from '../core/overlay/position/connected-position'; -// TODO(mmalerba): Figure out what the real width should be. -const CALENDAR_POPUP_WIDTH = 300; - - /** Component responsible for managing the datepicker popup/dialog. */ @Component({ moduleId: module.id, selector: 'md-datepicker, mat-datepicker', templateUrl: 'datepicker.html', + styleUrls: ['datepicker.css'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -142,7 +139,6 @@ export class MdDatepicker implements OnDestroy { private _createPopup(): void { const overlayState = new OverlayState(); overlayState.positionStrategy = this._createPopupPositionStrategy(); - overlayState.width = CALENDAR_POPUP_WIDTH; overlayState.hasBackdrop = true; overlayState.backdropClass = 'md-overlay-transparent-backdrop'; overlayState.direction = this._dir ? this._dir.value : 'ltr'; From 08ccf03c19504466557212805778600f7a75dc82 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 3 Mar 2017 09:28:10 -0800 Subject: [PATCH 09/37] feat(datepicker): wire up selected value propagation (#3330) * enable value propagation through various components & directives * add tests * fix lint * addressed comments * addressed feedback * fix lint * make datepicker selected property internal * add test for pristine after ngModel change --- src/demo-app/datepicker/datepicker-demo.html | 7 +- src/demo-app/datepicker/datepicker-demo.ts | 4 +- src/lib/datepicker/calendar-table.spec.ts | 9 +- src/lib/datepicker/calendar.spec.ts | 17 +- src/lib/datepicker/datepicker-input.ts | 85 ++++++- src/lib/datepicker/datepicker.html | 4 +- src/lib/datepicker/datepicker.spec.ts | 244 ++++++++++++++++++- src/lib/datepicker/datepicker.ts | 51 +++- src/lib/datepicker/index.ts | 21 +- src/lib/datepicker/month-view.spec.ts | 17 +- src/lib/datepicker/year-view.spec.ts | 17 +- 11 files changed, 421 insertions(+), 55 deletions(-) diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index 70a7cc1e6556..f974fc7228c7 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -1,11 +1,6 @@

Work in progress, not ready for use.

- - -
-
{{selected?.toNativeDate()}}
- - + diff --git a/src/demo-app/datepicker/datepicker-demo.ts b/src/demo-app/datepicker/datepicker-demo.ts index 240e721a5ef9..214febf1eff0 100644 --- a/src/demo-app/datepicker/datepicker-demo.ts +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -9,7 +9,5 @@ import {SimpleDate} from '@angular/material'; styleUrls: ['datepicker-demo.css'], }) export class DatepickerDemo { - startAt = new SimpleDate(2017, 0, 1); - date = SimpleDate.today(); - selected: SimpleDate; + date: SimpleDate; } diff --git a/src/lib/datepicker/calendar-table.spec.ts b/src/lib/datepicker/calendar-table.spec.ts index fb9869bda99a..3ebf0dbce262 100644 --- a/src/lib/datepicker/calendar-table.spec.ts +++ b/src/lib/datepicker/calendar-table.spec.ts @@ -1,15 +1,16 @@ -import {async, TestBed, ComponentFixture} from '@angular/core/testing'; -import {MdDatepickerModule} from './index'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; -import {MdCalendarTable, MdCalendarCell} from './calendar-table'; +import {MdCalendarCell, MdCalendarTable} from './calendar-table'; import {By} from '@angular/platform-browser'; describe('MdCalendarTable', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdDatepickerModule], declarations: [ + MdCalendarTable, + + // Test components. StandardCalendarTable, ], }); diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts index 27608c2b655b..e0de6b649550 100644 --- a/src/lib/datepicker/calendar.spec.ts +++ b/src/lib/datepicker/calendar.spec.ts @@ -1,16 +1,27 @@ -import {async, TestBed, ComponentFixture} from '@angular/core/testing'; -import {MdDatepickerModule} from './index'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; import {SimpleDate} from '../core/datetime/simple-date'; import {MdCalendar} from './calendar'; import {By} from '@angular/platform-browser'; +import {MdMonthView} from './month-view'; +import {MdYearView} from './year-view'; +import {MdCalendarTable} from './calendar-table'; +import {DatetimeModule} from '../core/datetime/index'; describe('MdCalendar', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdDatepickerModule], + imports: [ + DatetimeModule, + ], declarations: [ + MdCalendar, + MdCalendarTable, + MdMonthView, + MdYearView, + + // Test components. StandardCalendar, ], }); diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index 84290461e160..f7be57566e25 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -1,23 +1,100 @@ -import {Directive, ElementRef, Input} from '@angular/core'; +import { + AfterContentInit, Directive, ElementRef, forwardRef, Input, OnDestroy, + Renderer +} from '@angular/core'; import {MdDatepicker} from './datepicker'; +import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import {SimpleDate} from '../core/datetime/simple-date'; +import {CalendarLocale} from '../core/datetime/calendar-locale'; +import {Subscription} from 'rxjs'; + + +export const MD_DATEPICKER_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MdDatepickerInput), + multi: true +}; /** Directive used to connect an input to a MdDatepicker. */ @Directive({ selector: 'input[mdDatepicker], input[matDatepicker]', + providers: [MD_DATEPICKER_VALUE_ACCESSOR], + host: { + '(input)': '_onChange($event.target.value)', + '(blur)': '_onTouched()', + } }) -export class MdDatepickerInput { +export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor, OnDestroy { @Input() set mdDatepicker(value: MdDatepicker) { if (value) { this._datepicker = value; - this._datepicker._registerInput(this._elementRef); + this._datepicker._registerInput(this); } } private _datepicker: MdDatepicker; + @Input() + get value(): SimpleDate { + return this._value; + } + set value(value: SimpleDate) { + this._value = this._locale.parseDate(value); + const stringValue = this._value == null ? '' : this._locale.formatDate(this._value); + this._renderer.setElementProperty(this._elementRef.nativeElement, 'value', stringValue); + } + private _value: SimpleDate; + @Input() set matDatepicker(value: MdDatepicker) { this.mdDatepicker = value; } - constructor(private _elementRef: ElementRef) {} + _onChange = (value: any) => {}; + + _onTouched = () => {}; + + private _datepickerSubscription: Subscription; + + constructor(private _elementRef: ElementRef, private _renderer: Renderer, + private _locale: CalendarLocale) {} + + ngAfterContentInit() { + if (this._datepicker) { + this._datepickerSubscription = + this._datepicker.selectedChanged.subscribe((selected: SimpleDate) => { + this.value = selected; + this._onChange(selected); + }); + } + } + + ngOnDestroy() { + if (this._datepickerSubscription) { + this._datepickerSubscription.unsubscribe(); + } + } + + getPopupConnectionElementRef(): ElementRef { + return this._elementRef; + } + + // Implemented as part of ControlValueAccessor + writeValue(value: SimpleDate): void { + this.value = value; + } + + // Implemented as part of ControlValueAccessor + registerOnChange(fn: (value: any) => void): void { + this._onChange = value => fn(this._locale.parseDate(value)); + } + + // Implemented as part of ControlValueAccessor + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + // Implemented as part of ControlValueAccessor + setDisabledState(disabled: boolean): void { + this._renderer.setElementProperty(this._elementRef.nativeElement, 'disabled', disabled); + } } diff --git a/src/lib/datepicker/datepicker.html b/src/lib/datepicker/datepicker.html index 389890cfa5e2..c7cf5a083d31 100644 --- a/src/lib/datepicker/datepicker.html +++ b/src/lib/datepicker/datepicker.html @@ -1,6 +1,8 @@ diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index b6dbfab95629..45923506150a 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -1,16 +1,25 @@ -import {TestBed, async, ComponentFixture} from '@angular/core/testing'; +import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {MdDatepickerModule} from './index'; import {Component, ViewChild} from '@angular/core'; import {MdDatepicker} from './datepicker'; +import {MdDatepickerInput} from './datepicker-input'; +import {SimpleDate} from '../core/datetime/simple-date'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {By} from '@angular/platform-browser'; +import {dispatchFakeEvent} from '../core/testing/dispatch-events'; + describe('MdDatepicker', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdDatepickerModule], + imports: [MdDatepickerModule, FormsModule, ReactiveFormsModule], declarations: [ StandardDatepicker, MultiInputDatepicker, NoInputDatepicker, + DatepickerWithStartAt, + DatepickerWithNgModel, + DatepickerWithFormControl, ], }); @@ -46,7 +55,7 @@ describe('MdDatepicker', () => { expect(document.querySelector('md-dialog-container')).not.toBeNull(); }); - it('close should close popup', () => { + it('close should close popup', async(() => { testComponent.datepicker.openStandardUi(); fixture.detectChanges(); @@ -57,10 +66,12 @@ describe('MdDatepicker', () => { testComponent.datepicker.close(); fixture.detectChanges(); - expect(parseInt(getComputedStyle(popup).height)).toBe(0); - }); + fixture.whenStable().then(() => { + expect(parseInt(getComputedStyle(popup).height)).toBe(0); + }); + })); - it('close should close dialog', () => { + it('close should close dialog', async(() => { testComponent.datepicker.openTouchUi(); fixture.detectChanges(); @@ -69,7 +80,30 @@ describe('MdDatepicker', () => { testComponent.datepicker.close(); fixture.detectChanges(); - expect(document.querySelector('md-dialog-container')).toBeNull(); + fixture.whenStable().then(() => { + expect(document.querySelector('md-dialog-container')).toBeNull(); + }); + })); + + it('setting selected should update input and close calendar', async(() => { + testComponent.datepicker.openTouchUi(); + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + expect(testComponent.datepickerInput.value).toEqual(new SimpleDate(2020, 0, 1)); + + let selected = new SimpleDate(2017, 0, 1); + testComponent.datepicker._selected = selected; + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(document.querySelector('md-dialog-container')).toBeNull(); + expect(testComponent.datepickerInput.value).toEqual(selected); + }); + })); + + it('startAt should fallback to input value', () => { + expect(testComponent.datepicker.startAt).toEqual(new SimpleDate(2020, 0, 1)); }); }); @@ -95,14 +129,170 @@ describe('MdDatepicker', () => { expect(() => testComponent.datepicker.openStandardUi()).toThrow(); }); }); + + describe('datepicker with startAt', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithStartAt; + + beforeEach(() => { + fixture = TestBed.createComponent(DatepickerWithStartAt); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + }); + + it('explicit startAt should override input value', () => { + expect(testComponent.datepicker.startAt).toEqual(new SimpleDate(2010, 0, 1)); + }); + }); + + describe('datepicker with ngModel', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithNgModel; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(DatepickerWithNgModel); + detectModelChanges(fixture); + + testComponent = fixture.componentInstance; + })); + + it('should update datepicker when model changes', fakeAsync(() => { + expect(testComponent.datepickerInput.value).toBeNull(); + expect(testComponent.datepicker._selected).toBeNull(); + + let selected = new SimpleDate(2017, 0, 1); + testComponent.selected = selected; + detectModelChanges(fixture); + + expect(testComponent.datepickerInput.value).toEqual(selected); + expect(testComponent.datepicker._selected).toEqual(selected); + })); + + it('should update model when date is selected', fakeAsync(() => { + expect(testComponent.selected).toBeNull(); + expect(testComponent.datepickerInput.value).toBeNull(); + + let selected = new SimpleDate(2017, 0, 1); + testComponent.datepicker._selected = selected; + detectModelChanges(fixture); + + expect(testComponent.selected).toEqual(selected); + expect(testComponent.datepickerInput.value).toEqual(selected); + })); + + it('should mark input dirty after input event', () => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + + expect(inputEl.classList).toContain('ng-pristine'); + + dispatchFakeEvent(inputEl, 'input'); + fixture.detectChanges(); + + expect(inputEl.classList).toContain('ng-dirty'); + }); + + it('should mark input dirty after date selected', fakeAsync(() => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + + expect(inputEl.classList).toContain('ng-pristine'); + + testComponent.datepicker._selected = new SimpleDate(2017, 0, 1); + detectModelChanges(fixture); + + expect(inputEl.classList).toContain('ng-dirty'); + })); + + it('should not mark dirty after model change', fakeAsync(() => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + + expect(inputEl.classList).toContain('ng-pristine'); + + testComponent.selected = new SimpleDate(2017, 0, 1); + detectModelChanges(fixture); + + expect(inputEl.classList).toContain('ng-pristine'); + })); + + it('should mark input touched on blur', () => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + + expect(inputEl.classList).toContain('ng-untouched'); + + dispatchFakeEvent(inputEl, 'focus'); + fixture.detectChanges(); + + expect(inputEl.classList).toContain('ng-untouched'); + + dispatchFakeEvent(inputEl, 'blur'); + fixture.detectChanges(); + + expect(inputEl.classList).toContain('ng-touched'); + }); + }); + + describe('datepicker with formControl', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithFormControl; + + beforeEach(() => { + fixture = TestBed.createComponent(DatepickerWithFormControl); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + }); + + it('should update datepicker when formControl changes', () => { + expect(testComponent.datepickerInput.value).toBeNull(); + expect(testComponent.datepicker._selected).toBeNull(); + + let selected = new SimpleDate(2017, 0, 1); + testComponent.formControl.setValue(selected); + fixture.detectChanges(); + + expect(testComponent.datepickerInput.value).toEqual(selected); + expect(testComponent.datepicker._selected).toEqual(selected); + }); + + it('should update formControl when date is selected', () => { + expect(testComponent.formControl.value).toBeNull(); + expect(testComponent.datepickerInput.value).toBeNull(); + + let selected = new SimpleDate(2017, 0, 1); + testComponent.datepicker._selected = selected; + fixture.detectChanges(); + + expect(testComponent.formControl.value).toEqual(selected); + expect(testComponent.datepickerInput.value).toEqual(selected); + }); + + it('should disable input when form control disabled', () => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + + expect(inputEl.disabled).toBe(false); + + testComponent.formControl.disable(); + fixture.detectChanges(); + + expect(inputEl.disabled).toBe(true); + }); + }); }); +function detectModelChanges(fixture: ComponentFixture) { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); +} + + @Component({ - template: ``, + template: ``, }) class StandardDatepicker { @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } @@ -111,9 +301,7 @@ class StandardDatepicker { `, }) -class MultiInputDatepicker { - @ViewChild('d') datepicker: MdDatepicker; -} +class MultiInputDatepicker {} @Component({ @@ -122,3 +310,37 @@ class MultiInputDatepicker { class NoInputDatepicker { @ViewChild('d') datepicker: MdDatepicker; } + + +@Component({ + template: ` + + + `, +}) +class DatepickerWithStartAt { + @ViewChild('d') datepicker: MdDatepicker; +} + + +@Component({ + template: `` +}) +class DatepickerWithNgModel { + selected: SimpleDate = null; + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; +} + + +@Component({ + template: ` + + + ` +}) +class DatepickerWithFormControl { + formControl = new FormControl(); + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; +} diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 417fe8e514ad..27a325bb0d73 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -1,9 +1,9 @@ import { ChangeDetectionStrategy, - Component, - ElementRef, + Component, EventEmitter, + Input, OnDestroy, - Optional, + Optional, Output, TemplateRef, ViewChild, ViewContainerRef, @@ -22,6 +22,9 @@ import { OriginConnectionPosition, OverlayConnectionPosition } from '../core/overlay/position/connected-position'; +import {SimpleDate} from '../core/datetime/simple-date'; +import {MdDatepickerInput} from './datepicker-input'; +import {CalendarLocale} from '../core/datetime/calendar-locale'; /** Component responsible for managing the datepicker popup/dialog. */ @@ -34,6 +37,32 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class MdDatepicker implements OnDestroy { + /** The date to open the calendar to initially. */ + @Input() + get startAt(): SimpleDate { + // If an explicit startAt is set we start there, otherwise we start at whatever the currently + // selected value is. + if (this._startAt) { + return this._startAt; + } + if (this._datepickerInput) { + return this._datepickerInput.value; + } + return null; + } + set startAt(date: SimpleDate) { this._startAt = this._locale.parseDate(date); } + private _startAt: SimpleDate; + + @Output() selectedChanged = new EventEmitter(); + + get _selected(): SimpleDate { + return this._datepickerInput ? this._datepickerInput.value : null; + } + set _selected(value: SimpleDate) { + this.selectedChanged.emit(value); + this.close(); + } + /** * Whether the calendar UI is in touch mode. In touch mode the calendar opens in a dialog rather * than a popup and elements have more padding to allow for bigger touch targets. @@ -53,10 +82,11 @@ export class MdDatepicker implements OnDestroy { private _calendarPortal: TemplatePortal; /** The input element this datepicker is associated with. */ - private _inputElementRef: ElementRef; + private _datepickerInput: MdDatepickerInput; constructor(private _dialog: MdDialog, private _overlay: Overlay, - private _viewContainerRef: ViewContainerRef, @Optional() private _dir: Dir) {} + private _viewContainerRef: ViewContainerRef, private _locale: CalendarLocale, + @Optional() private _dir: Dir) {} ngOnDestroy() { this.close(); @@ -69,11 +99,11 @@ export class MdDatepicker implements OnDestroy { * Register an input with this datepicker. * @param inputElementRef An ElementRef for the input. */ - _registerInput(inputElementRef: ElementRef): void { - if (this._inputElementRef) { + _registerInput(input: MdDatepickerInput): void { + if (this._datepickerInput) { throw new MdError('An MdDatepicker can only be associated with a single input.'); } - this._inputElementRef = inputElementRef; + this._datepickerInput = input; } /** Opens the calendar in standard UI mode. */ @@ -91,7 +121,7 @@ export class MdDatepicker implements OnDestroy { * @param touchUi Whether to use the touch UI. */ private _open(touchUi = false): void { - if (!this._inputElementRef) { + if (!this._datepickerInput) { throw new MdError('Attempted to open an MdDatepicker with no associated input.'); } @@ -150,6 +180,7 @@ export class MdDatepicker implements OnDestroy { private _createPopupPositionStrategy(): PositionStrategy { let origin = {originX: 'start', originY: 'bottom'} as OriginConnectionPosition; let overlay = {overlayX: 'start', overlayY: 'top'} as OverlayConnectionPosition; - return this._overlay.position().connectedTo(this._inputElementRef, origin, overlay); + return this._overlay.position().connectedTo( + this._datepickerInput.getPopupConnectionElementRef(), origin, overlay); } } diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index 5f6c2f471738..b8138a0fd46c 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -20,8 +20,23 @@ export * from './year-view'; @NgModule({ - imports: [CommonModule, DatetimeModule, MdDialogModule, OverlayModule], - exports: [MdCalendar, MdCalendarTable, MdDatepicker, MdDatepickerInput, MdMonthView, MdYearView], - declarations: [MdCalendar, MdCalendarTable, MdDatepicker, MdDatepickerInput, MdMonthView, MdYearView], + imports: [ + CommonModule, + DatetimeModule, + MdDialogModule, + OverlayModule, + ], + exports: [ + MdDatepicker, + MdDatepickerInput, + ], + declarations: [ + MdCalendar, + MdCalendarTable, + MdDatepicker, + MdDatepickerInput, + MdMonthView, + MdYearView, + ], }) export class MdDatepickerModule {} diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts index c9a7badd5f92..3b19a23401ce 100644 --- a/src/lib/datepicker/month-view.spec.ts +++ b/src/lib/datepicker/month-view.spec.ts @@ -1,16 +1,23 @@ -import {async, TestBed, ComponentFixture} from '@angular/core/testing'; -import {MdDatepickerModule} from './index'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdMonthView} from './month-view'; import {SimpleDate} from '../core/datetime/simple-date'; +import {MdCalendarTable} from './calendar-table'; +import {DatetimeModule} from '../core/datetime/index'; describe('MdMonthView', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdDatepickerModule], + imports: [ + DatetimeModule, + ], declarations: [ + MdCalendarTable, + MdMonthView, + + // Test components. StandardMonthView, ], }); @@ -32,9 +39,9 @@ describe('MdMonthView', () => { testComponent = fixture.componentInstance; }); - it('has correct year label', () => { + it('has correct month label', () => { let labelEl = monthViewNativeElement.querySelector('.mat-calendar-table-label'); - expect(labelEl.innerHTML.trim()).toBe('Jan 2017'); + expect(labelEl.innerHTML.trim()).toBe('JAN'); }); it('has 31 days', () => { diff --git a/src/lib/datepicker/year-view.spec.ts b/src/lib/datepicker/year-view.spec.ts index a60febe29320..b86294299b0e 100644 --- a/src/lib/datepicker/year-view.spec.ts +++ b/src/lib/datepicker/year-view.spec.ts @@ -1,16 +1,23 @@ -import {async, TestBed, ComponentFixture} from '@angular/core/testing'; -import {MdDatepickerModule} from './index'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdYearView} from './year-view'; import {SimpleDate} from '../core/datetime/simple-date'; +import {MdCalendarTable} from './calendar-table'; +import {DatetimeModule} from '../core/datetime/index'; describe('MdYearView', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdDatepickerModule], + imports: [ + DatetimeModule, + ], declarations: [ + MdCalendarTable, + MdYearView, + + // Test components. StandardYearView, ], }); @@ -44,7 +51,7 @@ describe('MdYearView', () => { it('shows selected month if in same year', () => { let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); - expect(selectedEl.innerHTML.trim()).toBe('Mar'); + expect(selectedEl.innerHTML.trim()).toBe('MAR'); }); it('does not show selected month if in different year', () => { @@ -61,7 +68,7 @@ describe('MdYearView', () => { fixture.detectChanges(); let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); - expect(selectedEl.innerHTML.trim()).toBe('Dec'); + expect(selectedEl.innerHTML.trim()).toBe('DEC'); }); }); }); From 4c20c5fe347a3c1176baae9b80f88914b27f46f3 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Thu, 9 Mar 2017 14:04:14 -0800 Subject: [PATCH 10/37] feat(datepicker): add md-datepicker-trigger & compatibility w/ md-input-container (#3468) feat(datepicker): add mdDatepickerToggle & compatibility w/ md-input-container --- src/demo-app/datepicker/datepicker-demo.html | 27 ++++- src/demo-app/datepicker/datepicker-demo.ts | 1 + src/lib/datepicker/datepicker-input.ts | 18 ++- src/lib/datepicker/datepicker-toggle.scss | 13 +++ src/lib/datepicker/datepicker-toggle.ts | 30 +++++ src/lib/datepicker/datepicker.spec.ts | 111 ++++++++++++++++--- src/lib/datepicker/datepicker.ts | 28 ++--- src/lib/datepicker/index.ts | 3 + src/lib/input/input-container.html | 2 +- src/lib/input/input-container.scss | 7 +- src/lib/input/input-container.ts | 3 + 11 files changed, 199 insertions(+), 44 deletions(-) create mode 100644 src/lib/datepicker/datepicker-toggle.scss create mode 100644 src/lib/datepicker/datepicker-toggle.ts diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index f974fc7228c7..c975b470e636 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -1,6 +1,25 @@

Work in progress, not ready for use.

- - - - +

+ Use touch UI +

+
+

+ + + +

+

+ + + + + +

+

+ + + + + +

diff --git a/src/demo-app/datepicker/datepicker-demo.ts b/src/demo-app/datepicker/datepicker-demo.ts index 214febf1eff0..f0927ec1203c 100644 --- a/src/demo-app/datepicker/datepicker-demo.ts +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -10,4 +10,5 @@ import {SimpleDate} from '@angular/material'; }) export class DatepickerDemo { date: SimpleDate; + touch = false; } diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index f7be57566e25..12ab3b830c3b 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -1,5 +1,11 @@ import { - AfterContentInit, Directive, ElementRef, forwardRef, Input, OnDestroy, + AfterContentInit, + Directive, + ElementRef, + forwardRef, + Input, + OnDestroy, + Optional, Renderer } from '@angular/core'; import {MdDatepicker} from './datepicker'; @@ -7,6 +13,7 @@ import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {SimpleDate} from '../core/datetime/simple-date'; import {CalendarLocale} from '../core/datetime/calendar-locale'; import {Subscription} from 'rxjs'; +import {MdInputContainer} from '../input/input-container'; export const MD_DATEPICKER_VALUE_ACCESSOR: any = { @@ -55,8 +62,11 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor private _datepickerSubscription: Subscription; - constructor(private _elementRef: ElementRef, private _renderer: Renderer, - private _locale: CalendarLocale) {} + constructor( + private _elementRef: ElementRef, + private _renderer: Renderer, + private _locale: CalendarLocale, + @Optional() private _mdInputContainer: MdInputContainer) {} ngAfterContentInit() { if (this._datepicker) { @@ -75,7 +85,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor } getPopupConnectionElementRef(): ElementRef { - return this._elementRef; + return this._mdInputContainer ? this._mdInputContainer.underlineRef : this._elementRef; } // Implemented as part of ControlValueAccessor diff --git a/src/lib/datepicker/datepicker-toggle.scss b/src/lib/datepicker/datepicker-toggle.scss new file mode 100644 index 000000000000..92f1f5a42519 --- /dev/null +++ b/src/lib/datepicker/datepicker-toggle.scss @@ -0,0 +1,13 @@ +$mat-datepicker-toggle-icon-size: 24px !default; + + +.mat-datepicker-toggle { + display: inline-block; + background: url('data:image/svg+xml;utf8,') no-repeat; + background-size: contain; + height: $mat-datepicker-toggle-icon-size; + width: $mat-datepicker-toggle-icon-size; + border: none; + outline: none; + vertical-align: middle; +} diff --git a/src/lib/datepicker/datepicker-toggle.ts b/src/lib/datepicker/datepicker-toggle.ts new file mode 100644 index 000000000000..a6ba593205ac --- /dev/null +++ b/src/lib/datepicker/datepicker-toggle.ts @@ -0,0 +1,30 @@ +import {ChangeDetectionStrategy, Component, Input, ViewEncapsulation} from '@angular/core'; +import {MdDatepicker} from './datepicker'; + + +@Component({ + moduleId: module.id, + selector: 'button[mdDatepickerToggle], button[matDatepickerToggle]', + template: '', + styleUrls: ['datepicker-toggle.css'], + host: { + '[class.mat-datepicker-toggle]': 'true', + '(click)': '_open($event)', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdDatepickerToggle { + @Input('mdDatepickerToggle') datepicker: MdDatepicker; + + @Input('matDatepickerToggle') + get _datepicker() { return this.datepicker; } + set _datepicker(v: MdDatepicker) { this.datepicker = v; } + + _open(event: Event): void { + if (this.datepicker) { + this.datepicker.open(); + event.stopPropagation(); + } + } +} diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index 45923506150a..37f479907508 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -6,13 +6,14 @@ import {MdDatepickerInput} from './datepicker-input'; import {SimpleDate} from '../core/datetime/simple-date'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {By} from '@angular/platform-browser'; -import {dispatchFakeEvent} from '../core/testing/dispatch-events'; +import {dispatchFakeEvent, dispatchMouseEvent} from '../core/testing/dispatch-events'; +import {MdInputModule} from '../input/index'; describe('MdDatepicker', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdDatepickerModule, FormsModule, ReactiveFormsModule], + imports: [MdDatepickerModule, MdInputModule, FormsModule, ReactiveFormsModule], declarations: [ StandardDatepicker, MultiInputDatepicker, @@ -20,6 +21,8 @@ describe('MdDatepicker', () => { DatepickerWithStartAt, DatepickerWithNgModel, DatepickerWithFormControl, + DatepickerWithToggle, + InputContainerDatepicker, ], }); @@ -37,26 +40,29 @@ describe('MdDatepicker', () => { testComponent = fixture.componentInstance; }); - it('openStandardUi should open popup', () => { + it('open non-touch should open popup', () => { expect(document.querySelector('.cdk-overlay-pane')).toBeNull(); - testComponent.datepicker.openStandardUi(); + testComponent.datepicker.open(); fixture.detectChanges(); expect(document.querySelector('.cdk-overlay-pane')).not.toBeNull(); }); - it('openTouchUi should open dialog', () => { + it('open touch should open dialog', () => { + testComponent.touch = true; + fixture.detectChanges(); + expect(document.querySelector('md-dialog-container')).toBeNull(); - testComponent.datepicker.openTouchUi(); + testComponent.datepicker.open(); fixture.detectChanges(); expect(document.querySelector('md-dialog-container')).not.toBeNull(); }); it('close should close popup', async(() => { - testComponent.datepicker.openStandardUi(); + testComponent.datepicker.open(); fixture.detectChanges(); let popup = document.querySelector('.cdk-overlay-pane'); @@ -72,7 +78,10 @@ describe('MdDatepicker', () => { })); it('close should close dialog', async(() => { - testComponent.datepicker.openTouchUi(); + testComponent.touch = true; + fixture.detectChanges(); + + testComponent.datepicker.open(); fixture.detectChanges(); expect(document.querySelector('md-dialog-container')).not.toBeNull(); @@ -86,7 +95,10 @@ describe('MdDatepicker', () => { })); it('setting selected should update input and close calendar', async(() => { - testComponent.datepicker.openTouchUi(); + testComponent.touch = true; + fixture.detectChanges(); + + testComponent.datepicker.open(); fixture.detectChanges(); expect(document.querySelector('md-dialog-container')).not.toBeNull(); @@ -105,6 +117,12 @@ describe('MdDatepicker', () => { it('startAt should fallback to input value', () => { expect(testComponent.datepicker.startAt).toEqual(new SimpleDate(2020, 0, 1)); }); + + it('should attach popup to native input', () => { + let attachToRef = testComponent.datepickerInput.getPopupConnectionElementRef(); + expect(attachToRef.nativeElement.tagName.toLowerCase()) + .toBe('input', 'popup should be attached to native input'); + }); }); describe('datepicker with too many inputs', () => { @@ -126,7 +144,7 @@ describe('MdDatepicker', () => { }); it('should throw when opened with no registered inputs', () => { - expect(() => testComponent.datepicker.openStandardUi()).toThrow(); + expect(() => testComponent.datepicker.open()).toThrow(); }); }); @@ -277,6 +295,46 @@ describe('MdDatepicker', () => { expect(inputEl.disabled).toBe(true); }); }); + + describe('datepicker with mdDatepickerToggle', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithToggle; + + beforeEach(() => { + fixture = TestBed.createComponent(DatepickerWithToggle); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + }); + + it('should open calendar when toggle clicked', () => { + expect(document.querySelector('md-dialog-container')).toBeNull(); + + let toggle = fixture.debugElement.query(By.css('button')); + dispatchMouseEvent(toggle.nativeElement, 'click'); + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + }); + }); + + describe('datepicker inside input-container', () => { + let fixture: ComponentFixture; + let testComponent: InputContainerDatepicker; + + beforeEach(() => { + fixture = TestBed.createComponent(InputContainerDatepicker); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + }); + + it('should attach popup to input-container underline', () => { + let attachToRef = testComponent.datepickerInput.getPopupConnectionElementRef(); + expect(attachToRef.nativeElement.classList.contains('mat-input-underline')) + .toBe(true, 'popup should be attached to input-container underline'); + }); + }); }); @@ -288,9 +346,13 @@ function detectModelChanges(fixture: ComponentFixture) { @Component({ - template: ``, + template: ` + + + `, }) class StandardDatepicker { + touch = false; @ViewChild('d') datepicker: MdDatepicker; @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } @@ -324,7 +386,7 @@ class DatepickerWithStartAt { @Component({ - template: `` + template: ``, }) class DatepickerWithNgModel { selected: SimpleDate = null; @@ -337,10 +399,33 @@ class DatepickerWithNgModel { template: ` - ` + `, }) class DatepickerWithFormControl { formControl = new FormControl(); @ViewChild('d') datepicker: MdDatepicker; @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } + + +@Component({ + template: ` + + + + `, +}) +class DatepickerWithToggle {} + + +@Component({ + template: ` + + + + + `, +}) +class InputContainerDatepicker { + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; +} diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 27a325bb0d73..5e0e20cb3cf6 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -53,6 +53,13 @@ export class MdDatepicker implements OnDestroy { set startAt(date: SimpleDate) { this._startAt = this._locale.parseDate(date); } private _startAt: SimpleDate; + /** + * Whether the calendar UI is in touch mode. In touch mode the calendar opens in a dialog rather + * than a popup and elements have more padding to allow for bigger touch targets. + */ + @Input() + touchUi = false; + @Output() selectedChanged = new EventEmitter(); get _selected(): SimpleDate { @@ -63,12 +70,6 @@ export class MdDatepicker implements OnDestroy { this.close(); } - /** - * Whether the calendar UI is in touch mode. In touch mode the calendar opens in a dialog rather - * than a popup and elements have more padding to allow for bigger touch targets. - */ - touchUi: boolean; - /** The calendar template. */ @ViewChild(TemplateRef) calendarTemplate: TemplateRef; @@ -106,21 +107,11 @@ export class MdDatepicker implements OnDestroy { this._datepickerInput = input; } - /** Opens the calendar in standard UI mode. */ - openStandardUi(): void { - this._open(); - } - - /** Opens the calendar in touch UI mode. */ - openTouchUi(): void { - this._open(true); - } - /** * Open the calendar. * @param touchUi Whether to use the touch UI. */ - private _open(touchUi = false): void { + open(): void { if (!this._datepickerInput) { throw new MdError('Attempted to open an MdDatepicker with no associated input.'); } @@ -129,8 +120,7 @@ export class MdDatepicker implements OnDestroy { this._calendarPortal = new TemplatePortal(this.calendarTemplate, this._viewContainerRef); } - this.touchUi = touchUi; - touchUi ? this._openAsDialog() : this._openAsPopup(); + this.touchUi ? this._openAsDialog() : this._openAsPopup(); } /** Close the calendar. */ diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index b8138a0fd46c..f6afa6f473f2 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -9,6 +9,7 @@ import {MdDatepicker} from './datepicker'; import {MdDatepickerInput} from './datepicker-input'; import {MdDialogModule} from '../dialog/index'; import {MdCalendar} from './calendar'; +import {MdDatepickerToggle} from './datepicker-toggle'; export * from './calendar'; @@ -29,12 +30,14 @@ export * from './year-view'; exports: [ MdDatepicker, MdDatepickerInput, + MdDatepickerToggle, ], declarations: [ MdCalendar, MdCalendarTable, MdDatepicker, MdDatepickerInput, + MdDatepickerToggle, MdMonthView, MdYearView, ], diff --git a/src/lib/input/input-container.html b/src/lib/input/input-container.html index dbcbb3d12e5e..f5b1c8745ee3 100644 --- a/src/lib/input/input-container.html +++ b/src/lib/input/input-container.html @@ -29,7 +29,7 @@ -
Date: Fri, 10 Mar 2017 13:05:02 -0800 Subject: [PATCH 11/37] feat(datepicker): add aria-* attrs and keyboard bindings to datepicker input (#3542) * add aria-* attrs and keyboard bindings to datepicker input * fix rebase errors * added comment --- src/lib/datepicker/datepicker-input.ts | 14 +++++++++++++- src/lib/datepicker/datepicker.html | 1 + src/lib/datepicker/datepicker.ts | 16 ++++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index 12ab3b830c3b..f24dddf04c3d 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -14,6 +14,7 @@ import {SimpleDate} from '../core/datetime/simple-date'; import {CalendarLocale} from '../core/datetime/calendar-locale'; import {Subscription} from 'rxjs'; import {MdInputContainer} from '../input/input-container'; +import {DOWN_ARROW} from '../core/keyboard/keycodes'; export const MD_DATEPICKER_VALUE_ACCESSOR: any = { @@ -28,8 +29,12 @@ export const MD_DATEPICKER_VALUE_ACCESSOR: any = { selector: 'input[mdDatepicker], input[matDatepicker]', providers: [MD_DATEPICKER_VALUE_ACCESSOR], host: { + '[attr.aria-expanded]': '_datepicker?.opened || "false"', + '[attr.aria-haspopup]': 'true', + '[attr.aria-owns]': '_datepicker?.id', '(input)': '_onChange($event.target.value)', '(blur)': '_onTouched()', + '(keydown)': '_onKeydown($event)', } }) export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor, OnDestroy { @@ -40,7 +45,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor this._datepicker._registerInput(this); } } - private _datepicker: MdDatepicker; + _datepicker: MdDatepicker; @Input() get value(): SimpleDate { @@ -107,4 +112,11 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor setDisabledState(disabled: boolean): void { this._renderer.setElementProperty(this._elementRef.nativeElement, 'disabled', disabled); } + + _onKeydown(event: KeyboardEvent) { + if (event.altKey && event.keyCode === DOWN_ARROW) { + this._datepicker.open(); + event.preventDefault(); + } + } } diff --git a/src/lib/datepicker/datepicker.html b/src/lib/datepicker/datepicker.html index c7cf5a083d31..bf20f86edd3d 100644 --- a/src/lib/datepicker/datepicker.html +++ b/src/lib/datepicker/datepicker.html @@ -1,5 +1,6 @@ diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index a2f8fb9a94d8..fee217176ba5 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -1,9 +1,11 @@ import { ChangeDetectionStrategy, - Component, EventEmitter, + Component, + EventEmitter, Input, OnDestroy, - Optional, Output, + Optional, + Output, TemplateRef, ViewChild, ViewContainerRef, @@ -64,12 +66,32 @@ export class MdDatepicker implements OnDestroy { @Input() touchUi = false; + /** The minimum selectable date. */ + @Input() + get minDate(): SimpleDate { return this._minDate; }; + set minDate(date: SimpleDate) { this._minDate = this._locale.parseDate(date); } + private _minDate: SimpleDate; + + /** The maximum selectable date. */ + @Input() + get maxDate(): SimpleDate { return this._maxDate; }; + set maxDate(date: SimpleDate) { this._maxDate = this._locale.parseDate(date); } + private _maxDate: SimpleDate; + + /** A function used to filter which dates are selectable. */ + @Input() + dateFilter: (date: SimpleDate) => boolean; + + /** Emits new selected date when selected date changes. */ @Output() selectedChanged = new EventEmitter(); + /** Whether the calendar is open. */ opened = false; + /** The id for the datepicker calendar. */ id = `md-datepicker-${datepickerUid++}`; + /** The currently selected date. */ get _selected(): SimpleDate { return this._datepickerInput ? this._datepickerInput.value : null; } diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts index 3b19a23401ce..5a3fee271e61 100644 --- a/src/lib/datepicker/month-view.spec.ts +++ b/src/lib/datepicker/month-view.spec.ts @@ -19,6 +19,7 @@ describe('MdMonthView', () => { // Test components. StandardMonthView, + MonthViewWithDateFilter, ], }); @@ -71,6 +72,27 @@ describe('MdMonthView', () => { expect(selectedEl.innerHTML.trim()).toBe('31'); }); }); + + describe('month view with date filter', () => { + let fixture: ComponentFixture; + let testComponent: MonthViewWithDateFilter; + let monthViewNativeElement: Element; + + beforeEach(() => { + fixture = TestBed.createComponent(MonthViewWithDateFilter); + fixture.detectChanges(); + + let monthViewDebugElement = fixture.debugElement.query(By.directive(MdMonthView)); + monthViewNativeElement = monthViewDebugElement.nativeElement; + testComponent = fixture.componentInstance; + }); + + it('should disabled filtered dates', () => { + let cells = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + expect(cells[0].classList).toContain('mat-calendar-table-disabled'); + expect(cells[1].classList).not.toContain('mat-calendar-table-disabled'); + }); + }); }); @@ -81,3 +103,13 @@ class StandardMonthView { date = new SimpleDate(2017, 0, 5); selected = new SimpleDate(2017, 0, 10); } + + +@Component({ + template: `` +}) +class MonthViewWithDateFilter { + dateFilter(date: SimpleDate) { + return date.date % 2 == 0; + } +} diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts index 8fb387b9e358..ced1014d9e00 100644 --- a/src/lib/datepicker/month-view.ts +++ b/src/lib/datepicker/month-view.ts @@ -47,6 +47,9 @@ export class MdMonthView implements AfterContentInit { } private _selected: SimpleDate; + /** A function used to filter which dates are selectable. */ + @Input() dateFilter: (date: SimpleDate) => boolean; + /** Emits when a new date is selected. */ @Output() selectedChange = new EventEmitter(); @@ -76,7 +79,7 @@ export class MdMonthView implements AfterContentInit { /** Handles when a new date is selected. */ _dateSelected(date: number) { - if (this.selected && this.selected.date == date) { + if (this._selectedDate == date) { return; } this.selectedChange.emit(new SimpleDate(this.date.year, this.date.month, date)); @@ -104,8 +107,10 @@ export class MdMonthView implements AfterContentInit { this._weeks.push([]); cell = 0; } + let enabled = !this.dateFilter || + this.dateFilter(new SimpleDate(this.date.year, this.date.month, i + 1)); this._weeks[this._weeks.length - 1] - .push(new MdCalendarCell(i + 1, this._locale.dates[i + 1])); + .push(new MdCalendarCell(i + 1, this._locale.dates[i + 1], enabled)); } } diff --git a/src/lib/datepicker/year-view.html b/src/lib/datepicker/year-view.html index c2aa74d8e08c..2e70db4cf0a3 100644 --- a/src/lib/datepicker/year-view.html +++ b/src/lib/datepicker/year-view.html @@ -1,4 +1,5 @@ - { // Test components. StandardYearView, + YearViewWithDateFilter, ], }); @@ -71,6 +72,27 @@ describe('MdYearView', () => { expect(selectedEl.innerHTML.trim()).toBe('DEC'); }); }); + + describe('year view with date filter', () => { + let fixture: ComponentFixture; + let testComponent: YearViewWithDateFilter; + let yearViewNativeElement: Element; + + beforeEach(() => { + fixture = TestBed.createComponent(YearViewWithDateFilter); + fixture.detectChanges(); + + let yearViewDebugElement = fixture.debugElement.query(By.directive(MdYearView)); + yearViewNativeElement = yearViewDebugElement.nativeElement; + testComponent = fixture.componentInstance; + }); + + it('should disabled months with no enabled days', () => { + let cells = yearViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + expect(cells[0].classList).not.toContain('mat-calendar-table-disabled'); + expect(cells[1].classList).toContain('mat-calendar-table-disabled'); + }); + }); }); @@ -81,3 +103,19 @@ class StandardYearView { date = new SimpleDate(2017, 0, 5); selected = new SimpleDate(2017, 2, 10); } + + +@Component({ + template: `` +}) +class YearViewWithDateFilter { + dateFilter(date: SimpleDate) { + if (date.month == 0) { + return date.date == 10; + } + if (date.month == 1) { + return false; + } + return true; + } +} diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts index e49ea0b6b4b8..40f5d8f1bbd2 100644 --- a/src/lib/datepicker/year-view.ts +++ b/src/lib/datepicker/year-view.ts @@ -42,6 +42,9 @@ export class MdYearView implements AfterContentInit { } private _selected: SimpleDate; + /** A function used to filter which dates are selectable. */ + @Input() dateFilter: (date: SimpleDate) => boolean; + /** Emits when a new month is selected. */ @Output() selectedChange = new EventEmitter(); @@ -60,11 +63,7 @@ export class MdYearView implements AfterContentInit { */ _selectedMonth: number; - constructor(private _locale: CalendarLocale) { - // First row of months only contains 5 elements so we can fit the year label on the same row. - this._months = [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11]].map(row => row.map( - month => this._createCellForMonth(month))); - } + constructor(private _locale: CalendarLocale) {} ngAfterContentInit() { this._init(); @@ -72,9 +71,6 @@ export class MdYearView implements AfterContentInit { /** Handles when a new month is selected. */ _monthSelected(month: number) { - if (this.selected && this.selected.month == month) { - return; - } this.selectedChange.emit(new SimpleDate(this.date.year, month, 1)); } @@ -83,6 +79,10 @@ export class MdYearView implements AfterContentInit { this._selectedMonth = this._getMonthInCurrentYear(this.selected); this._todayMonth = this._getMonthInCurrentYear(SimpleDate.today()); this._yearLabel = this._locale.getCalendarYearHeaderLabel(this._date); + + // First row of months only contains 5 elements so we can fit the year label on the same row. + this._months = [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11]].map(row => row.map( + month => this._createCellForMonth(month))); } /** @@ -95,6 +95,24 @@ export class MdYearView implements AfterContentInit { /** Creates an MdCalendarCell for the given month. */ private _createCellForMonth(month: number) { - return new MdCalendarCell(month, this._locale.shortMonths[month].toLocaleUpperCase()); + return new MdCalendarCell( + month, this._locale.shortMonths[month].toLocaleUpperCase(), this._isMonthEnabled(month)); + } + + /** Whether the given month is enabled. */ + private _isMonthEnabled(month: number) { + if (!this.dateFilter) { + return true; + } + + // If any date in the month is enabled count the month as enabled. + for (let date = new SimpleDate(this.date.year, month, 1); date.month === month; + date = date.add({days: 1})) { + if (this.dateFilter(date)) { + return true; + } + } + + return false; } } From e6cf1ef1c365312bd1699641c8e41e81ee7b5cbb Mon Sep 17 00:00:00 2001 From: mmalerba Date: Mon, 20 Mar 2017 12:04:01 -0700 Subject: [PATCH 13/37] fix(datepicker): fix bug where calendar dialog could only be opened once (#3685) * fix(datepicker): fix bug where calendar dialog could only be opened once * add rxjs first operator --- src/lib/datepicker/datepicker.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index fee217176ba5..ebb2664facd7 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -27,6 +27,7 @@ import { import {SimpleDate} from '../core/datetime/simple-date'; import {MdDatepickerInput} from './datepicker-input'; import {CalendarLocale} from '../core/datetime/calendar-locale'; +import 'rxjs/add/operator/first'; /** Used to generate a unique ID for each datepicker instance. */ @@ -178,6 +179,7 @@ export class MdDatepicker implements OnDestroy { /** Open the calendar as a dialog. */ private _openAsDialog(): void { this._dialogRef = this._dialog.open(this.calendarTemplate); + this._dialogRef.afterClosed().first().subscribe(() => this.close()); } /** Open the calendar as a popup. */ From 12076e88136311904ff43845d03203b95924eb0c Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 24 Mar 2017 15:57:52 -0700 Subject: [PATCH 14/37] feat(datepicker): add keyboard support to calendar (#3655) * add active cell support to calendar-table, month-view, and year-view * stop normalizing the active date in calendar since we now care about the exact active date * add some key handlers * add keyboard support, break some tests * add tests * some finishing touches * fix tabindex * fix rxjs import * addressed some comments * refactor handleKeydown * fix commas --- src/lib/datepicker/_datepicker-theme.scss | 3 +- src/lib/datepicker/calendar-table.html | 7 +- src/lib/datepicker/calendar-table.spec.ts | 6 + src/lib/datepicker/calendar-table.ts | 18 +- src/lib/datepicker/calendar.html | 7 +- src/lib/datepicker/calendar.spec.ts | 381 ++++++++++++++++++++-- src/lib/datepicker/calendar.ts | 217 ++++++++++-- src/lib/datepicker/datepicker-input.ts | 2 +- src/lib/datepicker/index.ts | 2 + src/lib/datepicker/month-view.html | 1 + src/lib/datepicker/month-view.spec.ts | 12 +- src/lib/datepicker/month-view.ts | 32 +- src/lib/datepicker/year-view.html | 1 + src/lib/datepicker/year-view.spec.ts | 9 +- src/lib/datepicker/year-view.ts | 21 +- 15 files changed, 635 insertions(+), 84 deletions(-) diff --git a/src/lib/datepicker/_datepicker-theme.scss b/src/lib/datepicker/_datepicker-theme.scss index d3904a327186..bd3d1a56726e 100644 --- a/src/lib/datepicker/_datepicker-theme.scss +++ b/src/lib/datepicker/_datepicker-theme.scss @@ -45,7 +45,8 @@ } } - :not(.mat-calendar-table-disabled):hover { + :not(.mat-calendar-table-disabled):hover, + .cdk-keyboard-focused .mat-calendar-table-active { & > .mat-calendar-table-cell-content:not(.mat-calendar-table-selected) { background-color: mat-color($background, hover); } diff --git a/src/lib/datepicker/calendar-table.html b/src/lib/datepicker/calendar-table.html index 37b29b265d79..af00052cfc34 100644 --- a/src/lib/datepicker/calendar-table.html +++ b/src/lib/datepicker/calendar-table.html @@ -5,15 +5,16 @@ - - + {{_firstRowOffset >= labelMinRequiredCells ? label : ''}} -
{ expect(todayElement.classList) .toContain('mat-calendar-table-selected', 'today should be selected'); }); + + it('should mark active date', () => { + expect((cellEls[10] as HTMLElement).innerText.trim()).toBe('11'); + expect(cellEls[10].classList).toContain('mat-calendar-table-active'); + }); }); describe('calendar table with disabled cells', () => { @@ -129,6 +134,7 @@ describe('MdCalendarTable', () => { [selectedValue]="selectedValue" [labelMinRequiredCells]="labelMinRequiredCells" [numCols]="numCols" + [activeCell]="10" (selectedValueChange)="onSelect($event)"> `, }) diff --git a/src/lib/datepicker/calendar-table.ts b/src/lib/datepicker/calendar-table.ts index aabe9c76fe70..a9195f71cd8d 100644 --- a/src/lib/datepicker/calendar-table.ts +++ b/src/lib/datepicker/calendar-table.ts @@ -51,10 +51,13 @@ export class MdCalendarTable { /** Whether to allow selection of disabled cells. */ @Input() allowDisabledSelection = false; + /** The cell number of the active cell in the table. */ + @Input() activeCell = 0; + /** Emits when a new value is selected. */ @Output() selectedValueChange = new EventEmitter(); - _cellClicked(cell: MdCalendarCell) { + _cellClicked(cell: MdCalendarCell): void { if (!this.allowDisabledSelection && !cell.enabled) { return; } @@ -62,8 +65,19 @@ export class MdCalendarTable { } /** The number of blank cells to put at the beginning for the first row. */ - get _firstRowOffset() { + get _firstRowOffset(): number { return this.rows && this.rows.length && this.rows[0].length ? this.numCols - this.rows[0].length : 0; } + + _isActiveCell(rowIndex: number, colIndex: number): boolean { + let cellNumber = rowIndex * this.numCols + colIndex; + + // Account for the fact that the first row may not have as many cells. + if (rowIndex) { + cellNumber -= this._firstRowOffset; + } + + return cellNumber == this.activeCell; + } } diff --git a/src/lib/datepicker/calendar.html b/src/lib/datepicker/calendar.html index e570acda369f..1c84c68480c7 100644 --- a/src/lib/datepicker/calendar.html +++ b/src/lib/datepicker/calendar.html @@ -29,10 +29,11 @@
-
+
@@ -40,7 +41,7 @@ diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts index 7c40c71ce0a7..29f484bbdb9a 100644 --- a/src/lib/datepicker/calendar.spec.ts +++ b/src/lib/datepicker/calendar.spec.ts @@ -7,6 +7,21 @@ import {MdMonthView} from './month-view'; import {MdYearView} from './year-view'; import {MdCalendarTable} from './calendar-table'; import {DatetimeModule} from '../core/datetime/index'; +import { + dispatchFakeEvent, dispatchKeyboardEvent, + dispatchMouseEvent +} from '../core/testing/dispatch-events'; +import { + DOWN_ARROW, + END, + ENTER, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW +} from '../core/keyboard/keycodes'; describe('MdCalendar', () => { @@ -54,9 +69,9 @@ describe('MdCalendar', () => { testComponent = fixture.componentInstance; }); - it('should be in month view with specified month visible', () => { + it('should be in month view with specified month active', () => { expect(calendarInstance._monthView).toBe(true, 'should be in month view'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); }); it('should toggle view when period clicked', () => { @@ -74,17 +89,17 @@ describe('MdCalendar', () => { }); it('should go to next and previous month', () => { - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 1, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 28)); }); it('should go to previous and next year', () => { @@ -92,17 +107,17 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(calendarInstance._monthView).toBe(false, 'should be in year view'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 31)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); }); it('should go back to month view after selecting month in year view', () => { @@ -110,14 +125,14 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(calendarInstance._monthView).toBe(false, 'should be in year view'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); let monthCells = calendarElement.querySelectorAll('.mat-calendar-table-cell'); (monthCells[monthCells.length - 1] as HTMLElement).click(); fixture.detectChanges(); expect(calendarInstance._monthView).toBe(true, 'should be in month view'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 11, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 31)); expect(testComponent.selected).toBeFalsy('no date should be selected yet'); }); @@ -129,6 +144,290 @@ describe('MdCalendar', () => { expect(calendarInstance._monthView).toBe(true, 'should be in month view'); expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 31)); }); + + describe('a11y', () => { + describe('calendar body', () => { + let calendarBodyEl: HTMLElement; + + beforeEach(() => { + calendarBodyEl = calendarElement.querySelector('.mat-calendar-body') as HTMLElement; + expect(calendarBodyEl).not.toBeNull(); + + dispatchFakeEvent(calendarBodyEl, 'focus'); + fixture.detectChanges(); + }); + + it('should initially set start date active', () => { + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + }); + + describe('month view', () => { + it('should decrement date on left arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 30)); + + calendarInstance._activeDate = new SimpleDate(2017, 0, 1); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + }); + + it('should increment date on right arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 1)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 2)); + }); + + it('should go up a row on up arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 24)); + + calendarInstance._activeDate = new SimpleDate(2017, 0, 7); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + }); + + it('should go down a row on down arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 7)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 14)); + }); + + it('should go to beginning of the month on home press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 1)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 1)); + }); + + it('should go to end of the month on end press', () => { + calendarInstance._activeDate = new SimpleDate(2017, 0, 10); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + }); + + it('should go back one month on page up press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 10, 30)); + }); + + it('should go forward one month on page down press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 2, 28)); + }); + + it('should select active date on enter', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(testComponent.selected).toBeNull(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 30)); + }); + }); + + describe('year view', () => { + beforeEach(() => { + dispatchMouseEvent(periodButton, 'click'); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(false); + }); + + it('should decrement month on left arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 10, 30)); + }); + + it('should increment month on right arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 2, 28)); + }); + + it('should go up a row on up arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 7, 31)); + + calendarInstance._activeDate = new SimpleDate(2017, 6, 1); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 6, 1)); + + calendarInstance._activeDate = new SimpleDate(2017, 11, 10); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 4, 10)); + }); + + it('should go down a row on down arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 7, 31)); + + calendarInstance._activeDate = new SimpleDate(2017, 5, 1); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 5, 1)); + + calendarInstance._activeDate = new SimpleDate(2017, 8, 30); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 1, 28)); + }); + + it('should go to first month of the year on home press', () => { + calendarInstance._activeDate = new SimpleDate(2017, 8, 30); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 30)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 30)); + }); + + it('should go to last month of the year on end press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 31)); + }); + + it('should go back one year on page up press', () => { + calendarInstance._activeDate = new SimpleDate(2016, 1, 29); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2015, 1, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2014, 1, 28)); + }); + + it('should go forward one year on page down press', () => { + calendarInstance._activeDate = new SimpleDate(2016, 1, 29); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 1, 28)); + }); + + it('should return to month view on enter', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(true); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + expect(testComponent.selected).toBeNull(); + }); + }); + }); + }); }); describe('calendar with min and max date', () => { @@ -154,14 +453,14 @@ describe('MdCalendar', () => { testComponent.startAt = new SimpleDate(2000, 0, 1); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2016, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); }); it('should clamp startAt value above max date', () => { testComponent.startAt = new SimpleDate(2020, 0, 1); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); }); it('should not go back past min date', () => { @@ -169,18 +468,18 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(prevButton.classList).not.toContain('mat-calendar-disabled'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2016, 1, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 1, 1)); prevButton.click(); fixture.detectChanges(); expect(prevButton.classList).toContain('mat-calendar-disabled'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2016, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2016, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); }); it('should not go forward past max date', () => { @@ -188,18 +487,18 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(nextButton.classList).not.toContain('mat-calendar-disabled'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 11, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 1)); nextButton.click(); fixture.detectChanges(); expect(nextButton.classList).toContain('mat-calendar-disabled'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); }); }); @@ -231,6 +530,46 @@ describe('MdCalendar', () => { expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 2)); }); + + describe('a11y', () => { + let calendarBodyEl: HTMLElement; + + beforeEach(() => { + calendarBodyEl = calendarElement.querySelector('.mat-calendar-body') as HTMLElement; + expect(calendarBodyEl).not.toBeNull(); + + dispatchFakeEvent(calendarBodyEl, 'focus'); + fixture.detectChanges(); + }); + + it('should not allow selection of disabled date in month view', () => { + expect(calendarInstance._monthView).toBe(true); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 1)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(testComponent.selected).toBeNull(); + }); + + it('should allow entering month view at disabled month', () => { + let periodButton = + calendarElement.querySelector('.mat-calendar-period-button') as HTMLElement; + dispatchMouseEvent(periodButton, 'click'); + fixture.detectChanges(); + + calendarInstance._activeDate = new SimpleDate(2017, 10, 1); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(false); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(true); + expect(testComponent.selected).toBeNull(); + }); + }); }); }); @@ -239,7 +578,7 @@ describe('MdCalendar', () => { template: `` }) class StandardCalendar { - selected: SimpleDate; + selected: SimpleDate = null; } @@ -257,9 +596,9 @@ class CalendarWithMinMax { ` }) class CalendarWithDateFilter { - selected: SimpleDate; + selected: SimpleDate = null; dateFilter (date: SimpleDate) { - return date.date % 2 == 0; + return date.date % 2 == 0 && date.month != 10; } } diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index ed2d587a84a2..048d7c227c55 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -1,12 +1,24 @@ import { + AfterContentInit, ChangeDetectionStrategy, - ViewEncapsulation, Component, + EventEmitter, Input, - AfterContentInit, Output, EventEmitter + Output, + ViewEncapsulation } from '@angular/core'; import {SimpleDate} from '../core/datetime/simple-date'; import {CalendarLocale} from '../core/datetime/calendar-locale'; +import { + DOWN_ARROW, + END, ENTER, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW +} from '../core/keyboard/keycodes'; /** @@ -67,16 +79,14 @@ export class MdCalendar implements AfterContentInit { } /** - * A date representing the current period shown in the calendar. The current period is always - * normalized to the 1st of a month, this prevents date overflow issues (e.g. adding a month to - * January 31st and overflowing into March). + * The current active date. This determines which time period is shown and which date is + * highlighted when using keyboard navigation. */ - get _currentPeriod() { return this._normalizedCurrentPeriod; } - set _currentPeriod(value: SimpleDate) { - const clampedValue = value.clamp(this.minDate, this.maxDate); - this._normalizedCurrentPeriod = new SimpleDate(clampedValue.year, clampedValue.month, 1); + get _activeDate() { return this._clampedActiveDate; } + set _activeDate(value: SimpleDate) { + this._clampedActiveDate = value.clamp(this.minDate, this.maxDate); } - private _normalizedCurrentPeriod: SimpleDate; + private _clampedActiveDate: SimpleDate; /** Whether the calendar is in month view. */ _monthView: boolean; @@ -87,8 +97,8 @@ export class MdCalendar implements AfterContentInit { /** The label for the current calendar view. */ get _label(): string { return this._monthView ? - this._locale.getCalendarMonthHeaderLabel(this._currentPeriod).toLocaleUpperCase() : - this._locale.getCalendarYearHeaderLabel(this._currentPeriod); + this._locale.getCalendarMonthHeaderLabel(this._activeDate).toLocaleUpperCase() : + this._locale.getCalendarYearHeaderLabel(this._activeDate); } constructor(private _locale: CalendarLocale) { @@ -97,57 +107,208 @@ export class MdCalendar implements AfterContentInit { } ngAfterContentInit() { - this._currentPeriod = this.startAt || SimpleDate.today(); + this._activeDate = this.startAt || SimpleDate.today(); this._monthView = this.startView != 'year'; } /** Handles date selection in the month view. */ - _dateSelected(date: SimpleDate) { + _dateSelected(date: SimpleDate): void { if ((!date || !this.selected) && date != this.selected || date.compare(this.selected)) { this.selectedChange.emit(date); } } /** Handles month selection in the year view. */ - _monthSelected(month: SimpleDate) { - this._currentPeriod = month; + _monthSelected(month: SimpleDate): void { + this._activeDate = month; this._monthView = true; } /** Handles user clicks on the period label. */ - _currentPeriodClicked() { + _currentPeriodClicked(): void { this._monthView = !this._monthView; } /** Handles user clicks on the previous button. */ - _previousClicked() { - let amount = this._monthView ? {months: -1} : {years: -1}; - this._currentPeriod = this._currentPeriod.add(amount); + _previousClicked(): void { + this._activeDate = this._monthView ? + this._addCalendarMonths(this._activeDate, -1) : + this._addCalendarYears(this._activeDate, -1); } /** Handles user clicks on the next button. */ - _nextClicked() { - let amount = this._monthView ? {months: 1} : {years: 1}; - this._currentPeriod = this._currentPeriod.add(amount); + _nextClicked(): void { + this._activeDate = this._monthView ? + this._addCalendarMonths(this._activeDate, 1) : this._addCalendarYears(this._activeDate, 1); } /** Whether the previous period button is enabled. */ - _previousEnabled() { + _previousEnabled(): boolean { if (!this.minDate) { return true; } - return !this.minDate || !this._isSameView(this._currentPeriod, this.minDate); + return !this.minDate || !this._isSameView(this._activeDate, this.minDate); } /** Whether the next period button is enabled. */ - _nextEnabled() { - return !this.maxDate || !this._isSameView(this._currentPeriod, this.maxDate); + _nextEnabled(): boolean { + return !this.maxDate || !this._isSameView(this._activeDate, this.maxDate); } /** Whether the two dates represent the same view in the current view mode (month or year). */ - private _isSameView(date1: SimpleDate, date2: SimpleDate) { + private _isSameView(date1: SimpleDate, date2: SimpleDate): boolean { return this._monthView ? date1.year == date2.year && date1.month == date2.month : date1.year == date2.year; } + + /** Handles keydown events on the calendar body. */ + _handleCalendarBodyKeydown(event: KeyboardEvent): void { + // TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent + // disabled ones from being selected. This may not be ideal, we should look into whether + // navigation should skip over disabled dates, and if so, how to implement that efficiently. + if (this._monthView) { + this._handleCalendarBodyKeydownInMonthView(event); + } else { + this._handleCalendarBodyKeydownInYearView(event); + } + } + + /** Handles keydown events on the calendar body when calendar is in month view. */ + private _handleCalendarBodyKeydownInMonthView(event: KeyboardEvent): void { + switch (event.keyCode) { + case LEFT_ARROW: + this._activeDate = this._addCalendarDays(this._activeDate, -1); + break; + case RIGHT_ARROW: + this._activeDate = this._addCalendarDays(this._activeDate, 1); + break; + case UP_ARROW: + this._activeDate = this._addCalendarDays(this._activeDate, -7); + break; + case DOWN_ARROW: + this._activeDate = this._addCalendarDays(this._activeDate, 7); + break; + case HOME: + this._activeDate = new SimpleDate(this._activeDate.year, this._activeDate.month, 1); + break; + case END: + this._activeDate = new SimpleDate(this._activeDate.year, this._activeDate.month + 1, 0); + break; + case PAGE_UP: + this._activeDate = event.altKey ? + this._addCalendarYears(this._activeDate, -1) : + this._addCalendarMonths(this._activeDate, -1); + break; + case PAGE_DOWN: + this._activeDate = event.altKey ? + this._addCalendarYears(this._activeDate, 1) : + this._addCalendarMonths(this._activeDate, 1); + break; + case ENTER: + if (this._dateFilterForViews(this._activeDate)) { + this._dateSelected(this._activeDate); + break; + } + return; + default: + // Don't prevent default on keys that we don't explicitly handle. + return; + } + + event.preventDefault(); + } + + /** Handles keydown events on the calendar body when calendar is in year view. */ + private _handleCalendarBodyKeydownInYearView(event: KeyboardEvent): void { + switch (event.keyCode) { + case LEFT_ARROW: + this._activeDate = this._addCalendarMonths(this._activeDate, -1); + break; + case RIGHT_ARROW: + this._activeDate = this._addCalendarMonths(this._activeDate, 1); + break; + case UP_ARROW: + this._activeDate = this._prevMonthInSameCol(this._activeDate); + break; + case DOWN_ARROW: + this._activeDate = this._nextMonthInSameCol(this._activeDate); + break; + case HOME: + this._activeDate = this._addCalendarMonths(this._activeDate, -this._activeDate.month); + break; + case END: + this._activeDate = this._addCalendarMonths(this._activeDate, 11 - this._activeDate.month); + break; + case PAGE_UP: + this._activeDate = this._addCalendarYears(this._activeDate, event.altKey ? -10 : -1); + break; + case PAGE_DOWN: + this._activeDate = this._addCalendarYears(this._activeDate, event.altKey ? 10 : 1); + break; + case ENTER: + this._monthSelected(this._activeDate); + break; + default: + // Don't prevent default on keys that we don't explicitly handle. + return; + } + + event.preventDefault(); + } + + /** Adds the given number of days to the date. */ + private _addCalendarDays(date: SimpleDate, days: number): SimpleDate { + return date.add({days}); + } + + /** + * Adds the given number of months to the date. Months are counted as if flipping pages on a + * calendar and then finding the closest date in the new month. For example when adding 1 month to + * Jan 31, 2017, the resulting date will be Feb 28, 2017. + */ + private _addCalendarMonths(date: SimpleDate, months: number): SimpleDate { + let newDate = date.add({months}); + + // It's possible to wind up in the wrong month if the original month has more days than the new + // month. In this case we want to go to the last day of the desired month. + // Note: the additional + 12 % 12 ensures we end up with a positive number, since JS % doesn't + // guarantee this. + if (newDate.month != ((date.month + months) % 12 + 12) % 12) { + newDate = new SimpleDate(newDate.year, newDate.month, 0); + } + + return newDate; + } + + /** + * Adds the given number of months to the date. Months are counted as if flipping 12 pages for + * each year on a calendar and then finding the closest date in the new month. For example when + * adding 1 year to Feb 29, 2016, the resulting date will be Feb 28, 2017. + */ + private _addCalendarYears(date: SimpleDate, years: number): SimpleDate { + return this._addCalendarMonths(date, years * 12); + } + + /** + * Determine the date for the month that comes before the given month in the same column in the + * calendar table. + */ + private _prevMonthInSameCol(date: SimpleDate) { + // Determine how many months to jump forward given that there are 2 empty slots at the beginning + // of each year. + let increment = date.month <= 4 ? -5 : (date.month >= 7 ? -7 : -12); + return this._addCalendarMonths(date, increment); + } + + /** + * Determine the date for the month that comes after the given month in the same column in the + * calendar table. + */ + private _nextMonthInSameCol(date: SimpleDate): SimpleDate { + // Determine how many months to jump forward given that there are 2 empty slots at the beginning + // of each year. + let increment = date.month <= 4 ? 7 : (date.month >= 7 ? 5 : 12); + return this._addCalendarMonths(date, increment); + } } diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index f24dddf04c3d..e4426b0567fd 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -12,7 +12,7 @@ import {MdDatepicker} from './datepicker'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {SimpleDate} from '../core/datetime/simple-date'; import {CalendarLocale} from '../core/datetime/calendar-locale'; -import {Subscription} from 'rxjs'; +import {Subscription} from 'rxjs/Subscription'; import {MdInputContainer} from '../input/input-container'; import {DOWN_ARROW} from '../core/keyboard/keycodes'; diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index f6afa6f473f2..7b924a7b5bdc 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -10,6 +10,7 @@ import {MdDatepickerInput} from './datepicker-input'; import {MdDialogModule} from '../dialog/index'; import {MdCalendar} from './calendar'; import {MdDatepickerToggle} from './datepicker-toggle'; +import {StyleModule} from '../core/style/index'; export * from './calendar'; @@ -26,6 +27,7 @@ export * from './year-view'; DatetimeModule, MdDialogModule, OverlayModule, + StyleModule, ], exports: [ MdDatepicker, diff --git a/src/lib/datepicker/month-view.html b/src/lib/datepicker/month-view.html index e0e0559cfc9c..af76b0a61a4e 100644 --- a/src/lib/datepicker/month-view.html +++ b/src/lib/datepicker/month-view.html @@ -3,5 +3,6 @@ [todayValue]="_todayDate" [selectedValue]="_selectedDate" [labelMinRequiredCells]="3" + [activeCell]="activeDate.date - 1" (selectedValueChange)="_dateSelected($event)"> diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts index 5a3fee271e61..d7fdefc1e985 100644 --- a/src/lib/datepicker/month-view.spec.ts +++ b/src/lib/datepicker/month-view.spec.ts @@ -71,6 +71,12 @@ describe('MdMonthView', () => { let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-table-selected'); expect(selectedEl.innerHTML.trim()).toBe('31'); }); + + it('should mark active date', () => { + let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + expect((cellEls[4] as HTMLElement).innerText.trim()).toBe('5'); + expect(cellEls[4].classList).toContain('mat-calendar-table-active'); + }); }); describe('month view with date filter', () => { @@ -87,7 +93,7 @@ describe('MdMonthView', () => { testComponent = fixture.componentInstance; }); - it('should disabled filtered dates', () => { + it('should disable filtered dates', () => { let cells = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); expect(cells[0].classList).toContain('mat-calendar-table-disabled'); expect(cells[1].classList).not.toContain('mat-calendar-table-disabled'); @@ -97,7 +103,7 @@ describe('MdMonthView', () => { @Component({ - template: ``, + template: ``, }) class StandardMonthView { date = new SimpleDate(2017, 0, 5); @@ -106,7 +112,7 @@ class StandardMonthView { @Component({ - template: `` + template: `` }) class MonthViewWithDateFilter { dateFilter(date: SimpleDate) { diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts index ced1014d9e00..411405f9ef96 100644 --- a/src/lib/datepicker/month-view.ts +++ b/src/lib/datepicker/month-view.ts @@ -31,12 +31,15 @@ export class MdMonthView implements AfterContentInit { * The date to display in this month view (everything other than the month and year is ignored). */ @Input() - get date() { return this._date; } - set date(value) { - this._date = this._locale.parseDate(value) || SimpleDate.today(); - this._init(); + get activeDate() { return this._activeDate; } + set activeDate(value) { + let oldActiveDate = this._activeDate; + this._activeDate = this._locale.parseDate(value) || SimpleDate.today(); + if (!this._hasSameMonthAndYear(oldActiveDate, this._activeDate)) { + this._init(); + } } - private _date = SimpleDate.today(); + private _activeDate = SimpleDate.today(); /** The currently selected date. */ @Input() @@ -82,16 +85,16 @@ export class MdMonthView implements AfterContentInit { if (this._selectedDate == date) { return; } - this.selectedChange.emit(new SimpleDate(this.date.year, this.date.month, date)); + this.selectedChange.emit(new SimpleDate(this.activeDate.year, this.activeDate.month, date)); } /** Initializes this month view. */ private _init() { this._selectedDate = this._getDateInCurrentMonth(this.selected); this._todayDate = this._getDateInCurrentMonth(SimpleDate.today()); - this._monthLabel = this._locale.shortMonths[this.date.month].toLocaleUpperCase(); + this._monthLabel = this._locale.shortMonths[this.activeDate.month].toLocaleUpperCase(); - let firstOfMonth = new SimpleDate(this.date.year, this.date.month, 1); + let firstOfMonth = new SimpleDate(this.activeDate.year, this.activeDate.month, 1); this._firstWeekOffset = (DAYS_PER_WEEK + firstOfMonth.day - this._locale.firstDayOfWeek) % DAYS_PER_WEEK; @@ -100,7 +103,7 @@ export class MdMonthView implements AfterContentInit { /** Creates MdCalendarCells for the dates in this month. */ private _createWeekCells() { - let daysInMonth = new SimpleDate(this.date.year, this.date.month + 1, 0).date; + let daysInMonth = new SimpleDate(this.activeDate.year, this.activeDate.month + 1, 0).date; this._weeks = [[]]; for (let i = 0, cell = this._firstWeekOffset; i < daysInMonth; i++, cell++) { if (cell == DAYS_PER_WEEK) { @@ -108,7 +111,7 @@ export class MdMonthView implements AfterContentInit { cell = 0; } let enabled = !this.dateFilter || - this.dateFilter(new SimpleDate(this.date.year, this.date.month, i + 1)); + this.dateFilter(new SimpleDate(this.activeDate.year, this.activeDate.month, i + 1)); this._weeks[this._weeks.length - 1] .push(new MdCalendarCell(i + 1, this._locale.dates[i + 1], enabled)); } @@ -118,7 +121,12 @@ export class MdMonthView implements AfterContentInit { * Gets the date in this month that the given Date falls on. * Returns null if the given Date is in another month. */ - private _getDateInCurrentMonth(date: SimpleDate) { - return date && date.month == this.date.month && date.year == this.date.year ? date.date : null; + private _getDateInCurrentMonth(date: SimpleDate): number { + return this._hasSameMonthAndYear(date, this.activeDate) ? date.date : null; + } + + /** Checks whether the 2 dates are non-null and fall within the same month of the same year. */ + private _hasSameMonthAndYear(d1: SimpleDate, d2: SimpleDate): boolean { + return !!(d1 && d2 && d1.month == d2.month && d1.year == d2.year); } } diff --git a/src/lib/datepicker/year-view.html b/src/lib/datepicker/year-view.html index 2e70db4cf0a3..3ec753e219cc 100644 --- a/src/lib/datepicker/year-view.html +++ b/src/lib/datepicker/year-view.html @@ -4,5 +4,6 @@ [todayValue]="_todayMonth" [selectedValue]="_selectedMonth" [labelMinRequiredCells]="2" + [activeCell]="activeDate.month" (selectedValueChange)="_monthSelected($event)"> diff --git a/src/lib/datepicker/year-view.spec.ts b/src/lib/datepicker/year-view.spec.ts index a5cb6118df21..66674cba4c25 100644 --- a/src/lib/datepicker/year-view.spec.ts +++ b/src/lib/datepicker/year-view.spec.ts @@ -71,6 +71,12 @@ describe('MdYearView', () => { let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); expect(selectedEl.innerHTML.trim()).toBe('DEC'); }); + + it('should mark active date', () => { + let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + expect((cellEls[0] as HTMLElement).innerText.trim()).toBe('JAN'); + expect(cellEls[0].classList).toContain('mat-calendar-table-active'); + }); }); describe('year view with date filter', () => { @@ -97,7 +103,8 @@ describe('MdYearView', () => { @Component({ - template: ``, + template: ` + `, }) class StandardYearView { date = new SimpleDate(2017, 0, 5); diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts index 40f5d8f1bbd2..39ce2a1600ec 100644 --- a/src/lib/datepicker/year-view.ts +++ b/src/lib/datepicker/year-view.ts @@ -26,12 +26,15 @@ import {SimpleDate} from '../core/datetime/simple-date'; export class MdYearView implements AfterContentInit { /** The date to display in this year view (everything other than the year is ignored). */ @Input() - get date() { return this._date; } - set date(value) { - this._date = this._locale.parseDate(value) || SimpleDate.today(); - this._init(); + get activeDate() { return this._activeDate; } + set activeDate(value) { + let oldActiveDate = this._activeDate; + this._activeDate = this._locale.parseDate(value) || SimpleDate.today(); + if (oldActiveDate.year != this._activeDate.year) { + this._init(); + } } - private _date = SimpleDate.today(); + private _activeDate = SimpleDate.today(); /** The currently selected date. */ @Input() @@ -71,14 +74,14 @@ export class MdYearView implements AfterContentInit { /** Handles when a new month is selected. */ _monthSelected(month: number) { - this.selectedChange.emit(new SimpleDate(this.date.year, month, 1)); + this.selectedChange.emit(new SimpleDate(this.activeDate.year, month, this._activeDate.date)); } /** Initializes this month view. */ private _init() { this._selectedMonth = this._getMonthInCurrentYear(this.selected); this._todayMonth = this._getMonthInCurrentYear(SimpleDate.today()); - this._yearLabel = this._locale.getCalendarYearHeaderLabel(this._date); + this._yearLabel = this._locale.getCalendarYearHeaderLabel(this.activeDate); // First row of months only contains 5 elements so we can fit the year label on the same row. this._months = [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11]].map(row => row.map( @@ -90,7 +93,7 @@ export class MdYearView implements AfterContentInit { * Returns null if the given Date is in another year. */ private _getMonthInCurrentYear(date: SimpleDate) { - return date && date.year == this.date.year ? date.month : null; + return date && date.year == this.activeDate.year ? date.month : null; } /** Creates an MdCalendarCell for the given month. */ @@ -106,7 +109,7 @@ export class MdYearView implements AfterContentInit { } // If any date in the month is enabled count the month as enabled. - for (let date = new SimpleDate(this.date.year, month, 1); date.month === month; + for (let date = new SimpleDate(this.activeDate.year, month, 1); date.month === month; date = date.add({days: 1})) { if (this.dateFilter(date)) { return true; From 21cb16456a46e35899d2034a9d1710588d3630da Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 28 Mar 2017 15:34:05 -0700 Subject: [PATCH 15/37] angular 4 fixes --- src/lib/datepicker/datepicker.html | 4 +- src/lib/datepicker/datepicker.spec.ts | 166 +++++++++++++++++--------- 2 files changed, 114 insertions(+), 56 deletions(-) diff --git a/src/lib/datepicker/datepicker.html b/src/lib/datepicker/datepicker.html index 369f064a0ee9..bf5fffc2bbb3 100644 --- a/src/lib/datepicker/datepicker.html +++ b/src/lib/datepicker/datepicker.html @@ -1,4 +1,4 @@ - + diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index 37f479907508..116126ee27e2 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -1,4 +1,4 @@ -import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {MdDatepickerModule} from './index'; import {Component, ViewChild} from '@angular/core'; import {MdDatepicker} from './datepicker'; @@ -8,21 +8,28 @@ import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {By} from '@angular/platform-browser'; import {dispatchFakeEvent, dispatchMouseEvent} from '../core/testing/dispatch-events'; import {MdInputModule} from '../input/index'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; describe('MdDatepicker', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdDatepickerModule, MdInputModule, FormsModule, ReactiveFormsModule], + imports: [ + FormsModule, + MdDatepickerModule, + MdInputModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], declarations: [ - StandardDatepicker, - MultiInputDatepicker, - NoInputDatepicker, - DatepickerWithStartAt, - DatepickerWithNgModel, DatepickerWithFormControl, + DatepickerWithNgModel, + DatepickerWithStartAt, DatepickerWithToggle, InputContainerDatepicker, + MultiInputDatepicker, + NoInputDatepicker, + StandardDatepicker, ], }); @@ -33,23 +40,28 @@ describe('MdDatepicker', () => { let fixture: ComponentFixture; let testComponent: StandardDatepicker; - beforeEach(() => { + beforeEach(async(() => { fixture = TestBed.createComponent(StandardDatepicker); fixture.detectChanges(); testComponent = fixture.componentInstance; - }); + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); - it('open non-touch should open popup', () => { + it('open non-touch should open popup', async(() => { expect(document.querySelector('.cdk-overlay-pane')).toBeNull(); testComponent.datepicker.open(); fixture.detectChanges(); expect(document.querySelector('.cdk-overlay-pane')).not.toBeNull(); - }); + })); - it('open touch should open dialog', () => { + it('open touch should open dialog', async(() => { testComponent.touch = true; fixture.detectChanges(); @@ -59,7 +71,7 @@ describe('MdDatepicker', () => { fixture.detectChanges(); expect(document.querySelector('md-dialog-container')).not.toBeNull(); - }); + })); it('close should close popup', async(() => { testComponent.datepicker.open(); @@ -126,38 +138,48 @@ describe('MdDatepicker', () => { }); describe('datepicker with too many inputs', () => { - it('should throw when multiple inputs registered', () => { + it('should throw when multiple inputs registered', async(() => { let fixture = TestBed.createComponent(MultiInputDatepicker); expect(() => fixture.detectChanges()).toThrow(); - }); + })); }); describe('datepicker with no inputs', () => { let fixture: ComponentFixture; let testComponent: NoInputDatepicker; - beforeEach(() => { + beforeEach(async(() => { fixture = TestBed.createComponent(NoInputDatepicker); fixture.detectChanges(); testComponent = fixture.componentInstance; - }); + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); - it('should throw when opened with no registered inputs', () => { + it('should throw when opened with no registered inputs', async(() => { expect(() => testComponent.datepicker.open()).toThrow(); - }); + })); }); describe('datepicker with startAt', () => { let fixture: ComponentFixture; let testComponent: DatepickerWithStartAt; - beforeEach(() => { + beforeEach(async(() => { fixture = TestBed.createComponent(DatepickerWithStartAt); fixture.detectChanges(); testComponent = fixture.componentInstance; - }); + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); it('explicit startAt should override input value', () => { expect(testComponent.datepicker.startAt).toEqual(new SimpleDate(2010, 0, 1)); @@ -168,35 +190,52 @@ describe('MdDatepicker', () => { let fixture: ComponentFixture; let testComponent: DatepickerWithNgModel; - beforeEach(fakeAsync(() => { + beforeEach(async(() => { fixture = TestBed.createComponent(DatepickerWithNgModel); - detectModelChanges(fixture); + fixture.detectChanges(); - testComponent = fixture.componentInstance; + fixture.whenStable().then(() => { + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + }); })); - it('should update datepicker when model changes', fakeAsync(() => { + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); + + it('should update datepicker when model changes', async(() => { expect(testComponent.datepickerInput.value).toBeNull(); expect(testComponent.datepicker._selected).toBeNull(); let selected = new SimpleDate(2017, 0, 1); testComponent.selected = selected; - detectModelChanges(fixture); + fixture.detectChanges(); - expect(testComponent.datepickerInput.value).toEqual(selected); - expect(testComponent.datepicker._selected).toEqual(selected); + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(testComponent.datepickerInput.value).toEqual(selected); + expect(testComponent.datepicker._selected).toEqual(selected); + }); })); - it('should update model when date is selected', fakeAsync(() => { + it('should update model when date is selected', async(() => { expect(testComponent.selected).toBeNull(); expect(testComponent.datepickerInput.value).toBeNull(); let selected = new SimpleDate(2017, 0, 1); testComponent.datepicker._selected = selected; - detectModelChanges(fixture); + fixture.detectChanges(); - expect(testComponent.selected).toEqual(selected); - expect(testComponent.datepickerInput.value).toEqual(selected); + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(testComponent.selected).toEqual(selected); + expect(testComponent.datepickerInput.value).toEqual(selected); + }); })); it('should mark input dirty after input event', () => { @@ -210,26 +249,34 @@ describe('MdDatepicker', () => { expect(inputEl.classList).toContain('ng-dirty'); }); - it('should mark input dirty after date selected', fakeAsync(() => { + it('should mark input dirty after date selected', async(() => { let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; expect(inputEl.classList).toContain('ng-pristine'); testComponent.datepicker._selected = new SimpleDate(2017, 0, 1); - detectModelChanges(fixture); + fixture.detectChanges(); - expect(inputEl.classList).toContain('ng-dirty'); + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(inputEl.classList).toContain('ng-dirty'); + }); })); - it('should not mark dirty after model change', fakeAsync(() => { + it('should not mark dirty after model change', async(() => { let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; expect(inputEl.classList).toContain('ng-pristine'); testComponent.selected = new SimpleDate(2017, 0, 1); - detectModelChanges(fixture); + fixture.detectChanges(); - expect(inputEl.classList).toContain('ng-pristine'); + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(inputEl.classList).toContain('ng-pristine'); + }); })); it('should mark input touched on blur', () => { @@ -253,12 +300,17 @@ describe('MdDatepicker', () => { let fixture: ComponentFixture; let testComponent: DatepickerWithFormControl; - beforeEach(() => { + beforeEach(async(() => { fixture = TestBed.createComponent(DatepickerWithFormControl); fixture.detectChanges(); testComponent = fixture.componentInstance; - }); + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); it('should update datepicker when formControl changes', () => { expect(testComponent.datepickerInput.value).toBeNull(); @@ -300,14 +352,19 @@ describe('MdDatepicker', () => { let fixture: ComponentFixture; let testComponent: DatepickerWithToggle; - beforeEach(() => { + beforeEach(async(() => { fixture = TestBed.createComponent(DatepickerWithToggle); fixture.detectChanges(); testComponent = fixture.componentInstance; - }); + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); - it('should open calendar when toggle clicked', () => { + it('should open calendar when toggle clicked', async(() => { expect(document.querySelector('md-dialog-container')).toBeNull(); let toggle = fixture.debugElement.query(By.css('button')); @@ -315,19 +372,24 @@ describe('MdDatepicker', () => { fixture.detectChanges(); expect(document.querySelector('md-dialog-container')).not.toBeNull(); - }); + })); }); describe('datepicker inside input-container', () => { let fixture: ComponentFixture; let testComponent: InputContainerDatepicker; - beforeEach(() => { + beforeEach(async(() => { fixture = TestBed.createComponent(InputContainerDatepicker); fixture.detectChanges(); testComponent = fixture.componentInstance; - }); + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); it('should attach popup to input-container underline', () => { let attachToRef = testComponent.datepickerInput.getPopupConnectionElementRef(); @@ -338,13 +400,6 @@ describe('MdDatepicker', () => { }); -function detectModelChanges(fixture: ComponentFixture) { - fixture.detectChanges(); - tick(); - fixture.detectChanges(); -} - - @Component({ template: ` @@ -415,7 +470,9 @@ class DatepickerWithFormControl { `, }) -class DatepickerWithToggle {} +class DatepickerWithToggle { + @ViewChild('d') datepicker: MdDatepicker; +} @Component({ @@ -427,5 +484,6 @@ class DatepickerWithToggle {} `, }) class InputContainerDatepicker { + @ViewChild('d') datepicker: MdDatepicker; @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } From dbb838766391d1ef4d9c85fd544e22d86cd2b116 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Thu, 30 Mar 2017 16:46:13 -0700 Subject: [PATCH 16/37] =?UTF-8?q?fix(datepicker):=20use=20input's=20min=20?= =?UTF-8?q?&=20max=20properites=20rather=20than=20custom=20=E2=80=A6=20(#3?= =?UTF-8?q?854)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(datepicker): use input's min & max properites rather than custom ones on md-datepicker * add doc comments --- src/demo-app/datepicker/datepicker-demo.html | 5 ++- src/lib/datepicker/datepicker-input.ts | 22 ++++++++++++- src/lib/datepicker/datepicker.html | 4 +-- src/lib/datepicker/datepicker.spec.ts | 34 ++++++++++++++++++++ src/lib/datepicker/datepicker.ts | 22 ++++++------- 5 files changed, 69 insertions(+), 18 deletions(-) diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index faee6d1cb911..0ba2e16746d8 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -19,9 +19,8 @@

Work in progress, not ready for use.

- - + +

diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index e4426b0567fd..9f6cc473db33 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -32,12 +32,15 @@ export const MD_DATEPICKER_VALUE_ACCESSOR: any = { '[attr.aria-expanded]': '_datepicker?.opened || "false"', '[attr.aria-haspopup]': 'true', '[attr.aria-owns]': '_datepicker?.id', + '[min]': '_min?.toNativeDate()', + '[max]': '_max?.toNativeDate()', '(input)': '_onChange($event.target.value)', '(blur)': '_onTouched()', '(keydown)': '_onKeydown($event)', } }) export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor, OnDestroy { + /** The datepicker that this input is associated with. */ @Input() set mdDatepicker(value: MdDatepicker) { if (value) { @@ -47,6 +50,10 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor } _datepicker: MdDatepicker; + @Input() + set matDatepicker(value: MdDatepicker) { this.mdDatepicker = value; } + + /** The value of the input. */ @Input() get value(): SimpleDate { return this._value; @@ -58,8 +65,17 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor } private _value: SimpleDate; + /** The minimum valid date. */ @Input() - set matDatepicker(value: MdDatepicker) { this.mdDatepicker = value; } + get min(): SimpleDate { return this._min; } + set min(value: SimpleDate) { this._min = this._locale.parseDate(value); } + private _min: SimpleDate; + + /** The maximum valid date. */ + @Input() + get max(): SimpleDate { return this._max; } + set max(value: SimpleDate) { this._max = this._locale.parseDate(value); } + private _max: SimpleDate; _onChange = (value: any) => {}; @@ -89,6 +105,10 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor } } + /** + * Gets the element that the datepicker popup should be connected to. + * @return The element to connect the popup to. + */ getPopupConnectionElementRef(): ElementRef { return this._mdInputContainer ? this._mdInputContainer.underlineRef : this._elementRef; } diff --git a/src/lib/datepicker/datepicker.html b/src/lib/datepicker/datepicker.html index bf5fffc2bbb3..68c4abcaf2a0 100644 --- a/src/lib/datepicker/datepicker.html +++ b/src/lib/datepicker/datepicker.html @@ -4,8 +4,8 @@ [class.mat-datepicker-touch]="touchUi" [class.mat-datepicker-non-touch]="!touchUi" [startAt]="startAt" - [minDate]="minDate" - [maxDate]="maxDate" + [minDate]="_minDate" + [maxDate]="_maxDate" [dateFilter]="dateFilter" [(selected)]="_selected"> diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index 116126ee27e2..03f91edb4ecf 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -23,6 +23,7 @@ describe('MdDatepicker', () => { ], declarations: [ DatepickerWithFormControl, + DatepickerWithMinAndMax, DatepickerWithNgModel, DatepickerWithStartAt, DatepickerWithToggle, @@ -397,6 +398,28 @@ describe('MdDatepicker', () => { .toBe(true, 'popup should be attached to input-container underline'); }); }); + + describe('datepicker with min and max dates', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithMinAndMax; + + beforeEach(async(() => { + fixture = TestBed.createComponent(DatepickerWithMinAndMax); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); + + it('should use min and max dates specified by the input', () => { + expect(testComponent.datepicker._minDate).toEqual(new SimpleDate(2010, 0, 1)); + expect(testComponent.datepicker._maxDate).toEqual(new SimpleDate(2020, 0, 1)); + }); + }); }); @@ -487,3 +510,14 @@ class InputContainerDatepicker { @ViewChild('d') datepicker: MdDatepicker; @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } + + +@Component({ + template: ` + + + `, +}) +class DatepickerWithMinAndMax { + @ViewChild('d') datepicker: MdDatepicker; +} diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index ebb2664facd7..ec39254664ec 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -67,18 +67,6 @@ export class MdDatepicker implements OnDestroy { @Input() touchUi = false; - /** The minimum selectable date. */ - @Input() - get minDate(): SimpleDate { return this._minDate; }; - set minDate(date: SimpleDate) { this._minDate = this._locale.parseDate(date); } - private _minDate: SimpleDate; - - /** The maximum selectable date. */ - @Input() - get maxDate(): SimpleDate { return this._maxDate; }; - set maxDate(date: SimpleDate) { this._maxDate = this._locale.parseDate(date); } - private _maxDate: SimpleDate; - /** A function used to filter which dates are selectable. */ @Input() dateFilter: (date: SimpleDate) => boolean; @@ -101,6 +89,16 @@ export class MdDatepicker implements OnDestroy { this.close(); } + /** The minimum selectable date. */ + get _minDate(): SimpleDate { + return this._datepickerInput && this._datepickerInput.min; + } + + /** The maximum selectable date. */ + get _maxDate(): SimpleDate { + return this._datepickerInput && this._datepickerInput.max; + } + /** The calendar template. */ @ViewChild(TemplateRef) calendarTemplate: TemplateRef; From 559880d9cb698e13746d49a4b47eefc1a8f4da5e Mon Sep 17 00:00:00 2001 From: mmalerba Date: Mon, 10 Apr 2017 15:57:36 -0700 Subject: [PATCH 17/37] fix(datepicker): hide weekdays in year view (#3852) --- src/lib/datepicker/calendar.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/datepicker/calendar.html b/src/lib/datepicker/calendar.html index 1c84c68480c7..a7a241be7590 100644 --- a/src/lib/datepicker/calendar.html +++ b/src/lib/datepicker/calendar.html @@ -24,7 +24,7 @@
- +
{{day}}
From a85c0946b5f07fda141055b942c425260fe021fe Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 11 Apr 2017 13:49:50 -0700 Subject: [PATCH 18/37] fix(datepicker): calendar should update when input changes (#3824) * fix(datepicker): calendar selected date should change when input changes * startAt fix * fix tests * address comments * fix unsubscribe * make valueChange on datepicker-input internal --- src/lib/core/datetime/simple-date.ts | 7 ++++++ src/lib/datepicker/calendar.ts | 2 +- src/lib/datepicker/datepicker-input.ts | 30 ++++++++++++++++++++------ src/lib/datepicker/datepicker.html | 3 ++- src/lib/datepicker/datepicker.spec.ts | 12 +++++------ src/lib/datepicker/datepicker.ts | 26 ++++++++++++++++------ 6 files changed, 58 insertions(+), 22 deletions(-) diff --git a/src/lib/core/datetime/simple-date.ts b/src/lib/core/datetime/simple-date.ts index 6546cd923ea2..365b59d28f9e 100644 --- a/src/lib/core/datetime/simple-date.ts +++ b/src/lib/core/datetime/simple-date.ts @@ -16,6 +16,13 @@ export class SimpleDate { return SimpleDate.fromNativeDate(new Date()); } + /** + * Checks whether the given dates are equal. Null dates are considered equal to other null dates. + */ + static equals(first: SimpleDate, second: SimpleDate): boolean { + return first && second ? !first.compare(second) : first == second; + } + /** The native JS Date. */ private _date: Date; diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index 048d7c227c55..31244121cf19 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -113,7 +113,7 @@ export class MdCalendar implements AfterContentInit { /** Handles date selection in the month view. */ _dateSelected(date: SimpleDate): void { - if ((!date || !this.selected) && date != this.selected || date.compare(this.selected)) { + if (!SimpleDate.equals(date, this.selected)) { this.selectedChange.emit(date); } } diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index 9f6cc473db33..1bde8857b881 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -2,6 +2,7 @@ import { AfterContentInit, Directive, ElementRef, + EventEmitter, forwardRef, Input, OnDestroy, @@ -15,6 +16,7 @@ import {CalendarLocale} from '../core/datetime/calendar-locale'; import {Subscription} from 'rxjs/Subscription'; import {MdInputContainer} from '../input/input-container'; import {DOWN_ARROW} from '../core/keyboard/keycodes'; +import {Observable} from 'rxjs/Observable'; export const MD_DATEPICKER_VALUE_ACCESSOR: any = { @@ -34,7 +36,7 @@ export const MD_DATEPICKER_VALUE_ACCESSOR: any = { '[attr.aria-owns]': '_datepicker?.id', '[min]': '_min?.toNativeDate()', '[max]': '_max?.toNativeDate()', - '(input)': '_onChange($event.target.value)', + '(input)': '_onInput($event.target.value)', '(blur)': '_onTouched()', '(keydown)': '_onKeydown($event)', } @@ -56,14 +58,17 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor /** The value of the input. */ @Input() get value(): SimpleDate { - return this._value; + return this._locale.parseDate(this._elementRef.nativeElement.value); } set value(value: SimpleDate) { - this._value = this._locale.parseDate(value); - const stringValue = this._value == null ? '' : this._locale.formatDate(this._value); - this._renderer.setElementProperty(this._elementRef.nativeElement, 'value', stringValue); + let date = this._locale.parseDate(value); + let oldDate = this.value; + this._renderer.setElementProperty(this._elementRef.nativeElement, 'value', + date ? this._locale.formatDate(date) : ''); + if (!SimpleDate.equals(oldDate, date)) { + this._valueChangeEmitter.emit(date); + } } - private _value: SimpleDate; /** The minimum valid date. */ @Input() @@ -77,6 +82,11 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor set max(value: SimpleDate) { this._max = this._locale.parseDate(value); } private _max: SimpleDate; + private _valueChangeEmitter = new EventEmitter(); + + /** Emits when the value changes (either due to user input or programmatic change). */ + _valueChange: Observable = this._valueChangeEmitter.asObservable(); + _onChange = (value: any) => {}; _onTouched = () => {}; @@ -120,7 +130,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor // Implemented as part of ControlValueAccessor registerOnChange(fn: (value: any) => void): void { - this._onChange = value => fn(this._locale.parseDate(value)); + this._onChange = fn; } // Implemented as part of ControlValueAccessor @@ -139,4 +149,10 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor event.preventDefault(); } } + + _onInput(value: string) { + let date = this._locale.parseDate(value); + this._onChange(date); + this._valueChangeEmitter.emit(date); + } } diff --git a/src/lib/datepicker/datepicker.html b/src/lib/datepicker/datepicker.html index 68c4abcaf2a0..ef816fde849a 100644 --- a/src/lib/datepicker/datepicker.html +++ b/src/lib/datepicker/datepicker.html @@ -7,6 +7,7 @@ [minDate]="_minDate" [maxDate]="_maxDate" [dateFilter]="dateFilter" - [(selected)]="_selected"> + [selected]="_selected" + (selectedChange)="_selectAndClose($event)"> diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index 03f91edb4ecf..9dee866200ef 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -117,13 +117,13 @@ describe('MdDatepicker', () => { expect(document.querySelector('md-dialog-container')).not.toBeNull(); expect(testComponent.datepickerInput.value).toEqual(new SimpleDate(2020, 0, 1)); - let selected = new SimpleDate(2017, 0, 1); - testComponent.datepicker._selected = selected; + let cells = document.querySelectorAll('.mat-calendar-table-cell'); + dispatchMouseEvent(cells[1], 'click'); fixture.detectChanges(); fixture.whenStable().then(() => { expect(document.querySelector('md-dialog-container')).toBeNull(); - expect(testComponent.datepickerInput.value).toEqual(selected); + expect(testComponent.datepickerInput.value).toEqual(new SimpleDate(2020, 0, 2)); }); })); @@ -228,7 +228,7 @@ describe('MdDatepicker', () => { expect(testComponent.datepickerInput.value).toBeNull(); let selected = new SimpleDate(2017, 0, 1); - testComponent.datepicker._selected = selected; + testComponent.datepicker._selectAndClose(selected); fixture.detectChanges(); fixture.whenStable().then(() => { @@ -255,7 +255,7 @@ describe('MdDatepicker', () => { expect(inputEl.classList).toContain('ng-pristine'); - testComponent.datepicker._selected = new SimpleDate(2017, 0, 1); + testComponent.datepicker._selectAndClose(new SimpleDate(2017, 0, 1)); fixture.detectChanges(); fixture.whenStable().then(() => { @@ -330,7 +330,7 @@ describe('MdDatepicker', () => { expect(testComponent.datepickerInput.value).toBeNull(); let selected = new SimpleDate(2017, 0, 1); - testComponent.datepicker._selected = selected; + testComponent.datepicker._selectAndClose(selected); fixture.detectChanges(); expect(testComponent.formControl.value).toEqual(selected); diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index ec39254664ec..79d8f8401480 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -28,6 +28,7 @@ import {SimpleDate} from '../core/datetime/simple-date'; import {MdDatepickerInput} from './datepicker-input'; import {CalendarLocale} from '../core/datetime/calendar-locale'; import 'rxjs/add/operator/first'; +import {Subscription} from 'rxjs/Subscription'; /** Used to generate a unique ID for each datepicker instance. */ @@ -81,13 +82,7 @@ export class MdDatepicker implements OnDestroy { id = `md-datepicker-${datepickerUid++}`; /** The currently selected date. */ - get _selected(): SimpleDate { - return this._datepickerInput ? this._datepickerInput.value : null; - } - set _selected(value: SimpleDate) { - this.selectedChanged.emit(value); - this.close(); - } + _selected: SimpleDate = null; /** The minimum selectable date. */ get _minDate(): SimpleDate { @@ -114,6 +109,8 @@ export class MdDatepicker implements OnDestroy { /** The input element this datepicker is associated with. */ private _datepickerInput: MdDatepickerInput; + private _inputSubscription: Subscription; + constructor(private _dialog: MdDialog, private _overlay: Overlay, private _viewContainerRef: ViewContainerRef, private _locale: CalendarLocale, @Optional() private _dir: Dir) {} @@ -123,6 +120,19 @@ export class MdDatepicker implements OnDestroy { if (this._popupRef) { this._popupRef.dispose(); } + if (this._inputSubscription) { + this._inputSubscription.unsubscribe(); + } + } + + /** Selects the given date and closes the currently open popup or dialog. */ + _selectAndClose(date: SimpleDate): void { + let oldValue = this._selected; + this._selected = date; + if (!SimpleDate.equals(oldValue, this._selected)) { + this.selectedChanged.emit(date); + } + this.close(); } /** @@ -134,6 +144,8 @@ export class MdDatepicker implements OnDestroy { throw new MdError('An MdDatepicker can only be associated with a single input.'); } this._datepickerInput = input; + this._inputSubscription = + this._datepickerInput._valueChange.subscribe((value: SimpleDate) => this._selected = value); } /** From 531f452331957f97de36dc3577e85d167b94407d Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 14 Apr 2017 09:38:19 -0700 Subject: [PATCH 19/37] fix(datepicker): set focus properly when opening datepicker (#3839) * add keyboard support, break some tests * add tests * some finishing touches * addressed some comments * separate md-datepicker-content into own component * addressed comments * added some more explanation of the datepicker and datepicker-content components. --- src/lib/datepicker/datepicker-content.html | 11 ++++ ...atepicker.scss => datepicker-content.scss} | 4 +- src/lib/datepicker/datepicker.html | 13 ---- src/lib/datepicker/datepicker.ts | 65 ++++++++++++++----- src/lib/datepicker/index.ts | 7 +- 5 files changed, 67 insertions(+), 33 deletions(-) create mode 100644 src/lib/datepicker/datepicker-content.html rename src/lib/datepicker/{datepicker.scss => datepicker-content.scss} (89%) delete mode 100644 src/lib/datepicker/datepicker.html diff --git a/src/lib/datepicker/datepicker-content.html b/src/lib/datepicker/datepicker-content.html new file mode 100644 index 000000000000..0472de27b662 --- /dev/null +++ b/src/lib/datepicker/datepicker-content.html @@ -0,0 +1,11 @@ + + diff --git a/src/lib/datepicker/datepicker.scss b/src/lib/datepicker/datepicker-content.scss similarity index 89% rename from src/lib/datepicker/datepicker.scss rename to src/lib/datepicker/datepicker-content.scss index 097546c2298a..f3856a482c4e 100644 --- a/src/lib/datepicker/datepicker.scss +++ b/src/lib/datepicker/datepicker-content.scss @@ -11,11 +11,11 @@ $md-datepicker-non-touch-calendar-width: $md-datepicker-non-touch-calendar-cell-size * 7 + $md-datepicker-calendar-padding * 2; -.mat-datepicker-touch { +.mat-datepicker-content-touch { width: $md-datepicker-touch-calendar-width; } -.mat-datepicker-non-touch { +.mat-datepicker-content-non-touch { width: $md-datepicker-non-touch-calendar-width; @include mat-elevation(8); } diff --git a/src/lib/datepicker/datepicker.html b/src/lib/datepicker/datepicker.html deleted file mode 100644 index ef816fde849a..000000000000 --- a/src/lib/datepicker/datepicker.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 79d8f8401480..052d83a8b12d 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -1,19 +1,20 @@ import { + AfterContentInit, ChangeDetectionStrategy, Component, + ComponentRef, + ElementRef, EventEmitter, Input, OnDestroy, Optional, Output, - TemplateRef, - ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; import {Overlay} from '../core/overlay/overlay'; import {OverlayRef} from '../core/overlay/overlay-ref'; -import {TemplatePortal} from '../core/portal/portal'; +import {ComponentPortal} from '../core/portal/portal'; import {OverlayState} from '../core/overlay/overlay-state'; import {Dir} from '../core/rtl/dir'; import {MdError} from '../core/errors/error'; @@ -29,21 +30,48 @@ import {MdDatepickerInput} from './datepicker-input'; import {CalendarLocale} from '../core/datetime/calendar-locale'; import 'rxjs/add/operator/first'; import {Subscription} from 'rxjs/Subscription'; +import {MdDialogConfig} from '../dialog/dialog-config'; /** Used to generate a unique ID for each datepicker instance. */ let datepickerUid = 0; -/** Component responsible for managing the datepicker popup/dialog. */ +/** + * Component used as the content for the datepicker dialog and popup. We use this instead of using + * MdCalendar directly as the content so we can control the initial focus. This also gives us a + * place to put additional features of the popup that are not part of the calendar itself in the + * future. (e.g. confirmation buttons). + * @docs-internal + */ @Component({ moduleId: module.id, - selector: 'md-datepicker, mat-datepicker', - templateUrl: 'datepicker.html', - styleUrls: ['datepicker.css'], + selector: 'md-datepicker-content', + templateUrl: 'datepicker-content.html', + styleUrls: ['datepicker-content.css'], encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) +export class MdDatepickerContent implements AfterContentInit { + datepicker: MdDatepicker; + + constructor(private _elementRef: ElementRef) {} + + ngAfterContentInit() { + this._elementRef.nativeElement.querySelector('.mat-calendar-body').focus(); + } +} + + +// TODO(mmalerba): We use a component instead of a directive here so the user can use implicit +// template reference variables (e.g. #d vs #d="mdDatepicker"). We can change this to a directive if +// angular adds support for `exportAs: '$implicit'` on directives. +/** Component responsible for managing the datepicker popup/dialog. */ +@Component({ + moduleId: module.id, + selector: 'md-datepicker, mat-datepicker', + template: '', +}) export class MdDatepicker implements OnDestroy { /** The date to open the calendar to initially. */ @Input() @@ -94,9 +122,6 @@ export class MdDatepicker implements OnDestroy { return this._datepickerInput && this._datepickerInput.max; } - /** The calendar template. */ - @ViewChild(TemplateRef) calendarTemplate: TemplateRef; - /** A reference to the overlay when the calendar is opened as a popup. */ private _popupRef: OverlayRef; @@ -104,7 +129,7 @@ export class MdDatepicker implements OnDestroy { private _dialogRef: MdDialogRef; /** A portal containing the calendar for this datepicker. */ - private _calendarPortal: TemplatePortal; + private _calendarPortal: ComponentPortal; /** The input element this datepicker is associated with. */ private _datepickerInput: MdDatepickerInput; @@ -160,10 +185,6 @@ export class MdDatepicker implements OnDestroy { throw new MdError('Attempted to open an MdDatepicker with no associated input.'); } - if (!this._calendarPortal) { - this._calendarPortal = new TemplatePortal(this.calendarTemplate, this._viewContainerRef); - } - this.touchUi ? this._openAsDialog() : this._openAsPopup(); this.opened = true; } @@ -188,18 +209,28 @@ export class MdDatepicker implements OnDestroy { /** Open the calendar as a dialog. */ private _openAsDialog(): void { - this._dialogRef = this._dialog.open(this.calendarTemplate); + let config = new MdDialogConfig(); + config.viewContainerRef = this._viewContainerRef; + + this._dialogRef = this._dialog.open(MdDatepickerContent, config); this._dialogRef.afterClosed().first().subscribe(() => this.close()); + this._dialogRef.componentInstance.datepicker = this; } /** Open the calendar as a popup. */ private _openAsPopup(): void { + if (!this._calendarPortal) { + this._calendarPortal = new ComponentPortal(MdDatepickerContent, this._viewContainerRef); + } + if (!this._popupRef) { this._createPopup(); } if (!this._popupRef.hasAttached()) { - this._popupRef.attach(this._calendarPortal); + let componentRef: ComponentRef = + this._popupRef.attach(this._calendarPortal); + componentRef.instance.datepicker = this; } this._popupRef.backdropClick().first().subscribe(() => this.close()); diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index 7b924a7b5bdc..a3d5367f575c 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -5,7 +5,7 @@ import {MdCalendarTable} from './calendar-table'; import {MdYearView} from './year-view'; import {DatetimeModule} from '../core/datetime/index'; import {OverlayModule} from '../core/overlay/overlay-directives'; -import {MdDatepicker} from './datepicker'; +import {MdDatepicker, MdDatepickerContent} from './datepicker'; import {MdDatepickerInput} from './datepicker-input'; import {MdDialogModule} from '../dialog/index'; import {MdCalendar} from './calendar'; @@ -31,6 +31,7 @@ export * from './year-view'; ], exports: [ MdDatepicker, + MdDatepickerContent, MdDatepickerInput, MdDatepickerToggle, ], @@ -38,10 +39,14 @@ export * from './year-view'; MdCalendar, MdCalendarTable, MdDatepicker, + MdDatepickerContent, MdDatepickerInput, MdDatepickerToggle, MdMonthView, MdYearView, ], + entryComponents: [ + MdDatepickerContent, + ] }) export class MdDatepickerModule {} From 1e9dfbdc87b97a6083b006ab9dc09418cc2c085d Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 14 Apr 2017 10:30:43 -0700 Subject: [PATCH 20/37] fix(datepicker): make touch UI work well on mobile (#3853) * add keyboard support, break some tests * add tests * some finishing touches * addressed some comments * separate md-datepicker-content into own component * fix(datepicker): make touch UI work well on mobile * use vmin instead of media queries + vw, vh * fix non-touch styles * addressed comments * fix lint error about vmin * addressed comments --- src/lib/datepicker/calendar.scss | 1 + src/lib/datepicker/datepicker-content.html | 2 - src/lib/datepicker/datepicker-content.scss | 43 +++++++++++++++++----- src/lib/datepicker/datepicker.ts | 4 ++ stylelint-config.json | 2 +- 5 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/lib/datepicker/calendar.scss b/src/lib/datepicker/calendar.scss index 2d81baf71ade..307cb6d1cb4c 100644 --- a/src/lib/datepicker/calendar.scss +++ b/src/lib/datepicker/calendar.scss @@ -24,6 +24,7 @@ $mat-calendar-weekday-table-font-size: 11px !default; .mat-calendar-body { padding: $mat-calendar-padding; + outline: none; } .mat-calendar-controls { diff --git a/src/lib/datepicker/datepicker-content.html b/src/lib/datepicker/datepicker-content.html index 0472de27b662..f546bfcd5dda 100644 --- a/src/lib/datepicker/datepicker-content.html +++ b/src/lib/datepicker/datepicker-content.html @@ -1,7 +1,5 @@ Date: Fri, 14 Apr 2017 10:49:23 -0700 Subject: [PATCH 21/37] fix lint issues --- src/lib/datepicker/calendar-table.spec.ts | 2 +- src/lib/datepicker/calendar.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/datepicker/calendar-table.spec.ts b/src/lib/datepicker/calendar-table.spec.ts index 1d8325106e58..fa314a3d9af8 100644 --- a/src/lib/datepicker/calendar-table.spec.ts +++ b/src/lib/datepicker/calendar-table.spec.ts @@ -135,7 +135,7 @@ describe('MdCalendarTable', () => { [labelMinRequiredCells]="labelMinRequiredCells" [numCols]="numCols" [activeCell]="10" - (selectedValueChange)="onSelect($event)"> + (selectedValueChange)="onSelect($event)">
`, }) class StandardCalendarTable { diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index 31244121cf19..2863fbc10072 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -54,13 +54,13 @@ export class MdCalendar implements AfterContentInit { /** The minimum selectable date. */ @Input() - get minDate(): SimpleDate { return this._minDate; }; + get minDate(): SimpleDate { return this._minDate; } set minDate(date: SimpleDate) { this._minDate = this._locale.parseDate(date); } private _minDate: SimpleDate; /** The maximum selectable date. */ @Input() - get maxDate(): SimpleDate { return this._maxDate; }; + get maxDate(): SimpleDate { return this._maxDate; } set maxDate(date: SimpleDate) { this._maxDate = this._locale.parseDate(date); } private _maxDate: SimpleDate; From 5f39247d244781052d171cbf4b74bdfc8c469b63 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Mon, 17 Apr 2017 12:47:29 -0700 Subject: [PATCH 22/37] fix(datepicker): some misc cleanup (#4106) * prev & next icons with css * add some a11y labels * small fixes * fix sizing --- src/lib/core/datetime/calendar-locale.ts | 33 ++++++++++++- src/lib/core/datetime/simple-date.ts | 5 +- src/lib/datepicker/calendar.html | 24 +++------- src/lib/datepicker/calendar.scss | 55 +++++++++++++++------- src/lib/datepicker/calendar.ts | 18 ++++++- src/lib/datepicker/datepicker-content.scss | 10 ++-- src/lib/datepicker/datepicker-toggle.ts | 4 ++ src/lib/datepicker/datepicker.ts | 15 ++---- src/lib/datepicker/index.ts | 2 + 9 files changed, 111 insertions(+), 55 deletions(-) diff --git a/src/lib/core/datetime/calendar-locale.ts b/src/lib/core/datetime/calendar-locale.ts index 7179253c76a1..6061ec49baa3 100644 --- a/src/lib/core/datetime/calendar-locale.ts +++ b/src/lib/core/datetime/calendar-locale.ts @@ -3,7 +3,7 @@ import {Injectable} from '@angular/core'; /** Whether the browser supports the Intl API. */ -const SUPPORTS_INTL_API = !!Intl; +const SUPPORTS_INTL_API = typeof Intl != 'undefined'; /** Creates an array and fills it with values. */ @@ -52,6 +52,24 @@ export abstract class CalendarLocale { /** A label for the button used to open the calendar popup (used by screen readers). */ openCalendarLabel: string; + /** A label for the previous month button (used by screen readers). */ + prevMonthLabel: string; + + /** A label for the next month button (used by screen readers). */ + nextMonthLabel: string; + + /** A label for the previous year button (used by screen readers). */ + prevYearLabel: string; + + /** A label for the next year button (used by screen readers). */ + nextYearLabel: string; + + /** A label for the 'switch to month view' button (used by screen readers). */ + switchToMonthViewLabel: string; + + /** A label for the 'switch to year view' button (used by screen readers). */ + switchToYearViewLabel: string; + /** * Parses a SimpleDate from a value. * @param value The value to parse. @@ -124,6 +142,18 @@ export class DefaultCalendarLocale implements CalendarLocale { openCalendarLabel = 'Open calendar'; + prevMonthLabel = 'Previous month'; + + nextMonthLabel = 'Next month'; + + prevYearLabel = 'Previous year'; + + nextYearLabel = 'Next year'; + + switchToMonthViewLabel = 'Change to month view'; + + switchToYearViewLabel = 'Change to year view'; + parseDate(value: any) { if (value instanceof SimpleDate) { return value; @@ -160,7 +190,6 @@ export class DefaultCalendarLocale implements CalendarLocale { * Creates a function to format SimpleDates as strings using Intl.DateTimeFormat. * @param options The options to use for Intl.DateTimeFormat. * @returns The newly created format function, or null if the Intl API is not available. - * @private */ private _createFormatFunction(options: Object): (date: SimpleDate) => string { if (SUPPORTS_INTL_API) { diff --git a/src/lib/core/datetime/simple-date.ts b/src/lib/core/datetime/simple-date.ts index 365b59d28f9e..b4fe008d4ded 100644 --- a/src/lib/core/datetime/simple-date.ts +++ b/src/lib/core/datetime/simple-date.ts @@ -1,6 +1,7 @@ /** - * A replacement for the native JS Date class that allows us to avoid dealing with time zone - * details and the time component of the native Date. + * A wrapper for the native JS Date class that deals with some quirks for us: + * 1) The native Date constructor treats years in the range [0-99] as 19xx not 00xx. + * 2) ... (Eventually need to add support for other quirks related to time zones, DST). */ export class SimpleDate { /** diff --git a/src/lib/datepicker/calendar.html b/src/lib/datepicker/calendar.html index a7a241be7590..96de7d3e7b24 100644 --- a/src/lib/datepicker/calendar.html +++ b/src/lib/datepicker/calendar.html @@ -1,26 +1,16 @@
-
- -
diff --git a/src/lib/datepicker/calendar.scss b/src/lib/datepicker/calendar.scss index 307cb6d1cb4c..44405fd53a05 100644 --- a/src/lib/datepicker/calendar.scss +++ b/src/lib/datepicker/calendar.scss @@ -3,14 +3,21 @@ $mat-calendar-header-divider-width: 1px !default; $mat-calendar-controls-vertical-padding: 5%; // We want to indent to the middle of the first tile. There are 7 tiles, so 100% / 7 / 2. // Then we back up a little bit since the text in the cells is center-aligned. -$mat-calendar-controls-start-padding: calc(100% / 14 - 6px); -// Same as above, but we back up a little more since the arrow buttons have more padding. -$mat-calendar-controls-end-padding: calc(100% / 14 - 12px); +$mat-calendar-controls-start-padding: calc(100% / 14 - 22px); +// Same as above, but on other side for arrows. +$mat-calendar-controls-end-padding: calc(100% / 14 - 22px); $mat-calendar-period-font-size: 14px; $mat-calendar-arrow-size: 5px !default; $mat-calendar-arrow-disabled-opacity: 0.5 !default; $mat-calendar-weekday-table-font-size: 11px !default; +// Values chosen to approximate https://material.io/icons/#ic_navigate_before and +// https://material.io/icons/#ic_navigate_next as closely as possible. +$mat-calendar-prev-next-icon-border-width: 2px; +$mat-calendar-prev-next-icon-margin: 15.5px; +$mat-calendar-prev-icon-transform: translateX(2px) rotate(-45deg); +$mat-calendar-next-icon-transform: translateX(-2px) rotate(45deg); + .mat-calendar { display: block; @@ -37,22 +44,11 @@ $mat-calendar-weekday-table-font-size: 11px !default; flex: 1 1 auto; } -.mat-calendar-button { - background: transparent; - padding: 0; - margin: 0; - border: none; - outline: none; - - &.mat-calendar-disabled { - opacity: $mat-calendar-arrow-disabled-opacity; - } -} - .mat-calendar-period-button { font: inherit; font-size: $mat-calendar-period-font-size; font-weight: bold; + min-width: 0; } .mat-calendar-button > svg { @@ -67,7 +63,7 @@ $mat-calendar-weekday-table-font-size: 11px !default; border-right: $mat-calendar-arrow-size solid transparent; border-top-width: $mat-calendar-arrow-size; border-top-style: solid; - margin: 0 $mat-calendar-arrow-size; + margin: 0 0 0 $mat-calendar-arrow-size; vertical-align: middle; &.mat-calendar-invert { @@ -80,3 +76,30 @@ $mat-calendar-weekday-table-font-size: 11px !default; text-align: center; font-size: $mat-calendar-weekday-table-font-size; } + +.mat-calendar-previous-button, +.mat-calendar-next-button { + position: relative; + + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + margin: $mat-calendar-prev-next-icon-margin; + border: 0 solid currentColor; + border-top-width: $mat-calendar-prev-next-icon-border-width; + } +} + +.mat-calendar-previous-button::after { + border-left-width: $mat-calendar-prev-next-icon-border-width; + transform: $mat-calendar-prev-icon-transform; +} + +.mat-calendar-next-button::after { + border-right-width: $mat-calendar-prev-next-icon-border-width; + transform: $mat-calendar-next-icon-transform; +} diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index 2863fbc10072..9543b9027e82 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -95,12 +95,28 @@ export class MdCalendar implements AfterContentInit { _weekdays: string[]; /** The label for the current calendar view. */ - get _label(): string { + get _periodButtonText(): string { return this._monthView ? this._locale.getCalendarMonthHeaderLabel(this._activeDate).toLocaleUpperCase() : this._locale.getCalendarYearHeaderLabel(this._activeDate); } + get _periodButtonLabel(): string { + return this._monthView ? + this._locale.switchToYearViewLabel : + this._locale.switchToMonthViewLabel; + } + + /** The label for the the previous button. */ + get _prevButtonLabel(): string { + return this._monthView ? this._locale.prevMonthLabel : this._locale.prevYearLabel; + } + + /** The label for the the next button. */ + get _nextButtonLabel(): string { + return this._monthView ? this._locale.nextMonthLabel : this._locale.nextYearLabel; + } + constructor(private _locale: CalendarLocale) { this._weekdays = this._locale.narrowDays.slice(this._locale.firstDayOfWeek) .concat(this._locale.narrowDays.slice(0, this._locale.firstDayOfWeek)); diff --git a/src/lib/datepicker/datepicker-content.scss b/src/lib/datepicker/datepicker-content.scss index 8d1feca4d925..63a81951a071 100644 --- a/src/lib/datepicker/datepicker-content.scss +++ b/src/lib/datepicker/datepicker-content.scss @@ -11,12 +11,12 @@ $md-datepicker-non-touch-calendar-width: // the calendar grows, since some of the elements have pixel-based sizes. These numbers have been // chosen to minimize extra whitespace at larger sizes, while still ensuring we won't need // scrollbars at smaller sizes. -$md-datepicker-touch-width: 67vmin; +$md-datepicker-touch-width: 64vmin; $md-datepicker-touch-height: 80vmin; -$md-datepicker-touch-min-width: 253px; -$md-datepicker-touch-min-height: 300px; -$md-datepicker-touch-max-width: 778px; -$md-datepicker-touch-max-height: 800px; +$md-datepicker-touch-min-width: 250px; +$md-datepicker-touch-min-height: 312px; +$md-datepicker-touch-max-width: 750px; +$md-datepicker-touch-max-height: 788px; .mat-calendar { diff --git a/src/lib/datepicker/datepicker-toggle.ts b/src/lib/datepicker/datepicker-toggle.ts index a6ba593205ac..485d118453b5 100644 --- a/src/lib/datepicker/datepicker-toggle.ts +++ b/src/lib/datepicker/datepicker-toggle.ts @@ -1,5 +1,6 @@ import {ChangeDetectionStrategy, Component, Input, ViewEncapsulation} from '@angular/core'; import {MdDatepicker} from './datepicker'; +import {CalendarLocale} from '../core/datetime/calendar-locale'; @Component({ @@ -9,6 +10,7 @@ import {MdDatepicker} from './datepicker'; styleUrls: ['datepicker-toggle.css'], host: { '[class.mat-datepicker-toggle]': 'true', + '[attr.aria-label]': '_locale.openCalendarLabel', '(click)': '_open($event)', }, encapsulation: ViewEncapsulation.None, @@ -21,6 +23,8 @@ export class MdDatepickerToggle { get _datepicker() { return this.datepicker; } set _datepicker(v: MdDatepicker) { this.datepicker = v; } + constructor(public _locale: CalendarLocale) {} + _open(event: Event): void { if (this.datepicker) { this.datepicker.open(); diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index f00dd6c01e92..3c3f0c990aae 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -82,13 +82,7 @@ export class MdDatepicker implements OnDestroy { get startAt(): SimpleDate { // If an explicit startAt is set we start there, otherwise we start at whatever the currently // selected value is. - if (this._startAt) { - return this._startAt; - } - if (this._datepickerInput) { - return this._datepickerInput.value; - } - return null; + return this._startAt || (this._datepickerInput ? this._datepickerInput.value : null); } set startAt(date: SimpleDate) { this._startAt = this._locale.parseDate(date); } private _startAt: SimpleDate; @@ -166,7 +160,7 @@ export class MdDatepicker implements OnDestroy { /** * Register an input with this datepicker. - * @param inputElementRef An ElementRef for the input. + * @param input The datepicker input to register with this datepicker. */ _registerInput(input: MdDatepickerInput): void { if (this._datepickerInput) { @@ -177,10 +171,7 @@ export class MdDatepicker implements OnDestroy { this._datepickerInput._valueChange.subscribe((value: SimpleDate) => this._selected = value); } - /** - * Open the calendar. - * @param touchUi Whether to use the touch UI. - */ + /** Open the calendar. */ open(): void { if (this.opened) { return; diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index a3d5367f575c..2571620c0340 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -11,6 +11,7 @@ import {MdDialogModule} from '../dialog/index'; import {MdCalendar} from './calendar'; import {MdDatepickerToggle} from './datepicker-toggle'; import {StyleModule} from '../core/style/index'; +import {MdButtonModule} from '../button/index'; export * from './calendar'; @@ -25,6 +26,7 @@ export * from './year-view'; imports: [ CommonModule, DatetimeModule, + MdButtonModule, MdDialogModule, OverlayModule, StyleModule, From 8b6780c1b99a40317b027186a88cf3154982d70a Mon Sep 17 00:00:00 2001 From: mmalerba Date: Wed, 19 Apr 2017 08:45:31 -0700 Subject: [PATCH 23/37] refactor(datepicker): move weekdays into table header (#4129) * fix broken tests * md-calendar-table --> [md-calendar-body] * move md-calendar-body to tbody element * move weekdays into table header * finishing touches * fix year view padding * add some comments --- src/lib/datepicker/_datepicker-theme.scss | 34 +++++----- src/lib/datepicker/calendar-body.html | 24 +++++++ src/lib/datepicker/calendar-body.scss | 64 ++++++++++++++++++ .../{calendar-table.ts => calendar-body.ts} | 11 +-- src/lib/datepicker/calendar-table.html | 26 ------- src/lib/datepicker/calendar-table.scss | 67 ------------------- src/lib/datepicker/calendar-table.spec.ts | 54 ++++++++------- src/lib/datepicker/calendar.html | 6 +- src/lib/datepicker/calendar.scss | 47 ++++++++----- src/lib/datepicker/calendar.spec.ts | 34 +++++----- src/lib/datepicker/calendar.ts | 8 +-- src/lib/datepicker/datepicker.spec.ts | 2 +- src/lib/datepicker/datepicker.ts | 2 +- src/lib/datepicker/index.ts | 6 +- src/lib/datepicker/month-view.html | 23 ++++--- src/lib/datepicker/month-view.spec.ts | 26 +++---- src/lib/datepicker/month-view.ts | 11 ++- src/lib/datepicker/year-view.html | 24 ++++--- src/lib/datepicker/year-view.spec.ts | 26 +++---- src/lib/datepicker/year-view.ts | 2 +- 20 files changed, 263 insertions(+), 234 deletions(-) create mode 100644 src/lib/datepicker/calendar-body.html create mode 100644 src/lib/datepicker/calendar-body.scss rename src/lib/datepicker/{calendar-table.ts => calendar-body.ts} (92%) delete mode 100644 src/lib/datepicker/calendar-table.html delete mode 100644 src/lib/datepicker/calendar-table.scss diff --git a/src/lib/datepicker/_datepicker-theme.scss b/src/lib/datepicker/_datepicker-theme.scss index bd3d1a56726e..4eea688f7003 100644 --- a/src/lib/datepicker/_datepicker-theme.scss +++ b/src/lib/datepicker/_datepicker-theme.scss @@ -15,10 +15,6 @@ background-color: mat-color($background, card); } - .mat-calendar-header { - border-color: mat-color($foreground, divider); - } - .mat-calendar-arrow { border-top-color: mat-color($foreground, icon); } @@ -28,52 +24,56 @@ color: mat-color($foreground, icon); } - .mat-calendar-weekday-table { + .mat-calendar-table-header { color: mat-color($foreground, hint-text); } - .mat-calendar-table-label { + .mat-calendar-table-header-divider::after { + background: mat-color($foreground, divider); + } + + .mat-calendar-body-label { color: mat-color($foreground, secondary-text); } - .mat-calendar-table-cell-content { + .mat-calendar-body-cell-content { color: mat-color($foreground, text); border-color: transparent; - .mat-calendar-table-disabled > &:not(.mat-calendar-table-selected) { + .mat-calendar-body-disabled > &:not(.mat-calendar-body-selected) { color: mat-color($foreground, disabled-text); } } - :not(.mat-calendar-table-disabled):hover, - .cdk-keyboard-focused .mat-calendar-table-active { - & > .mat-calendar-table-cell-content:not(.mat-calendar-table-selected) { + :not(.mat-calendar-body-disabled):hover, + .cdk-keyboard-focused .mat-calendar-body-active { + & > .mat-calendar-body-cell-content:not(.mat-calendar-body-selected) { background-color: mat-color($background, hover); } } - .mat-calendar-table-selected { + .mat-calendar-body-selected { background-color: mat-color($primary); color: mat-color($primary, default-contrast); - .mat-calendar-table-disabled > & { + .mat-calendar-body-disabled > & { background-color: fade-out(mat-color($primary), $mat-datepicker-selected-fade-amount); } } - .mat-calendar-table-today { - &:not(.mat-calendar-table-selected) { + .mat-calendar-body-today { + &:not(.mat-calendar-body-selected) { // Note: though it's not text, the border is a hint about the fact that this is today's date, // so we use the hint color. border-color: mat-color($foreground, hint-text); - .mat-calendar-table-disabled > & { + .mat-calendar-body-disabled > & { border-color: fade-out(mat-color($foreground, hint-text), $mat-datepicker-today-fade-amount); } } - &.mat-calendar-table-selected { + &.mat-calendar-body-selected { box-shadow: inset 0 0 0 $mat-datepicker-selected-today-box-shadow-width mat-color($primary, default-contrast); } diff --git a/src/lib/datepicker/calendar-body.html b/src/lib/datepicker/calendar-body.html new file mode 100644 index 000000000000..22782713288b --- /dev/null +++ b/src/lib/datepicker/calendar-body.html @@ -0,0 +1,24 @@ + + + {{label}} + + + + + + {{_firstRowOffset >= labelMinRequiredCells ? label : ''}} + + +
+ {{item.displayValue}} +
+ + diff --git a/src/lib/datepicker/calendar-body.scss b/src/lib/datepicker/calendar-body.scss new file mode 100644 index 000000000000..39b1d22cbd9d --- /dev/null +++ b/src/lib/datepicker/calendar-body.scss @@ -0,0 +1,64 @@ +$mat-calendar-body-font-size: 13px !default; +$mat-calendar-body-header-font-size: 14px !default; +$mat-calendar-body-label-padding-start: 5% !default; +$mat-calendar-body-label-translation: -6px !default; +$mat-calendar-body-cell-min-size: 32px !default; +$mat-calendar-body-cell-size: 100% / 7 !default; +$mat-calendar-body-cell-content-margin: 5% !default; +$mat-calendar-body-cell-content-border-width: 1px !default; + +$mat-calendar-body-min-size: 7 * $mat-calendar-body-cell-min-size !default; +$mat-calendar-body-cell-padding: $mat-calendar-body-cell-size / 2 !default; +$mat-calendar-body-cell-content-size: 100% - $mat-calendar-body-cell-content-margin * 2 !default; + + +.mat-calendar-body { + font-size: $mat-calendar-body-font-size; + min-width: $mat-calendar-body-min-size; +} + +.mat-calendar-body-label { + padding: $mat-calendar-body-cell-padding 0 + $mat-calendar-body-cell-padding $mat-calendar-body-cell-padding; + height: 0; + line-height: 0; + transform: translateX($mat-calendar-body-label-translation); + text-align: left; + font-size: $mat-calendar-body-header-font-size; + font-weight: bold; +} + +.mat-calendar-body-cell { + position: relative; + width: $mat-calendar-body-cell-size; + height: 0; + line-height: 0; + padding: $mat-calendar-body-cell-padding 0; + text-align: center; +} + +.mat-calendar-body-cell-content { + position: absolute; + top: $mat-calendar-body-cell-content-margin; + left: $mat-calendar-body-cell-content-margin; + + display: flex; + align-items: center; + justify-content: center; + + box-sizing: border-box; + width: $mat-calendar-body-cell-content-size; + height: $mat-calendar-body-cell-content-size; + + border-width: $mat-calendar-body-cell-content-border-width; + border-style: solid; + border-radius: 50%; +} + +[dir='rtl'] { + .mat-calendar-body-label { + padding: 0 $mat-calendar-body-cell-padding 0 0; + transform: translateX(-$mat-calendar-body-label-translation); + text-align: right; + } +} diff --git a/src/lib/datepicker/calendar-table.ts b/src/lib/datepicker/calendar-body.ts similarity index 92% rename from src/lib/datepicker/calendar-table.ts rename to src/lib/datepicker/calendar-body.ts index a9195f71cd8d..b20376a95604 100644 --- a/src/lib/datepicker/calendar-table.ts +++ b/src/lib/datepicker/calendar-body.ts @@ -23,13 +23,16 @@ export class MdCalendarCell { */ @Component({ moduleId: module.id, - selector: 'md-calendar-table', - templateUrl: 'calendar-table.html', - styleUrls: ['calendar-table.css'], + selector: '[md-calendar-body]', + templateUrl: 'calendar-body.html', + styleUrls: ['calendar-body.css'], + host: { + 'class': 'mat-calendar-body', + }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MdCalendarTable { +export class MdCalendarBody { /** The label for the table. (e.g. "Jan 2017"). */ @Input() label: string; diff --git a/src/lib/datepicker/calendar-table.html b/src/lib/datepicker/calendar-table.html deleted file mode 100644 index af00052cfc34..000000000000 --- a/src/lib/datepicker/calendar-table.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - -
{{label}}
- {{_firstRowOffset >= labelMinRequiredCells ? label : ''}} - -
- {{item.displayValue}} -
-
diff --git a/src/lib/datepicker/calendar-table.scss b/src/lib/datepicker/calendar-table.scss deleted file mode 100644 index c96189cd84ac..000000000000 --- a/src/lib/datepicker/calendar-table.scss +++ /dev/null @@ -1,67 +0,0 @@ -$mat-calendar-table-font-size: 13px !default; -$mat-calendar-table-header-font-size: 14px !default; -$mat-calendar-table-label-padding-start: 5% !default; -$mat-calendar-table-label-translation: -6px !default; -$mat-calendar-table-cell-min-size: 32px !default; -$mat-calendar-table-cell-size: 100% / 7 !default; -$mat-calendar-table-cell-content-margin: 5% !default; -$mat-calendar-table-cell-content-border-width: 1px !default; - -$mat-calendar-table-min-size: 7 * $mat-calendar-table-cell-min-size !default; -$mat-calendar-table-cell-padding: $mat-calendar-table-cell-size / 2 !default; -$mat-calendar-table-cell-content-size: 100% - $mat-calendar-table-cell-content-margin * 2 !default; - - -.mat-calendar-table-table { - border-spacing: 0; - border-collapse: collapse; - font-size: $mat-calendar-table-font-size; - min-width: $mat-calendar-table-min-size; - width: 100%; -} - -.mat-calendar-table-label { - padding: $mat-calendar-table-cell-padding 0 - $mat-calendar-table-cell-padding $mat-calendar-table-cell-padding; - height: 0; - line-height: 0; - transform: translateX($mat-calendar-table-label-translation); - text-align: left; - font-size: $mat-calendar-table-header-font-size; - font-weight: bold; -} - -.mat-calendar-table-cell { - position: relative; - width: $mat-calendar-table-cell-size; - height: 0; - line-height: 0; - padding: $mat-calendar-table-cell-padding 0; - text-align: center; -} - -.mat-calendar-table-cell-content { - position: absolute; - top: $mat-calendar-table-cell-content-margin; - left: $mat-calendar-table-cell-content-margin; - - display: flex; - align-items: center; - justify-content: center; - - box-sizing: border-box; - width: $mat-calendar-table-cell-content-size; - height: $mat-calendar-table-cell-content-size; - - border-width: $mat-calendar-table-cell-content-border-width; - border-style: solid; - border-radius: 50%; -} - -[dir='rtl'] { - .mat-calendar-table-label { - padding: 0 $mat-calendar-table-cell-padding 0 0; - transform: translateX(-$mat-calendar-table-label-translation); - text-align: right; - } -} diff --git a/src/lib/datepicker/calendar-table.spec.ts b/src/lib/datepicker/calendar-table.spec.ts index fa314a3d9af8..a0fea7c9c430 100644 --- a/src/lib/datepicker/calendar-table.spec.ts +++ b/src/lib/datepicker/calendar-table.spec.ts @@ -1,6 +1,6 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; -import {MdCalendarCell, MdCalendarTable} from './calendar-table'; +import {MdCalendarCell, MdCalendarBody} from './calendar-body'; import {By} from '@angular/platform-browser'; import {SimpleDate} from '../core/datetime/simple-date'; @@ -9,7 +9,7 @@ describe('MdCalendarTable', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ - MdCalendarTable, + MdCalendarBody, // Test components. StandardCalendarTable, @@ -30,15 +30,15 @@ describe('MdCalendarTable', () => { let refreshElementLists = () => { rowEls = calendarTableNativeElement.querySelectorAll('tr'); - labelEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-table-label'); - cellEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-table-cell'); + labelEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-body-label'); + cellEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-body-cell'); }; beforeEach(() => { fixture = TestBed.createComponent(StandardCalendarTable); fixture.detectChanges(); - let calendarTableDebugElement = fixture.debugElement.query(By.directive(MdCalendarTable)); + let calendarTableDebugElement = fixture.debugElement.query(By.directive(MdCalendarBody)); calendarTableNativeElement = calendarTableDebugElement.nativeElement; testComponent = fixture.componentInstance; @@ -52,13 +52,13 @@ describe('MdCalendarTable', () => { }); it('highlights today', () => { - let todayCell = calendarTableNativeElement.querySelector('.mat-calendar-table-today'); + let todayCell = calendarTableNativeElement.querySelector('.mat-calendar-body-today'); expect(todayCell).not.toBeNull(); expect(todayCell.innerHTML.trim()).toBe('3'); }); it('highlights selected', () => { - let selectedCell = calendarTableNativeElement.querySelector('.mat-calendar-table-selected'); + let selectedCell = calendarTableNativeElement.querySelector('.mat-calendar-body-selected'); expect(selectedCell).not.toBeNull(); expect(selectedCell.innerHTML.trim()).toBe('4'); }); @@ -73,23 +73,23 @@ describe('MdCalendarTable', () => { expect(labelEls.length).toBe(1); expect(cellEls.length).toBe(11); expect(rowEls[0].firstElementChild.classList) - .toContain('mat-calendar-table-label', 'first cell should be the label'); + .toContain('mat-calendar-body-label', 'first cell should be the label'); expect(labelEls[0].getAttribute('colspan')).toBe('3'); }); it('cell should be selected on click', () => { let todayElement = - calendarTableNativeElement.querySelector('.mat-calendar-table-today') as HTMLElement; + calendarTableNativeElement.querySelector('.mat-calendar-body-today') as HTMLElement; todayElement.click(); fixture.detectChanges(); expect(todayElement.classList) - .toContain('mat-calendar-table-selected', 'today should be selected'); + .toContain('mat-calendar-body-selected', 'today should be selected'); }); it('should mark active date', () => { expect((cellEls[10] as HTMLElement).innerText.trim()).toBe('11'); - expect(cellEls[10].classList).toContain('mat-calendar-table-active'); + expect(cellEls[10].classList).toContain('mat-calendar-body-active'); }); }); @@ -103,10 +103,10 @@ describe('MdCalendarTable', () => { fixture = TestBed.createComponent(CalendarTableWithDisabledCells); fixture.detectChanges(); - let calendarTableDebugElement = fixture.debugElement.query(By.directive(MdCalendarTable)); + let calendarTableDebugElement = fixture.debugElement.query(By.directive(MdCalendarBody)); calendarTableNativeElement = calendarTableDebugElement.nativeElement; testComponent = fixture.componentInstance; - cellEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-table-cell'); + cellEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-body-cell'); }); it('should only allow selection of disabled cells when allowDisabledSelection is true', () => { @@ -128,15 +128,16 @@ describe('MdCalendarTable', () => { @Component({ - template: ` - `, + template: ` +
`, }) class StandardCalendarTable { label = 'Jan 2017'; @@ -153,10 +154,11 @@ class StandardCalendarTable { @Component({ - template: ` - ` + template: ` +
` }) class CalendarTableWithDisabledCells { rows = [[1, 2, 3, 4]].map(r => r.map(d => { diff --git a/src/lib/datepicker/calendar.html b/src/lib/datepicker/calendar.html index 96de7d3e7b24..a9a23b3ff788 100644 --- a/src/lib/datepicker/calendar.html +++ b/src/lib/datepicker/calendar.html @@ -13,13 +13,9 @@ (click)="_nextClicked()" [attr.aria-label]="_nextButtonLabel">
- - - -
{{day}}
-
svg { - vertical-align: middle; -} - .mat-calendar-arrow { display: inline-block; width: 0; @@ -71,12 +65,6 @@ $mat-calendar-next-icon-transform: translateX(-2px) rotate(45deg); } } -.mat-calendar-weekday-table { - width: 100%; - text-align: center; - font-size: $mat-calendar-weekday-table-font-size; -} - .mat-calendar-previous-button, .mat-calendar-next-button { position: relative; @@ -103,3 +91,32 @@ $mat-calendar-next-icon-transform: translateX(-2px) rotate(45deg); border-right-width: $mat-calendar-prev-next-icon-border-width; transform: $mat-calendar-next-icon-transform; } + +.mat-calendar-table { + border-spacing: 0; + border-collapse: collapse; + width: 100%; +} + +.mat-calendar-table-header th { + text-align: center; + font-size: $mat-calendar-weekday-table-font-size; + font-weight: normal; + padding: 0 0 $mat-calendar-padding 0; +} + +.mat-calendar-table-header-divider { + position: relative; + height: $mat-calendar-header-divider-width; + + // We use an absolutely positioned pseudo-element as the divider line for the table header so we + // can extend it all the way to the edge of the calendar. + &::after { + content: ''; + position: absolute; + top: 0; + left: -$mat-calendar-padding; + right: -$mat-calendar-padding; + height: $mat-calendar-header-divider-width; + } +} diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts index 29f484bbdb9a..60e2ce027817 100644 --- a/src/lib/datepicker/calendar.spec.ts +++ b/src/lib/datepicker/calendar.spec.ts @@ -5,10 +5,11 @@ import {MdCalendar} from './calendar'; import {By} from '@angular/platform-browser'; import {MdMonthView} from './month-view'; import {MdYearView} from './year-view'; -import {MdCalendarTable} from './calendar-table'; +import {MdCalendarBody} from './calendar-body'; import {DatetimeModule} from '../core/datetime/index'; import { - dispatchFakeEvent, dispatchKeyboardEvent, + dispatchFakeEvent, + dispatchKeyboardEvent, dispatchMouseEvent } from '../core/testing/dispatch-events'; import { @@ -32,7 +33,7 @@ describe('MdCalendar', () => { ], declarations: [ MdCalendar, - MdCalendarTable, + MdCalendarBody, MdMonthView, MdYearView, @@ -127,7 +128,7 @@ describe('MdCalendar', () => { expect(calendarInstance._monthView).toBe(false, 'should be in year view'); expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); - let monthCells = calendarElement.querySelectorAll('.mat-calendar-table-cell'); + let monthCells = calendarElement.querySelectorAll('.mat-calendar-body-cell'); (monthCells[monthCells.length - 1] as HTMLElement).click(); fixture.detectChanges(); @@ -137,7 +138,7 @@ describe('MdCalendar', () => { }); it('should select date in month view', () => { - let monthCells = calendarElement.querySelectorAll('.mat-calendar-table-cell'); + let monthCells = calendarElement.querySelectorAll('.mat-calendar-body-cell'); (monthCells[monthCells.length - 1] as HTMLElement).click(); fixture.detectChanges(); @@ -150,7 +151,7 @@ describe('MdCalendar', () => { let calendarBodyEl: HTMLElement; beforeEach(() => { - calendarBodyEl = calendarElement.querySelector('.mat-calendar-body') as HTMLElement; + calendarBodyEl = calendarElement.querySelector('.mat-calendar-content') as HTMLElement; expect(calendarBodyEl).not.toBeNull(); dispatchFakeEvent(calendarBodyEl, 'focus'); @@ -434,8 +435,8 @@ describe('MdCalendar', () => { let fixture: ComponentFixture; let testComponent: CalendarWithMinMax; let calendarElement: HTMLElement; - let prevButton: HTMLElement; - let nextButton: HTMLElement; + let prevButton: HTMLButtonElement; + let nextButton: HTMLButtonElement; let calendarInstance: MdCalendar; beforeEach(() => { @@ -443,8 +444,9 @@ describe('MdCalendar', () => { let calendarDebugElement = fixture.debugElement.query(By.directive(MdCalendar)); calendarElement = calendarDebugElement.nativeElement; - prevButton = calendarElement.querySelector('.mat-calendar-previous-button') as HTMLElement; - nextButton = calendarElement.querySelector('.mat-calendar-next-button') as HTMLElement; + prevButton = + calendarElement.querySelector('.mat-calendar-previous-button') as HTMLButtonElement; + nextButton = calendarElement.querySelector('.mat-calendar-next-button') as HTMLButtonElement; calendarInstance = calendarDebugElement.componentInstance; testComponent = fixture.componentInstance; }); @@ -467,13 +469,13 @@ describe('MdCalendar', () => { testComponent.startAt = new SimpleDate(2016, 1, 1); fixture.detectChanges(); - expect(prevButton.classList).not.toContain('mat-calendar-disabled'); + expect(prevButton.disabled).toBe(false, 'previous button should not be disabled'); expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 1, 1)); prevButton.click(); fixture.detectChanges(); - expect(prevButton.classList).toContain('mat-calendar-disabled'); + expect(prevButton.disabled).toBe(true, 'previous button should be disabled'); expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); prevButton.click(); @@ -486,13 +488,13 @@ describe('MdCalendar', () => { testComponent.startAt = new SimpleDate(2017, 11, 1); fixture.detectChanges(); - expect(nextButton.classList).not.toContain('mat-calendar-disabled'); + expect(nextButton.disabled).toBe(false, 'next button should not be disabled'); expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 1)); nextButton.click(); fixture.detectChanges(); - expect(nextButton.classList).toContain('mat-calendar-disabled'); + expect(nextButton.disabled).toBe(true, 'next button should be disabled'); expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); nextButton.click(); @@ -519,7 +521,7 @@ describe('MdCalendar', () => { }); it('should disable and prevent selection of filtered dates', () => { - let cells = calendarElement.querySelectorAll('.mat-calendar-table-cell'); + let cells = calendarElement.querySelectorAll('.mat-calendar-body-cell'); (cells[0] as HTMLElement).click(); fixture.detectChanges(); @@ -535,7 +537,7 @@ describe('MdCalendar', () => { let calendarBodyEl: HTMLElement; beforeEach(() => { - calendarBodyEl = calendarElement.querySelector('.mat-calendar-body') as HTMLElement; + calendarBodyEl = calendarElement.querySelector('.mat-calendar-content') as HTMLElement; expect(calendarBodyEl).not.toBeNull(); dispatchFakeEvent(calendarBodyEl, 'focus'); diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index 9543b9027e82..78007f3fa427 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -91,9 +91,6 @@ export class MdCalendar implements AfterContentInit { /** Whether the calendar is in month view. */ _monthView: boolean; - /** The names of the weekdays. */ - _weekdays: string[]; - /** The label for the current calendar view. */ get _periodButtonText(): string { return this._monthView ? @@ -117,10 +114,7 @@ export class MdCalendar implements AfterContentInit { return this._monthView ? this._locale.nextMonthLabel : this._locale.nextYearLabel; } - constructor(private _locale: CalendarLocale) { - this._weekdays = this._locale.narrowDays.slice(this._locale.firstDayOfWeek) - .concat(this._locale.narrowDays.slice(0, this._locale.firstDayOfWeek)); - } + constructor(private _locale: CalendarLocale) {} ngAfterContentInit() { this._activeDate = this.startAt || SimpleDate.today(); diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index 9dee866200ef..455a7761b4ff 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -117,7 +117,7 @@ describe('MdDatepicker', () => { expect(document.querySelector('md-dialog-container')).not.toBeNull(); expect(testComponent.datepickerInput.value).toEqual(new SimpleDate(2020, 0, 1)); - let cells = document.querySelectorAll('.mat-calendar-table-cell'); + let cells = document.querySelectorAll('.mat-calendar-body-cell'); dispatchMouseEvent(cells[1], 'click'); fixture.detectChanges(); diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 3c3f0c990aae..63153fdc4762 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -62,7 +62,7 @@ export class MdDatepickerContent implements AfterContentInit { constructor(private _elementRef: ElementRef) {} ngAfterContentInit() { - this._elementRef.nativeElement.querySelector('.mat-calendar-body').focus(); + this._elementRef.nativeElement.querySelector('.mat-calendar-content').focus(); } } diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index 2571620c0340..8526e5f8cd0e 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -1,7 +1,7 @@ import {NgModule} from '@angular/core'; import {MdMonthView} from './month-view'; import {CommonModule} from '@angular/common'; -import {MdCalendarTable} from './calendar-table'; +import {MdCalendarBody} from './calendar-body'; import {MdYearView} from './year-view'; import {DatetimeModule} from '../core/datetime/index'; import {OverlayModule} from '../core/overlay/overlay-directives'; @@ -15,7 +15,7 @@ import {MdButtonModule} from '../button/index'; export * from './calendar'; -export * from './calendar-table'; +export * from './calendar-body'; export * from './datepicker'; export * from './datepicker-input'; export * from './month-view'; @@ -39,7 +39,7 @@ export * from './year-view'; ], declarations: [ MdCalendar, - MdCalendarTable, + MdCalendarBody, MdDatepicker, MdDatepickerContent, MdDatepickerInput, diff --git a/src/lib/datepicker/month-view.html b/src/lib/datepicker/month-view.html index af76b0a61a4e..a572c8ded789 100644 --- a/src/lib/datepicker/month-view.html +++ b/src/lib/datepicker/month-view.html @@ -1,8 +1,15 @@ - - + + + + + + + +
{{day}}
diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts index d7fdefc1e985..ee544470a8a3 100644 --- a/src/lib/datepicker/month-view.spec.ts +++ b/src/lib/datepicker/month-view.spec.ts @@ -3,7 +3,7 @@ import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdMonthView} from './month-view'; import {SimpleDate} from '../core/datetime/simple-date'; -import {MdCalendarTable} from './calendar-table'; +import {MdCalendarBody} from './calendar-body'; import {DatetimeModule} from '../core/datetime/index'; @@ -14,7 +14,7 @@ describe('MdMonthView', () => { DatetimeModule, ], declarations: [ - MdCalendarTable, + MdCalendarBody, MdMonthView, // Test components. @@ -41,17 +41,17 @@ describe('MdMonthView', () => { }); it('has correct month label', () => { - let labelEl = monthViewNativeElement.querySelector('.mat-calendar-table-label'); + let labelEl = monthViewNativeElement.querySelector('.mat-calendar-body-label'); expect(labelEl.innerHTML.trim()).toBe('JAN'); }); it('has 31 days', () => { - let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); expect(cellEls.length).toBe(31); }); it('shows selected date if in same month', () => { - let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-table-selected'); + let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-body-selected'); expect(selectedEl.innerHTML.trim()).toBe('10'); }); @@ -59,23 +59,23 @@ describe('MdMonthView', () => { testComponent.selected = new SimpleDate(2017, 2, 10); fixture.detectChanges(); - let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-table-selected'); + let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-body-selected'); expect(selectedEl).toBeNull(); }); it('fires selected change event on cell clicked', () => { - let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); (cellEls[cellEls.length - 1] as HTMLElement).click(); fixture.detectChanges(); - let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-table-selected'); + let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-body-selected'); expect(selectedEl.innerHTML.trim()).toBe('31'); }); it('should mark active date', () => { - let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); expect((cellEls[4] as HTMLElement).innerText.trim()).toBe('5'); - expect(cellEls[4].classList).toContain('mat-calendar-table-active'); + expect(cellEls[4].classList).toContain('mat-calendar-body-active'); }); }); @@ -94,9 +94,9 @@ describe('MdMonthView', () => { }); it('should disable filtered dates', () => { - let cells = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); - expect(cells[0].classList).toContain('mat-calendar-table-disabled'); - expect(cells[1].classList).not.toContain('mat-calendar-table-disabled'); + let cells = monthViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + expect(cells[0].classList).toContain('mat-calendar-body-disabled'); + expect(cells[1].classList).not.toContain('mat-calendar-body-disabled'); }); }); }); diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts index 411405f9ef96..94ea70d7fc18 100644 --- a/src/lib/datepicker/month-view.ts +++ b/src/lib/datepicker/month-view.ts @@ -7,7 +7,7 @@ import { Output, AfterContentInit } from '@angular/core'; -import {MdCalendarCell} from './calendar-table'; +import {MdCalendarCell} from './calendar-body'; import {CalendarLocale} from '../core/datetime/calendar-locale'; import {SimpleDate} from '../core/datetime/simple-date'; @@ -74,7 +74,14 @@ export class MdMonthView implements AfterContentInit { /** The date of the month that today falls on. Null if today is in another month. */ _todayDate: number; - constructor(private _locale: CalendarLocale) {} + /** The names of the weekdays. */ + _weekdays: string[]; + + constructor(private _locale: CalendarLocale) { + // Rotate the labels for days of the week based on the configured first day of the week. + this._weekdays = this._locale.narrowDays.slice(this._locale.firstDayOfWeek) + .concat(this._locale.narrowDays.slice(0, this._locale.firstDayOfWeek)); + } ngAfterContentInit(): void { this._init(); diff --git a/src/lib/datepicker/year-view.html b/src/lib/datepicker/year-view.html index 3ec753e219cc..d8a7b447cb89 100644 --- a/src/lib/datepicker/year-view.html +++ b/src/lib/datepicker/year-view.html @@ -1,9 +1,15 @@ - - + + + + + + +
diff --git a/src/lib/datepicker/year-view.spec.ts b/src/lib/datepicker/year-view.spec.ts index 66674cba4c25..5511aafac9b4 100644 --- a/src/lib/datepicker/year-view.spec.ts +++ b/src/lib/datepicker/year-view.spec.ts @@ -3,7 +3,7 @@ import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdYearView} from './year-view'; import {SimpleDate} from '../core/datetime/simple-date'; -import {MdCalendarTable} from './calendar-table'; +import {MdCalendarBody} from './calendar-body'; import {DatetimeModule} from '../core/datetime/index'; @@ -14,7 +14,7 @@ describe('MdYearView', () => { DatetimeModule, ], declarations: [ - MdCalendarTable, + MdCalendarBody, MdYearView, // Test components. @@ -41,17 +41,17 @@ describe('MdYearView', () => { }); it('has correct year label', () => { - let labelEl = yearViewNativeElement.querySelector('.mat-calendar-table-label'); + let labelEl = yearViewNativeElement.querySelector('.mat-calendar-body-label'); expect(labelEl.innerHTML.trim()).toBe('2017'); }); it('has 12 months', () => { - let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); expect(cellEls.length).toBe(12); }); it('shows selected month if in same year', () => { - let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); + let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-body-selected'); expect(selectedEl.innerHTML.trim()).toBe('MAR'); }); @@ -59,23 +59,23 @@ describe('MdYearView', () => { testComponent.selected = new SimpleDate(2016, 2, 10); fixture.detectChanges(); - let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); + let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-body-selected'); expect(selectedEl).toBeNull(); }); it('fires selected change event on cell clicked', () => { - let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); (cellEls[cellEls.length - 1] as HTMLElement).click(); fixture.detectChanges(); - let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); + let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-body-selected'); expect(selectedEl.innerHTML.trim()).toBe('DEC'); }); it('should mark active date', () => { - let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); expect((cellEls[0] as HTMLElement).innerText.trim()).toBe('JAN'); - expect(cellEls[0].classList).toContain('mat-calendar-table-active'); + expect(cellEls[0].classList).toContain('mat-calendar-body-active'); }); }); @@ -94,9 +94,9 @@ describe('MdYearView', () => { }); it('should disabled months with no enabled days', () => { - let cells = yearViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); - expect(cells[0].classList).not.toContain('mat-calendar-table-disabled'); - expect(cells[1].classList).toContain('mat-calendar-table-disabled'); + let cells = yearViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + expect(cells[0].classList).not.toContain('mat-calendar-body-disabled'); + expect(cells[1].classList).toContain('mat-calendar-body-disabled'); }); }); }); diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts index 39ce2a1600ec..a9bc7616988a 100644 --- a/src/lib/datepicker/year-view.ts +++ b/src/lib/datepicker/year-view.ts @@ -7,7 +7,7 @@ import { Output, EventEmitter } from '@angular/core'; -import {MdCalendarCell} from './calendar-table'; +import {MdCalendarCell} from './calendar-body'; import {CalendarLocale} from '../core/datetime/calendar-locale'; import {SimpleDate} from '../core/datetime/simple-date'; From f229b1b11eceed9a2a4a61df66f05629b6cde46b Mon Sep 17 00:00:00 2001 From: mmalerba Date: Thu, 20 Apr 2017 10:33:06 -0700 Subject: [PATCH 24/37] feat(datepicker): add DateAdapter and NativeDateAdapter (#4148) * added DateAdapter and NativeDateAdapter * add tests * addressed some comments * more comments addressed * another round of comments addressed * add default formats --- src/lib/core/datetime/date-adapter.ts | 201 ++++++++++++++ src/lib/core/datetime/index.ts | 2 + .../core/datetime/native-date-adapter.spec.ts | 256 ++++++++++++++++++ src/lib/core/datetime/native-date-adapter.ts | 167 ++++++++++++ 4 files changed, 626 insertions(+) create mode 100644 src/lib/core/datetime/date-adapter.ts create mode 100644 src/lib/core/datetime/native-date-adapter.spec.ts create mode 100644 src/lib/core/datetime/native-date-adapter.ts diff --git a/src/lib/core/datetime/date-adapter.ts b/src/lib/core/datetime/date-adapter.ts new file mode 100644 index 000000000000..6fab81e943ba --- /dev/null +++ b/src/lib/core/datetime/date-adapter.ts @@ -0,0 +1,201 @@ +/** 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. */ + protected locale: any; + + /** + * Gets the year component of the given date. + * @param date The date to extract the year from. + * @returns The year component. + */ + abstract getYear(date: D): number; + + /** + * Gets the month component of the given date. + * @param date The date to extract the month from. + * @returns The month component (0-indexed, 0 = January). + */ + abstract getMonth(date: D): number; + + /** + * Gets the date of the month component of the given date. + * @param date The date to extract the date of the month from. + * @returns The month component (1-indexed, 1 = first of month). + */ + abstract getDate(date: D): number; + + /** + * Gets the day of the week component of the given date. + * @param date The date to extract the day of the week from. + * @returns The month component (0-indexed, 0 = Sunday). + */ + abstract getDayOfWeek(date: D): number; + + /** + * Gets a list of names for the months. + * @param style The naming style (e.g. long = 'January', short = 'Jan', narrow = 'J'). + * @returns An ordered list of all month names, starting with January. + */ + abstract getMonthNames(style: 'long' | 'short' | 'narrow'): string[]; + + /** + * Gets a list of names for the dates of the month. + * @returns An ordered list of all date of the month names, starting with '1'. + */ + abstract getDateNames(): string[]; + + /** + * Gets a list of names for the days of the week. + * @param style The naming style (e.g. long = 'Sunday', short = 'Sun', narrow = 'S'). + * @returns An ordered list of all weekday names, starting with Sunday. + */ + abstract getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[]; + + /** + * Gets the name for the year of the given date. + * @param date The date to get the year name for. + * @returns The name of the given year (e.g. '2017'). + */ + abstract getYearName(date: D): string; + + /** + * Gets the name for the month and year of the given date. + * @param date The date to get the month and year name for. + * @param monthStyle The naming style for the month + * (e.g. long = 'January', short = 'Jan', narrow = 'J'). + * @returns The name of the month and year of the given date (e.g. 'Jan 2017'). + */ + abstract getMonthYearName(date: D, monthStyle: 'long' | 'short' | 'narrow'): string; + + /** + * Gets the first day of the week. + * @returns The first day of the week (0-indexed, 0 = Sunday). + */ + abstract getFirstDayOfWeek(): number; + + /** + * Gets a set of default formats to use for displaying the date in different contexts. + * @returns An object with the following default formats: + * - date: The default format for showing just the date without any time information. + */ + abstract getDefaultFormats(): {date: any}; + + /** + * Clones the given date. + * @param date The date to clone + * @returns A new date equal to the given date. + */ + abstract clone(date: D): D; + + /** + * Creates a date with the given year, month, and date. + * @param year The full year of the date. (e.g. 89 means the year 89, not the year 1989). + * @param month The month of the date (0-indexed, 0 = January). If `month` is less than 0 or + * greater than 11, it should roll into the previous / next year. + * @param date The date of month of the date. If `date` is less than 1 or greater than the number + * of days in the `month`, it should roll into the previous / next month. + * @returns The new date. + */ + abstract createDate(year: number, month: number, date: number): D; + + /** + * Gets today's date. + * @returns Today's date. + */ + abstract today(): D; + + /** + * Parses a date from a value. + * @param value The value to parse. + * @param fmt The expected format of the value being parsed (type is implementation-dependent). + * @returns The parsed date, or null if date could not be parsed. + */ + abstract parse(value: any, fmt?: any): D | null; + + /** + * Formats a date as a string. + * @param date The value to parse. + * @param fmt The format to use for the result string. + * @returns The parsed date, or null if date could not be parsed. + */ + abstract format(date: D, fmt?: any): string; + + /** + * Adds the given number of years to the date. Years are counted as if flipping 12 pages on the + * calendar for each year and then finding the closest date in the new month. For example when + * adding 1 year to Feb 29, 2016, the resulting date will be Feb 28, 2017. + * @param date The date to add years to. + * @param years The number of years to add (may be negative). + * @returns A new date equal to the given one with the specified number of years added. + */ + abstract addCalendarYears(date: D, years: number): D; + + /** + * Adds the given number of months to the date. Months are counted as if flipping a page on the + * calendar for each month and then finding the closest date in the new month. For example when + * adding 1 month to Jan 31, 2017, the resulting date will be Feb 28, 2017. + * @param date The date to add months to. + * @param months The number of months to add (may be negative). + * @returns A new date equal to the given one with the specified number of months added. + */ + abstract addCalendarMonths(date: D, months: number): D; + + /** + * Adds the given number of days to the date. Days are counted as if moving one cell on the + * calendar for each day. + * @param date The date to add days to. + * @param days The number of days to add (may be negative). + * @returns A new date equal to the given one with the specified number of days added. + */ + abstract addCalendarDays(date: D, days: number): D; + + /** + * Sets the locale used for all dates. + * @param locale The new locale. + */ + setLocale(locale: any) { + this.locale = locale; + } + + /** + * Compares two dates. + * @param first The first date to compare. + * @param second The second date to compare. + * @returns 0 if the dates are equal, a number less than 0 if the first date is earlier, + * a number greater than 0 if the first date is later. + */ + compareDate(first: D, second: D): number { + return this.getYear(first) - this.getYear(second) || + this.getMonth(first) - this.getMonth(second) || + this.getDate(first) - this.getDate(second); + } + + /** + * Checks if two dates are equal. + * @param first The first date to check. + * @param second The second date to check. + * @returns {boolean} Whether the two dates are equal. + * Null dates are considered equal to other null dates. + */ + sameDate(first: D | null, second: D | null): boolean { + return first && second ? !this.compareDate(first, second) : first == second; + } + + /** + * Clamp the given date between min and max dates. + * @param date The date to clamp. + * @param min The minimum value to allow. If null or omitted no min is enforced. + * @param max The maximum value to allow. If null or omitted no max is enforced. + * @returns `min` if `date` is less than `min`, `max` if date is greater than `max`, + * otherwise `date`. + */ + clampDate(date: D, min?: D | null, max?: D | null): D { + if (min && this.compareDate(date, min) < 0) { + return min; + } + if (max && this.compareDate(date, max) > 0) { + return max; + } + return date; + } +} diff --git a/src/lib/core/datetime/index.ts b/src/lib/core/datetime/index.ts index 12c859a57f23..1c4aa3500008 100644 --- a/src/lib/core/datetime/index.ts +++ b/src/lib/core/datetime/index.ts @@ -3,7 +3,9 @@ import {DefaultCalendarLocale, CalendarLocale} from './calendar-locale'; export * from './calendar-locale'; +export * from './date-adapter'; export * from './simple-date'; +export * from './native-date-adapter'; @NgModule({ diff --git a/src/lib/core/datetime/native-date-adapter.spec.ts b/src/lib/core/datetime/native-date-adapter.spec.ts new file mode 100644 index 000000000000..44d0c877fa9b --- /dev/null +++ b/src/lib/core/datetime/native-date-adapter.spec.ts @@ -0,0 +1,256 @@ +import {NativeDateAdapter} from './native-date-adapter'; + + +// When constructing a Date, the month is zero-based. This can be confusing, since people are +// used to seeing them one-based. So we create these aliases to make reading the tests easier. +const JAN = 0, FEB = 1, MAR = 2, APR = 3, MAY = 4, JUN = 5, JUL = 6, AUG = 7, SEP = 8, OCT = 9, + NOV = 10, DEC = 11; + + +describe('NativeDateAdapter', () => { + let adapter; + + beforeEach(() => { + adapter = new NativeDateAdapter(); + }); + + it('should get year', () => { + expect(adapter.getYear(new Date(2017, JAN, 1))).toBe(2017); + }); + + it('should get month', () => { + expect(adapter.getMonth(new Date(2017, JAN, 1))).toBe(0); + }); + + it('should get date', () => { + expect(adapter.getDate(new Date(2017, JAN, 1))).toBe(1); + }); + + it('should get day of week', () => { + expect(adapter.getDayOfWeek(new Date(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([ + 'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D' + ]); + }); + + 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(['S', 'M', 'T', 'W', 'T', 'F', 'S']); + }); + + 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(new Date(2017, JAN, 1))).toBe('2017'); + }); + + it('should get year name in a different locale', () => { + adapter.setLocale('ja-JP'); + expect(adapter.getYearName(new Date(2017, JAN, 1))).toBe('2017年'); + }); + + it('should get long month and year name', () => { + expect(adapter.getMonthYearName(new Date(2017, JAN, 1), 'long')).toBe('January 2017'); + }); + + it('should get short month and year name', () => { + expect(adapter.getMonthYearName(new Date(2017, JAN, 1), 'short')).toBe('Jan 2017'); + }); + + it('should get narrow month and year name', () => { + expect(adapter.getMonthYearName(new Date(2017, JAN, 1), 'narrow')).toBe('J 2017'); + }); + + it('should get month and year name in a different locale', () => { + adapter.setLocale('ja-JP'); + expect(adapter.getMonthYearName(new Date(2017, JAN, 1), 'long')).toBe('2017年1月'); + }); + + it('should get first day of week', () => { + expect(adapter.getFirstDayOfWeek()).toBe(0); + }); + + it('should get default formats', () => { + let dtf = new Intl.DateTimeFormat('en-US', adapter.getDefaultFormats().date); + expect(dtf.format(new Date(2017, 1, 1))).toEqual('2/1/2017'); + }); + + it('should create Date', () => { + expect(adapter.createDate(2017, JAN, 1)).toEqual(new Date(2017, JAN, 1)); + }); + + it('should create Date with month and date overflow', () => { + expect(adapter.createDate(2017, DEC + 1, 32)).toEqual(new Date(2018, FEB, 1)); + }); + + it('should create Date with month date underflow', () => { + expect(adapter.createDate(2017, JAN - 1, 0)).toEqual(new Date(2016, NOV, 30)); + }); + + it('should create Date with low year number', () => { + expect(adapter.createDate(-1, JAN, 1).getFullYear()).toBe(-1); + expect(adapter.createDate(0, JAN, 1).getFullYear()).toBe(0); + expect(adapter.createDate(50, JAN, 1).getFullYear()).toBe(50); + expect(adapter.createDate(99, JAN, 1).getFullYear()).toBe(99); + expect(adapter.createDate(100, JAN, 1).getFullYear()).toBe(100); + }); + + it('should create Date with low year number and over/under-flow', () => { + expect(adapter.createDate(50, 12 * 51, 1).getFullYear()).toBe(101); + expect(adapter.createDate(50, 12, 1).getFullYear()).toBe(51); + expect(adapter.createDate(50, -12, 1).getFullYear()).toBe(49); + expect(adapter.createDate(50, -12 * 51, 1).getFullYear()).toBe(-1); + }); + + it("should get today's date", () => { + expect(adapter.sameDate(adapter.today(), new Date())) + .toBe(true, "should be equal to today's date"); + }); + + it('should parse string', () => { + expect(adapter.parse('1/1/17')).toEqual(new Date(2017, JAN, 1)); + }); + + it('should parse number', () => { + let timestamp = new Date().getTime(); + expect(adapter.parse(timestamp)).toEqual(new Date(timestamp)); + }); + + it ('should parse Date', () => { + let date = new Date(2017, JAN, 1); + expect(adapter.parse(date)).toEqual(date); + expect(adapter.parse(date)).not.toBe(date); + }); + + it('should parse invalid value as null', () => { + expect(adapter.parse('hello')).toBeNull(); + }); + + it('should format', () => { + expect(adapter.format(new Date(2017, JAN, 1))).toEqual('1/1/2017'); + }); + + it('should format with custom format', () => { + expect(adapter.format(new Date(2017, JAN, 1), {year: 'numeric', month: 'long', day: 'numeric'})) + .toEqual('January 1, 2017'); + }); + + it('should format with a different locale', () => { + adapter.setLocale('ja-JP'); + expect(adapter.format(new Date(2017, JAN, 1))).toEqual('2017/1/1'); + }); + + it('should add years', () => { + expect(adapter.addCalendarYears(new Date(2017, JAN, 1), 1)).toEqual(new Date(2018, JAN, 1)); + expect(adapter.addCalendarYears(new Date(2017, JAN, 1), -1)).toEqual(new Date(2016, JAN, 1)); + }); + + it('should respect leap years when adding years', () => { + expect(adapter.addCalendarYears(new Date(2016, FEB, 29), 1)).toEqual(new Date(2017, FEB, 28)); + expect(adapter.addCalendarYears(new Date(2016, FEB, 29), -1)).toEqual(new Date(2015, FEB, 28)); + }); + + it('should add months', () => { + expect(adapter.addCalendarMonths(new Date(2017, JAN, 1), 1)).toEqual(new Date(2017, FEB, 1)); + expect(adapter.addCalendarMonths(new Date(2017, JAN, 1), -1)).toEqual(new Date(2016, DEC, 1)); + }); + + it('should respect month length differences when adding months', () => { + expect(adapter.addCalendarMonths(new Date(2017, JAN, 31), 1)).toEqual(new Date(2017, FEB, 28)); + expect(adapter.addCalendarMonths(new Date(2017, MAR, 31), -1)).toEqual(new Date(2017, FEB, 28)); + }); + + it('should add days', () => { + expect(adapter.addCalendarDays(new Date(2017, JAN, 1), 1)).toEqual(new Date(2017, JAN, 2)); + expect(adapter.addCalendarDays(new Date(2017, JAN, 1), -1)).toEqual(new Date(2016, DEC, 31)); + }); + + it('should clone', () => { + let date = new Date(2017, JAN, 1); + expect(adapter.clone(date)).toEqual(date); + expect(adapter.clone(date)).not.toBe(date); + }); + + it('should compare dates', () => { + expect(adapter.compareDate(new Date(2017, JAN, 1), new Date(2017, JAN, 2))).toBeLessThan(0); + expect(adapter.compareDate(new Date(2017, JAN, 1), new Date(2017, FEB, 1))).toBeLessThan(0); + expect(adapter.compareDate(new Date(2017, JAN, 1), new Date(2018, JAN, 1))).toBeLessThan(0); + expect(adapter.compareDate(new Date(2017, JAN, 1), new Date(2017, JAN, 1))).toBe(0); + expect(adapter.compareDate(new Date(2018, JAN, 1), new Date(2017, JAN, 1))).toBeGreaterThan(0); + expect(adapter.compareDate(new Date(2017, FEB, 1), new Date(2017, JAN, 1))).toBeGreaterThan(0); + expect(adapter.compareDate(new Date(2017, JAN, 2), new Date(2017, JAN, 1))).toBeGreaterThan(0); + }); + + it('should clamp date at lower bound', () => { + expect(adapter.clampDate( + new Date(2017, JAN, 1), new Date(2018, JAN, 1), new Date(2019, JAN, 1))) + .toEqual(new Date(2018, JAN, 1)); + }); + + it('should clamp date at upper bound', () => { + expect(adapter.clampDate( + new Date(2020, JAN, 1), new Date(2018, JAN, 1), new Date(2019, JAN, 1))) + .toEqual(new Date(2019, JAN, 1)); + }); + + it('should clamp date already within bounds', () => { + expect(adapter.clampDate( + new Date(2018, FEB, 1), new Date(2018, JAN, 1), new Date(2019, JAN, 1))) + .toEqual(new Date(2018, FEB, 1)); + }); +}); diff --git a/src/lib/core/datetime/native-date-adapter.ts b/src/lib/core/datetime/native-date-adapter.ts new file mode 100644 index 000000000000..08c22fee11ff --- /dev/null +++ b/src/lib/core/datetime/native-date-adapter.ts @@ -0,0 +1,167 @@ +import {DateAdapter} from './date-adapter'; + + +// TODO(mmalerba): Remove when we no longer support safari 9. +/** Whether the browser supports the Intl API. */ +const SUPPORTS_INTL_API = typeof Intl != 'undefined'; + + +/** The default month names to use if Intl API is not available. */ +const DEFAULT_MONTH_NAMES = { + 'long': [ + 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', + 'October', 'November', 'December' + ], + 'short': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], + 'narrow': ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D'] +}; + + +/** The default date names to use if Intl API is not available. */ +const DEFAULT_DATE_NAMES = range(31, i => String(i + 1)); + + +/** The default day of the week names to use if Intl API is not available. */ +const DEFAULT_DAY_OF_WEEK_NAMES = { + 'long': ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], + 'short': ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + 'narrow': ['S', 'M', 'T', 'W', 'T', 'F', 'S'] +}; + + +/** Creates an array and fills it with values. */ +function range(length: number, valueFunction: (index: number) => T): T[] { + return Array.apply(null, Array(length)).map((v: undefined, i: number) => valueFunction(i)); +} + + +/** Adapts the native JS Date for use with cdk-based components that work with dates. */ +export class NativeDateAdapter extends DateAdapter { + getYear(date: Date): number { + return date.getFullYear(); + } + + getMonth(date: Date): number { + return date.getMonth(); + } + + getDate(date: Date): number { + return date.getDate(); + } + + getDayOfWeek(date: Date): number { + return date.getDay(); + } + + getMonthNames(style: 'long' | 'short' | 'narrow'): string[] { + if (SUPPORTS_INTL_API) { + let dtf = new Intl.DateTimeFormat(this.locale, {month: style}); + return range(12, i => dtf.format(new Date(2017, i, 1))); + } + return DEFAULT_MONTH_NAMES[style]; + } + + getDateNames(): string[] { + if (SUPPORTS_INTL_API) { + let dtf = new Intl.DateTimeFormat(this.locale, {day: 'numeric'}); + return range(31, i => dtf.format(new Date(2017, 0, i + 1))); + } + return DEFAULT_DATE_NAMES; + } + + getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] { + if (SUPPORTS_INTL_API) { + let dtf = new Intl.DateTimeFormat(this.locale, {weekday: style}); + return range(7, i => dtf.format(new Date(2017, 0, i + 1))); + } + return DEFAULT_DAY_OF_WEEK_NAMES[style]; + } + + getYearName(date: Date): string { + if (SUPPORTS_INTL_API) { + let dtf = new Intl.DateTimeFormat(this.locale, {year: 'numeric'}); + return dtf.format(date); + } + return String(this.getYear(date)); + } + + getMonthYearName(date: Date, monthStyle: 'long' | 'short' | 'narrow'): string { + if (SUPPORTS_INTL_API) { + let dtf = new Intl.DateTimeFormat(this.locale, {month: monthStyle, year: 'numeric'}); + return dtf.format(date); + } + let monthName = this.getMonthNames(monthStyle)[this.getMonth(date)]; + return `${monthName} ${this.getYear(date)}`; + } + + getFirstDayOfWeek(): number { + // We can't tell using native JS Date what the first day of the week is, we default to Sunday. + return 0; + } + + getDefaultFormats(): {date: Object} { + return { + date: { + year: 'numeric', + month: 'numeric', + day: 'numeric' + } + }; + } + + clone(date: Date): Date { + return this.createDate(this.getYear(date), this.getMonth(date), this.getDate(date)); + } + + createDate(year: number, month: number, date: number): Date { + let result = new Date(year, month, date); + // We need to correct for the fact that JS native Date treats years in range [0, 99] as + // abbreviations for 19xx. + if (year >= 0 && year < 100) { + result.setFullYear(this.getYear(result) - 1900); + } + return result; + } + + today(): Date { + return new Date(); + } + + parse(value: any, fmt?: Object): Date | null { + // We have no way using the native JS Date to set the parse format or locale, so we ignore these + // parameters. + let timestamp = typeof value == 'number' ? value : Date.parse(value); + return isNaN(timestamp) ? null : new Date(timestamp); + } + + format(date: Date, fmt?: Object): string { + if (SUPPORTS_INTL_API) { + let dtf = new Intl.DateTimeFormat(this.locale, fmt); + return dtf.format(date); + } + return date.toDateString(); + } + + addCalendarYears(date: Date, years: number): Date { + return this.addCalendarMonths(date, years * 12); + } + + addCalendarMonths(date: Date, months: number): Date { + let newDate = + this.createDate(this.getYear(date), this.getMonth(date) + months, this.getDate(date)); + + // It's possible to wind up in the wrong month if the original month has more days than the new + // month. In this case we want to go to the last day of the desired month. + // Note: the additional + 12 % 12 ensures we end up with a positive number, since JS % doesn't + // guarantee this. + if (this.getMonth(newDate) != ((this.getMonth(date) + months) % 12 + 12) % 12) { + newDate = this.createDate(this.getYear(newDate), this.getMonth(newDate), 0); + } + + return newDate; + } + + addCalendarDays(date: Date, days: number): Date { + return this.createDate(this.getYear(date), this.getMonth(date), this.getDate(date) + days); + } +} From 9ab583a3a0de79f93f4751d04e3eaf10b6e47645 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 21 Apr 2017 10:21:23 -0700 Subject: [PATCH 25/37] refactor(datepicker): replace SimpleDate & CalendarLocale with DateAdapter (#4189) * rename test file to match the file its testing * remove SimpleDate and CalendarLocale * switch MdMonthView to use DateAdapter * swtich MdYearView to use DateAdapter * switch MdCalendar to use DateAdapter * switch MdDatepicker to use DateAdapter * switch MdDatepickerInput and MdDatepickerToggle to use DateAdapter * fix demo * fix some small bugs * fix tests * alias months in tests * address comments and remove overflow on createDate * s/l10n/intl --- src/demo-app/datepicker/datepicker-demo.ts | 6 +- src/lib/core/datetime/calendar-locale.spec.ts | 105 --------- src/lib/core/datetime/calendar-locale.ts | 201 ------------------ src/lib/core/datetime/date-adapter.ts | 18 +- src/lib/core/datetime/index.ts | 7 +- .../core/datetime/native-date-adapter.spec.ts | 17 +- src/lib/core/datetime/native-date-adapter.ts | 44 +++- src/lib/core/datetime/simple-date.spec.ts | 59 ----- src/lib/core/datetime/simple-date.ts | 102 --------- ...ar-table.spec.ts => calendar-body.spec.ts} | 59 +++-- src/lib/datepicker/calendar-body.ts | 8 +- src/lib/datepicker/calendar.spec.ts | 171 ++++++++------- src/lib/datepicker/calendar.ts | 173 +++++++-------- src/lib/datepicker/datepicker-input.ts | 56 +++-- src/lib/datepicker/datepicker-intl.ts | 36 ++++ src/lib/datepicker/datepicker-toggle.ts | 12 +- src/lib/datepicker/datepicker.spec.ts | 57 ++--- src/lib/datepicker/datepicker.ts | 41 ++-- src/lib/datepicker/index.ts | 6 + src/lib/datepicker/month-view.html | 2 +- src/lib/datepicker/month-view.spec.ts | 17 +- src/lib/datepicker/month-view.ts | 78 ++++--- src/lib/datepicker/year-view.html | 2 +- src/lib/datepicker/year-view.spec.ts | 21 +- src/lib/datepicker/year-view.ts | 66 +++--- 25 files changed, 491 insertions(+), 873 deletions(-) delete mode 100644 src/lib/core/datetime/calendar-locale.spec.ts delete mode 100644 src/lib/core/datetime/calendar-locale.ts delete mode 100644 src/lib/core/datetime/simple-date.spec.ts delete mode 100644 src/lib/core/datetime/simple-date.ts rename src/lib/datepicker/{calendar-table.spec.ts => calendar-body.spec.ts} (67%) create mode 100644 src/lib/datepicker/datepicker-intl.ts diff --git a/src/demo-app/datepicker/datepicker-demo.ts b/src/demo-app/datepicker/datepicker-demo.ts index 5c681a100b8f..ffd03c9ca1ea 100644 --- a/src/demo-app/datepicker/datepicker-demo.ts +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -1,5 +1,4 @@ import {Component} from '@angular/core'; -import {SimpleDate} from '@angular/material'; @Component({ @@ -9,8 +8,9 @@ import {SimpleDate} from '@angular/material'; styleUrls: ['datepicker-demo.css'], }) export class DatepickerDemo { - date: SimpleDate; + date: Date; touch = false; - dateFilter = (date: SimpleDate) => !this._blacklistedMonths.has(date.month) && date.date % 2 == 0; + dateFilter = + (date: Date) => !this._blacklistedMonths.has(date.getMonth()) && date.getDate() % 2 == 0 private _blacklistedMonths = new Set([2, 3]); } diff --git a/src/lib/core/datetime/calendar-locale.spec.ts b/src/lib/core/datetime/calendar-locale.spec.ts deleted file mode 100644 index d37b0e072e9b..000000000000 --- a/src/lib/core/datetime/calendar-locale.spec.ts +++ /dev/null @@ -1,105 +0,0 @@ -import {inject, TestBed, async} from '@angular/core/testing'; -import {CalendarLocale} from './calendar-locale'; -import {DatetimeModule} from './index'; -import {SimpleDate} from './simple-date'; - - -describe('DefaultCalendarLocale', () => { - let calendarLocale: CalendarLocale; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [DatetimeModule], - }); - - TestBed.compileComponents(); - })); - - beforeEach(inject([CalendarLocale], (cl: CalendarLocale) => { - calendarLocale = cl; - })); - - it('lists months', () => { - expect(calendarLocale.months).toEqual([ - 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', - 'October', 'November', 'December' - ]); - }); - - it('lists short months', () => { - expect(calendarLocale.shortMonths).toEqual([ - 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' - ]); - }); - - it('lists narrow months', () => { - expect(calendarLocale.narrowMonths).toEqual([ - 'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D' - ]); - }); - - it('lists days', () => { - expect(calendarLocale.days).toEqual([ - 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' - ]); - }); - - it('lists short days', () => { - expect(calendarLocale.shortDays).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); - }); - - it('lists narrow days', () => { - expect(calendarLocale.narrowDays).toEqual(['S', 'M', 'T', 'W', 'T', 'F', 'S']); - }); - - it('lists dates', () => { - expect(calendarLocale.dates).toEqual([ - null, '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('has first day of the week', () => { - expect(calendarLocale.firstDayOfWeek).toBe(0); - }); - - it('has calendar label', () => { - expect(calendarLocale.calendarLabel).toBe('Calendar'); - }); - - it('has open calendar label', () => { - expect(calendarLocale.openCalendarLabel).toBe('Open calendar'); - }); - - it('parses SimpleDate from string', () => { - expect(calendarLocale.parseDate('1/1/2017')).toEqual(new SimpleDate(2017, 0, 1)); - }); - - it('parses SimpleDate from number', () => { - let timestamp = new Date().getTime(); - expect(calendarLocale.parseDate(timestamp)) - .toEqual(SimpleDate.fromNativeDate(new Date(timestamp))); - }); - - it ('parses SimpleDate from SimpleDate by copying', () => { - let originalSimpleDate = new SimpleDate(2017, 0, 1); - expect(calendarLocale.parseDate(originalSimpleDate)).toEqual(originalSimpleDate); - }); - - it('parses null for invalid dates', () => { - expect(calendarLocale.parseDate('hello')).toBeNull(); - }); - - it('formats SimpleDates', () => { - expect(calendarLocale.formatDate(new SimpleDate(2017, 0, 1))).toEqual('1/1/2017'); - }); - - it('gets header label for calendar month', () => { - expect(calendarLocale.getCalendarMonthHeaderLabel(new SimpleDate(2017, 0, 1))) - .toEqual('Jan 2017'); - }); - - it('gets header label for calendar year', () => { - expect(calendarLocale.getCalendarYearHeaderLabel(new SimpleDate(2017, 0, 1))).toBe('2017'); - }); -}); diff --git a/src/lib/core/datetime/calendar-locale.ts b/src/lib/core/datetime/calendar-locale.ts deleted file mode 100644 index 6061ec49baa3..000000000000 --- a/src/lib/core/datetime/calendar-locale.ts +++ /dev/null @@ -1,201 +0,0 @@ -import {SimpleDate} from './simple-date'; -import {Injectable} from '@angular/core'; - - -/** Whether the browser supports the Intl API. */ -const SUPPORTS_INTL_API = typeof Intl != 'undefined'; - - -/** Creates an array and fills it with values. */ -function range(length: number, valueFunction: (index: number) => T): T[] { - return Array.apply(null, Array(length)).map((v: undefined, i: number) => valueFunction(i)); -} - - -/** - * This class encapsulates the details of how to localize all information needed for displaying a - * calendar. It is used by md-datepicker to render a properly localized calendar. Unless otherwise - * specified by the user DefaultCalendarLocale will be provided as the CalendarLocale. - */ -@Injectable() -export abstract class CalendarLocale { - /** Labels to use for the long form of the month. (e.g. 'January') */ - months: string[]; - - /** Labels to use for the short form of the month. (e.g. 'Jan') */ - shortMonths: string[]; - - /** Labels to use for the narrow form of the month. (e.g. 'J') */ - narrowMonths: string[]; - - /** Labels to use for the long form of the week days. (e.g. 'Sunday') */ - days: string[]; - - /** Labels to use for the short form of the week days. (e.g. 'Sun') */ - shortDays: string[]; - - /** Labels to use for the narrow form of the week days. (e.g. 'S') */ - narrowDays: string[]; - - /** - * Labels to use for the dates of the month. (e.g. null, '1', '2', ..., '31'). - * Note that the 0th index is null, since there is no January 0th. - */ - dates: string[]; - - /** The first day of the week. (e.g. 0 = Sunday, 6 = Saturday). */ - firstDayOfWeek: number; - - /** A label for the calendar popup (used by screen readers). */ - calendarLabel: string; - - /** A label for the button used to open the calendar popup (used by screen readers). */ - openCalendarLabel: string; - - /** A label for the previous month button (used by screen readers). */ - prevMonthLabel: string; - - /** A label for the next month button (used by screen readers). */ - nextMonthLabel: string; - - /** A label for the previous year button (used by screen readers). */ - prevYearLabel: string; - - /** A label for the next year button (used by screen readers). */ - nextYearLabel: string; - - /** A label for the 'switch to month view' button (used by screen readers). */ - switchToMonthViewLabel: string; - - /** A label for the 'switch to year view' button (used by screen readers). */ - switchToYearViewLabel: string; - - /** - * Parses a SimpleDate from a value. - * @param value The value to parse. - */ - parseDate: (value: any) => SimpleDate; - - /** - * Formats a SimpleDate to a string. - * @param date The date to format. - */ - formatDate: (date: SimpleDate) => string; - - /** - * Gets a label to display as the heading for the specified calendar month. - * @param date A date that falls within the month to be labeled. - */ - getCalendarMonthHeaderLabel: (date: SimpleDate) => string; - - /** - * Gets a label to display as the heading for the specified calendar year. - * @param date A date that falls within the year to be labeled. - */ - getCalendarYearHeaderLabel: (date: SimpleDate) => string; -} - - -/** - * The default implementation of CalendarLocale. This implementation is a best attempt at - * localization using only the functionality natively available in JS. If more robust localization - * is needed, an alternate class can be provided as the CalendarLocale for the app. - */ -export class DefaultCalendarLocale implements CalendarLocale { - months = SUPPORTS_INTL_API ? this._createMonthsArray('long') : - [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December' - ]; - - shortMonths = SUPPORTS_INTL_API ? this._createMonthsArray('short') : - ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - - narrowMonths = SUPPORTS_INTL_API ? this._createMonthsArray('narrow') : - ['J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D']; - - days = SUPPORTS_INTL_API ? this._createDaysArray('long') : - ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; - - shortDays = SUPPORTS_INTL_API ? this._createDaysArray('short') : - ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - - narrowDays = SUPPORTS_INTL_API ? this._createDaysArray('narrow') : - ['S', 'M', 'T', 'W', 'T', 'F', 'S']; - - dates = [null].concat( - SUPPORTS_INTL_API ? this._createDatesArray('numeric') : range(31, i => String(i + 1))); - - firstDayOfWeek = 0; - - calendarLabel = 'Calendar'; - - openCalendarLabel = 'Open calendar'; - - prevMonthLabel = 'Previous month'; - - nextMonthLabel = 'Next month'; - - prevYearLabel = 'Previous year'; - - nextYearLabel = 'Next year'; - - switchToMonthViewLabel = 'Change to month view'; - - switchToYearViewLabel = 'Change to year view'; - - parseDate(value: any) { - if (value instanceof SimpleDate) { - return value; - } - let timestamp = typeof value == 'number' ? value : Date.parse(value); - return isNaN(timestamp) ? null : SimpleDate.fromNativeDate(new Date(timestamp)); - } - - formatDate = this._createFormatFunction(undefined) || - ((date: SimpleDate) => date.toNativeDate().toDateString()); - - getCalendarMonthHeaderLabel = this._createFormatFunction({month: 'short', year: 'numeric'}) || - ((date: SimpleDate) => this.shortMonths[date.month] + ' ' + date.year); - - getCalendarYearHeaderLabel = this._createFormatFunction({year: 'numeric'}) || - ((date: SimpleDate) => String(date.year)); - - private _createMonthsArray(format: string) { - let dtf = new Intl.DateTimeFormat(undefined, {month: format}); - return range(12, i => dtf.format(new Date(2017, i, 1))); - } - - private _createDaysArray(format: string) { - let dtf = new Intl.DateTimeFormat(undefined, {weekday: format}); - return range(7, i => dtf.format(new Date(2017, 0, i + 1))); - } - - private _createDatesArray(format: string) { - let dtf = new Intl.DateTimeFormat(undefined, {day: format}); - return range(31, i => dtf.format(new Date(2017, 0, i + 1))); - } - - /** - * Creates a function to format SimpleDates as strings using Intl.DateTimeFormat. - * @param options The options to use for Intl.DateTimeFormat. - * @returns The newly created format function, or null if the Intl API is not available. - */ - private _createFormatFunction(options: Object): (date: SimpleDate) => string { - if (SUPPORTS_INTL_API) { - let dtf = new Intl.DateTimeFormat(undefined, options); - return (date: SimpleDate) => dtf.format(date.toNativeDate()); - } - return null; - } -} diff --git a/src/lib/core/datetime/date-adapter.ts b/src/lib/core/datetime/date-adapter.ts index 6fab81e943ba..c7d9d1274bf9 100644 --- a/src/lib/core/datetime/date-adapter.ts +++ b/src/lib/core/datetime/date-adapter.ts @@ -73,6 +73,13 @@ export abstract class DateAdapter { */ abstract getFirstDayOfWeek(): number; + /** + * Gets the number of days in the month of the given date. + * @param date The date whose month should be checked. + * @returns The number of days in the month of the given date. + */ + abstract getNumDaysInMonth(date: D): number; + /** * Gets a set of default formats to use for displaying the date in different contexts. * @returns An object with the following default formats: @@ -88,13 +95,12 @@ export abstract class DateAdapter { abstract clone(date: D): D; /** - * Creates a date with the given year, month, and date. + * Creates a date with the given year, month, and date. Does not allow over/under-flow of the + * month and date. * @param year The full year of the date. (e.g. 89 means the year 89, not the year 1989). - * @param month The month of the date (0-indexed, 0 = January). If `month` is less than 0 or - * greater than 11, it should roll into the previous / next year. - * @param date The date of month of the date. If `date` is less than 1 or greater than the number - * of days in the `month`, it should roll into the previous / next month. - * @returns The new date. + * @param month The month of the date (0-indexed, 0 = January). Must be an integer 0 - 11. + * @param date The date of month of the date. Must be an integer 1 - length of the given month. + * @returns The new date, or null if invalid. */ abstract createDate(year: number, month: number, date: number): D; diff --git a/src/lib/core/datetime/index.ts b/src/lib/core/datetime/index.ts index 1c4aa3500008..9dd521c46bd2 100644 --- a/src/lib/core/datetime/index.ts +++ b/src/lib/core/datetime/index.ts @@ -1,14 +1,13 @@ import {NgModule} from '@angular/core'; -import {DefaultCalendarLocale, CalendarLocale} from './calendar-locale'; +import {DateAdapter} from './date-adapter'; +import {NativeDateAdapter} from './native-date-adapter'; -export * from './calendar-locale'; export * from './date-adapter'; -export * from './simple-date'; export * from './native-date-adapter'; @NgModule({ - providers: [{provide: CalendarLocale, useClass: DefaultCalendarLocale}], + providers: [{provide: DateAdapter, useClass: NativeDateAdapter}], }) export class DatetimeModule {} diff --git a/src/lib/core/datetime/native-date-adapter.spec.ts b/src/lib/core/datetime/native-date-adapter.spec.ts index 44d0c877fa9b..84b013b050e9 100644 --- a/src/lib/core/datetime/native-date-adapter.spec.ts +++ b/src/lib/core/datetime/native-date-adapter.spec.ts @@ -134,12 +134,14 @@ describe('NativeDateAdapter', () => { expect(adapter.createDate(2017, JAN, 1)).toEqual(new Date(2017, JAN, 1)); }); - it('should create Date with month and date overflow', () => { - expect(adapter.createDate(2017, DEC + 1, 32)).toEqual(new Date(2018, FEB, 1)); + it('should not create Date with month over/under-flow', () => { + expect(adapter.createDate(2017, DEC + 1, 1)).toBeNull(); + expect(adapter.createDate(2017, JAN - 1, 1)).toBeNull(); }); - it('should create Date with month date underflow', () => { - expect(adapter.createDate(2017, JAN - 1, 0)).toEqual(new Date(2016, NOV, 30)); + it('should not create Date with date over/under-flow', () => { + expect(adapter.createDate(2017, JAN, 32)).toBeNull(); + expect(adapter.createDate(2017, JAN, 0)).toBeNull(); }); it('should create Date with low year number', () => { @@ -150,13 +152,6 @@ describe('NativeDateAdapter', () => { expect(adapter.createDate(100, JAN, 1).getFullYear()).toBe(100); }); - it('should create Date with low year number and over/under-flow', () => { - expect(adapter.createDate(50, 12 * 51, 1).getFullYear()).toBe(101); - expect(adapter.createDate(50, 12, 1).getFullYear()).toBe(51); - expect(adapter.createDate(50, -12, 1).getFullYear()).toBe(49); - expect(adapter.createDate(50, -12 * 51, 1).getFullYear()).toBe(-1); - }); - it("should get today's date", () => { expect(adapter.sameDate(adapter.today(), new Date())) .toBe(true, "should be equal to today's date"); diff --git a/src/lib/core/datetime/native-date-adapter.ts b/src/lib/core/datetime/native-date-adapter.ts index 08c22fee11ff..57a7461f9e6c 100644 --- a/src/lib/core/datetime/native-date-adapter.ts +++ b/src/lib/core/datetime/native-date-adapter.ts @@ -99,6 +99,11 @@ export class NativeDateAdapter extends DateAdapter { return 0; } + getNumDaysInMonth(date: Date): number { + return this.getDate(this._createDateWithOverflow( + this.getYear(date), this.getMonth(date) + 1, 0)); + } + getDefaultFormats(): {date: Object} { return { date: { @@ -114,12 +119,20 @@ export class NativeDateAdapter extends DateAdapter { } createDate(year: number, month: number, date: number): Date { - let result = new Date(year, month, date); - // We need to correct for the fact that JS native Date treats years in range [0, 99] as - // abbreviations for 19xx. - if (year >= 0 && year < 100) { - result.setFullYear(this.getYear(result) - 1900); + // Check for invalid month and date (except upper bound on date which we have to check after + // creating the Date). + if (month < 0 || month > 11 || date < 1) { + return null; } + + let result = this._createDateWithOverflow(year, month, date); + + // Check that the date wasn't above the upper bound for the month, causing the month to + // overflow. + if (result.getMonth() != month) { + return null; + } + return result; } @@ -147,21 +160,34 @@ export class NativeDateAdapter extends DateAdapter { } addCalendarMonths(date: Date, months: number): Date { - let newDate = - this.createDate(this.getYear(date), this.getMonth(date) + months, this.getDate(date)); + let newDate = this._createDateWithOverflow( + this.getYear(date), this.getMonth(date) + months, this.getDate(date)); // It's possible to wind up in the wrong month if the original month has more days than the new // month. In this case we want to go to the last day of the desired month. // Note: the additional + 12 % 12 ensures we end up with a positive number, since JS % doesn't // guarantee this. if (this.getMonth(newDate) != ((this.getMonth(date) + months) % 12 + 12) % 12) { - newDate = this.createDate(this.getYear(newDate), this.getMonth(newDate), 0); + newDate = this._createDateWithOverflow(this.getYear(newDate), this.getMonth(newDate), 0); } return newDate; } addCalendarDays(date: Date, days: number): Date { - return this.createDate(this.getYear(date), this.getMonth(date), this.getDate(date) + days); + return this._createDateWithOverflow( + this.getYear(date), this.getMonth(date), this.getDate(date) + days); + } + + /** Creates a date but allows the month and date to overflow. */ + private _createDateWithOverflow(year: number, month: number, date: number) { + let result = new Date(year, month, date); + + // We need to correct for the fact that JS native Date treats years in range [0, 99] as + // abbreviations for 19xx. + if (year >= 0 && year < 100) { + result.setFullYear(this.getYear(result) - 1900); + } + return result; } } diff --git a/src/lib/core/datetime/simple-date.spec.ts b/src/lib/core/datetime/simple-date.spec.ts deleted file mode 100644 index 9f49d2e3038c..000000000000 --- a/src/lib/core/datetime/simple-date.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {SimpleDate} from './simple-date'; - - -describe('SimpleDate', () => { - it('can be created from native Date', () => { - expect(SimpleDate.fromNativeDate(new Date(2017, 0, 1))).toEqual(new SimpleDate(2017, 0, 1)); - }); - - it('can be converted to native Date', () => { - expect(new SimpleDate(2017, 0, 1).toNativeDate()).toEqual(new Date(2017, 0, 1)); - }); - - it('handles month and date overflow', () => { - expect(new SimpleDate(2017, 12, 32)).toEqual(new SimpleDate(2018, 1, 1)); - }); - - it('handles month and date underflow', () => { - expect(new SimpleDate(2017, -1, 0)).toEqual(new SimpleDate(2016, 10, 30)); - }); - - it('handles low year numbers', () => { - expect(new SimpleDate(-1, 0, 1).year).toBe(-1); - expect(new SimpleDate(0, 0, 1).year).toBe(0); - expect(new SimpleDate(50, 0, 1).year).toBe(50); - expect(new SimpleDate(99, 0, 1).year).toBe(99); - expect(new SimpleDate(100, 0, 1).year).toBe(100); - }); - - it('handles low year number with over/under-flow', () => { - expect(new SimpleDate(50, 12 * 51, 1).year).toBe(101); - expect(new SimpleDate(50, 12, 1).year).toBe(51); - expect(new SimpleDate(50, -12, 1).year).toBe(49); - expect(new SimpleDate(50, -12 * 51, 1).year).toBe(-1); - }); - - it('adds years, months, and days', () => { - expect(new SimpleDate(2017, 0, 1).add({years: 1, months: 1, days: 1})) - .toEqual(new SimpleDate(2018, 1, 2)); - }); - - it('clamps date at lower bound', () => { - let date = new SimpleDate(2017, 0, 1); - let lower = new SimpleDate(2018, 1, 2); - let upper = new SimpleDate(2019, 2, 3); - expect(date.clamp(lower, upper)).toEqual(lower); - }); - - it('clamps date at upper bound', () => { - let date = new SimpleDate(2020, 0, 1); - let lower = new SimpleDate(2018, 1, 2); - let upper = new SimpleDate(2019, 2, 3); - expect(date.clamp(lower, upper)).toEqual(upper); - }); - - it('clamp treats null as unbounded', () => { - let date = new SimpleDate(2017, 0, 1); - expect(date.clamp(null, null)).toEqual(date); - }); -}); diff --git a/src/lib/core/datetime/simple-date.ts b/src/lib/core/datetime/simple-date.ts deleted file mode 100644 index b4fe008d4ded..000000000000 --- a/src/lib/core/datetime/simple-date.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * A wrapper for the native JS Date class that deals with some quirks for us: - * 1) The native Date constructor treats years in the range [0-99] as 19xx not 00xx. - * 2) ... (Eventually need to add support for other quirks related to time zones, DST). - */ -export class SimpleDate { - /** - * Create a SimpleDate from a native JS Date object. - * @param nativeDate The native JS Date object to convert. - */ - static fromNativeDate(nativeDate: Date): SimpleDate { - return new SimpleDate(nativeDate.getFullYear(), nativeDate.getMonth(), nativeDate.getDate()); - } - - /** Creates a SimpleDate object representing today. */ - static today(): SimpleDate { - return SimpleDate.fromNativeDate(new Date()); - } - - /** - * Checks whether the given dates are equal. Null dates are considered equal to other null dates. - */ - static equals(first: SimpleDate, second: SimpleDate): boolean { - return first && second ? !first.compare(second) : first == second; - } - - /** The native JS Date. */ - private _date: Date; - - constructor(year: number, month: number, date: number) { - this._date = new Date(year, month, date); - // We need to correct for the fact that JS native Date treats years in range [0, 99] as - // abbreviations for 19xx. - if (year >= 0 && year < 100) { - this._date = new Date(this._date.setFullYear(this.year - 1900)); - } - } - - /** The year component of this date. */ - get year(): number { - return this._date.getFullYear(); - } - - /** The month component of this date. (0-indexed, 0 = January). */ - get month(): number { - return this._date.getMonth(); - } - - /** The date component of this date. (1-indexed, 1 = 1st of month). */ - get date(): number { - return this._date.getDate(); - } - - /** The day component of this date. (0-indexed, 0 = Sunday) */ - get day(): number { - return this._date.getDay(); - } - - /** - * Adds an amount of time (in days, months, and years) to the date. - * @param amount The amount of time to add. - * @returns A new SimpleDate with the given amount of time added. - */ - add(amount: {days?: number, months?: number, years?: number}): SimpleDate { - return new SimpleDate( - this.year + (amount.years || 0), - this.month + (amount.months || 0), - this.date + (amount.days || 0)); - } - - /** - * Compares this SimpleDate with another SimpleDate. - * @param other The other SimpleDate - * @returns 0 if the dates are equal, a number less than 0 if this date is earlier, - * a number greater than 0 if this date is greater. - */ - compare(other: SimpleDate): number { - return this.year - other.year || this.month - other.month || this.date - other.date; - } - - /** - * Clamps the date between the given min and max dates. - * @param min The minimum date - * @param max The maximum date - * @returns A new SimpleDate equal to this one clamped between the given min and max dates. - */ - clamp(min: SimpleDate, max: SimpleDate): SimpleDate { - let clampedDate: SimpleDate = this; - if (min && this.compare(min) < 0) { - clampedDate = min; - } - if (max && this.compare(max) > 0) { - clampedDate = max; - } - return new SimpleDate(clampedDate.year, clampedDate.month, clampedDate.date); - } - - /** Converts the SimpleDate to a native JS Date object. */ - toNativeDate(): Date { - return new Date(this.year, this.month, this.date); - } -} diff --git a/src/lib/datepicker/calendar-table.spec.ts b/src/lib/datepicker/calendar-body.spec.ts similarity index 67% rename from src/lib/datepicker/calendar-table.spec.ts rename to src/lib/datepicker/calendar-body.spec.ts index a0fea7c9c430..31a00e24406f 100644 --- a/src/lib/datepicker/calendar-table.spec.ts +++ b/src/lib/datepicker/calendar-body.spec.ts @@ -1,64 +1,63 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; -import {MdCalendarCell, MdCalendarBody} from './calendar-body'; +import {MdCalendarBody, MdCalendarCell} from './calendar-body'; import {By} from '@angular/platform-browser'; -import {SimpleDate} from '../core/datetime/simple-date'; -describe('MdCalendarTable', () => { +describe('MdCalendarBody', () => { beforeEach(async(() => { TestBed.configureTestingModule({ declarations: [ MdCalendarBody, // Test components. - StandardCalendarTable, - CalendarTableWithDisabledCells, + StandardCalendarBody, + CalendarBodyWithDisabledCells, ], }); TestBed.compileComponents(); })); - describe('standard calendar table', () => { - let fixture: ComponentFixture; - let testComponent: StandardCalendarTable; - let calendarTableNativeElement: Element; + describe('standard calendar body', () => { + let fixture: ComponentFixture; + let testComponent: StandardCalendarBody; + let calendarBodyNativeElement: Element; let rowEls: NodeListOf; let labelEls: NodeListOf; let cellEls: NodeListOf; let refreshElementLists = () => { - rowEls = calendarTableNativeElement.querySelectorAll('tr'); - labelEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-body-label'); - cellEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-body-cell'); + rowEls = calendarBodyNativeElement.querySelectorAll('tr'); + labelEls = calendarBodyNativeElement.querySelectorAll('.mat-calendar-body-label'); + cellEls = calendarBodyNativeElement.querySelectorAll('.mat-calendar-body-cell'); }; beforeEach(() => { - fixture = TestBed.createComponent(StandardCalendarTable); + fixture = TestBed.createComponent(StandardCalendarBody); fixture.detectChanges(); - let calendarTableDebugElement = fixture.debugElement.query(By.directive(MdCalendarBody)); - calendarTableNativeElement = calendarTableDebugElement.nativeElement; + let calendarBodyDebugElement = fixture.debugElement.query(By.directive(MdCalendarBody)); + calendarBodyNativeElement = calendarBodyDebugElement.nativeElement; testComponent = fixture.componentInstance; refreshElementLists(); }); - it('creates table', () => { + it('creates body', () => { expect(rowEls.length).toBe(3); expect(labelEls.length).toBe(1); expect(cellEls.length).toBe(14); }); it('highlights today', () => { - let todayCell = calendarTableNativeElement.querySelector('.mat-calendar-body-today'); + let todayCell = calendarBodyNativeElement.querySelector('.mat-calendar-body-today'); expect(todayCell).not.toBeNull(); expect(todayCell.innerHTML.trim()).toBe('3'); }); it('highlights selected', () => { - let selectedCell = calendarTableNativeElement.querySelector('.mat-calendar-body-selected'); + let selectedCell = calendarBodyNativeElement.querySelector('.mat-calendar-body-selected'); expect(selectedCell).not.toBeNull(); expect(selectedCell.innerHTML.trim()).toBe('4'); }); @@ -79,7 +78,7 @@ describe('MdCalendarTable', () => { it('cell should be selected on click', () => { let todayElement = - calendarTableNativeElement.querySelector('.mat-calendar-body-today') as HTMLElement; + calendarBodyNativeElement.querySelector('.mat-calendar-body-today') as HTMLElement; todayElement.click(); fixture.detectChanges(); @@ -93,20 +92,20 @@ describe('MdCalendarTable', () => { }); }); - describe('calendar table with disabled cells', () => { - let fixture: ComponentFixture; - let testComponent: CalendarTableWithDisabledCells; - let calendarTableNativeElement: Element; + describe('calendar body with disabled cells', () => { + let fixture: ComponentFixture; + let testComponent: CalendarBodyWithDisabledCells; + let calendarBodyNativeElement: Element; let cellEls: NodeListOf; beforeEach(() => { - fixture = TestBed.createComponent(CalendarTableWithDisabledCells); + fixture = TestBed.createComponent(CalendarBodyWithDisabledCells); fixture.detectChanges(); - let calendarTableDebugElement = fixture.debugElement.query(By.directive(MdCalendarBody)); - calendarTableNativeElement = calendarTableDebugElement.nativeElement; + let calendarBodyDebugElement = fixture.debugElement.query(By.directive(MdCalendarBody)); + calendarBodyNativeElement = calendarBodyDebugElement.nativeElement; testComponent = fixture.componentInstance; - cellEls = calendarTableNativeElement.querySelectorAll('.mat-calendar-body-cell'); + cellEls = calendarBodyNativeElement.querySelectorAll('.mat-calendar-body-cell'); }); it('should only allow selection of disabled cells when allowDisabledSelection is true', () => { @@ -139,7 +138,7 @@ describe('MdCalendarTable', () => { (selectedValueChange)="onSelect($event)"> `, }) -class StandardCalendarTable { +class StandardCalendarBody { label = 'Jan 2017'; rows = [[1, 2, 3, 4, 5, 6, 7], [8, 9, 10, 11, 12, 13, 14]].map(r => r.map(createCell)); todayValue = 3; @@ -160,14 +159,14 @@ class StandardCalendarTable { (selectedValueChange)="selected = $event"> ` }) -class CalendarTableWithDisabledCells { +class CalendarBodyWithDisabledCells { rows = [[1, 2, 3, 4]].map(r => r.map(d => { let cell = createCell(d); cell.enabled = d % 2 == 0; return cell; })); allowDisabledSelection = false; - selected: SimpleDate; + selected: Date; } diff --git a/src/lib/datepicker/calendar-body.ts b/src/lib/datepicker/calendar-body.ts index b20376a95604..d46f2a56eb09 100644 --- a/src/lib/datepicker/calendar-body.ts +++ b/src/lib/datepicker/calendar-body.ts @@ -1,10 +1,10 @@ import { - Component, - ViewEncapsulation, ChangeDetectionStrategy, - Input, + Component, EventEmitter, - Output + Input, + Output, + ViewEncapsulation } from '@angular/core'; diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts index 60e2ce027817..1faaeba88769 100644 --- a/src/lib/datepicker/calendar.spec.ts +++ b/src/lib/datepicker/calendar.spec.ts @@ -1,6 +1,5 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; -import {SimpleDate} from '../core/datetime/simple-date'; import {MdCalendar} from './calendar'; import {By} from '@angular/platform-browser'; import {MdMonthView} from './month-view'; @@ -23,6 +22,13 @@ import { RIGHT_ARROW, UP_ARROW } from '../core/keyboard/keycodes'; +import {MdDatepickerIntl} from './datepicker-intl'; + + +// When constructing a Date, the month is zero-based. This can be confusing, since people are +// used to seeing them one-based. So we create these aliases to make reading the tests easier. +const JAN = 0, FEB = 1, MAR = 2, APR = 3, MAY = 4, JUN = 5, JUL = 6, AUG = 7, SEP = 8, OCT = 9, + NOV = 10, DEC = 11; describe('MdCalendar', () => { @@ -42,6 +48,9 @@ describe('MdCalendar', () => { CalendarWithMinMax, CalendarWithDateFilter, ], + providers: [ + MdDatepickerIntl, + ], }); TestBed.compileComponents(); @@ -54,7 +63,7 @@ describe('MdCalendar', () => { let periodButton: HTMLElement; let prevButton: HTMLElement; let nextButton: HTMLElement; - let calendarInstance: MdCalendar; + let calendarInstance: MdCalendar; beforeEach(() => { fixture = TestBed.createComponent(StandardCalendar); @@ -72,7 +81,7 @@ describe('MdCalendar', () => { it('should be in month view with specified month active', () => { expect(calendarInstance._monthView).toBe(true, 'should be in month view'); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); }); it('should toggle view when period clicked', () => { @@ -90,17 +99,17 @@ describe('MdCalendar', () => { }); it('should go to next and previous month', () => { - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 28)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 28)); }); it('should go to previous and next year', () => { @@ -108,17 +117,17 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(calendarInstance._monthView).toBe(false, 'should be in year view'); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 31)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); }); it('should go back to month view after selecting month in year view', () => { @@ -126,14 +135,14 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(calendarInstance._monthView).toBe(false, 'should be in year view'); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); let monthCells = calendarElement.querySelectorAll('.mat-calendar-body-cell'); (monthCells[monthCells.length - 1] as HTMLElement).click(); fixture.detectChanges(); expect(calendarInstance._monthView).toBe(true, 'should be in month view'); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, DEC, 31)); expect(testComponent.selected).toBeFalsy('no date should be selected yet'); }); @@ -143,7 +152,7 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(calendarInstance._monthView).toBe(true, 'should be in month view'); - expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 31)); + expect(testComponent.selected).toEqual(new Date(2017, JAN, 31)); }); describe('a11y', () => { @@ -159,7 +168,7 @@ describe('MdCalendar', () => { }); it('should initially set start date active', () => { - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); }); describe('month view', () => { @@ -167,104 +176,104 @@ describe('MdCalendar', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 30)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 30)); - calendarInstance._activeDate = new SimpleDate(2017, 0, 1); + calendarInstance._activeDate = new Date(2017, JAN, 1); fixture.detectChanges(); dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, DEC, 31)); }); it('should increment date on right arrow press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 1)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 2)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 2)); }); it('should go up a row on up arrow press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 24)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 24)); - calendarInstance._activeDate = new SimpleDate(2017, 0, 7); + calendarInstance._activeDate = new Date(2017, JAN, 7); fixture.detectChanges(); dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, DEC, 31)); }); it('should go down a row on down arrow press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 7)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 7)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 14)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 14)); }); it('should go to beginning of the month on home press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 1)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 1)); }); it('should go to end of the month on end press', () => { - calendarInstance._activeDate = new SimpleDate(2017, 0, 10); + calendarInstance._activeDate = new Date(2017, JAN, 10); dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); }); it('should go back one month on page up press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, DEC, 31)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 10, 30)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, NOV, 30)); }); it('should go forward one month on page down press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 28)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 2, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, MAR, 28)); }); it('should select active date on enter', () => { @@ -276,7 +285,7 @@ describe('MdCalendar', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); fixture.detectChanges(); - expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 30)); + expect(testComponent.selected).toEqual(new Date(2017, JAN, 30)); }); }); @@ -292,127 +301,127 @@ describe('MdCalendar', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, DEC, 31)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 10, 30)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, NOV, 30)); }); it('should increment month on right arrow press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 28)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 2, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, MAR, 28)); }); it('should go up a row on up arrow press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 7, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, AUG, 31)); - calendarInstance._activeDate = new SimpleDate(2017, 6, 1); + calendarInstance._activeDate = new Date(2017, JUL, 1); fixture.detectChanges(); dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 6, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, JUL, 1)); - calendarInstance._activeDate = new SimpleDate(2017, 11, 10); + calendarInstance._activeDate = new Date(2017, DEC, 10); fixture.detectChanges(); dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 4, 10)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, MAY, 10)); }); it('should go down a row on down arrow press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 7, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, AUG, 31)); - calendarInstance._activeDate = new SimpleDate(2017, 5, 1); + calendarInstance._activeDate = new Date(2017, JUN, 1); fixture.detectChanges(); dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 5, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2018, JUN, 1)); - calendarInstance._activeDate = new SimpleDate(2017, 8, 30); + calendarInstance._activeDate = new Date(2017, SEP, 30); fixture.detectChanges(); dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 1, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2018, FEB, 28)); }); it('should go to first month of the year on home press', () => { - calendarInstance._activeDate = new SimpleDate(2017, 8, 30); + calendarInstance._activeDate = new Date(2017, SEP, 30); fixture.detectChanges(); dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 30)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 30)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 30)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 30)); }); it('should go to last month of the year on end press', () => { dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, DEC, 31)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 31)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, DEC, 31)); }); it('should go back one year on page up press', () => { - calendarInstance._activeDate = new SimpleDate(2016, 1, 29); + calendarInstance._activeDate = new Date(2016, FEB, 29); fixture.detectChanges(); dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2015, 1, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2015, FEB, 28)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2014, 1, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2014, FEB, 28)); }); it('should go forward one year on page down press', () => { - calendarInstance._activeDate = new SimpleDate(2016, 1, 29); + calendarInstance._activeDate = new Date(2016, FEB, 29); fixture.detectChanges(); dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 28)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 1, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2018, FEB, 28)); }); it('should return to month view on enter', () => { @@ -423,7 +432,7 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(calendarInstance._monthView).toBe(true); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 28)); expect(testComponent.selected).toBeNull(); }); }); @@ -437,7 +446,7 @@ describe('MdCalendar', () => { let calendarElement: HTMLElement; let prevButton: HTMLButtonElement; let nextButton: HTMLButtonElement; - let calendarInstance: MdCalendar; + let calendarInstance: MdCalendar; beforeEach(() => { fixture = TestBed.createComponent(CalendarWithMinMax); @@ -452,55 +461,55 @@ describe('MdCalendar', () => { }); it('should clamp startAt value below min date', () => { - testComponent.startAt = new SimpleDate(2000, 0, 1); + testComponent.startAt = new Date(2000, JAN, 1); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 1)); }); it('should clamp startAt value above max date', () => { - testComponent.startAt = new SimpleDate(2020, 0, 1); + testComponent.startAt = new Date(2020, JAN, 1); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 1)); }); it('should not go back past min date', () => { - testComponent.startAt = new SimpleDate(2016, 1, 1); + testComponent.startAt = new Date(2016, FEB, 1); fixture.detectChanges(); expect(prevButton.disabled).toBe(false, 'previous button should not be disabled'); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 1, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, FEB, 1)); prevButton.click(); fixture.detectChanges(); expect(prevButton.disabled).toBe(true, 'previous button should be disabled'); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 1)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 1)); }); it('should not go forward past max date', () => { - testComponent.startAt = new SimpleDate(2017, 11, 1); + testComponent.startAt = new Date(2017, DEC, 1); fixture.detectChanges(); expect(nextButton.disabled).toBe(false, 'next button should not be disabled'); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, DEC, 1)); nextButton.click(); fixture.detectChanges(); expect(nextButton.disabled).toBe(true, 'next button should be disabled'); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 1)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 1)); }); }); @@ -508,7 +517,7 @@ describe('MdCalendar', () => { let fixture: ComponentFixture; let testComponent: CalendarWithDateFilter; let calendarElement: HTMLElement; - let calendarInstance: MdCalendar; + let calendarInstance: MdCalendar; beforeEach(() => { fixture = TestBed.createComponent(CalendarWithDateFilter); @@ -530,7 +539,7 @@ describe('MdCalendar', () => { (cells[1] as HTMLElement).click(); fixture.detectChanges(); - expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 2)); + expect(testComponent.selected).toEqual(new Date(2017, JAN, 2)); }); describe('a11y', () => { @@ -546,7 +555,7 @@ describe('MdCalendar', () => { it('should not allow selection of disabled date in month view', () => { expect(calendarInstance._monthView).toBe(true); - expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 1)); dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); fixture.detectChanges(); @@ -560,7 +569,7 @@ describe('MdCalendar', () => { dispatchMouseEvent(periodButton, 'click'); fixture.detectChanges(); - calendarInstance._activeDate = new SimpleDate(2017, 10, 1); + calendarInstance._activeDate = new Date(2017, NOV, 1); fixture.detectChanges(); expect(calendarInstance._monthView).toBe(false); @@ -580,7 +589,7 @@ describe('MdCalendar', () => { template: `` }) class StandardCalendar { - selected: SimpleDate = null; + selected: Date = null; } @@ -588,7 +597,7 @@ class StandardCalendar { template: `` }) class CalendarWithMinMax { - startAt: SimpleDate; + startAt: Date; } @@ -598,9 +607,9 @@ class CalendarWithMinMax { ` }) class CalendarWithDateFilter { - selected: SimpleDate = null; + selected: Date = null; - dateFilter (date: SimpleDate) { - return date.date % 2 == 0 && date.month != 10; + dateFilter (date: Date) { + return date.getDate() % 2 == 0 && date.getMonth() != NOV; } } diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index 78007f3fa427..fd2ccf7579cd 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -7,11 +7,10 @@ import { Output, ViewEncapsulation } from '@angular/core'; -import {SimpleDate} from '../core/datetime/simple-date'; -import {CalendarLocale} from '../core/datetime/calendar-locale'; import { DOWN_ARROW, - END, ENTER, + END, + ENTER, HOME, LEFT_ARROW, PAGE_DOWN, @@ -19,6 +18,8 @@ import { RIGHT_ARROW, UP_ARROW } from '../core/keyboard/keycodes'; +import {DateAdapter} from '../core/datetime/index'; +import {MdDatepickerIntl} from './datepicker-intl'; /** @@ -36,57 +37,57 @@ import { encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MdCalendar implements AfterContentInit { +export class MdCalendar implements AfterContentInit { /** A date representing the period (month or year) to start the calendar in. */ @Input() - get startAt() { return this._startAt; } - set startAt(value: any) { this._startAt = this._locale.parseDate(value); } - private _startAt: SimpleDate; + get startAt(): D { return this._startAt; } + set startAt(value: D) { this._startAt = this._dateAdapter.parse(value); } + private _startAt: D; /** Whether the calendar should be started in month or year view. */ @Input() startView: 'month' | 'year' = 'month'; /** The currently selected date. */ @Input() - get selected() { return this._selected; } - set selected(value: any) { this._selected = this._locale.parseDate(value); } - private _selected: SimpleDate; + get selected(): D { return this._selected; } + set selected(value: D) { this._selected = this._dateAdapter.parse(value); } + private _selected: D; /** The minimum selectable date. */ @Input() - get minDate(): SimpleDate { return this._minDate; } - set minDate(date: SimpleDate) { this._minDate = this._locale.parseDate(date); } - private _minDate: SimpleDate; + get minDate(): D { return this._minDate; } + set minDate(date: D) { this._minDate = this._dateAdapter.parse(date); } + private _minDate: D; /** The maximum selectable date. */ @Input() - get maxDate(): SimpleDate { return this._maxDate; } - set maxDate(date: SimpleDate) { this._maxDate = this._locale.parseDate(date); } - private _maxDate: SimpleDate; + get maxDate(): D { return this._maxDate; } + set maxDate(date: D) { this._maxDate = this._dateAdapter.parse(date); } + private _maxDate: D; /** A function used to filter which dates are selectable. */ - @Input() dateFilter: (date: SimpleDate) => boolean; + @Input() dateFilter: (date: D) => boolean; /** Emits when the currently selected date changes. */ - @Output() selectedChange = new EventEmitter(); + @Output() selectedChange = new EventEmitter(); /** Date filter for the month and year views. */ - _dateFilterForViews = (date: SimpleDate) => { + _dateFilterForViews = (date: D) => { return !!date && (!this.dateFilter || this.dateFilter(date)) && - (!this.minDate || date.compare(this.minDate) >= 0) && - (!this.maxDate || date.compare(this.maxDate) <= 0); + (!this.minDate || this._dateAdapter.compareDate(date, this.minDate) >= 0) && + (!this.maxDate || this._dateAdapter.compareDate(date, this.maxDate) <= 0); } /** * The current active date. This determines which time period is shown and which date is * highlighted when using keyboard navigation. */ - get _activeDate() { return this._clampedActiveDate; } - set _activeDate(value: SimpleDate) { - this._clampedActiveDate = value.clamp(this.minDate, this.maxDate); + get _activeDate(): D { return this._clampedActiveDate; } + set _activeDate(value: D) { + this._clampedActiveDate = this._dateAdapter.clampDate(value, this.minDate, this.maxDate); } - private _clampedActiveDate: SimpleDate; + private _clampedActiveDate: D; /** Whether the calendar is in month view. */ _monthView: boolean; @@ -94,42 +95,40 @@ export class MdCalendar implements AfterContentInit { /** The label for the current calendar view. */ get _periodButtonText(): string { return this._monthView ? - this._locale.getCalendarMonthHeaderLabel(this._activeDate).toLocaleUpperCase() : - this._locale.getCalendarYearHeaderLabel(this._activeDate); + this._dateAdapter.getMonthYearName(this._activeDate, 'short').toLocaleUpperCase() : + this._dateAdapter.getYearName(this._activeDate); } get _periodButtonLabel(): string { - return this._monthView ? - this._locale.switchToYearViewLabel : - this._locale.switchToMonthViewLabel; + return this._monthView ? this._intl.switchToYearViewLabel : this._intl.switchToMonthViewLabel; } /** The label for the the previous button. */ get _prevButtonLabel(): string { - return this._monthView ? this._locale.prevMonthLabel : this._locale.prevYearLabel; + return this._monthView ? this._intl.prevMonthLabel : this._intl.prevYearLabel; } /** The label for the the next button. */ get _nextButtonLabel(): string { - return this._monthView ? this._locale.nextMonthLabel : this._locale.nextYearLabel; + return this._monthView ? this._intl.nextMonthLabel : this._intl.nextYearLabel; } - constructor(private _locale: CalendarLocale) {} + constructor(private _dateAdapter: DateAdapter, private _intl: MdDatepickerIntl) {} ngAfterContentInit() { - this._activeDate = this.startAt || SimpleDate.today(); + this._activeDate = this.startAt || this._dateAdapter.today(); this._monthView = this.startView != 'year'; } /** Handles date selection in the month view. */ - _dateSelected(date: SimpleDate): void { - if (!SimpleDate.equals(date, this.selected)) { + _dateSelected(date: D): void { + if (!this._dateAdapter.sameDate(date, this.selected)) { this.selectedChange.emit(date); } } /** Handles month selection in the year view. */ - _monthSelected(month: SimpleDate): void { + _monthSelected(month: D): void { this._activeDate = month; this._monthView = true; } @@ -142,14 +141,15 @@ export class MdCalendar implements AfterContentInit { /** Handles user clicks on the previous button. */ _previousClicked(): void { this._activeDate = this._monthView ? - this._addCalendarMonths(this._activeDate, -1) : - this._addCalendarYears(this._activeDate, -1); + this._dateAdapter.addCalendarMonths(this._activeDate, -1) : + this._dateAdapter.addCalendarYears(this._activeDate, -1); } /** Handles user clicks on the next button. */ _nextClicked(): void { this._activeDate = this._monthView ? - this._addCalendarMonths(this._activeDate, 1) : this._addCalendarYears(this._activeDate, 1); + this._dateAdapter.addCalendarMonths(this._activeDate, 1) : + this._dateAdapter.addCalendarYears(this._activeDate, 1); } /** Whether the previous period button is enabled. */ @@ -166,10 +166,11 @@ export class MdCalendar implements AfterContentInit { } /** Whether the two dates represent the same view in the current view mode (month or year). */ - private _isSameView(date1: SimpleDate, date2: SimpleDate): boolean { + private _isSameView(date1: D, date2: D): boolean { return this._monthView ? - date1.year == date2.year && date1.month == date2.month : - date1.year == date2.year; + this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2) && + this._dateAdapter.getMonth(date1) == this._dateAdapter.getMonth(date2) : + this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2); } /** Handles keydown events on the calendar body. */ @@ -188,32 +189,35 @@ export class MdCalendar implements AfterContentInit { private _handleCalendarBodyKeydownInMonthView(event: KeyboardEvent): void { switch (event.keyCode) { case LEFT_ARROW: - this._activeDate = this._addCalendarDays(this._activeDate, -1); + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, -1); break; case RIGHT_ARROW: - this._activeDate = this._addCalendarDays(this._activeDate, 1); + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, 1); break; case UP_ARROW: - this._activeDate = this._addCalendarDays(this._activeDate, -7); + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, -7); break; case DOWN_ARROW: - this._activeDate = this._addCalendarDays(this._activeDate, 7); + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, 7); break; case HOME: - this._activeDate = new SimpleDate(this._activeDate.year, this._activeDate.month, 1); + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, + 1 - this._dateAdapter.getDate(this._activeDate)); break; case END: - this._activeDate = new SimpleDate(this._activeDate.year, this._activeDate.month + 1, 0); + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, + (this._dateAdapter.getNumDaysInMonth(this._activeDate) - + this._dateAdapter.getDate(this._activeDate))); break; case PAGE_UP: this._activeDate = event.altKey ? - this._addCalendarYears(this._activeDate, -1) : - this._addCalendarMonths(this._activeDate, -1); + this._dateAdapter.addCalendarYears(this._activeDate, -1) : + this._dateAdapter.addCalendarMonths(this._activeDate, -1); break; case PAGE_DOWN: this._activeDate = event.altKey ? - this._addCalendarYears(this._activeDate, 1) : - this._addCalendarMonths(this._activeDate, 1); + this._dateAdapter.addCalendarYears(this._activeDate, 1) : + this._dateAdapter.addCalendarMonths(this._activeDate, 1); break; case ENTER: if (this._dateFilterForViews(this._activeDate)) { @@ -233,10 +237,10 @@ export class MdCalendar implements AfterContentInit { private _handleCalendarBodyKeydownInYearView(event: KeyboardEvent): void { switch (event.keyCode) { case LEFT_ARROW: - this._activeDate = this._addCalendarMonths(this._activeDate, -1); + this._activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, -1); break; case RIGHT_ARROW: - this._activeDate = this._addCalendarMonths(this._activeDate, 1); + this._activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, 1); break; case UP_ARROW: this._activeDate = this._prevMonthInSameCol(this._activeDate); @@ -245,16 +249,20 @@ export class MdCalendar implements AfterContentInit { this._activeDate = this._nextMonthInSameCol(this._activeDate); break; case HOME: - this._activeDate = this._addCalendarMonths(this._activeDate, -this._activeDate.month); + this._activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, + -this._dateAdapter.getMonth(this._activeDate)); break; case END: - this._activeDate = this._addCalendarMonths(this._activeDate, 11 - this._activeDate.month); + this._activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, + 11 - this._dateAdapter.getMonth(this._activeDate)); break; case PAGE_UP: - this._activeDate = this._addCalendarYears(this._activeDate, event.altKey ? -10 : -1); + this._activeDate = + this._dateAdapter.addCalendarYears(this._activeDate, event.altKey ? -10 : -1); break; case PAGE_DOWN: - this._activeDate = this._addCalendarYears(this._activeDate, event.altKey ? 10 : 1); + this._activeDate = + this._dateAdapter.addCalendarYears(this._activeDate, event.altKey ? 10 : 1); break; case ENTER: this._monthSelected(this._activeDate); @@ -267,58 +275,27 @@ export class MdCalendar implements AfterContentInit { event.preventDefault(); } - /** Adds the given number of days to the date. */ - private _addCalendarDays(date: SimpleDate, days: number): SimpleDate { - return date.add({days}); - } - - /** - * Adds the given number of months to the date. Months are counted as if flipping pages on a - * calendar and then finding the closest date in the new month. For example when adding 1 month to - * Jan 31, 2017, the resulting date will be Feb 28, 2017. - */ - private _addCalendarMonths(date: SimpleDate, months: number): SimpleDate { - let newDate = date.add({months}); - - // It's possible to wind up in the wrong month if the original month has more days than the new - // month. In this case we want to go to the last day of the desired month. - // Note: the additional + 12 % 12 ensures we end up with a positive number, since JS % doesn't - // guarantee this. - if (newDate.month != ((date.month + months) % 12 + 12) % 12) { - newDate = new SimpleDate(newDate.year, newDate.month, 0); - } - - return newDate; - } - - /** - * Adds the given number of months to the date. Months are counted as if flipping 12 pages for - * each year on a calendar and then finding the closest date in the new month. For example when - * adding 1 year to Feb 29, 2016, the resulting date will be Feb 28, 2017. - */ - private _addCalendarYears(date: SimpleDate, years: number): SimpleDate { - return this._addCalendarMonths(date, years * 12); - } - /** * Determine the date for the month that comes before the given month in the same column in the * calendar table. */ - private _prevMonthInSameCol(date: SimpleDate) { + private _prevMonthInSameCol(date: D): D { // Determine how many months to jump forward given that there are 2 empty slots at the beginning // of each year. - let increment = date.month <= 4 ? -5 : (date.month >= 7 ? -7 : -12); - return this._addCalendarMonths(date, increment); + let increment = this._dateAdapter.getMonth(date) <= 4 ? -5 : + (this._dateAdapter.getMonth(date) >= 7 ? -7 : -12); + return this._dateAdapter.addCalendarMonths(date, increment); } /** * Determine the date for the month that comes after the given month in the same column in the * calendar table. */ - private _nextMonthInSameCol(date: SimpleDate): SimpleDate { + private _nextMonthInSameCol(date: D): D { // Determine how many months to jump forward given that there are 2 empty slots at the beginning // of each year. - let increment = date.month <= 4 ? 7 : (date.month >= 7 ? 5 : 12); - return this._addCalendarMonths(date, increment); + let increment = this._dateAdapter.getMonth(date) <= 4 ? 7 : + (this._dateAdapter.getMonth(date) >= 7 ? 5 : 12); + return this._dateAdapter.addCalendarMonths(date, increment); } } diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index 1bde8857b881..ea189ed57de1 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -11,12 +11,10 @@ import { } from '@angular/core'; import {MdDatepicker} from './datepicker'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; -import {SimpleDate} from '../core/datetime/simple-date'; -import {CalendarLocale} from '../core/datetime/calendar-locale'; import {Subscription} from 'rxjs/Subscription'; import {MdInputContainer} from '../input/input-container'; import {DOWN_ARROW} from '../core/keyboard/keycodes'; -import {Observable} from 'rxjs/Observable'; +import {DateAdapter} from '../core/datetime/index'; export const MD_DATEPICKER_VALUE_ACCESSOR: any = { @@ -34,58 +32,56 @@ export const MD_DATEPICKER_VALUE_ACCESSOR: any = { '[attr.aria-expanded]': '_datepicker?.opened || "false"', '[attr.aria-haspopup]': 'true', '[attr.aria-owns]': '_datepicker?.id', - '[min]': '_min?.toNativeDate()', - '[max]': '_max?.toNativeDate()', + '[min]': '_min', + '[max]': '_max', '(input)': '_onInput($event.target.value)', '(blur)': '_onTouched()', '(keydown)': '_onKeydown($event)', } }) -export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor, OnDestroy { +export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor, OnDestroy { /** The datepicker that this input is associated with. */ @Input() - set mdDatepicker(value: MdDatepicker) { + set mdDatepicker(value: MdDatepicker) { if (value) { this._datepicker = value; this._datepicker._registerInput(this); } } - _datepicker: MdDatepicker; + _datepicker: MdDatepicker; @Input() - set matDatepicker(value: MdDatepicker) { this.mdDatepicker = value; } + set matDatepicker(value: MdDatepicker) { this.mdDatepicker = value; } /** The value of the input. */ @Input() - get value(): SimpleDate { - return this._locale.parseDate(this._elementRef.nativeElement.value); + get value(): D { + return this._dateAdapter.parse(this._elementRef.nativeElement.value); } - set value(value: SimpleDate) { - let date = this._locale.parseDate(value); + set value(value: D) { + let date = this._dateAdapter.parse(value); let oldDate = this.value; this._renderer.setElementProperty(this._elementRef.nativeElement, 'value', - date ? this._locale.formatDate(date) : ''); - if (!SimpleDate.equals(oldDate, date)) { - this._valueChangeEmitter.emit(date); + date ? this._dateAdapter.format(date) : ''); + if (!this._dateAdapter.sameDate(oldDate, date)) { + this._valueChange.emit(date); } } /** The minimum valid date. */ @Input() - get min(): SimpleDate { return this._min; } - set min(value: SimpleDate) { this._min = this._locale.parseDate(value); } - private _min: SimpleDate; + get min(): D { return this._min; } + set min(value: D) { this._min = this._dateAdapter.parse(value); } + private _min: D; /** The maximum valid date. */ @Input() - get max(): SimpleDate { return this._max; } - set max(value: SimpleDate) { this._max = this._locale.parseDate(value); } - private _max: SimpleDate; - - private _valueChangeEmitter = new EventEmitter(); + get max(): D { return this._max; } + set max(value: D) { this._max = this._dateAdapter.parse(value); } + private _max: D; /** Emits when the value changes (either due to user input or programmatic change). */ - _valueChange: Observable = this._valueChangeEmitter.asObservable(); + _valueChange = new EventEmitter(); _onChange = (value: any) => {}; @@ -96,13 +92,13 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor constructor( private _elementRef: ElementRef, private _renderer: Renderer, - private _locale: CalendarLocale, + private _dateAdapter: DateAdapter, @Optional() private _mdInputContainer: MdInputContainer) {} ngAfterContentInit() { if (this._datepicker) { this._datepickerSubscription = - this._datepicker.selectedChanged.subscribe((selected: SimpleDate) => { + this._datepicker.selectedChanged.subscribe((selected: D) => { this.value = selected; this._onChange(selected); }); @@ -124,7 +120,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor } // Implemented as part of ControlValueAccessor - writeValue(value: SimpleDate): void { + writeValue(value: D): void { this.value = value; } @@ -151,8 +147,8 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor } _onInput(value: string) { - let date = this._locale.parseDate(value); + let date = this._dateAdapter.parse(value); this._onChange(date); - this._valueChangeEmitter.emit(date); + this._valueChange.emit(date); } } diff --git a/src/lib/datepicker/datepicker-intl.ts b/src/lib/datepicker/datepicker-intl.ts new file mode 100644 index 000000000000..a96ea07b12f7 --- /dev/null +++ b/src/lib/datepicker/datepicker-intl.ts @@ -0,0 +1,36 @@ +import {Injectable} from '@angular/core'; + + +/** Datepicker data that requires internationalization. */ +@Injectable() +export class MdDatepickerIntl { + /** A label for the calendar popup (used by screen readers). */ + calendarLabel = 'Calendar'; + + /** A label for the button used to open the calendar popup (used by screen readers). */ + openCalendarLabel = 'Open calendar'; + + /** A label for the previous month button (used by screen readers). */ + prevMonthLabel = 'Previous month'; + + /** A label for the next month button (used by screen readers). */ + nextMonthLabel = 'Next month'; + + /** A label for the previous year button (used by screen readers). */ + prevYearLabel = 'Previous year'; + + /** A label for the next year button (used by screen readers). */ + nextYearLabel = 'Next year'; + + /** A label for the 'switch to month view' button (used by screen readers). */ + switchToMonthViewLabel = 'Change to month view'; + + /** A label for the 'switch to year view' button (used by screen readers). */ + switchToYearViewLabel = 'Change to year view'; + + /** + * The format to use when displaying dates without time information. If unspecified the `date` + * format supplied by {@link DateAdapter#getDefaultFormats} will be used. + */ + dateFormat: any; +} diff --git a/src/lib/datepicker/datepicker-toggle.ts b/src/lib/datepicker/datepicker-toggle.ts index 485d118453b5..c00f6da5686f 100644 --- a/src/lib/datepicker/datepicker-toggle.ts +++ b/src/lib/datepicker/datepicker-toggle.ts @@ -1,6 +1,6 @@ import {ChangeDetectionStrategy, Component, Input, ViewEncapsulation} from '@angular/core'; import {MdDatepicker} from './datepicker'; -import {CalendarLocale} from '../core/datetime/calendar-locale'; +import {MdDatepickerIntl} from './datepicker-intl'; @Component({ @@ -10,20 +10,20 @@ import {CalendarLocale} from '../core/datetime/calendar-locale'; styleUrls: ['datepicker-toggle.css'], host: { '[class.mat-datepicker-toggle]': 'true', - '[attr.aria-label]': '_locale.openCalendarLabel', + '[attr.aria-label]': '_intl.openCalendarLabel', '(click)': '_open($event)', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MdDatepickerToggle { - @Input('mdDatepickerToggle') datepicker: MdDatepicker; +export class MdDatepickerToggle { + @Input('mdDatepickerToggle') datepicker: MdDatepicker; @Input('matDatepickerToggle') get _datepicker() { return this.datepicker; } - set _datepicker(v: MdDatepicker) { this.datepicker = v; } + set _datepicker(v: MdDatepicker) { this.datepicker = v; } - constructor(public _locale: CalendarLocale) {} + constructor(public _intl: MdDatepickerIntl) {} _open(event: Event): void { if (this.datepicker) { diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index 455a7761b4ff..dd8872161be9 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -3,7 +3,6 @@ import {MdDatepickerModule} from './index'; import {Component, ViewChild} from '@angular/core'; import {MdDatepicker} from './datepicker'; import {MdDatepickerInput} from './datepicker-input'; -import {SimpleDate} from '../core/datetime/simple-date'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {By} from '@angular/platform-browser'; import {dispatchFakeEvent, dispatchMouseEvent} from '../core/testing/dispatch-events'; @@ -11,6 +10,12 @@ import {MdInputModule} from '../input/index'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +// When constructing a Date, the month is zero-based. This can be confusing, since people are +// used to seeing them one-based. So we create these aliases to make reading the tests easier. +const JAN = 0, FEB = 1, MAR = 2, APR = 3, MAY = 4, JUN = 5, JUL = 6, AUG = 7, SEP = 8, OCT = 9, + NOV = 10, DEC = 11; + + describe('MdDatepicker', () => { beforeEach(async(() => { TestBed.configureTestingModule({ @@ -115,7 +120,7 @@ describe('MdDatepicker', () => { fixture.detectChanges(); expect(document.querySelector('md-dialog-container')).not.toBeNull(); - expect(testComponent.datepickerInput.value).toEqual(new SimpleDate(2020, 0, 1)); + expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1)); let cells = document.querySelectorAll('.mat-calendar-body-cell'); dispatchMouseEvent(cells[1], 'click'); @@ -123,12 +128,12 @@ describe('MdDatepicker', () => { fixture.whenStable().then(() => { expect(document.querySelector('md-dialog-container')).toBeNull(); - expect(testComponent.datepickerInput.value).toEqual(new SimpleDate(2020, 0, 2)); + expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 2)); }); })); it('startAt should fallback to input value', () => { - expect(testComponent.datepicker.startAt).toEqual(new SimpleDate(2020, 0, 1)); + expect(testComponent.datepicker.startAt).toEqual(new Date(2020, JAN, 1)); }); it('should attach popup to native input', () => { @@ -183,7 +188,7 @@ describe('MdDatepicker', () => { })); it('explicit startAt should override input value', () => { - expect(testComponent.datepicker.startAt).toEqual(new SimpleDate(2010, 0, 1)); + expect(testComponent.datepicker.startAt).toEqual(new Date(2010, JAN, 1)); }); }); @@ -211,7 +216,7 @@ describe('MdDatepicker', () => { expect(testComponent.datepickerInput.value).toBeNull(); expect(testComponent.datepicker._selected).toBeNull(); - let selected = new SimpleDate(2017, 0, 1); + let selected = new Date(2017, JAN, 1); testComponent.selected = selected; fixture.detectChanges(); @@ -227,7 +232,7 @@ describe('MdDatepicker', () => { expect(testComponent.selected).toBeNull(); expect(testComponent.datepickerInput.value).toBeNull(); - let selected = new SimpleDate(2017, 0, 1); + let selected = new Date(2017, JAN, 1); testComponent.datepicker._selectAndClose(selected); fixture.detectChanges(); @@ -255,7 +260,7 @@ describe('MdDatepicker', () => { expect(inputEl.classList).toContain('ng-pristine'); - testComponent.datepicker._selectAndClose(new SimpleDate(2017, 0, 1)); + testComponent.datepicker._selectAndClose(new Date(2017, JAN, 1)); fixture.detectChanges(); fixture.whenStable().then(() => { @@ -270,7 +275,7 @@ describe('MdDatepicker', () => { expect(inputEl.classList).toContain('ng-pristine'); - testComponent.selected = new SimpleDate(2017, 0, 1); + testComponent.selected = new Date(2017, JAN, 1); fixture.detectChanges(); fixture.whenStable().then(() => { @@ -317,7 +322,7 @@ describe('MdDatepicker', () => { expect(testComponent.datepickerInput.value).toBeNull(); expect(testComponent.datepicker._selected).toBeNull(); - let selected = new SimpleDate(2017, 0, 1); + let selected = new Date(2017, JAN, 1); testComponent.formControl.setValue(selected); fixture.detectChanges(); @@ -329,7 +334,7 @@ describe('MdDatepicker', () => { expect(testComponent.formControl.value).toBeNull(); expect(testComponent.datepickerInput.value).toBeNull(); - let selected = new SimpleDate(2017, 0, 1); + let selected = new Date(2017, JAN, 1); testComponent.datepicker._selectAndClose(selected); fixture.detectChanges(); @@ -416,8 +421,8 @@ describe('MdDatepicker', () => { })); it('should use min and max dates specified by the input', () => { - expect(testComponent.datepicker._minDate).toEqual(new SimpleDate(2010, 0, 1)); - expect(testComponent.datepicker._maxDate).toEqual(new SimpleDate(2020, 0, 1)); + expect(testComponent.datepicker._minDate).toEqual(new Date(2010, JAN, 1)); + expect(testComponent.datepicker._maxDate).toEqual(new Date(2020, JAN, 1)); }); }); }); @@ -431,8 +436,8 @@ describe('MdDatepicker', () => { }) class StandardDatepicker { touch = false; - @ViewChild('d') datepicker: MdDatepicker; - @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } @@ -448,7 +453,7 @@ class MultiInputDatepicker {} template: ``, }) class NoInputDatepicker { - @ViewChild('d') datepicker: MdDatepicker; + @ViewChild('d') datepicker: MdDatepicker; } @@ -459,7 +464,7 @@ class NoInputDatepicker { `, }) class DatepickerWithStartAt { - @ViewChild('d') datepicker: MdDatepicker; + @ViewChild('d') datepicker: MdDatepicker; } @@ -467,9 +472,9 @@ class DatepickerWithStartAt { template: ``, }) class DatepickerWithNgModel { - selected: SimpleDate = null; - @ViewChild('d') datepicker: MdDatepicker; - @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; + selected: Date = null; + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } @@ -481,8 +486,8 @@ class DatepickerWithNgModel { }) class DatepickerWithFormControl { formControl = new FormControl(); - @ViewChild('d') datepicker: MdDatepicker; - @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } @@ -494,7 +499,7 @@ class DatepickerWithFormControl { `, }) class DatepickerWithToggle { - @ViewChild('d') datepicker: MdDatepicker; + @ViewChild('d') datepicker: MdDatepicker; } @@ -507,8 +512,8 @@ class DatepickerWithToggle { `, }) class InputContainerDatepicker { - @ViewChild('d') datepicker: MdDatepicker; - @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } @@ -519,5 +524,5 @@ class InputContainerDatepicker { `, }) class DatepickerWithMinAndMax { - @ViewChild('d') datepicker: MdDatepicker; + @ViewChild('d') datepicker: MdDatepicker; } diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 63153fdc4762..9fb7129294ea 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -25,12 +25,11 @@ import { OriginConnectionPosition, OverlayConnectionPosition } from '../core/overlay/position/connected-position'; -import {SimpleDate} from '../core/datetime/simple-date'; import {MdDatepickerInput} from './datepicker-input'; -import {CalendarLocale} from '../core/datetime/calendar-locale'; import 'rxjs/add/operator/first'; import {Subscription} from 'rxjs/Subscription'; import {MdDialogConfig} from '../dialog/dialog-config'; +import {DateAdapter} from '../core/datetime/index'; /** Used to generate a unique ID for each datepicker instance. */ @@ -56,8 +55,8 @@ let datepickerUid = 0; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MdDatepickerContent implements AfterContentInit { - datepicker: MdDatepicker; +export class MdDatepickerContent implements AfterContentInit { + datepicker: MdDatepicker; constructor(private _elementRef: ElementRef) {} @@ -76,16 +75,16 @@ export class MdDatepickerContent implements AfterContentInit { selector: 'md-datepicker, mat-datepicker', template: '', }) -export class MdDatepicker implements OnDestroy { +export class MdDatepicker implements OnDestroy { /** The date to open the calendar to initially. */ @Input() - get startAt(): SimpleDate { + get startAt(): D { // If an explicit startAt is set we start there, otherwise we start at whatever the currently // selected value is. return this._startAt || (this._datepickerInput ? this._datepickerInput.value : null); } - set startAt(date: SimpleDate) { this._startAt = this._locale.parseDate(date); } - private _startAt: SimpleDate; + set startAt(date: D) { this._startAt = this._dateAdapter.parse(date); } + private _startAt: D; /** * Whether the calendar UI is in touch mode. In touch mode the calendar opens in a dialog rather @@ -96,10 +95,10 @@ export class MdDatepicker implements OnDestroy { /** A function used to filter which dates are selectable. */ @Input() - dateFilter: (date: SimpleDate) => boolean; + dateFilter: (date: D) => boolean; /** Emits new selected date when selected date changes. */ - @Output() selectedChanged = new EventEmitter(); + @Output() selectedChanged = new EventEmitter(); /** Whether the calendar is open. */ opened = false; @@ -108,15 +107,15 @@ export class MdDatepicker implements OnDestroy { id = `md-datepicker-${datepickerUid++}`; /** The currently selected date. */ - _selected: SimpleDate = null; + _selected: D = null; /** The minimum selectable date. */ - get _minDate(): SimpleDate { + get _minDate(): D { return this._datepickerInput && this._datepickerInput.min; } /** The maximum selectable date. */ - get _maxDate(): SimpleDate { + get _maxDate(): D { return this._datepickerInput && this._datepickerInput.max; } @@ -127,15 +126,15 @@ export class MdDatepicker implements OnDestroy { private _dialogRef: MdDialogRef; /** A portal containing the calendar for this datepicker. */ - private _calendarPortal: ComponentPortal; + private _calendarPortal: ComponentPortal>; /** The input element this datepicker is associated with. */ - private _datepickerInput: MdDatepickerInput; + private _datepickerInput: MdDatepickerInput; private _inputSubscription: Subscription; constructor(private _dialog: MdDialog, private _overlay: Overlay, - private _viewContainerRef: ViewContainerRef, private _locale: CalendarLocale, + private _viewContainerRef: ViewContainerRef, private _dateAdapter: DateAdapter, @Optional() private _dir: Dir) {} ngOnDestroy() { @@ -149,10 +148,10 @@ export class MdDatepicker implements OnDestroy { } /** Selects the given date and closes the currently open popup or dialog. */ - _selectAndClose(date: SimpleDate): void { + _selectAndClose(date: D): void { let oldValue = this._selected; this._selected = date; - if (!SimpleDate.equals(oldValue, this._selected)) { + if (!this._dateAdapter.sameDate(oldValue, this._selected)) { this.selectedChanged.emit(date); } this.close(); @@ -162,13 +161,13 @@ export class MdDatepicker implements OnDestroy { * Register an input with this datepicker. * @param input The datepicker input to register with this datepicker. */ - _registerInput(input: MdDatepickerInput): void { + _registerInput(input: MdDatepickerInput): void { if (this._datepickerInput) { throw new MdError('An MdDatepicker can only be associated with a single input.'); } this._datepickerInput = input; this._inputSubscription = - this._datepickerInput._valueChange.subscribe((value: SimpleDate) => this._selected = value); + this._datepickerInput._valueChange.subscribe((value: D) => this._selected = value); } /** Open the calendar. */ @@ -223,7 +222,7 @@ export class MdDatepicker implements OnDestroy { } if (!this._popupRef.hasAttached()) { - let componentRef: ComponentRef = + let componentRef: ComponentRef> = this._popupRef.attach(this._calendarPortal); componentRef.instance.datepicker = this; } diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index 8526e5f8cd0e..a5fae36c6df4 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -12,12 +12,15 @@ import {MdCalendar} from './calendar'; import {MdDatepickerToggle} from './datepicker-toggle'; import {StyleModule} from '../core/style/index'; import {MdButtonModule} from '../button/index'; +import {MdDatepickerIntl} from './datepicker-intl'; export * from './calendar'; export * from './calendar-body'; export * from './datepicker'; export * from './datepicker-input'; +export * from './datepicker-intl'; +export * from './datepicker-toggle'; export * from './month-view'; export * from './year-view'; @@ -47,6 +50,9 @@ export * from './year-view'; MdMonthView, MdYearView, ], + providers: [ + MdDatepickerIntl, + ], entryComponents: [ MdDatepickerContent, ] diff --git a/src/lib/datepicker/month-view.html b/src/lib/datepicker/month-view.html index a572c8ded789..15991190d67d 100644 --- a/src/lib/datepicker/month-view.html +++ b/src/lib/datepicker/month-view.html @@ -9,7 +9,7 @@ [todayValue]="_todayDate" [selectedValue]="_selectedDate" [labelMinRequiredCells]="3" - [activeCell]="activeDate.date - 1" + [activeCell]="_dateAdapter.getDate(activeDate) - 1" (selectedValueChange)="_dateSelected($event)"> diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts index ee544470a8a3..a06f21079dbd 100644 --- a/src/lib/datepicker/month-view.spec.ts +++ b/src/lib/datepicker/month-view.spec.ts @@ -2,11 +2,16 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdMonthView} from './month-view'; -import {SimpleDate} from '../core/datetime/simple-date'; import {MdCalendarBody} from './calendar-body'; import {DatetimeModule} from '../core/datetime/index'; +// When constructing a Date, the month is zero-based. This can be confusing, since people are +// used to seeing them one-based. So we create these aliases to make reading the tests easier. +const JAN = 0, FEB = 1, MAR = 2, APR = 3, MAY = 4, JUN = 5, JUL = 6, AUG = 7, SEP = 8, OCT = 9, + NOV = 10, DEC = 11; + + describe('MdMonthView', () => { beforeEach(async(() => { TestBed.configureTestingModule({ @@ -56,7 +61,7 @@ describe('MdMonthView', () => { }); it('does not show selected date if in different month', () => { - testComponent.selected = new SimpleDate(2017, 2, 10); + testComponent.selected = new Date(2017, MAR, 10); fixture.detectChanges(); let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-body-selected'); @@ -106,8 +111,8 @@ describe('MdMonthView', () => { template: ``, }) class StandardMonthView { - date = new SimpleDate(2017, 0, 5); - selected = new SimpleDate(2017, 0, 10); + date = new Date(2017, JAN, 5); + selected = new Date(2017, JAN, 10); } @@ -115,7 +120,7 @@ class StandardMonthView { template: `` }) class MonthViewWithDateFilter { - dateFilter(date: SimpleDate) { - return date.date % 2 == 0; + dateFilter(date: Date) { + return date.getDate() % 2 == 0; } } diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts index 94ea70d7fc18..8313085812bf 100644 --- a/src/lib/datepicker/month-view.ts +++ b/src/lib/datepicker/month-view.ts @@ -1,15 +1,14 @@ import { - Component, - ViewEncapsulation, + AfterContentInit, ChangeDetectionStrategy, - Input, + Component, EventEmitter, + Input, Output, - AfterContentInit + ViewEncapsulation } from '@angular/core'; import {MdCalendarCell} from './calendar-body'; -import {CalendarLocale} from '../core/datetime/calendar-locale'; -import {SimpleDate} from '../core/datetime/simple-date'; +import {DateAdapter} from '../core/datetime/index'; const DAYS_PER_WEEK = 7; @@ -26,35 +25,35 @@ const DAYS_PER_WEEK = 7; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MdMonthView implements AfterContentInit { +export class MdMonthView implements AfterContentInit { /** * The date to display in this month view (everything other than the month and year is ignored). */ @Input() - get activeDate() { return this._activeDate; } - set activeDate(value) { + get activeDate(): D { return this._activeDate; } + set activeDate(value: D) { let oldActiveDate = this._activeDate; - this._activeDate = this._locale.parseDate(value) || SimpleDate.today(); + this._activeDate = this._dateAdapter.parse(value) || this._dateAdapter.today(); if (!this._hasSameMonthAndYear(oldActiveDate, this._activeDate)) { this._init(); } } - private _activeDate = SimpleDate.today(); + private _activeDate: D; /** The currently selected date. */ @Input() - get selected() { return this._selected; } - set selected(value) { - this._selected = this._locale.parseDate(value); + get selected(): D { return this._selected; } + set selected(value: D) { + this._selected = this._dateAdapter.parse(value); this._selectedDate = this._getDateInCurrentMonth(this.selected); } - private _selected: SimpleDate; + private _selected: D; /** A function used to filter which dates are selectable. */ - @Input() dateFilter: (date: SimpleDate) => boolean; + @Input() dateFilter: (date: D) => boolean; /** Emits when a new date is selected. */ - @Output() selectedChange = new EventEmitter(); + @Output() selectedChange = new EventEmitter(); /** The label for this month (e.g. "January 2017"). */ _monthLabel: string; @@ -77,10 +76,14 @@ export class MdMonthView implements AfterContentInit { /** The names of the weekdays. */ _weekdays: string[]; - constructor(private _locale: CalendarLocale) { + constructor(public _dateAdapter: DateAdapter) { + const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); + const weekdays = this._dateAdapter.getDayOfWeekNames('narrow'); + // Rotate the labels for days of the week based on the configured first day of the week. - this._weekdays = this._locale.narrowDays.slice(this._locale.firstDayOfWeek) - .concat(this._locale.narrowDays.slice(0, this._locale.firstDayOfWeek)); + this._weekdays = weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek)); + + this._activeDate = this._dateAdapter.today(); } ngAfterContentInit(): void { @@ -92,25 +95,32 @@ export class MdMonthView implements AfterContentInit { if (this._selectedDate == date) { return; } - this.selectedChange.emit(new SimpleDate(this.activeDate.year, this.activeDate.month, date)); + this.selectedChange.emit(this._dateAdapter.createDate( + this._dateAdapter.getYear(this.activeDate), this._dateAdapter.getMonth(this.activeDate), + date)); } /** Initializes this month view. */ private _init() { this._selectedDate = this._getDateInCurrentMonth(this.selected); - this._todayDate = this._getDateInCurrentMonth(SimpleDate.today()); - this._monthLabel = this._locale.shortMonths[this.activeDate.month].toLocaleUpperCase(); + this._todayDate = this._getDateInCurrentMonth(this._dateAdapter.today()); + this._monthLabel = + this._dateAdapter.getMonthNames('short')[this._dateAdapter.getMonth(this.activeDate)] + .toLocaleUpperCase(); - let firstOfMonth = new SimpleDate(this.activeDate.year, this.activeDate.month, 1); + let firstOfMonth = this._dateAdapter.createDate(this._dateAdapter.getYear(this.activeDate), + this._dateAdapter.getMonth(this.activeDate), 1); this._firstWeekOffset = - (DAYS_PER_WEEK + firstOfMonth.day - this._locale.firstDayOfWeek) % DAYS_PER_WEEK; + (DAYS_PER_WEEK + this._dateAdapter.getDayOfWeek(firstOfMonth) - + this._dateAdapter.getFirstDayOfWeek()) % DAYS_PER_WEEK; this._createWeekCells(); } /** Creates MdCalendarCells for the dates in this month. */ private _createWeekCells() { - let daysInMonth = new SimpleDate(this.activeDate.year, this.activeDate.month + 1, 0).date; + let daysInMonth = this._dateAdapter.getNumDaysInMonth(this.activeDate); + let dateNames = this._dateAdapter.getDateNames(); this._weeks = [[]]; for (let i = 0, cell = this._firstWeekOffset; i < daysInMonth; i++, cell++) { if (cell == DAYS_PER_WEEK) { @@ -118,9 +128,11 @@ export class MdMonthView implements AfterContentInit { cell = 0; } let enabled = !this.dateFilter || - this.dateFilter(new SimpleDate(this.activeDate.year, this.activeDate.month, i + 1)); + this.dateFilter(this._dateAdapter.createDate( + this._dateAdapter.getYear(this.activeDate), + this._dateAdapter.getMonth(this.activeDate), i + 1)); this._weeks[this._weeks.length - 1] - .push(new MdCalendarCell(i + 1, this._locale.dates[i + 1], enabled)); + .push(new MdCalendarCell(i + 1, dateNames[i], enabled)); } } @@ -128,12 +140,14 @@ export class MdMonthView implements AfterContentInit { * Gets the date in this month that the given Date falls on. * Returns null if the given Date is in another month. */ - private _getDateInCurrentMonth(date: SimpleDate): number { - return this._hasSameMonthAndYear(date, this.activeDate) ? date.date : null; + private _getDateInCurrentMonth(date: D): number { + return this._hasSameMonthAndYear(date, this.activeDate) ? + this._dateAdapter.getDate(date) : null; } /** Checks whether the 2 dates are non-null and fall within the same month of the same year. */ - private _hasSameMonthAndYear(d1: SimpleDate, d2: SimpleDate): boolean { - return !!(d1 && d2 && d1.month == d2.month && d1.year == d2.year); + private _hasSameMonthAndYear(d1: D, d2: D): boolean { + return !!(d1 && d2 && this._dateAdapter.getMonth(d1) == this._dateAdapter.getMonth(d2) && + this._dateAdapter.getYear(d1) == this._dateAdapter.getYear(d2)); } } diff --git a/src/lib/datepicker/year-view.html b/src/lib/datepicker/year-view.html index d8a7b447cb89..fe68455874c0 100644 --- a/src/lib/datepicker/year-view.html +++ b/src/lib/datepicker/year-view.html @@ -9,7 +9,7 @@ [todayValue]="_todayMonth" [selectedValue]="_selectedMonth" [labelMinRequiredCells]="2" - [activeCell]="activeDate.month" + [activeCell]="_dateAdapter.getMonth(activeDate)" (selectedValueChange)="_monthSelected($event)"> diff --git a/src/lib/datepicker/year-view.spec.ts b/src/lib/datepicker/year-view.spec.ts index 5511aafac9b4..3247bfea711c 100644 --- a/src/lib/datepicker/year-view.spec.ts +++ b/src/lib/datepicker/year-view.spec.ts @@ -2,11 +2,16 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing'; import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdYearView} from './year-view'; -import {SimpleDate} from '../core/datetime/simple-date'; import {MdCalendarBody} from './calendar-body'; import {DatetimeModule} from '../core/datetime/index'; +// When constructing a Date, the month is zero-based. This can be confusing, since people are +// used to seeing them one-based. So we create these aliases to make reading the tests easier. +const JAN = 0, FEB = 1, MAR = 2, APR = 3, MAY = 4, JUN = 5, JUL = 6, AUG = 7, SEP = 8, OCT = 9, + NOV = 10, DEC = 11; + + describe('MdYearView', () => { beforeEach(async(() => { TestBed.configureTestingModule({ @@ -56,7 +61,7 @@ describe('MdYearView', () => { }); it('does not show selected month if in different year', () => { - testComponent.selected = new SimpleDate(2016, 2, 10); + testComponent.selected = new Date(2016, MAR, 10); fixture.detectChanges(); let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-body-selected'); @@ -107,8 +112,8 @@ describe('MdYearView', () => { `, }) class StandardYearView { - date = new SimpleDate(2017, 0, 5); - selected = new SimpleDate(2017, 2, 10); + date = new Date(2017, JAN, 5); + selected = new Date(2017, MAR, 10); } @@ -116,11 +121,11 @@ class StandardYearView { template: `` }) class YearViewWithDateFilter { - dateFilter(date: SimpleDate) { - if (date.month == 0) { - return date.date == 10; + dateFilter(date: Date) { + if (date.getMonth() == JAN) { + return date.getDate() == 10; } - if (date.month == 1) { + if (date.getMonth() == FEB) { return false; } return true; diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts index a9bc7616988a..5792ae263560 100644 --- a/src/lib/datepicker/year-view.ts +++ b/src/lib/datepicker/year-view.ts @@ -1,15 +1,14 @@ import { - Component, - ViewEncapsulation, + AfterContentInit, ChangeDetectionStrategy, + Component, + EventEmitter, Input, - AfterContentInit, Output, - EventEmitter + ViewEncapsulation } from '@angular/core'; import {MdCalendarCell} from './calendar-body'; -import {CalendarLocale} from '../core/datetime/calendar-locale'; -import {SimpleDate} from '../core/datetime/simple-date'; +import {DateAdapter} from '../core/datetime/index'; /** @@ -23,33 +22,33 @@ import {SimpleDate} from '../core/datetime/simple-date'; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MdYearView implements AfterContentInit { +export class MdYearView implements AfterContentInit { /** The date to display in this year view (everything other than the year is ignored). */ @Input() - get activeDate() { return this._activeDate; } - set activeDate(value) { + get activeDate(): D { return this._activeDate; } + set activeDate(value: D) { let oldActiveDate = this._activeDate; - this._activeDate = this._locale.parseDate(value) || SimpleDate.today(); - if (oldActiveDate.year != this._activeDate.year) { + this._activeDate = this._dateAdapter.parse(value) || this._dateAdapter.today(); + if (this._dateAdapter.getYear(oldActiveDate) != this._dateAdapter.getYear(this._activeDate)) { this._init(); } } - private _activeDate = SimpleDate.today(); + private _activeDate: D; /** The currently selected date. */ @Input() - get selected() { return this._selected; } - set selected(value) { - this._selected = this._locale.parseDate(value); + get selected(): D { return this._selected; } + set selected(value: D) { + this._selected = this._dateAdapter.parse(value); this._selectedMonth = this._getMonthInCurrentYear(this.selected); } - private _selected: SimpleDate; + private _selected: D; /** A function used to filter which dates are selectable. */ - @Input() dateFilter: (date: SimpleDate) => boolean; + @Input() dateFilter: (date: D) => boolean; /** Emits when a new month is selected. */ - @Output() selectedChange = new EventEmitter(); + @Output() selectedChange = new EventEmitter(); /** Grid of calendar cells representing the months of the year. */ _months: MdCalendarCell[][]; @@ -66,7 +65,9 @@ export class MdYearView implements AfterContentInit { */ _selectedMonth: number; - constructor(private _locale: CalendarLocale) {} + constructor(public _dateAdapter: DateAdapter) { + this._activeDate = this._dateAdapter.today(); + } ngAfterContentInit() { this._init(); @@ -74,32 +75,36 @@ export class MdYearView implements AfterContentInit { /** Handles when a new month is selected. */ _monthSelected(month: number) { - this.selectedChange.emit(new SimpleDate(this.activeDate.year, month, this._activeDate.date)); + this.selectedChange.emit(this._dateAdapter.createDate( + this._dateAdapter.getYear(this.activeDate), month, + this._dateAdapter.getDate(this.activeDate))); } /** Initializes this month view. */ private _init() { this._selectedMonth = this._getMonthInCurrentYear(this.selected); - this._todayMonth = this._getMonthInCurrentYear(SimpleDate.today()); - this._yearLabel = this._locale.getCalendarYearHeaderLabel(this.activeDate); + this._todayMonth = this._getMonthInCurrentYear(this._dateAdapter.today()); + this._yearLabel = this._dateAdapter.getYearName(this.activeDate); + let monthNames = this._dateAdapter.getMonthNames('short'); // First row of months only contains 5 elements so we can fit the year label on the same row. this._months = [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11]].map(row => row.map( - month => this._createCellForMonth(month))); + month => this._createCellForMonth(month, monthNames[month]))); } /** * Gets the month in this year that the given Date falls on. * Returns null if the given Date is in another year. */ - private _getMonthInCurrentYear(date: SimpleDate) { - return date && date.year == this.activeDate.year ? date.month : null; + private _getMonthInCurrentYear(date: D) { + return date && this._dateAdapter.getYear(date) == this._dateAdapter.getYear(this.activeDate) ? + this._dateAdapter.getMonth(date) : null; } /** Creates an MdCalendarCell for the given month. */ - private _createCellForMonth(month: number) { + private _createCellForMonth(month: number, monthName: string) { return new MdCalendarCell( - month, this._locale.shortMonths[month].toLocaleUpperCase(), this._isMonthEnabled(month)); + month, monthName.toLocaleUpperCase(), this._isMonthEnabled(month)); } /** Whether the given month is enabled. */ @@ -108,9 +113,12 @@ export class MdYearView implements AfterContentInit { return true; } + let firstOfMonth = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.activeDate), month, 1); + // If any date in the month is enabled count the month as enabled. - for (let date = new SimpleDate(this.activeDate.year, month, 1); date.month === month; - date = date.add({days: 1})) { + for (let date = firstOfMonth; this._dateAdapter.getMonth(date) == month; + date = this._dateAdapter.addCalendarDays(date, 1)) { if (this.dateFilter(date)) { return true; } From cb8a49db570397e42dc2e2488f69dde1fa3bc624 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Thu, 27 Apr 2017 12:00:55 -0700 Subject: [PATCH 26/37] datepicker: create injectable for date formats and bundle it along with date adapter into MdNativeDateModule (#4296) * New module structure for DateAdapter. * pass through format options * move date-formats to core/datetime * don't subclass error * add test for missing providers case --- src/demo-app/demo-app-module.ts | 16 +- src/lib/core/core.ts | 3 - src/lib/core/datetime/date-adapter.ts | 25 +- src/lib/core/datetime/date-formats.ts | 15 + src/lib/core/datetime/index.ts | 11 +- .../core/datetime/native-date-adapter.spec.ts | 22 - src/lib/core/datetime/native-date-adapter.ts | 25 +- src/lib/core/datetime/native-date-formats.ts | 12 + src/lib/datepicker/calendar.spec.ts | 4 +- src/lib/datepicker/calendar.ts | 34 +- src/lib/datepicker/datepicker-errors.ts | 6 + src/lib/datepicker/datepicker-input.ts | 32 +- src/lib/datepicker/datepicker-intl.ts | 6 - src/lib/datepicker/datepicker.spec.ts | 618 +++++++++--------- src/lib/datepicker/datepicker.ts | 20 +- src/lib/datepicker/index.ts | 2 - src/lib/datepicker/month-view.spec.ts | 4 +- src/lib/datepicker/month-view.ts | 19 +- src/lib/datepicker/year-view.spec.ts | 4 +- src/lib/datepicker/year-view.ts | 19 +- src/lib/module.ts | 2 - 21 files changed, 494 insertions(+), 405 deletions(-) create mode 100644 src/lib/core/datetime/date-formats.ts create mode 100644 src/lib/core/datetime/native-date-formats.ts create mode 100644 src/lib/datepicker/datepicker-errors.ts diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index 673d27703e83..13dc3ab25d9e 100644 --- a/src/demo-app/demo-app-module.ts +++ b/src/demo-app/demo-app-module.ts @@ -1,4 +1,4 @@ -import {NgModule, ApplicationRef} from '@angular/core'; +import {ApplicationRef, NgModule} from '@angular/core'; import {BrowserModule} from '@angular/platform-browser'; import {HttpModule} from '@angular/http'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; @@ -6,14 +6,15 @@ import {RouterModule} from '@angular/router'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; import {DemoApp, Home} from './demo-app/demo-app'; import { - MaterialModule, - OverlayContainer, FullscreenOverlayContainer, + MaterialModule, + MdNativeDateModule, MdSelectionModule, + OverlayContainer } from '@angular/material'; import {DEMO_APP_ROUTES} from './demo-app/routes'; import {ProgressBarDemo} from './progress-bar/progress-bar-demo'; -import {JazzDialog, ContentElementDialog, DialogDemo, IFrameDialog} from './dialog/dialog-demo'; +import {ContentElementDialog, DialogDemo, IFrameDialog, JazzDialog} from './dialog/dialog-demo'; import {RippleDemo} from './ripple/ripple-demo'; import {IconDemo} from './icon/icon-demo'; import {GesturesDemo} from './gestures/gestures-demo'; @@ -27,18 +28,18 @@ import {ListDemo} from './list/list-demo'; import {BaselineDemo} from './baseline/baseline-demo'; import {GridListDemo} from './grid-list/grid-list-demo'; import {LiveAnnouncerDemo} from './live-announcer/live-announcer-demo'; -import {OverlayDemo, SpagettiPanel, RotiniPanel} from './overlay/overlay-demo'; +import {OverlayDemo, RotiniPanel, SpagettiPanel} from './overlay/overlay-demo'; import {SlideToggleDemo} from './slide-toggle/slide-toggle-demo'; import {ToolbarDemo} from './toolbar/toolbar-demo'; import {ButtonDemo} from './button/button-demo'; -import {MdCheckboxDemoNestedChecklist, CheckboxDemo} from './checkbox/checkbox-demo'; +import {CheckboxDemo, MdCheckboxDemoNestedChecklist} from './checkbox/checkbox-demo'; import {SelectDemo} from './select/select-demo'; import {SliderDemo} from './slider/slider-demo'; import {SidenavDemo} from './sidenav/sidenav-demo'; import {SnackBarDemo} from './snack-bar/snack-bar-demo'; import {PortalDemo, ScienceJoke} from './portal/portal-demo'; import {MenuDemo} from './menu/menu-demo'; -import {TabsDemo, SunnyTabContent, RainyTabContent, FoggyTabContent} from './tabs/tabs-demo'; +import {FoggyTabContent, RainyTabContent, SunnyTabContent, TabsDemo} from './tabs/tabs-demo'; import {PlatformDemo} from './platform/platform-demo'; import {AutocompleteDemo} from './autocomplete/autocomplete-demo'; import {InputDemo} from './input/input-demo'; @@ -55,6 +56,7 @@ import {DatepickerDemo} from './datepicker/datepicker-demo'; ReactiveFormsModule, RouterModule.forRoot(DEMO_APP_ROUTES), MaterialModule, + MdNativeDateModule, MdSelectionModule, ], declarations: [ diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index cc992c11679d..f1df043b945d 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -8,7 +8,6 @@ import {OverlayModule} from './overlay/overlay-directives'; import {A11yModule} from './a11y/index'; import {MdSelectionModule} from './selection/index'; import {MdRippleModule} from './ripple/index'; -import {DatetimeModule} from './datetime/index'; // RTL @@ -135,7 +134,6 @@ export * from './datetime/index'; A11yModule, MdOptionModule, MdSelectionModule, - DatetimeModule, ], exports: [ MdLineModule, @@ -147,7 +145,6 @@ export * from './datetime/index'; A11yModule, MdOptionModule, MdSelectionModule, - DatetimeModule, ], }) export class MdCoreModule {} diff --git a/src/lib/core/datetime/date-adapter.ts b/src/lib/core/datetime/date-adapter.ts index c7d9d1274bf9..33148d3aa714 100644 --- a/src/lib/core/datetime/date-adapter.ts +++ b/src/lib/core/datetime/date-adapter.ts @@ -58,15 +58,6 @@ export abstract class DateAdapter { */ abstract getYearName(date: D): string; - /** - * Gets the name for the month and year of the given date. - * @param date The date to get the month and year name for. - * @param monthStyle The naming style for the month - * (e.g. long = 'January', short = 'Jan', narrow = 'J'). - * @returns The name of the month and year of the given date (e.g. 'Jan 2017'). - */ - abstract getMonthYearName(date: D, monthStyle: 'long' | 'short' | 'narrow'): string; - /** * Gets the first day of the week. * @returns The first day of the week (0-indexed, 0 = Sunday). @@ -80,13 +71,6 @@ export abstract class DateAdapter { */ abstract getNumDaysInMonth(date: D): number; - /** - * Gets a set of default formats to use for displaying the date in different contexts. - * @returns An object with the following default formats: - * - date: The default format for showing just the date without any time information. - */ - abstract getDefaultFormats(): {date: any}; - /** * Clones the given date. * @param date The date to clone @@ -113,18 +97,19 @@ export abstract class DateAdapter { /** * Parses a date from a value. * @param value The value to parse. - * @param fmt The expected format of the value being parsed (type is implementation-dependent). + * @param parseFormat The expected format of the value being parsed + * (type is implementation-dependent). * @returns The parsed date, or null if date could not be parsed. */ - abstract parse(value: any, fmt?: any): D | null; + abstract parse(value: any, parseFormat: any): D | null; /** * Formats a date as a string. * @param date The value to parse. - * @param fmt The format to use for the result string. + * @param displayFormat The format to use to display the date as a string. * @returns The parsed date, or null if date could not be parsed. */ - abstract format(date: D, fmt?: any): string; + abstract format(date: D, displayFormat: any): string; /** * Adds the given number of years to the date. Years are counted as if flipping 12 pages on the diff --git a/src/lib/core/datetime/date-formats.ts b/src/lib/core/datetime/date-formats.ts new file mode 100644 index 000000000000..3a93cb160f05 --- /dev/null +++ b/src/lib/core/datetime/date-formats.ts @@ -0,0 +1,15 @@ +import {InjectionToken} from '@angular/core'; + + +export type MdDateFormats = { + parse: { + dateInput: any + }, + display: { + dateInput: any, + monthYearLabel: any, + } +}; + + +export const MD_DATE_FORMATS = new InjectionToken('md-date-formats'); diff --git a/src/lib/core/datetime/index.ts b/src/lib/core/datetime/index.ts index 9dd521c46bd2..7228f0ca52fe 100644 --- a/src/lib/core/datetime/index.ts +++ b/src/lib/core/datetime/index.ts @@ -1,6 +1,8 @@ import {NgModule} from '@angular/core'; import {DateAdapter} from './date-adapter'; import {NativeDateAdapter} from './native-date-adapter'; +import {MD_DATE_FORMATS} from './date-formats'; +import {MD_NATIVE_DATE_FORMATS} from './native-date-formats'; export * from './date-adapter'; @@ -10,4 +12,11 @@ export * from './native-date-adapter'; @NgModule({ providers: [{provide: DateAdapter, useClass: NativeDateAdapter}], }) -export class DatetimeModule {} +export class NativeDateModule {} + + +@NgModule({ + imports: [NativeDateModule], + providers: [{provide: MD_DATE_FORMATS, useValue: MD_NATIVE_DATE_FORMATS}], +}) +export class MdNativeDateModule {} diff --git a/src/lib/core/datetime/native-date-adapter.spec.ts b/src/lib/core/datetime/native-date-adapter.spec.ts index 84b013b050e9..b7fd459e1138 100644 --- a/src/lib/core/datetime/native-date-adapter.spec.ts +++ b/src/lib/core/datetime/native-date-adapter.spec.ts @@ -104,32 +104,10 @@ describe('NativeDateAdapter', () => { expect(adapter.getYearName(new Date(2017, JAN, 1))).toBe('2017年'); }); - it('should get long month and year name', () => { - expect(adapter.getMonthYearName(new Date(2017, JAN, 1), 'long')).toBe('January 2017'); - }); - - it('should get short month and year name', () => { - expect(adapter.getMonthYearName(new Date(2017, JAN, 1), 'short')).toBe('Jan 2017'); - }); - - it('should get narrow month and year name', () => { - expect(adapter.getMonthYearName(new Date(2017, JAN, 1), 'narrow')).toBe('J 2017'); - }); - - it('should get month and year name in a different locale', () => { - adapter.setLocale('ja-JP'); - expect(adapter.getMonthYearName(new Date(2017, JAN, 1), 'long')).toBe('2017年1月'); - }); - it('should get first day of week', () => { expect(adapter.getFirstDayOfWeek()).toBe(0); }); - it('should get default formats', () => { - let dtf = new Intl.DateTimeFormat('en-US', adapter.getDefaultFormats().date); - expect(dtf.format(new Date(2017, 1, 1))).toEqual('2/1/2017'); - }); - it('should create Date', () => { expect(adapter.createDate(2017, JAN, 1)).toEqual(new Date(2017, JAN, 1)); }); diff --git a/src/lib/core/datetime/native-date-adapter.ts b/src/lib/core/datetime/native-date-adapter.ts index 57a7461f9e6c..7ea5c8e4efac 100644 --- a/src/lib/core/datetime/native-date-adapter.ts +++ b/src/lib/core/datetime/native-date-adapter.ts @@ -85,15 +85,6 @@ export class NativeDateAdapter extends DateAdapter { return String(this.getYear(date)); } - getMonthYearName(date: Date, monthStyle: 'long' | 'short' | 'narrow'): string { - if (SUPPORTS_INTL_API) { - let dtf = new Intl.DateTimeFormat(this.locale, {month: monthStyle, year: 'numeric'}); - return dtf.format(date); - } - let monthName = this.getMonthNames(monthStyle)[this.getMonth(date)]; - return `${monthName} ${this.getYear(date)}`; - } - getFirstDayOfWeek(): number { // We can't tell using native JS Date what the first day of the week is, we default to Sunday. return 0; @@ -104,16 +95,6 @@ export class NativeDateAdapter extends DateAdapter { this.getYear(date), this.getMonth(date) + 1, 0)); } - getDefaultFormats(): {date: Object} { - return { - date: { - year: 'numeric', - month: 'numeric', - day: 'numeric' - } - }; - } - clone(date: Date): Date { return this.createDate(this.getYear(date), this.getMonth(date), this.getDate(date)); } @@ -140,16 +121,16 @@ export class NativeDateAdapter extends DateAdapter { return new Date(); } - parse(value: any, fmt?: Object): Date | null { + parse(value: any, parseFormat: Object): Date | null { // We have no way using the native JS Date to set the parse format or locale, so we ignore these // parameters. let timestamp = typeof value == 'number' ? value : Date.parse(value); return isNaN(timestamp) ? null : new Date(timestamp); } - format(date: Date, fmt?: Object): string { + format(date: Date, displayFormat: Object): string { if (SUPPORTS_INTL_API) { - let dtf = new Intl.DateTimeFormat(this.locale, fmt); + let dtf = new Intl.DateTimeFormat(this.locale, displayFormat); return dtf.format(date); } return date.toDateString(); diff --git a/src/lib/core/datetime/native-date-formats.ts b/src/lib/core/datetime/native-date-formats.ts new file mode 100644 index 000000000000..5d20f3b03097 --- /dev/null +++ b/src/lib/core/datetime/native-date-formats.ts @@ -0,0 +1,12 @@ +import {MdDateFormats} from './date-formats'; + + +export const MD_NATIVE_DATE_FORMATS: MdDateFormats = { + parse: { + dateInput: null, + }, + display: { + dateInput: {year: 'numeric', month: 'numeric', day: 'numeric'}, + monthYearLabel: {year: 'numeric', month: 'short'}, + } +}; diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts index 1faaeba88769..8fc00dba5978 100644 --- a/src/lib/datepicker/calendar.spec.ts +++ b/src/lib/datepicker/calendar.spec.ts @@ -5,7 +5,6 @@ import {By} from '@angular/platform-browser'; import {MdMonthView} from './month-view'; import {MdYearView} from './year-view'; import {MdCalendarBody} from './calendar-body'; -import {DatetimeModule} from '../core/datetime/index'; import { dispatchFakeEvent, dispatchKeyboardEvent, @@ -23,6 +22,7 @@ import { UP_ARROW } from '../core/keyboard/keycodes'; import {MdDatepickerIntl} from './datepicker-intl'; +import {MdNativeDateModule} from '../core/datetime/index'; // When constructing a Date, the month is zero-based. This can be confusing, since people are @@ -35,7 +35,7 @@ describe('MdCalendar', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - DatetimeModule, + MdNativeDateModule, ], declarations: [ MdCalendar, diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index fd2ccf7579cd..dad4a0d9a193 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -3,7 +3,9 @@ import { ChangeDetectionStrategy, Component, EventEmitter, + Inject, Input, + Optional, Output, ViewEncapsulation } from '@angular/core'; @@ -20,6 +22,8 @@ import { } from '../core/keyboard/keycodes'; import {DateAdapter} from '../core/datetime/index'; import {MdDatepickerIntl} from './datepicker-intl'; +import {createMissingDateImplError} from './datepicker-errors'; +import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats'; /** @@ -41,7 +45,9 @@ export class MdCalendar implements AfterContentInit { /** A date representing the period (month or year) to start the calendar in. */ @Input() get startAt(): D { return this._startAt; } - set startAt(value: D) { this._startAt = this._dateAdapter.parse(value); } + set startAt(value: D) { + this._startAt = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); + } private _startAt: D; /** Whether the calendar should be started in month or year view. */ @@ -50,19 +56,25 @@ export class MdCalendar implements AfterContentInit { /** The currently selected date. */ @Input() get selected(): D { return this._selected; } - set selected(value: D) { this._selected = this._dateAdapter.parse(value); } + set selected(value: D) { + this._selected = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); + } private _selected: D; /** The minimum selectable date. */ @Input() get minDate(): D { return this._minDate; } - set minDate(date: D) { this._minDate = this._dateAdapter.parse(date); } + set minDate(date: D) { + this._minDate = this._dateAdapter.parse(date, this._dateFormats.parse.dateInput); + } private _minDate: D; /** The maximum selectable date. */ @Input() get maxDate(): D { return this._maxDate; } - set maxDate(date: D) { this._maxDate = this._dateAdapter.parse(date); } + set maxDate(date: D) { + this._maxDate = this._dateAdapter.parse(date, this._dateFormats.parse.dateInput); + } private _maxDate: D; /** A function used to filter which dates are selectable. */ @@ -95,7 +107,8 @@ export class MdCalendar implements AfterContentInit { /** The label for the current calendar view. */ get _periodButtonText(): string { return this._monthView ? - this._dateAdapter.getMonthYearName(this._activeDate, 'short').toLocaleUpperCase() : + this._dateAdapter.format(this._activeDate, this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase() : this._dateAdapter.getYearName(this._activeDate); } @@ -113,7 +126,16 @@ export class MdCalendar implements AfterContentInit { return this._monthView ? this._intl.nextMonthLabel : this._intl.nextYearLabel; } - constructor(private _dateAdapter: DateAdapter, private _intl: MdDatepickerIntl) {} + constructor(private _intl: MdDatepickerIntl, + @Optional() private _dateAdapter: DateAdapter, + @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats) { + if (!this._dateAdapter) { + throw createMissingDateImplError('DateAdapter'); + } + if (!this._dateFormats) { + throw createMissingDateImplError('MD_DATE_FORMATS'); + } + } ngAfterContentInit() { this._activeDate = this.startAt || this._dateAdapter.today(); diff --git a/src/lib/datepicker/datepicker-errors.ts b/src/lib/datepicker/datepicker-errors.ts new file mode 100644 index 000000000000..494b0d49232e --- /dev/null +++ b/src/lib/datepicker/datepicker-errors.ts @@ -0,0 +1,6 @@ +/** @docs-private */ +export function createMissingDateImplError(provider: string) { + return new Error( + `MdDatepicker: No provider found for ${provider}. You must import one of the following` + + `modules at your application root: MdNativeDateModule, or provide a custom implementation.`); +} diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index ea189ed57de1..a0361fbe34ad 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -4,6 +4,7 @@ import { ElementRef, EventEmitter, forwardRef, + Inject, Input, OnDestroy, Optional, @@ -15,6 +16,8 @@ import {Subscription} from 'rxjs/Subscription'; import {MdInputContainer} from '../input/input-container'; import {DOWN_ARROW} from '../core/keyboard/keycodes'; import {DateAdapter} from '../core/datetime/index'; +import {createMissingDateImplError} from './datepicker-errors'; +import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats'; export const MD_DATEPICKER_VALUE_ACCESSOR: any = { @@ -56,13 +59,14 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces /** The value of the input. */ @Input() get value(): D { - return this._dateAdapter.parse(this._elementRef.nativeElement.value); + return this._dateAdapter.parse(this._elementRef.nativeElement.value, + this._dateFormats.parse.dateInput); } set value(value: D) { - let date = this._dateAdapter.parse(value); + let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); let oldDate = this.value; this._renderer.setElementProperty(this._elementRef.nativeElement, 'value', - date ? this._dateAdapter.format(date) : ''); + date ? this._dateAdapter.format(date, this._dateFormats.display.dateInput) : ''); if (!this._dateAdapter.sameDate(oldDate, date)) { this._valueChange.emit(date); } @@ -71,13 +75,17 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces /** The minimum valid date. */ @Input() get min(): D { return this._min; } - set min(value: D) { this._min = this._dateAdapter.parse(value); } + set min(value: D) { + this._min = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); + } private _min: D; /** The maximum valid date. */ @Input() get max(): D { return this._max; } - set max(value: D) { this._max = this._dateAdapter.parse(value); } + set max(value: D) { + this._max = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); + } private _max: D; /** Emits when the value changes (either due to user input or programmatic change). */ @@ -92,8 +100,16 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces constructor( private _elementRef: ElementRef, private _renderer: Renderer, - private _dateAdapter: DateAdapter, - @Optional() private _mdInputContainer: MdInputContainer) {} + @Optional() private _dateAdapter: DateAdapter, + @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats, + @Optional() private _mdInputContainer: MdInputContainer) { + if (!this._dateAdapter) { + throw createMissingDateImplError('DateAdapter'); + } + if (!this._dateFormats) { + throw createMissingDateImplError('MD_DATE_FORMATS'); + } + } ngAfterContentInit() { if (this._datepicker) { @@ -147,7 +163,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces } _onInput(value: string) { - let date = this._dateAdapter.parse(value); + let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); this._onChange(date); this._valueChange.emit(date); } diff --git a/src/lib/datepicker/datepicker-intl.ts b/src/lib/datepicker/datepicker-intl.ts index a96ea07b12f7..973bf29cccef 100644 --- a/src/lib/datepicker/datepicker-intl.ts +++ b/src/lib/datepicker/datepicker-intl.ts @@ -27,10 +27,4 @@ export class MdDatepickerIntl { /** A label for the 'switch to year view' button (used by screen readers). */ switchToYearViewLabel = 'Change to year view'; - - /** - * The format to use when displaying dates without time information. If unspecified the `date` - * format supplied by {@link DateAdapter#getDefaultFormats} will be used. - */ - dateFormat: any; } diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index dd8872161be9..be161e143591 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -8,6 +8,7 @@ import {By} from '@angular/platform-browser'; import {dispatchFakeEvent, dispatchMouseEvent} from '../core/testing/dispatch-events'; import {MdInputModule} from '../input/index'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MdNativeDateModule} from '../core/datetime/index'; // When constructing a Date, the month is zero-based. This can be confusing, since people are @@ -17,412 +18,447 @@ const JAN = 0, FEB = 1, MAR = 2, APR = 3, MAY = 4, JUN = 5, JUL = 6, AUG = 7, SE describe('MdDatepicker', () => { - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [ - FormsModule, - MdDatepickerModule, - MdInputModule, - NoopAnimationsModule, - ReactiveFormsModule, - ], - declarations: [ - DatepickerWithFormControl, - DatepickerWithMinAndMax, - DatepickerWithNgModel, - DatepickerWithStartAt, - DatepickerWithToggle, - InputContainerDatepicker, - MultiInputDatepicker, - NoInputDatepicker, - StandardDatepicker, - ], - }); + describe('with MdNativeDateModule', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MdDatepickerModule, + MdInputModule, + MdNativeDateModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [ + DatepickerWithFormControl, + DatepickerWithMinAndMax, + DatepickerWithNgModel, + DatepickerWithStartAt, + DatepickerWithToggle, + InputContainerDatepicker, + MultiInputDatepicker, + NoInputDatepicker, + StandardDatepicker, + ], + }); - TestBed.compileComponents(); - })); + TestBed.compileComponents(); + })); - describe('standard datepicker', () => { - let fixture: ComponentFixture; - let testComponent: StandardDatepicker; + describe('standard datepicker', () => { + let fixture: ComponentFixture; + let testComponent: StandardDatepicker; - beforeEach(async(() => { - fixture = TestBed.createComponent(StandardDatepicker); - fixture.detectChanges(); + beforeEach(async(() => { + fixture = TestBed.createComponent(StandardDatepicker); + fixture.detectChanges(); - testComponent = fixture.componentInstance; - })); + testComponent = fixture.componentInstance; + })); - afterEach(async(() => { - testComponent.datepicker.close(); - fixture.detectChanges(); - })); + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); + + it('open non-touch should open popup', async(() => { + expect(document.querySelector('.cdk-overlay-pane')).toBeNull(); - it('open non-touch should open popup', async(() => { - expect(document.querySelector('.cdk-overlay-pane')).toBeNull(); + testComponent.datepicker.open(); + fixture.detectChanges(); - testComponent.datepicker.open(); - fixture.detectChanges(); + expect(document.querySelector('.cdk-overlay-pane')).not.toBeNull(); + })); - expect(document.querySelector('.cdk-overlay-pane')).not.toBeNull(); - })); + it('open touch should open dialog', async(() => { + testComponent.touch = true; + fixture.detectChanges(); - it('open touch should open dialog', async(() => { - testComponent.touch = true; - fixture.detectChanges(); + expect(document.querySelector('md-dialog-container')).toBeNull(); - expect(document.querySelector('md-dialog-container')).toBeNull(); + testComponent.datepicker.open(); + fixture.detectChanges(); - testComponent.datepicker.open(); - fixture.detectChanges(); + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + })); - expect(document.querySelector('md-dialog-container')).not.toBeNull(); - })); + it('close should close popup', async(() => { + testComponent.datepicker.open(); + fixture.detectChanges(); - it('close should close popup', async(() => { - testComponent.datepicker.open(); - fixture.detectChanges(); + let popup = document.querySelector('.cdk-overlay-pane'); + expect(popup).not.toBeNull(); + expect(parseInt(getComputedStyle(popup).height)).not.toBe(0); - let popup = document.querySelector('.cdk-overlay-pane'); - expect(popup).not.toBeNull(); - expect(parseInt(getComputedStyle(popup).height)).not.toBe(0); + testComponent.datepicker.close(); + fixture.detectChanges(); - testComponent.datepicker.close(); - fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(parseInt(getComputedStyle(popup).height)).toBe(0); + }); + })); - fixture.whenStable().then(() => { - expect(parseInt(getComputedStyle(popup).height)).toBe(0); - }); - })); + it('close should close dialog', async(() => { + testComponent.touch = true; + fixture.detectChanges(); - it('close should close dialog', async(() => { - testComponent.touch = true; - fixture.detectChanges(); + testComponent.datepicker.open(); + fixture.detectChanges(); - testComponent.datepicker.open(); - fixture.detectChanges(); + expect(document.querySelector('md-dialog-container')).not.toBeNull(); - expect(document.querySelector('md-dialog-container')).not.toBeNull(); + testComponent.datepicker.close(); + fixture.detectChanges(); - testComponent.datepicker.close(); - fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(document.querySelector('md-dialog-container')).toBeNull(); + }); + })); - fixture.whenStable().then(() => { - expect(document.querySelector('md-dialog-container')).toBeNull(); - }); - })); + it('setting selected should update input and close calendar', async(() => { + testComponent.touch = true; + fixture.detectChanges(); - it('setting selected should update input and close calendar', async(() => { - testComponent.touch = true; - fixture.detectChanges(); + testComponent.datepicker.open(); + fixture.detectChanges(); - testComponent.datepicker.open(); - fixture.detectChanges(); + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1)); - expect(document.querySelector('md-dialog-container')).not.toBeNull(); - expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1)); + let cells = document.querySelectorAll('.mat-calendar-body-cell'); + dispatchMouseEvent(cells[1], 'click'); + fixture.detectChanges(); - let cells = document.querySelectorAll('.mat-calendar-body-cell'); - dispatchMouseEvent(cells[1], 'click'); - fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(document.querySelector('md-dialog-container')).toBeNull(); + expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 2)); + }); + })); - fixture.whenStable().then(() => { - expect(document.querySelector('md-dialog-container')).toBeNull(); - expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 2)); + it('startAt should fallback to input value', () => { + expect(testComponent.datepicker.startAt).toEqual(new Date(2020, JAN, 1)); }); - })); - it('startAt should fallback to input value', () => { - expect(testComponent.datepicker.startAt).toEqual(new Date(2020, JAN, 1)); + it('should attach popup to native input', () => { + let attachToRef = testComponent.datepickerInput.getPopupConnectionElementRef(); + expect(attachToRef.nativeElement.tagName.toLowerCase()) + .toBe('input', 'popup should be attached to native input'); + }); }); - it('should attach popup to native input', () => { - let attachToRef = testComponent.datepickerInput.getPopupConnectionElementRef(); - expect(attachToRef.nativeElement.tagName.toLowerCase()) - .toBe('input', 'popup should be attached to native input'); + describe('datepicker with too many inputs', () => { + it('should throw when multiple inputs registered', async(() => { + let fixture = TestBed.createComponent(MultiInputDatepicker); + expect(() => fixture.detectChanges()).toThrow(); + })); }); - }); - describe('datepicker with too many inputs', () => { - it('should throw when multiple inputs registered', async(() => { - let fixture = TestBed.createComponent(MultiInputDatepicker); - expect(() => fixture.detectChanges()).toThrow(); - })); - }); - - describe('datepicker with no inputs', () => { - let fixture: ComponentFixture; - let testComponent: NoInputDatepicker; + describe('datepicker with no inputs', () => { + let fixture: ComponentFixture; + let testComponent: NoInputDatepicker; - beforeEach(async(() => { - fixture = TestBed.createComponent(NoInputDatepicker); - fixture.detectChanges(); + beforeEach(async(() => { + fixture = TestBed.createComponent(NoInputDatepicker); + fixture.detectChanges(); - testComponent = fixture.componentInstance; - })); + testComponent = fixture.componentInstance; + })); - afterEach(async(() => { - testComponent.datepicker.close(); - fixture.detectChanges(); - })); + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); - it('should throw when opened with no registered inputs', async(() => { - expect(() => testComponent.datepicker.open()).toThrow(); - })); - }); + it('should throw when opened with no registered inputs', async(() => { + expect(() => testComponent.datepicker.open()).toThrow(); + })); + }); - describe('datepicker with startAt', () => { - let fixture: ComponentFixture; - let testComponent: DatepickerWithStartAt; + describe('datepicker with startAt', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithStartAt; - beforeEach(async(() => { - fixture = TestBed.createComponent(DatepickerWithStartAt); - fixture.detectChanges(); + beforeEach(async(() => { + fixture = TestBed.createComponent(DatepickerWithStartAt); + fixture.detectChanges(); - testComponent = fixture.componentInstance; - })); + testComponent = fixture.componentInstance; + })); - afterEach(async(() => { - testComponent.datepicker.close(); - fixture.detectChanges(); - })); + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); - it('explicit startAt should override input value', () => { - expect(testComponent.datepicker.startAt).toEqual(new Date(2010, JAN, 1)); + it('explicit startAt should override input value', () => { + expect(testComponent.datepicker.startAt).toEqual(new Date(2010, JAN, 1)); + }); }); - }); - describe('datepicker with ngModel', () => { - let fixture: ComponentFixture; - let testComponent: DatepickerWithNgModel; + describe('datepicker with ngModel', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithNgModel; - beforeEach(async(() => { - fixture = TestBed.createComponent(DatepickerWithNgModel); - fixture.detectChanges(); + beforeEach(async(() => { + fixture = TestBed.createComponent(DatepickerWithNgModel); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); - fixture.whenStable().then(() => { + testComponent = fixture.componentInstance; + }); + })); + + afterEach(async(() => { + testComponent.datepicker.close(); fixture.detectChanges(); + })); - testComponent = fixture.componentInstance; - }); - })); + it('should update datepicker when model changes', async(() => { + expect(testComponent.datepickerInput.value).toBeNull(); + expect(testComponent.datepicker._selected).toBeNull(); - afterEach(async(() => { - testComponent.datepicker.close(); - fixture.detectChanges(); - })); + let selected = new Date(2017, JAN, 1); + testComponent.selected = selected; + fixture.detectChanges(); - it('should update datepicker when model changes', async(() => { - expect(testComponent.datepickerInput.value).toBeNull(); - expect(testComponent.datepicker._selected).toBeNull(); + fixture.whenStable().then(() => { + fixture.detectChanges(); - let selected = new Date(2017, JAN, 1); - testComponent.selected = selected; - fixture.detectChanges(); + expect(testComponent.datepickerInput.value).toEqual(selected); + expect(testComponent.datepicker._selected).toEqual(selected); + }); + })); - fixture.whenStable().then(() => { + it('should update model when date is selected', async(() => { + expect(testComponent.selected).toBeNull(); + expect(testComponent.datepickerInput.value).toBeNull(); + + let selected = new Date(2017, JAN, 1); + testComponent.datepicker._selectAndClose(selected); fixture.detectChanges(); - expect(testComponent.datepickerInput.value).toEqual(selected); - expect(testComponent.datepicker._selected).toEqual(selected); - }); - })); + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(testComponent.selected).toEqual(selected); + expect(testComponent.datepickerInput.value).toEqual(selected); + }); + })); - it('should update model when date is selected', async(() => { - expect(testComponent.selected).toBeNull(); - expect(testComponent.datepickerInput.value).toBeNull(); + it('should mark input dirty after input event', () => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; - let selected = new Date(2017, JAN, 1); - testComponent.datepicker._selectAndClose(selected); - fixture.detectChanges(); + expect(inputEl.classList).toContain('ng-pristine'); - fixture.whenStable().then(() => { + dispatchFakeEvent(inputEl, 'input'); fixture.detectChanges(); - expect(testComponent.selected).toEqual(selected); - expect(testComponent.datepickerInput.value).toEqual(selected); + expect(inputEl.classList).toContain('ng-dirty'); }); - })); - it('should mark input dirty after input event', () => { - let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + it('should mark input dirty after date selected', async(() => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; - expect(inputEl.classList).toContain('ng-pristine'); + expect(inputEl.classList).toContain('ng-pristine'); - dispatchFakeEvent(inputEl, 'input'); - fixture.detectChanges(); + testComponent.datepicker._selectAndClose(new Date(2017, JAN, 1)); + fixture.detectChanges(); - expect(inputEl.classList).toContain('ng-dirty'); - }); + fixture.whenStable().then(() => { + fixture.detectChanges(); - it('should mark input dirty after date selected', async(() => { - let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + expect(inputEl.classList).toContain('ng-dirty'); + }); + })); - expect(inputEl.classList).toContain('ng-pristine'); + it('should not mark dirty after model change', async(() => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; - testComponent.datepicker._selectAndClose(new Date(2017, JAN, 1)); - fixture.detectChanges(); + expect(inputEl.classList).toContain('ng-pristine'); - fixture.whenStable().then(() => { + testComponent.selected = new Date(2017, JAN, 1); fixture.detectChanges(); - expect(inputEl.classList).toContain('ng-dirty'); - }); - })); + fixture.whenStable().then(() => { + fixture.detectChanges(); - it('should not mark dirty after model change', async(() => { - let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + expect(inputEl.classList).toContain('ng-pristine'); + }); + })); - expect(inputEl.classList).toContain('ng-pristine'); + it('should mark input touched on blur', () => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; - testComponent.selected = new Date(2017, JAN, 1); - fixture.detectChanges(); + expect(inputEl.classList).toContain('ng-untouched'); - fixture.whenStable().then(() => { + dispatchFakeEvent(inputEl, 'focus'); fixture.detectChanges(); - expect(inputEl.classList).toContain('ng-pristine'); - }); - })); + expect(inputEl.classList).toContain('ng-untouched'); - it('should mark input touched on blur', () => { - let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + dispatchFakeEvent(inputEl, 'blur'); + fixture.detectChanges(); - expect(inputEl.classList).toContain('ng-untouched'); + expect(inputEl.classList).toContain('ng-touched'); + }); + }); - dispatchFakeEvent(inputEl, 'focus'); - fixture.detectChanges(); + describe('datepicker with formControl', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithFormControl; - expect(inputEl.classList).toContain('ng-untouched'); + beforeEach(async(() => { + fixture = TestBed.createComponent(DatepickerWithFormControl); + fixture.detectChanges(); - dispatchFakeEvent(inputEl, 'blur'); - fixture.detectChanges(); + testComponent = fixture.componentInstance; + })); - expect(inputEl.classList).toContain('ng-touched'); - }); - }); + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); - describe('datepicker with formControl', () => { - let fixture: ComponentFixture; - let testComponent: DatepickerWithFormControl; + it('should update datepicker when formControl changes', () => { + expect(testComponent.datepickerInput.value).toBeNull(); + expect(testComponent.datepicker._selected).toBeNull(); - beforeEach(async(() => { - fixture = TestBed.createComponent(DatepickerWithFormControl); - fixture.detectChanges(); + let selected = new Date(2017, JAN, 1); + testComponent.formControl.setValue(selected); + fixture.detectChanges(); - testComponent = fixture.componentInstance; - })); + expect(testComponent.datepickerInput.value).toEqual(selected); + expect(testComponent.datepicker._selected).toEqual(selected); + }); - afterEach(async(() => { - testComponent.datepicker.close(); - fixture.detectChanges(); - })); + it('should update formControl when date is selected', () => { + expect(testComponent.formControl.value).toBeNull(); + expect(testComponent.datepickerInput.value).toBeNull(); - it('should update datepicker when formControl changes', () => { - expect(testComponent.datepickerInput.value).toBeNull(); - expect(testComponent.datepicker._selected).toBeNull(); + let selected = new Date(2017, JAN, 1); + testComponent.datepicker._selectAndClose(selected); + fixture.detectChanges(); - let selected = new Date(2017, JAN, 1); - testComponent.formControl.setValue(selected); - fixture.detectChanges(); + expect(testComponent.formControl.value).toEqual(selected); + expect(testComponent.datepickerInput.value).toEqual(selected); + }); - expect(testComponent.datepickerInput.value).toEqual(selected); - expect(testComponent.datepicker._selected).toEqual(selected); - }); + it('should disable input when form control disabled', () => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; - it('should update formControl when date is selected', () => { - expect(testComponent.formControl.value).toBeNull(); - expect(testComponent.datepickerInput.value).toBeNull(); + expect(inputEl.disabled).toBe(false); - let selected = new Date(2017, JAN, 1); - testComponent.datepicker._selectAndClose(selected); - fixture.detectChanges(); + testComponent.formControl.disable(); + fixture.detectChanges(); - expect(testComponent.formControl.value).toEqual(selected); - expect(testComponent.datepickerInput.value).toEqual(selected); + expect(inputEl.disabled).toBe(true); + }); }); - it('should disable input when form control disabled', () => { - let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + describe('datepicker with mdDatepickerToggle', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithToggle; - expect(inputEl.disabled).toBe(false); + beforeEach(async(() => { + fixture = TestBed.createComponent(DatepickerWithToggle); + fixture.detectChanges(); - testComponent.formControl.disable(); - fixture.detectChanges(); + testComponent = fixture.componentInstance; + })); - expect(inputEl.disabled).toBe(true); - }); - }); + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); - describe('datepicker with mdDatepickerToggle', () => { - let fixture: ComponentFixture; - let testComponent: DatepickerWithToggle; + it('should open calendar when toggle clicked', async(() => { + expect(document.querySelector('md-dialog-container')).toBeNull(); - beforeEach(async(() => { - fixture = TestBed.createComponent(DatepickerWithToggle); - fixture.detectChanges(); + let toggle = fixture.debugElement.query(By.css('button')); + dispatchMouseEvent(toggle.nativeElement, 'click'); + fixture.detectChanges(); - testComponent = fixture.componentInstance; - })); + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + })); + }); - afterEach(async(() => { - testComponent.datepicker.close(); - fixture.detectChanges(); - })); + describe('datepicker inside input-container', () => { + let fixture: ComponentFixture; + let testComponent: InputContainerDatepicker; - it('should open calendar when toggle clicked', async(() => { - expect(document.querySelector('md-dialog-container')).toBeNull(); + beforeEach(async(() => { + fixture = TestBed.createComponent(InputContainerDatepicker); + fixture.detectChanges(); - let toggle = fixture.debugElement.query(By.css('button')); - dispatchMouseEvent(toggle.nativeElement, 'click'); - fixture.detectChanges(); + testComponent = fixture.componentInstance; + })); - expect(document.querySelector('md-dialog-container')).not.toBeNull(); - })); - }); + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); - describe('datepicker inside input-container', () => { - let fixture: ComponentFixture; - let testComponent: InputContainerDatepicker; + it('should attach popup to input-container underline', () => { + let attachToRef = testComponent.datepickerInput.getPopupConnectionElementRef(); + expect(attachToRef.nativeElement.classList.contains('mat-input-underline')) + .toBe(true, 'popup should be attached to input-container underline'); + }); + }); - beforeEach(async(() => { - fixture = TestBed.createComponent(InputContainerDatepicker); - fixture.detectChanges(); + describe('datepicker with min and max dates', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithMinAndMax; - testComponent = fixture.componentInstance; - })); + beforeEach(async(() => { + fixture = TestBed.createComponent(DatepickerWithMinAndMax); + fixture.detectChanges(); - afterEach(async(() => { - testComponent.datepicker.close(); - fixture.detectChanges(); - })); + testComponent = fixture.componentInstance; + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); - it('should attach popup to input-container underline', () => { - let attachToRef = testComponent.datepickerInput.getPopupConnectionElementRef(); - expect(attachToRef.nativeElement.classList.contains('mat-input-underline')) - .toBe(true, 'popup should be attached to input-container underline'); + it('should use min and max dates specified by the input', () => { + expect(testComponent.datepicker._minDate).toEqual(new Date(2010, JAN, 1)); + expect(testComponent.datepicker._maxDate).toEqual(new Date(2020, JAN, 1)); + }); }); }); - describe('datepicker with min and max dates', () => { - let fixture: ComponentFixture; - let testComponent: DatepickerWithMinAndMax; - + describe('with missing DateAdapter and MD_DATE_FORMATS', () => { beforeEach(async(() => { - fixture = TestBed.createComponent(DatepickerWithMinAndMax); - fixture.detectChanges(); - - testComponent = fixture.componentInstance; - })); + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MdDatepickerModule, + MdInputModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [ + DatepickerWithFormControl, + DatepickerWithMinAndMax, + DatepickerWithNgModel, + DatepickerWithStartAt, + DatepickerWithToggle, + InputContainerDatepicker, + MultiInputDatepicker, + NoInputDatepicker, + StandardDatepicker, + ], + }); - afterEach(async(() => { - testComponent.datepicker.close(); - fixture.detectChanges(); + TestBed.compileComponents(); })); - it('should use min and max dates specified by the input', () => { - expect(testComponent.datepicker._minDate).toEqual(new Date(2010, JAN, 1)); - expect(testComponent.datepicker._maxDate).toEqual(new Date(2020, JAN, 1)); + it('should throw when created', () => { + expect(() => TestBed.createComponent(StandardDatepicker)) + .toThrowError(/MdDatepicker: No provider found for .*/); }); }); }); diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 9fb7129294ea..50dc30c63dfb 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -5,6 +5,7 @@ import { ComponentRef, ElementRef, EventEmitter, + Inject, Input, OnDestroy, Optional, @@ -30,6 +31,8 @@ import 'rxjs/add/operator/first'; import {Subscription} from 'rxjs/Subscription'; import {MdDialogConfig} from '../dialog/dialog-config'; import {DateAdapter} from '../core/datetime/index'; +import {createMissingDateImplError} from './datepicker-errors'; +import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats'; /** Used to generate a unique ID for each datepicker instance. */ @@ -83,7 +86,9 @@ export class MdDatepicker implements OnDestroy { // selected value is. return this._startAt || (this._datepickerInput ? this._datepickerInput.value : null); } - set startAt(date: D) { this._startAt = this._dateAdapter.parse(date); } + set startAt(date: D) { + this._startAt = this._dateAdapter.parse(date, this._dateFormats.parse.dateInput); + } private _startAt: D; /** @@ -134,8 +139,17 @@ export class MdDatepicker implements OnDestroy { private _inputSubscription: Subscription; constructor(private _dialog: MdDialog, private _overlay: Overlay, - private _viewContainerRef: ViewContainerRef, private _dateAdapter: DateAdapter, - @Optional() private _dir: Dir) {} + private _viewContainerRef: ViewContainerRef, + @Optional() private _dateAdapter: DateAdapter, + @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats, + @Optional() private _dir: Dir) { + if (!this._dateAdapter) { + throw createMissingDateImplError('DateAdapter'); + } + if (!this._dateFormats) { + throw createMissingDateImplError('MD_DATE_FORMATS'); + } + } ngOnDestroy() { this.close(); diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index a5fae36c6df4..186757dcc51f 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -3,7 +3,6 @@ import {MdMonthView} from './month-view'; import {CommonModule} from '@angular/common'; import {MdCalendarBody} from './calendar-body'; import {MdYearView} from './year-view'; -import {DatetimeModule} from '../core/datetime/index'; import {OverlayModule} from '../core/overlay/overlay-directives'; import {MdDatepicker, MdDatepickerContent} from './datepicker'; import {MdDatepickerInput} from './datepicker-input'; @@ -28,7 +27,6 @@ export * from './year-view'; @NgModule({ imports: [ CommonModule, - DatetimeModule, MdButtonModule, MdDialogModule, OverlayModule, diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts index a06f21079dbd..9b74cc7f1a3a 100644 --- a/src/lib/datepicker/month-view.spec.ts +++ b/src/lib/datepicker/month-view.spec.ts @@ -3,7 +3,7 @@ import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdMonthView} from './month-view'; import {MdCalendarBody} from './calendar-body'; -import {DatetimeModule} from '../core/datetime/index'; +import {MdNativeDateModule} from '../core/datetime/index'; // When constructing a Date, the month is zero-based. This can be confusing, since people are @@ -16,7 +16,7 @@ describe('MdMonthView', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - DatetimeModule, + MdNativeDateModule, ], declarations: [ MdCalendarBody, diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts index 8313085812bf..d18e96e9d7d5 100644 --- a/src/lib/datepicker/month-view.ts +++ b/src/lib/datepicker/month-view.ts @@ -3,12 +3,16 @@ import { ChangeDetectionStrategy, Component, EventEmitter, + Inject, Input, + Optional, Output, ViewEncapsulation } from '@angular/core'; import {MdCalendarCell} from './calendar-body'; import {DateAdapter} from '../core/datetime/index'; +import {createMissingDateImplError} from './datepicker-errors'; +import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats'; const DAYS_PER_WEEK = 7; @@ -33,7 +37,8 @@ export class MdMonthView implements AfterContentInit { get activeDate(): D { return this._activeDate; } set activeDate(value: D) { let oldActiveDate = this._activeDate; - this._activeDate = this._dateAdapter.parse(value) || this._dateAdapter.today(); + this._activeDate = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput) || + this._dateAdapter.today(); if (!this._hasSameMonthAndYear(oldActiveDate, this._activeDate)) { this._init(); } @@ -44,7 +49,7 @@ export class MdMonthView implements AfterContentInit { @Input() get selected(): D { return this._selected; } set selected(value: D) { - this._selected = this._dateAdapter.parse(value); + this._selected = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); this._selectedDate = this._getDateInCurrentMonth(this.selected); } private _selected: D; @@ -76,7 +81,15 @@ export class MdMonthView implements AfterContentInit { /** The names of the weekdays. */ _weekdays: string[]; - constructor(public _dateAdapter: DateAdapter) { + constructor(@Optional() public _dateAdapter: DateAdapter, + @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats) { + if (!this._dateAdapter) { + throw createMissingDateImplError('DateAdapter'); + } + if (!this._dateFormats) { + throw createMissingDateImplError('MD_DATE_FORMATS'); + } + const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); const weekdays = this._dateAdapter.getDayOfWeekNames('narrow'); diff --git a/src/lib/datepicker/year-view.spec.ts b/src/lib/datepicker/year-view.spec.ts index 3247bfea711c..b1fcc3269dd4 100644 --- a/src/lib/datepicker/year-view.spec.ts +++ b/src/lib/datepicker/year-view.spec.ts @@ -3,7 +3,7 @@ import {Component} from '@angular/core'; import {By} from '@angular/platform-browser'; import {MdYearView} from './year-view'; import {MdCalendarBody} from './calendar-body'; -import {DatetimeModule} from '../core/datetime/index'; +import {MdNativeDateModule} from '../core/datetime/index'; // When constructing a Date, the month is zero-based. This can be confusing, since people are @@ -16,7 +16,7 @@ describe('MdYearView', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - DatetimeModule, + MdNativeDateModule, ], declarations: [ MdCalendarBody, diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts index 5792ae263560..c8e4115a65fa 100644 --- a/src/lib/datepicker/year-view.ts +++ b/src/lib/datepicker/year-view.ts @@ -3,12 +3,16 @@ import { ChangeDetectionStrategy, Component, EventEmitter, + Inject, Input, + Optional, Output, ViewEncapsulation } from '@angular/core'; import {MdCalendarCell} from './calendar-body'; import {DateAdapter} from '../core/datetime/index'; +import {createMissingDateImplError} from './datepicker-errors'; +import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats'; /** @@ -28,7 +32,8 @@ export class MdYearView implements AfterContentInit { get activeDate(): D { return this._activeDate; } set activeDate(value: D) { let oldActiveDate = this._activeDate; - this._activeDate = this._dateAdapter.parse(value) || this._dateAdapter.today(); + this._activeDate = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput) || + this._dateAdapter.today(); if (this._dateAdapter.getYear(oldActiveDate) != this._dateAdapter.getYear(this._activeDate)) { this._init(); } @@ -39,7 +44,7 @@ export class MdYearView implements AfterContentInit { @Input() get selected(): D { return this._selected; } set selected(value: D) { - this._selected = this._dateAdapter.parse(value); + this._selected = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); this._selectedMonth = this._getMonthInCurrentYear(this.selected); } private _selected: D; @@ -65,7 +70,15 @@ export class MdYearView implements AfterContentInit { */ _selectedMonth: number; - constructor(public _dateAdapter: DateAdapter) { + constructor(@Optional() public _dateAdapter: DateAdapter, + @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats) { + if (!this._dateAdapter) { + throw createMissingDateImplError('DateAdapter'); + } + if (!this._dateFormats) { + throw createMissingDateImplError('MD_DATE_FORMATS'); + } + this._activeDate = this._dateAdapter.today(); } diff --git a/src/lib/module.ts b/src/lib/module.ts index 442521de14ed..fa603ea37c17 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -8,7 +8,6 @@ import { OverlayModule, A11yModule, MdCommonModule, - DatetimeModule, } from './core/index'; import {MdButtonToggleModule} from './button-toggle/index'; @@ -72,7 +71,6 @@ const MATERIAL_MODULES = [ PlatformModule, MdCommonModule, ObserveContentModule, - DatetimeModule, ]; /** @deprecated */ From 264271585a4923e8458191fd91aa6f44117d2218 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Sat, 29 Apr 2017 11:05:37 -0700 Subject: [PATCH 27/37] fix lint issues --- src/lib/datepicker/_datepicker-theme.scss | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/lib/datepicker/_datepicker-theme.scss b/src/lib/datepicker/_datepicker-theme.scss index 4eea688f7003..50a4be417d6d 100644 --- a/src/lib/datepicker/_datepicker-theme.scss +++ b/src/lib/datepicker/_datepicker-theme.scss @@ -55,10 +55,10 @@ .mat-calendar-body-selected { background-color: mat-color($primary); color: mat-color($primary, default-contrast); + } - .mat-calendar-body-disabled > & { - background-color: fade-out(mat-color($primary), $mat-datepicker-selected-fade-amount); - } + .mat-calendar-body-disabled > .mat-calendar-body-selected { + background-color: fade-out(mat-color($primary), $mat-datepicker-selected-fade-amount); } .mat-calendar-body-today { @@ -66,11 +66,6 @@ // Note: though it's not text, the border is a hint about the fact that this is today's date, // so we use the hint color. border-color: mat-color($foreground, hint-text); - - .mat-calendar-body-disabled > & { - border-color: - fade-out(mat-color($foreground, hint-text), $mat-datepicker-today-fade-amount); - } } &.mat-calendar-body-selected { @@ -78,4 +73,8 @@ mat-color($primary, default-contrast); } } + + .mat-calendar-body-disabled > .mat-calendar-body-today:not(.mat-calendar-body-selected) { + border-color: fade-out(mat-color($foreground, hint-text), $mat-datepicker-today-fade-amount); + } } From ac706fe2c8719ec78628649a2d009de842428ce1 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Tue, 2 May 2017 09:56:58 -0700 Subject: [PATCH 28/37] fix(datepicker): make datepicker work with screen readers (#4349) * first pass a11y * escape key support * month labels * don't steal focus from fwd/back buttons * fix tests * address some comments * fix lint --- src/demo-app/datepicker/datepicker-demo.html | 7 ++-- src/lib/core/datetime/date-formats.ts | 2 + src/lib/core/datetime/native-date-formats.ts | 2 + src/lib/datepicker/calendar-body.html | 16 ++++++-- src/lib/datepicker/calendar-body.scss | 1 + src/lib/datepicker/calendar-body.spec.ts | 2 +- src/lib/datepicker/calendar-body.ts | 5 ++- src/lib/datepicker/calendar.html | 2 +- src/lib/datepicker/calendar.ts | 42 ++++++++++++++------ src/lib/datepicker/datepicker-toggle.scss | 3 +- src/lib/datepicker/datepicker.ts | 26 ++++++++++-- src/lib/datepicker/month-view.html | 5 ++- src/lib/datepicker/month-view.ts | 18 ++++++--- src/lib/datepicker/year-view.html | 1 + src/lib/datepicker/year-view.ts | 5 ++- 15 files changed, 103 insertions(+), 34 deletions(-) diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index 0ba2e16746d8..c42cf75c2693 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -5,13 +5,13 @@

Work in progress, not ready for use.


- +

- + @@ -19,7 +19,8 @@

Work in progress, not ready for use.

- + diff --git a/src/lib/core/datetime/date-formats.ts b/src/lib/core/datetime/date-formats.ts index 3a93cb160f05..b5e4bf29aee5 100644 --- a/src/lib/core/datetime/date-formats.ts +++ b/src/lib/core/datetime/date-formats.ts @@ -8,6 +8,8 @@ export type MdDateFormats = { display: { dateInput: any, monthYearLabel: any, + dateA11yLabel: any, + monthYearA11yLabel: any, } }; diff --git a/src/lib/core/datetime/native-date-formats.ts b/src/lib/core/datetime/native-date-formats.ts index 5d20f3b03097..cfc0ea1e61d4 100644 --- a/src/lib/core/datetime/native-date-formats.ts +++ b/src/lib/core/datetime/native-date-formats.ts @@ -8,5 +8,7 @@ export const MD_NATIVE_DATE_FORMATS: MdDateFormats = { display: { dateInput: {year: 'numeric', month: 'numeric', day: 'numeric'}, monthYearLabel: {year: 'numeric', month: 'short'}, + dateA11yLabel: {year: 'numeric', month: 'long', day: 'numeric'}, + monthYearA11yLabel: {year: 'numeric', month: 'long'}, } }; diff --git a/src/lib/datepicker/calendar-body.html b/src/lib/datepicker/calendar-body.html index 22782713288b..b2891281d2e9 100644 --- a/src/lib/datepicker/calendar-body.html +++ b/src/lib/datepicker/calendar-body.html @@ -1,19 +1,29 @@ - - + + {{label}} - + + {{_firstRowOffset >= labelMinRequiredCells ? label : ''}}

-
implements AfterContentInit { return this._monthView ? this._intl.nextMonthLabel : this._intl.nextYearLabel; } - constructor(private _intl: MdDatepickerIntl, + constructor(private _elementRef: ElementRef, + private _intl: MdDatepickerIntl, + private _ngZone: NgZone, @Optional() private _dateAdapter: DateAdapter, @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats) { if (!this._dateAdapter) { @@ -139,6 +143,7 @@ export class MdCalendar implements AfterContentInit { ngAfterContentInit() { this._activeDate = this.startAt || this._dateAdapter.today(); + this._focusActiveCell(); this._monthView = this.startView != 'year'; } @@ -187,14 +192,6 @@ export class MdCalendar implements AfterContentInit { return !this.maxDate || !this._isSameView(this._activeDate, this.maxDate); } - /** Whether the two dates represent the same view in the current view mode (month or year). */ - private _isSameView(date1: D, date2: D): boolean { - return this._monthView ? - this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2) && - this._dateAdapter.getMonth(date1) == this._dateAdapter.getMonth(date2) : - this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2); - } - /** Handles keydown events on the calendar body. */ _handleCalendarBodyKeydown(event: KeyboardEvent): void { // TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent @@ -207,6 +204,22 @@ export class MdCalendar implements AfterContentInit { } } + /** Focuses the active cell after the microtask queue is empty. */ + _focusActiveCell() { + this._ngZone.runOutsideAngular(() => this._ngZone.onStable.first().subscribe(() => { + let activeEl = this._elementRef.nativeElement.querySelector('.mat-calendar-body-active'); + activeEl.focus(); + })); + } + + /** Whether the two dates represent the same view in the current view mode (month or year). */ + private _isSameView(date1: D, date2: D): boolean { + return this._monthView ? + this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2) && + this._dateAdapter.getMonth(date1) == this._dateAdapter.getMonth(date2) : + this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2); + } + /** Handles keydown events on the calendar body when calendar is in month view. */ private _handleCalendarBodyKeydownInMonthView(event: KeyboardEvent): void { switch (event.keyCode) { @@ -244,14 +257,17 @@ export class MdCalendar implements AfterContentInit { case ENTER: if (this._dateFilterForViews(this._activeDate)) { this._dateSelected(this._activeDate); - break; + // Prevent unexpected default actions such as form submission. + event.preventDefault(); } return; default: - // Don't prevent default on keys that we don't explicitly handle. + // Don't prevent default or focus active cell on keys that we don't explicitly handle. return; } + this._focusActiveCell(); + // Prevent unexpected default actions such as form submission. event.preventDefault(); } @@ -290,10 +306,12 @@ export class MdCalendar implements AfterContentInit { this._monthSelected(this._activeDate); break; default: - // Don't prevent default on keys that we don't explicitly handle. + // Don't prevent default or focus active cell on keys that we don't explicitly handle. return; } + this._focusActiveCell(); + // Prevent unexpected default actions such as form submission. event.preventDefault(); } diff --git a/src/lib/datepicker/datepicker-toggle.scss b/src/lib/datepicker/datepicker-toggle.scss index 92f1f5a42519..dd394dd25f7c 100644 --- a/src/lib/datepicker/datepicker-toggle.scss +++ b/src/lib/datepicker/datepicker-toggle.scss @@ -3,7 +3,8 @@ $mat-datepicker-toggle-icon-size: 24px !default; .mat-datepicker-toggle { display: inline-block; - background: url('data:image/svg+xml;utf8,') no-repeat; + // Note: SVG needs to be base64 encoded or it will not work on IE11. + background: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNHB4IiBoZWlnaHQ9IjI0cHgiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iY3VycmVudENvbG9yIj48cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTE5IDNoLTFWMWgtMnYySDhWMUg2djJINWMtMS4xMSAwLTEuOTkuOS0xLjk5IDJMMyAxOWMwIDEuMS44OSAyIDIgMmgxNGMxLjEgMCAyLS45IDItMlY1YzAtMS4xLS45LTItMi0yem0wIDE2SDVWOGgxNHYxMXpNNyAxMGg1djVIN3oiLz48L3N2Zz4=') no-repeat; background-size: contain; height: $mat-datepicker-toggle-icon-size; width: $mat-datepicker-toggle-icon-size; diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 50dc30c63dfb..ad59b42996e5 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -3,13 +3,13 @@ import { ChangeDetectionStrategy, Component, ComponentRef, - ElementRef, EventEmitter, Inject, Input, OnDestroy, Optional, Output, + ViewChild, ViewContainerRef, ViewEncapsulation } from '@angular/core'; @@ -33,6 +33,8 @@ import {MdDialogConfig} from '../dialog/dialog-config'; import {DateAdapter} from '../core/datetime/index'; import {createMissingDateImplError} from './datepicker-errors'; import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats'; +import {ESCAPE} from '../core/keyboard/keycodes'; +import {MdCalendar} from './calendar'; /** Used to generate a unique ID for each datepicker instance. */ @@ -54,6 +56,7 @@ let datepickerUid = 0; host: { 'class': 'mat-datepicker-content', '[class.mat-datepicker-content-touch]': 'datepicker.touchUi', + '(keydown)': '_handleKeydown($event)', }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, @@ -61,10 +64,27 @@ let datepickerUid = 0; export class MdDatepickerContent implements AfterContentInit { datepicker: MdDatepicker; - constructor(private _elementRef: ElementRef) {} + @ViewChild(MdCalendar) _calendar: MdCalendar; ngAfterContentInit() { - this._elementRef.nativeElement.querySelector('.mat-calendar-content').focus(); + this._calendar._focusActiveCell(); + } + + /** + * Handles keydown event on datepicker content. + * @param event The event. + */ + _handleKeydown(event: KeyboardEvent): void { + switch (event.keyCode) { + case ESCAPE: + this.datepicker.close(); + break; + default: + // Return so that we don't preventDefault on keys that are not explicitly handled. + return; + } + + event.preventDefault(); } } diff --git a/src/lib/datepicker/month-view.html b/src/lib/datepicker/month-view.html index 15991190d67d..4688b323ee8f 100644 --- a/src/lib/datepicker/month-view.html +++ b/src/lib/datepicker/month-view.html @@ -1,9 +1,10 @@ - - + + implements AfterContentInit { _todayDate: number; /** The names of the weekdays. */ - _weekdays: string[]; + _weekdays: {long: string, narrow: string}[]; constructor(@Optional() public _dateAdapter: DateAdapter, @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats) { @@ -91,9 +91,13 @@ export class MdMonthView implements AfterContentInit { } const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); - const weekdays = this._dateAdapter.getDayOfWeekNames('narrow'); + const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow'); + const longWeekdays = this._dateAdapter.getDayOfWeekNames('long'); // Rotate the labels for days of the week based on the configured first day of the week. + let weekdays = longWeekdays.map((long, i) => { + return {long, narrow: narrowWeekdays[i]}; + }); this._weekdays = weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek)); this._activeDate = this._dateAdapter.today(); @@ -140,12 +144,14 @@ export class MdMonthView implements AfterContentInit { this._weeks.push([]); cell = 0; } + let date = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.activeDate), + this._dateAdapter.getMonth(this.activeDate), i + 1); let enabled = !this.dateFilter || - this.dateFilter(this._dateAdapter.createDate( - this._dateAdapter.getYear(this.activeDate), - this._dateAdapter.getMonth(this.activeDate), i + 1)); + this.dateFilter(date); + let ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel); this._weeks[this._weeks.length - 1] - .push(new MdCalendarCell(i + 1, dateNames[i], enabled)); + .push(new MdCalendarCell(i + 1, dateNames[i], ariaLabel, enabled)); } } diff --git a/src/lib/datepicker/year-view.html b/src/lib/datepicker/year-view.html index fe68455874c0..537db7e82634 100644 --- a/src/lib/datepicker/year-view.html +++ b/src/lib/datepicker/year-view.html @@ -3,6 +3,7 @@ implements AfterContentInit { /** Creates an MdCalendarCell for the given month. */ private _createCellForMonth(month: number, monthName: string) { + let ariaLabel = this._dateAdapter.format( + this._dateAdapter.createDate(this._dateAdapter.getYear(this.activeDate), month, 1), + this._dateFormats.display.monthYearA11yLabel); return new MdCalendarCell( - month, monthName.toLocaleUpperCase(), this._isMonthEnabled(month)); + month, monthName.toLocaleUpperCase(), ariaLabel, this._isMonthEnabled(month)); } /** Whether the given month is enabled. */ From a6428e5da2fcf9b0cd695db1c53003efbae0b1d4 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Wed, 3 May 2017 14:43:26 -0700 Subject: [PATCH 29/37] docs(datepicker): update readme & demo (#4368) * docs(datepicker) update readme * address comments * Add a more comprehensive demo * avoid string date properties in docs --- src/demo-app/datepicker/datepicker-demo.html | 62 ++++-- src/demo-app/datepicker/datepicker-demo.ts | 11 +- src/lib/datepicker/README.md | 4 +- src/lib/datepicker/datepicker-content.html | 1 + src/lib/datepicker/datepicker.md | 191 +++++++++++++++++++ src/lib/datepicker/datepicker.ts | 3 + 6 files changed, 251 insertions(+), 21 deletions(-) create mode 100644 src/lib/datepicker/datepicker.md diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index c42cf75c2693..1bc0d5964b2a 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -1,27 +1,61 @@ -

Work in progress, not ready for use.

- +

Options

Use touch UI + Filter odd months and dates + Start in year view

-

- - - + + + + + + + + + +

- - - + + +

+ +

Result

+

- + - - - + + + +

+

+ + + +

diff --git a/src/demo-app/datepicker/datepicker-demo.ts b/src/demo-app/datepicker/datepicker-demo.ts index ffd03c9ca1ea..7a70a9c0b4bb 100644 --- a/src/demo-app/datepicker/datepicker-demo.ts +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -8,9 +8,12 @@ import {Component} from '@angular/core'; styleUrls: ['datepicker-demo.css'], }) export class DatepickerDemo { + touch: boolean; + filterOdd: boolean; + yearView: boolean; + minDate: Date; + maxDate: Date; + startAt: Date; date: Date; - touch = false; - dateFilter = - (date: Date) => !this._blacklistedMonths.has(date.getMonth()) && date.getDate() % 2 == 0 - private _blacklistedMonths = new Set([2, 3]); + dateFilter = (date: Date) => date.getMonth() % 2 == 0 && date.getDate() % 2 == 0; } diff --git a/src/lib/datepicker/README.md b/src/lib/datepicker/README.md index f206ff3f0b46..2ee9acdf6254 100644 --- a/src/lib/datepicker/README.md +++ b/src/lib/datepicker/README.md @@ -1,3 +1 @@ -# md-datepicker - -Work in progress, not ready for use. +Please see the official documentation at https://material.angular.io/components/component/datepicker diff --git a/src/lib/datepicker/datepicker-content.html b/src/lib/datepicker/datepicker-content.html index f546bfcd5dda..dd8a696741cb 100644 --- a/src/lib/datepicker/datepicker-content.html +++ b/src/lib/datepicker/datepicker-content.html @@ -1,6 +1,7 @@ + +### Current state +Currently the datepicker is in the beginning stages and supports basic date selection functionality. +There are many more features that will be added in future iterations, including: + * Support for datetimes (e.g. May 2, 2017 at 12:30pm) and month + year only (e.g. May 2017) + * Support for selecting and displaying date ranges + * Support for custom time zones + * Infinite scrolling through calendar months + * Built in support for [Moment.js](https://momentjs.com/) dates + +### Connecting a datepicker to an input +A datepicker is composed of a text input and a calendar pop-up, connected via the `mdDatepicker` +property on the text input. + +```html + + +``` + +An optional datepicker toggle button is available. A toggle can be added to the example above: + +```html + + + +``` + +This works exactly the same with an input that is part of an `` and the toggle +can easily be used as a prefix or suffix on the material input: + +```html + + + + + +``` + +### Setting the calendar starting view +By default the calendar will open in month view, this can be changed by setting the `startView` +property of `md-datepicker` to `"year"`. In year view the user will see all months of the year and +then proceed to month view after choosing a month. + +The month or year that the calendar opens to is determined by first checking if any date is +currently selected, if so it will open to the month or year containing that date. Otherwise it will +open to the month or year containing today's date. This behavior can be overridden by using the +`startAt` property of `md-datepicker`. In this case the calendar will open to the month or year +containing the `startAt` date. + +```ts +startDate = new Date(1990, 0, 1); +``` + +```html +... + +``` + +### Preventing selection of specific dates +There are two ways to restrict the dates available for selection in the datepicker. The first is by +using the `min` and `max` properties of the input. This will disable all dates on the calendar +before or after the respective given dates. It will also prevent the user from advancing the +calendar past the `month` or `year` (depending on current view) containing the `min` or `max` date. + +The second way to restrict selection is using the `dateFilter` property of `md-datepicker`. The +`dateFilter` property accepts a function of ` => boolean` (where `` is the date type used by +the datepicker, see section on +[choosing a date implementation](#choosing-a-date-implementation-and-date-format-settings)). +A result of `true` indicates that the date is selectable and a result of `false` indicates that it +is not. One important difference between using `dateFilter` vs using `min` or `max` is that +filtering out all dates before a certain point, will not prevent the user from advancing the +calendar past that point. + +```ts +myFilter = (d: Date) => d.getFullYear() > 2005 +minDate = new Date(2000, 0, 1); +maxDate = new Date(2020, 11, 31); +``` + +```html + + +``` + +In this example the user can back past 2005, but all of the dates before then will be unselectable. +They will not be able to go further back in the calendar than 2000. + +### Touch UI mode +The datepicker normally opens as a popup under the input. However this is not ideal for touch +devices that don't have as much screen real estate and need bigger click targets. For this reason +`md-datepicker` has a `touchUi` property that can be set to `true` in order to enable a more touch +friendly UI where the calendar opens in a large dialog. + +### Manually opening and closing the calendar +The calendar popup can be programmatically controlled using the `open` and `close` methods on the +`md-datepicker`. It also has an `opened` property that reflects the status of the popup. + +```ts +@Component({...}) +export class MyComponent implements AfterViewInit { + @ViewChild(MdDatepicker) dp: MdDatepicker; + + ngAfterViewInit() { + dp.open(); + } +} +``` + +### Choosing a date implementation and date format settings +The datepicker was built to be date implementation agnostic. This means that it can be made to work +with a variety of different date implementations. However it also means that developers need to make +sure to provide the appropriate pieces for the datepicker to work with their chosen implementation. +The easiest way to ensure this is just to import one of the pre-made modules (currently +`MdNativeDateModule` is the only implementation that ships with material, but there are plans to add +a module for Moment.js support): + * `MdNativeDateModule` - support for native JavaScript Date object + +These modules include providers for `DateAdapter` and `MD_DATE_FORMATS` + +```ts +@NgModule({ + imports: [MdDatepickerModule, MdNativeDateModule], +}) +export class MyApp {} +``` + +Because `DateAdapter` is a generic class, `MdDatepicker` and `MdDatepickerInput` also need to be +made generic. When working with these classes (for example as a `ViewChild`) you should include the +appropriate generic type that corresponds to the `DateAdapter` implementation you are using. For +example: + +```ts +@Component({...}) +export class MyComponent { + @ViewChild(MdDatepicker) datepicker: MdDatepicker; +} +``` + +#### Customizing the date implementation +The datepicker does all of its interaction with date objects via the `DateAdapter`. Making the +datepicker work with a different date implementation is as easy as extending `DateAdapter`, and +using your subclass as the provider. You will also want to make sure that the `MD_DATE_FORMATS` +provided in your app are formats that can be understood by your date implementation. + +```ts +@NgModule({ + imports: [MdDatepickerModule], + providers: [ + {provide: DateAdapter, useClass: MyDateAdapter}, + {provide: MD_DATE_FORMATS, useValue: MY_DATE_FORMATS}, + ], +}) +export class MyApp {} +``` + +#### Customizing the parse and display formats +The `MD_DATE_FORMATS` object is just a collection of formats that the datepicker uses when parsing +and displaying dates. These formats are passed through to the `DateAdapter` so you will want to make +sure that the format objects you're using are compatible with the `DateAdapter` used in your app. +This example shows how to use the native `Date` implementation from material, but with custom +formats. + +```ts +@NgModule({ + imports: [MdDatepickerModule], + providers: [ + {provide: DateAdapter, useClass: NativeDateAdapter}, + {provide: MD_DATE_FORMATS, useValue: MY_NATIVE_DATE_FORMATS}, + ], +}) +export class MyApp {} +``` + +### Localizing labels and messages +The various text strings used by the datepicker are provided through `MdDatepickerIntl`. +Localization of these messages can be done by providing a subclass with translated values in your +application root module. + +```ts +@NgModule({ + imports: [MdDatepickerModule, MdNativeDateModule], + providers: [ + {provide: MdDatepickerIntl, useClass: MyIntl}, + ], +}) +export class MyApp {} +``` diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index ad59b42996e5..403db81325b1 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -111,6 +111,9 @@ export class MdDatepicker implements OnDestroy { } private _startAt: D; + @Input() + startView: 'month' | 'year' = 'month'; + /** * Whether the calendar UI is in touch mode. In touch mode the calendar opens in a dialog rather * than a popup and elements have more padding to allow for bigger touch targets. From 7964052117ed0f45df330fd7ecda8cd7c25389f8 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Wed, 3 May 2017 15:26:06 -0700 Subject: [PATCH 30/37] fix(datepicker): require actual date objects for min, max, etc (#4381) --- src/lib/datepicker/calendar-body.html | 1 + src/lib/datepicker/calendar.spec.ts | 13 +++++++++--- src/lib/datepicker/calendar.ts | 28 ++++---------------------- src/lib/datepicker/datepicker-input.ts | 14 ++----------- src/lib/datepicker/datepicker.spec.ts | 13 ++++++++---- src/lib/datepicker/datepicker.ts | 21 ++++++------------- src/lib/datepicker/month-view.spec.ts | 3 ++- src/lib/datepicker/month-view.ts | 5 ++--- src/lib/datepicker/year-view.spec.ts | 3 ++- src/lib/datepicker/year-view.ts | 5 ++--- 10 files changed, 40 insertions(+), 66 deletions(-) diff --git a/src/lib/datepicker/calendar-body.html b/src/lib/datepicker/calendar-body.html index b2891281d2e9..a668b1060fef 100644 --- a/src/lib/datepicker/calendar-body.html +++ b/src/lib/datepicker/calendar-body.html @@ -24,6 +24,7 @@ [class.mat-calendar-body-disabled]="!item.enabled" [class.mat-calendar-body-active]="_isActiveCell(rowIndex, colIndex)" [attr.aria-label]="item.ariaLabel" + [attr.aria-disabled]="!item.enabled || null" (click)="_cellClicked(item)">
{ @Component({ - template: `` + template: `` }) class StandardCalendar { selected: Date = null; + startDate = new Date(2017, JAN, 31); } @Component({ - template: `` + template: ` + + ` }) class CalendarWithMinMax { startAt: Date; + minDate = new Date(2016, JAN, 1); + maxDate = new Date(2018, JAN, 1); } @Component({ template: ` - + + ` }) class CalendarWithDateFilter { selected: Date = null; + startDate = new Date(2017, JAN, 1); dateFilter (date: Date) { return date.getDate() % 2 == 0 && date.getMonth() != NOV; diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index c323eb29b630..9c6905a7f02d 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -45,39 +45,19 @@ import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats'; }) export class MdCalendar implements AfterContentInit { /** A date representing the period (month or year) to start the calendar in. */ - @Input() - get startAt(): D { return this._startAt; } - set startAt(value: D) { - this._startAt = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); - } - private _startAt: D; + @Input() startAt: D; /** Whether the calendar should be started in month or year view. */ @Input() startView: 'month' | 'year' = 'month'; /** The currently selected date. */ - @Input() - get selected(): D { return this._selected; } - set selected(value: D) { - this._selected = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); - } - private _selected: D; + @Input() selected: D; /** The minimum selectable date. */ - @Input() - get minDate(): D { return this._minDate; } - set minDate(date: D) { - this._minDate = this._dateAdapter.parse(date, this._dateFormats.parse.dateInput); - } - private _minDate: D; + @Input() minDate: D; /** The maximum selectable date. */ - @Input() - get maxDate(): D { return this._maxDate; } - set maxDate(date: D) { - this._maxDate = this._dateAdapter.parse(date, this._dateFormats.parse.dateInput); - } - private _maxDate: D; + @Input() maxDate: D; /** A function used to filter which dates are selectable. */ @Input() dateFilter: (date: D) => boolean; diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index a0361fbe34ad..7767fcae98ef 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -73,20 +73,10 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces } /** The minimum valid date. */ - @Input() - get min(): D { return this._min; } - set min(value: D) { - this._min = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); - } - private _min: D; + @Input() min: D; /** The maximum valid date. */ - @Input() - get max(): D { return this._max; } - set max(value: D) { - this._max = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); - } - private _max: D; + @Input() max: D; /** Emits when the value changes (either due to user input or programmatic change). */ _valueChange = new EventEmitter(); diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index be161e143591..f92e76672fa9 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -466,12 +466,13 @@ describe('MdDatepicker', () => { @Component({ template: ` - + `, }) class StandardDatepicker { touch = false; + date = new Date(2020, JAN, 1); @ViewChild('d') datepicker: MdDatepicker; @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; } @@ -495,11 +496,13 @@ class NoInputDatepicker { @Component({ template: ` - - + + `, }) class DatepickerWithStartAt { + date = new Date(2020, JAN, 1); + startDate = new Date(2010, JAN, 1); @ViewChild('d') datepicker: MdDatepicker; } @@ -555,10 +558,12 @@ class InputContainerDatepicker { @Component({ template: ` - + `, }) class DatepickerWithMinAndMax { + minDate = new Date(2010, JAN, 1); + maxDate = new Date(2020, JAN, 1); @ViewChild('d') datepicker: MdDatepicker; } diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 403db81325b1..17f8945e2c44 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -4,7 +4,6 @@ import { Component, ComponentRef, EventEmitter, - Inject, Input, OnDestroy, Optional, @@ -32,7 +31,6 @@ import {Subscription} from 'rxjs/Subscription'; import {MdDialogConfig} from '../dialog/dialog-config'; import {DateAdapter} from '../core/datetime/index'; import {createMissingDateImplError} from './datepicker-errors'; -import {MD_DATE_FORMATS, MdDateFormats} from '../core/datetime/date-formats'; import {ESCAPE} from '../core/keyboard/keycodes'; import {MdCalendar} from './calendar'; @@ -106,24 +104,20 @@ export class MdDatepicker implements OnDestroy { // selected value is. return this._startAt || (this._datepickerInput ? this._datepickerInput.value : null); } - set startAt(date: D) { - this._startAt = this._dateAdapter.parse(date, this._dateFormats.parse.dateInput); - } + set startAt(date: D) { this._startAt = date; } private _startAt: D; - @Input() - startView: 'month' | 'year' = 'month'; + /** The view that the calendar should start in. */ + @Input() startView: 'month' | 'year' = 'month'; /** * Whether the calendar UI is in touch mode. In touch mode the calendar opens in a dialog rather * than a popup and elements have more padding to allow for bigger touch targets. */ - @Input() - touchUi = false; + @Input() touchUi = false; /** A function used to filter which dates are selectable. */ - @Input() - dateFilter: (date: D) => boolean; + @Input() dateFilter: (date: D) => boolean; /** Emits new selected date when selected date changes. */ @Output() selectedChanged = new EventEmitter(); @@ -164,14 +158,11 @@ export class MdDatepicker implements OnDestroy { constructor(private _dialog: MdDialog, private _overlay: Overlay, private _viewContainerRef: ViewContainerRef, @Optional() private _dateAdapter: DateAdapter, - @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats, @Optional() private _dir: Dir) { if (!this._dateAdapter) { throw createMissingDateImplError('DateAdapter'); } - if (!this._dateFormats) { - throw createMissingDateImplError('MD_DATE_FORMATS'); - } + } ngOnDestroy() { diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts index 9b74cc7f1a3a..9cef08b5897f 100644 --- a/src/lib/datepicker/month-view.spec.ts +++ b/src/lib/datepicker/month-view.spec.ts @@ -117,9 +117,10 @@ class StandardMonthView { @Component({ - template: `` + template: `` }) class MonthViewWithDateFilter { + activeDate = new Date(2017, JAN, 1); dateFilter(date: Date) { return date.getDate() % 2 == 0; } diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts index d25036d9a6ca..19c7570d204d 100644 --- a/src/lib/datepicker/month-view.ts +++ b/src/lib/datepicker/month-view.ts @@ -37,8 +37,7 @@ export class MdMonthView implements AfterContentInit { get activeDate(): D { return this._activeDate; } set activeDate(value: D) { let oldActiveDate = this._activeDate; - this._activeDate = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput) || - this._dateAdapter.today(); + this._activeDate = value || this._dateAdapter.today(); if (!this._hasSameMonthAndYear(oldActiveDate, this._activeDate)) { this._init(); } @@ -49,7 +48,7 @@ export class MdMonthView implements AfterContentInit { @Input() get selected(): D { return this._selected; } set selected(value: D) { - this._selected = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); + this._selected = value; this._selectedDate = this._getDateInCurrentMonth(this.selected); } private _selected: D; diff --git a/src/lib/datepicker/year-view.spec.ts b/src/lib/datepicker/year-view.spec.ts index b1fcc3269dd4..496938f7842d 100644 --- a/src/lib/datepicker/year-view.spec.ts +++ b/src/lib/datepicker/year-view.spec.ts @@ -118,9 +118,10 @@ class StandardYearView { @Component({ - template: `` + template: `` }) class YearViewWithDateFilter { + activeDate = new Date(2017, JAN, 1); dateFilter(date: Date) { if (date.getMonth() == JAN) { return date.getDate() == 10; diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts index ca27058130e3..16532964571f 100644 --- a/src/lib/datepicker/year-view.ts +++ b/src/lib/datepicker/year-view.ts @@ -32,8 +32,7 @@ export class MdYearView implements AfterContentInit { get activeDate(): D { return this._activeDate; } set activeDate(value: D) { let oldActiveDate = this._activeDate; - this._activeDate = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput) || - this._dateAdapter.today(); + this._activeDate = value || this._dateAdapter.today(); if (this._dateAdapter.getYear(oldActiveDate) != this._dateAdapter.getYear(this._activeDate)) { this._init(); } @@ -44,7 +43,7 @@ export class MdYearView implements AfterContentInit { @Input() get selected(): D { return this._selected; } set selected(value: D) { - this._selected = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); + this._selected = value; this._selectedMonth = this._getMonthInCurrentYear(this.selected); } private _selected: D; From 14b252336f126d82178a66224f614b6187f2dbc2 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 5 May 2017 15:01:18 -0700 Subject: [PATCH 31/37] feat(datepicker): input validation for min, max, and date filter (#4393) * support validation based on date filter * add validation support for min & max * update docs to talk about validation * fix imports * address comments --- src/demo-app/datepicker/datepicker-demo.html | 12 ++- src/demo-app/datepicker/datepicker-demo.ts | 2 +- src/lib/core/datetime/date-adapter.ts | 8 ++ src/lib/core/datetime/native-date-adapter.ts | 17 ++++ src/lib/datepicker/datepicker-content.html | 2 +- src/lib/datepicker/datepicker-input.ts | 100 ++++++++++++++++--- src/lib/datepicker/datepicker.md | 36 ++++--- src/lib/datepicker/datepicker.spec.ts | 79 +++++++++++++-- src/lib/datepicker/datepicker.ts | 7 +- 9 files changed, 215 insertions(+), 48 deletions(-) diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index 1bc0d5964b2a..1f6f5a882802 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -30,18 +30,22 @@

Result

+ Too early! + Too late! + Date unavailable! + [startView]="yearView ? 'year' : 'month'">

@@ -49,13 +53,13 @@

Result

[(ngModel)]="date" [min]="minDate" [max]="maxDate" + [mdDatepickerFilter]="filterOdd ? dateFilter : null" placeholder="Pick a date"> + [startView]="yearView ? 'year' : 'month'">

diff --git a/src/demo-app/datepicker/datepicker-demo.ts b/src/demo-app/datepicker/datepicker-demo.ts index 7a70a9c0b4bb..0b2264e380dd 100644 --- a/src/demo-app/datepicker/datepicker-demo.ts +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -15,5 +15,5 @@ export class DatepickerDemo { maxDate: Date; startAt: Date; date: Date; - dateFilter = (date: Date) => date.getMonth() % 2 == 0 && date.getDate() % 2 == 0; + dateFilter = (date: Date) => date.getMonth() % 2 == 1 && date.getDate() % 2 == 0; } diff --git a/src/lib/core/datetime/date-adapter.ts b/src/lib/core/datetime/date-adapter.ts index 33148d3aa714..938d318339af 100644 --- a/src/lib/core/datetime/date-adapter.ts +++ b/src/lib/core/datetime/date-adapter.ts @@ -140,6 +140,14 @@ export abstract class DateAdapter { */ abstract addCalendarDays(date: D, days: number): D; + /** + * Gets the RFC 3339 compatible date string (https://tools.ietf.org/html/rfc3339) for the given + * date. + * @param date The date to get the ISO date string for. + * @returns The ISO date string date string. + */ + abstract getISODateString(date: D): string; + /** * Sets the locale used for all dates. * @param locale The new locale. diff --git a/src/lib/core/datetime/native-date-adapter.ts b/src/lib/core/datetime/native-date-adapter.ts index 7ea5c8e4efac..9e952076eb86 100644 --- a/src/lib/core/datetime/native-date-adapter.ts +++ b/src/lib/core/datetime/native-date-adapter.ts @@ -160,6 +160,14 @@ export class NativeDateAdapter extends DateAdapter { this.getYear(date), this.getMonth(date), this.getDate(date) + days); } + getISODateString(date: Date): string { + return [ + date.getUTCFullYear(), + this._2digit(date.getUTCMonth() + 1), + this._2digit(date.getUTCDate()) + ].join('-'); + } + /** Creates a date but allows the month and date to overflow. */ private _createDateWithOverflow(year: number, month: number, date: number) { let result = new Date(year, month, date); @@ -171,4 +179,13 @@ export class NativeDateAdapter extends DateAdapter { } return result; } + + /** + * Pads a number to make it two digits. + * @param n The number to pad. + * @returns The padded number. + */ + private _2digit(n: number) { + return ('00' + n).slice(-2); + } } diff --git a/src/lib/datepicker/datepicker-content.html b/src/lib/datepicker/datepicker-content.html index dd8a696741cb..0f3a70ec7dff 100644 --- a/src/lib/datepicker/datepicker-content.html +++ b/src/lib/datepicker/datepicker-content.html @@ -4,7 +4,7 @@ [startView]="datepicker.startView" [minDate]="datepicker._minDate" [maxDate]="datepicker._maxDate" - [dateFilter]="datepicker.dateFilter" + [dateFilter]="datepicker._dateFilter" [selected]="datepicker._selected" (selectedChange)="datepicker._selectAndClose($event)"> diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index 7767fcae98ef..d40868dd69ac 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -11,7 +11,16 @@ import { Renderer } from '@angular/core'; import {MdDatepicker} from './datepicker'; -import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + ValidatorFn, + Validators +} from '@angular/forms'; import {Subscription} from 'rxjs/Subscription'; import {MdInputContainer} from '../input/input-container'; import {DOWN_ARROW} from '../core/keyboard/keycodes'; @@ -27,22 +36,30 @@ export const MD_DATEPICKER_VALUE_ACCESSOR: any = { }; +export const MD_DATEPICKER_VALIDATORS: any = { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MdDatepickerInput), + multi: true +}; + + /** Directive used to connect an input to a MdDatepicker. */ @Directive({ selector: 'input[mdDatepicker], input[matDatepicker]', - providers: [MD_DATEPICKER_VALUE_ACCESSOR], + providers: [MD_DATEPICKER_VALUE_ACCESSOR, MD_DATEPICKER_VALIDATORS], host: { '[attr.aria-expanded]': '_datepicker?.opened || "false"', '[attr.aria-haspopup]': 'true', '[attr.aria-owns]': '_datepicker?.id', - '[min]': '_min', - '[max]': '_max', + '[attr.min]': 'min ? _dateAdapter.getISODateString(min) : null', + '[attr.max]': 'max ? _dateAdapter.getISODateString(max) : null', '(input)': '_onInput($event.target.value)', '(blur)': '_onTouched()', '(keydown)': '_onKeydown($event)', } }) -export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor, OnDestroy { +export class MdDatepickerInput implements AfterContentInit, ControlValueAccessor, OnDestroy, + Validator { /** The datepicker that this input is associated with. */ @Input() set mdDatepicker(value: MdDatepicker) { @@ -53,8 +70,17 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces } _datepicker: MdDatepicker; - @Input() - set matDatepicker(value: MdDatepicker) { this.mdDatepicker = value; } + @Input() set matDatepicker(value: MdDatepicker) { this.mdDatepicker = value; } + + @Input() set mdDatepickerFilter(filter: (date: D | null) => boolean) { + this._dateFilter = filter; + this._validatorOnChange(); + } + _dateFilter: (date: D | null) => boolean; + + @Input() set matDatepickerFilter(filter: (date: D | null) => boolean) { + this.mdDatepickerFilter = filter; + } /** The value of the input. */ @Input() @@ -73,20 +99,58 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces } /** The minimum valid date. */ - @Input() min: D; + @Input() + get min(): D { return this._min; } + set min(value: D) { + this._min = value; + this._validatorOnChange(); + } + private _min: D; /** The maximum valid date. */ - @Input() max: D; + @Input() + get max(): D { return this._max; } + set max(value: D) { + this._max = value; + this._validatorOnChange(); + } + private _max: D; /** Emits when the value changes (either due to user input or programmatic change). */ _valueChange = new EventEmitter(); - _onChange = (value: any) => {}; - _onTouched = () => {}; + private _cvaOnChange = (value: any) => {}; + + private _validatorOnChange = () => {}; + private _datepickerSubscription: Subscription; + /** The form control validator for the min date. */ + private _minValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + return (!this.min || !control.value || + this._dateAdapter.compareDate(this.min, control.value) < 0) ? + null : {'mdDatepickerMin': {'min': this.min, 'actual': control.value}}; + } + + /** The form control validator for the max date. */ + private _maxValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + return (!this.max || !control.value || + this._dateAdapter.compareDate(this.max, control.value) > 0) ? + null : {'mdDatepickerMax': {'max': this.max, 'actual': control.value}}; + } + + /** The form control validator for the date filter. */ + private _filterValidator: ValidatorFn = (control: AbstractControl): ValidationErrors | null => { + return !this._dateFilter || !control.value || this._dateFilter(control.value) ? + null : {'mdDatepickerFilter': true}; + } + + /** The combined form control validator for this input. */ + private _validator: ValidatorFn = + Validators.compose([this._minValidator, this._maxValidator, this._filterValidator]); + constructor( private _elementRef: ElementRef, private _renderer: Renderer, @@ -106,7 +170,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces this._datepickerSubscription = this._datepicker.selectedChanged.subscribe((selected: D) => { this.value = selected; - this._onChange(selected); + this._cvaOnChange(selected); }); } } @@ -117,6 +181,14 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces } } + registerOnValidatorChange(fn: () => void): void { + this._validatorOnChange = fn; + } + + validate(c: AbstractControl): ValidationErrors | null { + return this._validator ? this._validator(c) : null; + } + /** * Gets the element that the datepicker popup should be connected to. * @return The element to connect the popup to. @@ -132,7 +204,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces // Implemented as part of ControlValueAccessor registerOnChange(fn: (value: any) => void): void { - this._onChange = fn; + this._cvaOnChange = fn; } // Implemented as part of ControlValueAccessor @@ -154,7 +226,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces _onInput(value: string) { let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); - this._onChange(date); + this._cvaOnChange(date); this._valueChange.emit(date); } } diff --git a/src/lib/datepicker/datepicker.md b/src/lib/datepicker/datepicker.md index 56278e9931b6..ec235cfcc3dc 100644 --- a/src/lib/datepicker/datepicker.md +++ b/src/lib/datepicker/datepicker.md @@ -60,20 +60,22 @@ startDate = new Date(1990, 0, 1); ``` -### Preventing selection of specific dates -There are two ways to restrict the dates available for selection in the datepicker. The first is by -using the `min` and `max` properties of the input. This will disable all dates on the calendar -before or after the respective given dates. It will also prevent the user from advancing the -calendar past the `month` or `year` (depending on current view) containing the `min` or `max` date. +### Date validation +There are three properties that add date validation to the datepicker input. The first two are the +`min` and `max` properties. In addition to enforcing validation on the input, these properties will +disable all dates on the calendar popup before or after the respective values and prevent the user +from advancing the calendar past the `month` or `year` (depending on current view) containing the +`min` or `max` date. -The second way to restrict selection is using the `dateFilter` property of `md-datepicker`. The -`dateFilter` property accepts a function of ` => boolean` (where `` is the date type used by +The second way to add date validation is using the `mdDatepickerFilter` property of the datepicker +input. This property accepts a function of ` => boolean` (where `` is the date type used by the datepicker, see section on [choosing a date implementation](#choosing-a-date-implementation-and-date-format-settings)). -A result of `true` indicates that the date is selectable and a result of `false` indicates that it -is not. One important difference between using `dateFilter` vs using `min` or `max` is that -filtering out all dates before a certain point, will not prevent the user from advancing the -calendar past that point. +A result of `true` indicates that the date is valid and a result of `false` indicates that it is +not. Again this will also disable the dates on the calendar that are invalid. However, one important +difference between using `mdDatepickerFilter` vs using `min` or `max` is that filtering out all +dates before or after a certain point, will not prevent the user from advancing the calendar past +that point. ```ts myFilter = (d: Date) => d.getFullYear() > 2005 @@ -82,12 +84,18 @@ maxDate = new Date(2020, 11, 31); ``` ```html - - + + ``` In this example the user can back past 2005, but all of the dates before then will be unselectable. -They will not be able to go further back in the calendar than 2000. +They will not be able to go further back in the calendar than 2000. If they manually type in a date +that is before the min, after the max, or filtered out, the input will have validation errors. + +Each validation property has a different error that can be checked: + * A value that violates the `min` property will have a `mdDatepickerMin` error. + * A value that violates the `max` property will have a `mdDatepickerMax` error. + * A value that violates the `mdDatepickerFilter` property will have a `mdDatepickerFilter` error. ### Touch UI mode The datepicker normally opens as a popup under the input. However this is not ideal for touch diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index f92e76672fa9..19d91acfbc2e 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -30,6 +30,7 @@ describe('MdDatepicker', () => { ReactiveFormsModule, ], declarations: [ + DatepickerWithFilterAndValidation, DatepickerWithFormControl, DatepickerWithMinAndMax, DatepickerWithNgModel, @@ -428,6 +429,58 @@ describe('MdDatepicker', () => { expect(testComponent.datepicker._maxDate).toEqual(new Date(2020, JAN, 1)); }); }); + + describe('datepicker with filter and validation', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithFilterAndValidation; + + beforeEach(async(() => { + fixture = TestBed.createComponent(DatepickerWithFilterAndValidation); + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + })); + + afterEach(async(() => { + testComponent.datepicker.close(); + fixture.detectChanges(); + })); + + it('should mark input invalid', async(() => { + testComponent.date = new Date(2017, JAN, 1); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('input')).nativeElement.classList) + .toContain('ng-invalid'); + + testComponent.date = new Date(2017, JAN, 2); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(fixture.debugElement.query(By.css('input')).nativeElement.classList) + .not.toContain('ng-invalid'); + }); + }); + })); + + it('should disable filtered calendar cells', () => { + fixture.detectChanges(); + + testComponent.datepicker.open(); + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + + let cells = document.querySelectorAll('.mat-calendar-body-cell'); + expect(cells[0].classList).toContain('mat-calendar-body-disabled'); + expect(cells[1].classList).not.toContain('mat-calendar-body-disabled'); + }); + }); }); describe('with missing DateAdapter and MD_DATE_FORMATS', () => { @@ -440,17 +493,7 @@ describe('MdDatepicker', () => { NoopAnimationsModule, ReactiveFormsModule, ], - declarations: [ - DatepickerWithFormControl, - DatepickerWithMinAndMax, - DatepickerWithNgModel, - DatepickerWithStartAt, - DatepickerWithToggle, - InputContainerDatepicker, - MultiInputDatepicker, - NoInputDatepicker, - StandardDatepicker, - ], + declarations: [StandardDatepicker], }); TestBed.compileComponents(); @@ -567,3 +610,17 @@ class DatepickerWithMinAndMax { maxDate = new Date(2020, JAN, 1); @ViewChild('d') datepicker: MdDatepicker; } + + +@Component({ + template: ` + + + + `, +}) +class DatepickerWithFilterAndValidation { + @ViewChild('d') datepicker: MdDatepicker; + date: Date; + filter = (date: Date) => date.getDate() != 1; +} diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 17f8945e2c44..4ba113254244 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -116,9 +116,6 @@ export class MdDatepicker implements OnDestroy { */ @Input() touchUi = false; - /** A function used to filter which dates are selectable. */ - @Input() dateFilter: (date: D) => boolean; - /** Emits new selected date when selected date changes. */ @Output() selectedChanged = new EventEmitter(); @@ -141,6 +138,10 @@ export class MdDatepicker implements OnDestroy { return this._datepickerInput && this._datepickerInput.max; } + get _dateFilter(): (date: D | null) => boolean { + return this._datepickerInput && this._datepickerInput._dateFilter; + } + /** A reference to the overlay when the calendar is opened as a popup. */ private _popupRef: OverlayRef; From 7fe110ad723886cd82819a35d7a035440519887f Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Fri, 5 May 2017 15:41:12 -0700 Subject: [PATCH 32/37] refactor(datepicker): migrate from Renderer to Renderer2 --- src/lib/datepicker/datepicker-input.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index d40868dd69ac..5dc2304436d7 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -8,7 +8,7 @@ import { Input, OnDestroy, Optional, - Renderer + Renderer2 } from '@angular/core'; import {MdDatepicker} from './datepicker'; import { @@ -91,7 +91,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces set value(value: D) { let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); let oldDate = this.value; - this._renderer.setElementProperty(this._elementRef.nativeElement, 'value', + this._renderer.setProperty(this._elementRef.nativeElement, 'value', date ? this._dateAdapter.format(date, this._dateFormats.display.dateInput) : ''); if (!this._dateAdapter.sameDate(oldDate, date)) { this._valueChange.emit(date); @@ -153,7 +153,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces constructor( private _elementRef: ElementRef, - private _renderer: Renderer, + private _renderer: Renderer2, @Optional() private _dateAdapter: DateAdapter, @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats, @Optional() private _mdInputContainer: MdInputContainer) { @@ -214,7 +214,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces // Implemented as part of ControlValueAccessor setDisabledState(disabled: boolean): void { - this._renderer.setElementProperty(this._elementRef.nativeElement, 'disabled', disabled); + this._renderer.setProperty(this._elementRef.nativeElement, 'disabled', disabled); } _onKeydown(event: KeyboardEvent) { From 361a05b06ec93e179ae4985dfa90506ae81e3d12 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Mon, 8 May 2017 15:41:24 -0700 Subject: [PATCH 33/37] fix tests on safari 9 --- .../core/datetime/native-date-adapter.spec.ts | 78 +++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/src/lib/core/datetime/native-date-adapter.spec.ts b/src/lib/core/datetime/native-date-adapter.spec.ts index b7fd459e1138..7fdee4cf2a0f 100644 --- a/src/lib/core/datetime/native-date-adapter.spec.ts +++ b/src/lib/core/datetime/native-date-adapter.spec.ts @@ -1,6 +1,9 @@ import {NativeDateAdapter} from './native-date-adapter'; +const SUPPORTS_INTL = typeof Intl != 'undefined'; + + // When constructing a Date, the month is zero-based. This can be confusing, since people are // used to seeing them one-based. So we create these aliases to make reading the tests easier. const JAN = 0, FEB = 1, MAR = 2, APR = 3, MAY = 4, JUN = 5, JUL = 6, AUG = 7, SEP = 8, OCT = 9, @@ -51,9 +54,16 @@ describe('NativeDateAdapter', () => { 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月' - ]); + if (SUPPORTS_INTL) { + expect(adapter.getMonthNames('long')).toEqual([ + '1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月' + ]); + } else { + expect(adapter.getMonthNames('long')).toEqual([ + 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', + 'October', 'November', 'December' + ]); + } }); it('should get date names', () => { @@ -65,11 +75,18 @@ describe('NativeDateAdapter', () => { 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日' - ]); + if (SUPPORTS_INTL) { + 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', () => { @@ -90,9 +107,15 @@ describe('NativeDateAdapter', () => { it('should get day of week names in a different locale', () => { adapter.setLocale('ja-JP'); - expect(adapter.getDayOfWeekNames('long')).toEqual([ - '日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日' - ]); + if (SUPPORTS_INTL) { + expect(adapter.getDayOfWeekNames('long')).toEqual([ + '日曜日', '月曜日', '火曜日', '水曜日', '木曜日', '金曜日', '土曜日' + ]); + } else { + expect(adapter.getDayOfWeekNames('long')).toEqual([ + 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' + ]); + } }); it('should get year name', () => { @@ -101,7 +124,11 @@ describe('NativeDateAdapter', () => { it('should get year name in a different locale', () => { adapter.setLocale('ja-JP'); - expect(adapter.getYearName(new Date(2017, JAN, 1))).toBe('2017年'); + if (SUPPORTS_INTL) { + expect(adapter.getYearName(new Date(2017, JAN, 1))).toBe('2017年'); + } else { + expect(adapter.getYearName(new Date(2017, JAN, 1))).toBe('2017'); + } }); it('should get first day of week', () => { @@ -155,17 +182,36 @@ describe('NativeDateAdapter', () => { }); it('should format', () => { - expect(adapter.format(new Date(2017, JAN, 1))).toEqual('1/1/2017'); + if (SUPPORTS_INTL) { + expect(adapter.format(new Date(2017, JAN, 1))).toEqual('1/1/2017'); + } else { + expect(adapter.format(new Date(2017, JAN, 1))).toEqual('Sun Jan 01 2017'); + } }); it('should format with custom format', () => { - expect(adapter.format(new Date(2017, JAN, 1), {year: 'numeric', month: 'long', day: 'numeric'})) - .toEqual('January 1, 2017'); + if (SUPPORTS_INTL) { + expect(adapter.format(new Date(2017, JAN, 1), { + year: 'numeric', + month: 'long', + day: 'numeric' + })).toEqual('January 1, 2017'); + } else { + expect(adapter.format(new Date(2017, JAN, 1), { + year: 'numeric', + month: 'long', + day: 'numeric' + })).toEqual('Sun Jan 01 2017'); + } }); it('should format with a different locale', () => { adapter.setLocale('ja-JP'); - expect(adapter.format(new Date(2017, JAN, 1))).toEqual('2017/1/1'); + if (SUPPORTS_INTL) { + expect(adapter.format(new Date(2017, JAN, 1))).toEqual('2017/1/1'); + } else { + expect(adapter.format(new Date(2017, JAN, 1))).toEqual('Sun Jan 01 2017'); + } }); it('should add years', () => { From 4f5ccb28d24427becb6a02e07b55334b5f96051c Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Mon, 8 May 2017 16:53:09 -0700 Subject: [PATCH 34/37] fix Edge/IE tests that fail because of different Intl implementation --- .../core/datetime/native-date-adapter.spec.ts | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/src/lib/core/datetime/native-date-adapter.spec.ts b/src/lib/core/datetime/native-date-adapter.spec.ts index 7fdee4cf2a0f..f5f6a6b4c868 100644 --- a/src/lib/core/datetime/native-date-adapter.spec.ts +++ b/src/lib/core/datetime/native-date-adapter.spec.ts @@ -1,4 +1,5 @@ import {NativeDateAdapter} from './native-date-adapter'; +import {Platform} from '../platform/index'; const SUPPORTS_INTL = typeof Intl != 'undefined'; @@ -12,9 +13,11 @@ const JAN = 0, FEB = 1, MAR = 2, APR = 3, MAY = 4, JUN = 5, JUL = 6, AUG = 7, SE describe('NativeDateAdapter', () => { let adapter; + let platform; beforeEach(() => { adapter = new NativeDateAdapter(); + platform = new Platform(); }); it('should get year', () => { @@ -47,9 +50,16 @@ describe('NativeDateAdapter', () => { }); it('should get narrow month names', () => { - expect(adapter.getMonthNames('narrow')).toEqual([ - 'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D' - ]); + // Edge & IE use same value for short and narrow. + if (platform.EDGE || platform.TRIDENT) { + expect(adapter.getMonthNames('narrow')).toEqual([ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' + ]); + } else { + 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', () => { @@ -102,7 +112,14 @@ describe('NativeDateAdapter', () => { }); it('should get narrow day of week names', () => { - expect(adapter.getDayOfWeekNames('narrow')).toEqual(['S', 'M', 'T', 'W', 'T', 'F', 'S']); + // Edge & IE use two-letter narrow days. + if (platform.EDGE || platform.TRIDENT) { + expect(adapter.getDayOfWeekNames('narrow')).toEqual([ + 'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa' + ]); + } else {expect(adapter.getDayOfWeekNames('narrow')).toEqual([ + 'S', 'M', 'T', 'W', 'T', 'F', 'S' + ]);} }); it('should get day of week names in a different locale', () => { @@ -163,7 +180,7 @@ describe('NativeDateAdapter', () => { }); it('should parse string', () => { - expect(adapter.parse('1/1/17')).toEqual(new Date(2017, JAN, 1)); + expect(adapter.parse('1/1/2017')).toEqual(new Date(2017, JAN, 1)); }); it('should parse number', () => { @@ -208,7 +225,12 @@ describe('NativeDateAdapter', () => { it('should format with a different locale', () => { adapter.setLocale('ja-JP'); if (SUPPORTS_INTL) { - expect(adapter.format(new Date(2017, JAN, 1))).toEqual('2017/1/1'); + // Edge & IE use a different format in Japanese. + if (platform.EDGE || platform.TRIDENT) { + expect(adapter.format(new Date(2017, JAN, 1))).toEqual('2017年1月1日'); + } else { + expect(adapter.format(new Date(2017, JAN, 1))).toEqual('2017/1/1'); + } } else { expect(adapter.format(new Date(2017, JAN, 1))).toEqual('Sun Jan 01 2017'); } From 9bbed07bb58b571dd0fbca657a4f810e9c43d635 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 9 May 2017 09:28:39 -0700 Subject: [PATCH 35/37] strip direction characters when formatting --- src/lib/core/datetime/native-date-adapter.ts | 23 +++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/lib/core/datetime/native-date-adapter.ts b/src/lib/core/datetime/native-date-adapter.ts index 9e952076eb86..92a7590b23ac 100644 --- a/src/lib/core/datetime/native-date-adapter.ts +++ b/src/lib/core/datetime/native-date-adapter.ts @@ -56,7 +56,7 @@ export class NativeDateAdapter extends DateAdapter { getMonthNames(style: 'long' | 'short' | 'narrow'): string[] { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, {month: style}); - return range(12, i => dtf.format(new Date(2017, i, 1))); + return range(12, i => this._stripDirectionCharacters(dtf.format(new Date(2017, i, 1)))); } return DEFAULT_MONTH_NAMES[style]; } @@ -64,7 +64,7 @@ export class NativeDateAdapter extends DateAdapter { getDateNames(): string[] { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, {day: 'numeric'}); - return range(31, i => dtf.format(new Date(2017, 0, i + 1))); + return range(31, i => this._stripDirectionCharacters(dtf.format(new Date(2017, 0, i + 1)))); } return DEFAULT_DATE_NAMES; } @@ -72,7 +72,7 @@ export class NativeDateAdapter extends DateAdapter { getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, {weekday: style}); - return range(7, i => dtf.format(new Date(2017, 0, i + 1))); + return range(7, i => this._stripDirectionCharacters(dtf.format(new Date(2017, 0, i + 1)))); } return DEFAULT_DAY_OF_WEEK_NAMES[style]; } @@ -80,7 +80,7 @@ export class NativeDateAdapter extends DateAdapter { getYearName(date: Date): string { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, {year: 'numeric'}); - return dtf.format(date); + return this._stripDirectionCharacters(dtf.format(date)); } return String(this.getYear(date)); } @@ -131,9 +131,9 @@ export class NativeDateAdapter extends DateAdapter { format(date: Date, displayFormat: Object): string { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, displayFormat); - return dtf.format(date); + return this._stripDirectionCharacters(dtf.format(date)); } - return date.toDateString(); + return this._stripDirectionCharacters(date.toDateString()); } addCalendarYears(date: Date, years: number): Date { @@ -188,4 +188,15 @@ export class NativeDateAdapter extends DateAdapter { private _2digit(n: number) { return ('00' + n).slice(-2); } + + /** + * Strip out unicode LTR and RTL characters. Edge and IE insert these into formatted dates while + * other browsers do not. We remove them to make output consistent and because they interfere with + * date parsing. + * @param s The string to strip direction characters from. + * @returns {string} The stripped string. + */ + private _stripDirectionCharacters(s: string) { + return s.replace(/[\u200e\u200f]/g, ''); + } } From 0697350b6803a66b0a044888d9f90f615f3d1b84 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Tue, 9 May 2017 09:51:43 -0700 Subject: [PATCH 36/37] fix lint --- src/lib/core/datetime/native-date-adapter.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/core/datetime/native-date-adapter.spec.ts b/src/lib/core/datetime/native-date-adapter.spec.ts index f5f6a6b4c868..110a23d6caf1 100644 --- a/src/lib/core/datetime/native-date-adapter.spec.ts +++ b/src/lib/core/datetime/native-date-adapter.spec.ts @@ -117,9 +117,11 @@ describe('NativeDateAdapter', () => { expect(adapter.getDayOfWeekNames('narrow')).toEqual([ 'Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa' ]); - } else {expect(adapter.getDayOfWeekNames('narrow')).toEqual([ - 'S', 'M', 'T', 'W', 'T', 'F', 'S' - ]);} + } else { + expect(adapter.getDayOfWeekNames('narrow')).toEqual([ + 'S', 'M', 'T', 'W', 'T', 'F', 'S' + ]); + } }); it('should get day of week names in a different locale', () => { From 3b31d7b88998d3c26530436e72e1b57283e1cbde Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Wed, 10 May 2017 16:17:57 -0700 Subject: [PATCH 37/37] addressed comments --- src/lib/core/datetime/native-date-adapter.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/lib/core/datetime/native-date-adapter.ts b/src/lib/core/datetime/native-date-adapter.ts index 92a7590b23ac..36c4744a0456 100644 --- a/src/lib/core/datetime/native-date-adapter.ts +++ b/src/lib/core/datetime/native-date-adapter.ts @@ -56,7 +56,7 @@ export class NativeDateAdapter extends DateAdapter { getMonthNames(style: 'long' | 'short' | 'narrow'): string[] { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, {month: style}); - return range(12, i => this._stripDirectionCharacters(dtf.format(new Date(2017, i, 1)))); + return range(12, i => this._stripDirectionalityCharacters(dtf.format(new Date(2017, i, 1)))); } return DEFAULT_MONTH_NAMES[style]; } @@ -64,7 +64,8 @@ export class NativeDateAdapter extends DateAdapter { getDateNames(): string[] { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, {day: 'numeric'}); - return range(31, i => this._stripDirectionCharacters(dtf.format(new Date(2017, 0, i + 1)))); + return range(31, i => this._stripDirectionalityCharacters( + dtf.format(new Date(2017, 0, i + 1)))); } return DEFAULT_DATE_NAMES; } @@ -72,7 +73,8 @@ export class NativeDateAdapter extends DateAdapter { getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, {weekday: style}); - return range(7, i => this._stripDirectionCharacters(dtf.format(new Date(2017, 0, i + 1)))); + return range(7, i => this._stripDirectionalityCharacters( + dtf.format(new Date(2017, 0, i + 1)))); } return DEFAULT_DAY_OF_WEEK_NAMES[style]; } @@ -80,7 +82,7 @@ export class NativeDateAdapter extends DateAdapter { getYearName(date: Date): string { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, {year: 'numeric'}); - return this._stripDirectionCharacters(dtf.format(date)); + return this._stripDirectionalityCharacters(dtf.format(date)); } return String(this.getYear(date)); } @@ -131,9 +133,9 @@ export class NativeDateAdapter extends DateAdapter { format(date: Date, displayFormat: Object): string { if (SUPPORTS_INTL_API) { let dtf = new Intl.DateTimeFormat(this.locale, displayFormat); - return this._stripDirectionCharacters(dtf.format(date)); + return this._stripDirectionalityCharacters(dtf.format(date)); } - return this._stripDirectionCharacters(date.toDateString()); + return this._stripDirectionalityCharacters(date.toDateString()); } addCalendarYears(date: Date, years: number): Date { @@ -194,9 +196,9 @@ export class NativeDateAdapter extends DateAdapter { * other browsers do not. We remove them to make output consistent and because they interfere with * date parsing. * @param s The string to strip direction characters from. - * @returns {string} The stripped string. + * @returns The stripped string. */ - private _stripDirectionCharacters(s: string) { + private _stripDirectionalityCharacters(s: string) { return s.replace(/[\u200e\u200f]/g, ''); } }
{{day}}
{{day.narrow}}