diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html new file mode 100644 index 000000000000..1f6f5a882802 --- /dev/null +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -0,0 +1,65 @@ +

Options

+

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

+

+ + + + + + + + + + +

+

+ + + + + +

+ +

Result

+ +

+ + + + Too early! + Too late! + Date unavailable! + + + +

+

+ + + + +

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 new file mode 100644 index 000000000000..0b2264e380dd --- /dev/null +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -0,0 +1,19 @@ +import {Component} from '@angular/core'; + + +@Component({ + moduleId: module.id, + selector: 'datepicker-demo', + templateUrl: 'datepicker-demo.html', + styleUrls: ['datepicker-demo.css'], +}) +export class DatepickerDemo { + touch: boolean; + filterOdd: boolean; + yearView: boolean; + minDate: Date; + maxDate: Date; + startAt: Date; + date: Date; + dateFilter = (date: Date) => date.getMonth() % 2 == 1 && date.getDate() % 2 == 0; +} diff --git a/src/demo-app/demo-app-module.ts b/src/demo-app/demo-app-module.ts index c18519702c1e..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,22 +28,23 @@ 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'; import {StyleDemo} from './style/style-demo'; +import {DatepickerDemo} from './datepicker/datepicker-demo'; @NgModule({ @@ -54,6 +56,7 @@ import {StyleDemo} from './style/style-demo'; ReactiveFormsModule, RouterModule.forRoot(DEMO_APP_ROUTES), MaterialModule, + MdNativeDateModule, MdSelectionModule, ], declarations: [ @@ -64,6 +67,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/core.ts b/src/lib/core/core.ts index 302bdff76340..f1df043b945d 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -120,6 +120,8 @@ export {CompatibilityModule, NoConflictStyleCompatibilityMode} from './compatibi // Common material module export {MdCommonModule} from './common-behaviors/common-module'; +// Datetime +export * from './datetime/index'; @NgModule({ imports: [ diff --git a/src/lib/core/datetime/date-adapter.ts b/src/lib/core/datetime/date-adapter.ts new file mode 100644 index 000000000000..938d318339af --- /dev/null +++ b/src/lib/core/datetime/date-adapter.ts @@ -0,0 +1,200 @@ +/** 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 first day of the week. + * @returns The first day of the week (0-indexed, 0 = Sunday). + */ + 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; + + /** + * 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. 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). 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; + + /** + * Gets today's date. + * @returns Today's date. + */ + abstract today(): D; + + /** + * Parses a date from a value. + * @param value The value to parse. + * @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, parseFormat: any): D | null; + + /** + * Formats a date as a string. + * @param date The value to parse. + * @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, displayFormat: 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; + + /** + * 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. + */ + 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/date-formats.ts b/src/lib/core/datetime/date-formats.ts new file mode 100644 index 000000000000..b5e4bf29aee5 --- /dev/null +++ b/src/lib/core/datetime/date-formats.ts @@ -0,0 +1,17 @@ +import {InjectionToken} from '@angular/core'; + + +export type MdDateFormats = { + parse: { + dateInput: any + }, + display: { + dateInput: any, + monthYearLabel: any, + dateA11yLabel: any, + monthYearA11yLabel: 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 new file mode 100644 index 000000000000..7228f0ca52fe --- /dev/null +++ b/src/lib/core/datetime/index.ts @@ -0,0 +1,22 @@ +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'; +export * from './native-date-adapter'; + + +@NgModule({ + providers: [{provide: DateAdapter, useClass: NativeDateAdapter}], +}) +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 new file mode 100644 index 000000000000..110a23d6caf1 --- /dev/null +++ b/src/lib/core/datetime/native-date-adapter.spec.ts @@ -0,0 +1,299 @@ +import {NativeDateAdapter} from './native-date-adapter'; +import {Platform} from '../platform/index'; + + +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, + NOV = 10, DEC = 11; + + +describe('NativeDateAdapter', () => { + let adapter; + let platform; + + beforeEach(() => { + adapter = new NativeDateAdapter(); + platform = new Platform(); + }); + + 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', () => { + // 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', () => { + adapter.setLocale('ja-JP'); + 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', () => { + 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'); + 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', () => { + 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', () => { + // 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', () => { + adapter.setLocale('ja-JP'); + 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', () => { + expect(adapter.getYearName(new Date(2017, JAN, 1))).toBe('2017'); + }); + + it('should get year name in a different locale', () => { + adapter.setLocale('ja-JP'); + 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', () => { + expect(adapter.getFirstDayOfWeek()).toBe(0); + }); + + it('should create Date', () => { + expect(adapter.createDate(2017, JAN, 1)).toEqual(new Date(2017, JAN, 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 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', () => { + 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 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/2017')).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', () => { + 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', () => { + 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'); + if (SUPPORTS_INTL) { + // 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'); + } + }); + + 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..36c4744a0456 --- /dev/null +++ b/src/lib/core/datetime/native-date-adapter.ts @@ -0,0 +1,204 @@ +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 => this._stripDirectionalityCharacters(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 => this._stripDirectionalityCharacters( + 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 => this._stripDirectionalityCharacters( + 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 this._stripDirectionalityCharacters(dtf.format(date)); + } + return String(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; + } + + getNumDaysInMonth(date: Date): number { + return this.getDate(this._createDateWithOverflow( + this.getYear(date), this.getMonth(date) + 1, 0)); + } + + clone(date: Date): Date { + return this.createDate(this.getYear(date), this.getMonth(date), this.getDate(date)); + } + + createDate(year: number, month: number, date: number): Date { + // 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; + } + + today(): Date { + return new Date(); + } + + 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, displayFormat: Object): string { + if (SUPPORTS_INTL_API) { + let dtf = new Intl.DateTimeFormat(this.locale, displayFormat); + return this._stripDirectionalityCharacters(dtf.format(date)); + } + return this._stripDirectionalityCharacters(date.toDateString()); + } + + addCalendarYears(date: Date, years: number): Date { + return this.addCalendarMonths(date, years * 12); + } + + addCalendarMonths(date: Date, months: number): 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._createDateWithOverflow(this.getYear(newDate), this.getMonth(newDate), 0); + } + + return newDate; + } + + addCalendarDays(date: Date, days: number): Date { + return this._createDateWithOverflow( + 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); + + // 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; + } + + /** + * 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); + } + + /** + * 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 The stripped string. + */ + private _stripDirectionalityCharacters(s: string) { + return s.replace(/[\u200e\u200f]/g, ''); + } +} 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..cfc0ea1e61d4 --- /dev/null +++ b/src/lib/core/datetime/native-date-formats.ts @@ -0,0 +1,14 @@ +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'}, + dateA11yLabel: {year: 'numeric', month: 'long', day: 'numeric'}, + monthYearA11yLabel: {year: 'numeric', month: 'long'}, + } +}; 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..2ee9acdf6254 --- /dev/null +++ b/src/lib/datepicker/README.md @@ -0,0 +1 @@ +Please see the official documentation at https://material.angular.io/components/component/datepicker diff --git a/src/lib/datepicker/_datepicker-theme.scss b/src/lib/datepicker/_datepicker-theme.scss new file mode 100644 index 000000000000..50a4be417d6d --- /dev/null +++ b/src/lib/datepicker/_datepicker-theme.scss @@ -0,0 +1,80 @@ +@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-datepicker-selected-today-box-shadow-width: 1px; + $mat-datepicker-selected-fade-amount: 0.6; + $mat-datepicker-today-fade-amount: 0.2; + + .mat-calendar { + background-color: mat-color($background, card); + } + + .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-table-header { + color: mat-color($foreground, hint-text); + } + + .mat-calendar-table-header-divider::after { + background: mat-color($foreground, divider); + } + + .mat-calendar-body-label { + color: mat-color($foreground, secondary-text); + } + + .mat-calendar-body-cell-content { + color: mat-color($foreground, text); + border-color: transparent; + + .mat-calendar-body-disabled > &:not(.mat-calendar-body-selected) { + color: mat-color($foreground, disabled-text); + } + } + + :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-body-selected { + background-color: mat-color($primary); + color: mat-color($primary, default-contrast); + } + + .mat-calendar-body-disabled > .mat-calendar-body-selected { + background-color: fade-out(mat-color($primary), $mat-datepicker-selected-fade-amount); + } + + .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-body-selected { + box-shadow: inset 0 0 0 $mat-datepicker-selected-today-box-shadow-width + 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); + } +} diff --git a/src/lib/datepicker/calendar-body.html b/src/lib/datepicker/calendar-body.html new file mode 100644 index 000000000000..a668b1060fef --- /dev/null +++ b/src/lib/datepicker/calendar-body.html @@ -0,0 +1,35 @@ + + + {{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..fc4f2c685f93 --- /dev/null +++ b/src/lib/datepicker/calendar-body.scss @@ -0,0 +1,65 @@ +$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; + outline: none; +} + +.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-body.spec.ts b/src/lib/datepicker/calendar-body.spec.ts new file mode 100644 index 000000000000..d4396771ee80 --- /dev/null +++ b/src/lib/datepicker/calendar-body.spec.ts @@ -0,0 +1,175 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {Component} from '@angular/core'; +import {MdCalendarBody, MdCalendarCell} from './calendar-body'; +import {By} from '@angular/platform-browser'; + + +describe('MdCalendarBody', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ + MdCalendarBody, + + // Test components. + StandardCalendarBody, + CalendarBodyWithDisabledCells, + ], + }); + + TestBed.compileComponents(); + })); + + 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 = calendarBodyNativeElement.querySelectorAll('tr'); + labelEls = calendarBodyNativeElement.querySelectorAll('.mat-calendar-body-label'); + cellEls = calendarBodyNativeElement.querySelectorAll('.mat-calendar-body-cell'); + }; + + beforeEach(() => { + fixture = TestBed.createComponent(StandardCalendarBody); + fixture.detectChanges(); + + let calendarBodyDebugElement = fixture.debugElement.query(By.directive(MdCalendarBody)); + calendarBodyNativeElement = calendarBodyDebugElement.nativeElement; + testComponent = fixture.componentInstance; + + refreshElementLists(); + }); + + it('creates body', () => { + expect(rowEls.length).toBe(3); + expect(labelEls.length).toBe(1); + expect(cellEls.length).toBe(14); + }); + + it('highlights today', () => { + let todayCell = calendarBodyNativeElement.querySelector('.mat-calendar-body-today'); + expect(todayCell).not.toBeNull(); + expect(todayCell.innerHTML.trim()).toBe('3'); + }); + + it('highlights selected', () => { + let selectedCell = calendarBodyNativeElement.querySelector('.mat-calendar-body-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) + .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 = + calendarBodyNativeElement.querySelector('.mat-calendar-body-today') as HTMLElement; + todayElement.click(); + fixture.detectChanges(); + + expect(todayElement.classList) + .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-body-active'); + }); + }); + + describe('calendar body with disabled cells', () => { + let fixture: ComponentFixture; + let testComponent: CalendarBodyWithDisabledCells; + let calendarBodyNativeElement: Element; + let cellEls: NodeListOf; + + beforeEach(() => { + fixture = TestBed.createComponent(CalendarBodyWithDisabledCells); + fixture.detectChanges(); + + let calendarBodyDebugElement = fixture.debugElement.query(By.directive(MdCalendarBody)); + calendarBodyNativeElement = calendarBodyDebugElement.nativeElement; + testComponent = fixture.componentInstance; + cellEls = calendarBodyNativeElement.querySelectorAll('.mat-calendar-body-cell'); + }); + + it('should only allow selection of disabled cells when allowDisabledSelection is true', () => { + (cellEls[0] as HTMLElement).click(); + fixture.detectChanges(); + + expect(testComponent.selected).toBeFalsy(); + + testComponent.allowDisabledSelection = true; + fixture.detectChanges(); + + (cellEls[0] as HTMLElement).click(); + fixture.detectChanges(); + + expect(testComponent.selected).toBe(1); + }); + }); +}); + + +@Component({ + template: ` +
`, +}) +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; + selectedValue = 4; + labelMinRequiredCells = 3; + numCols = 7; + + onSelect(value: number) { + this.selectedValue = value; + } +} + + +@Component({ + template: ` +
` +}) +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: Date; +} + + +function createCell(value: number) { + return new MdCalendarCell(value, `${value}`, `${value}-label`, true); +} diff --git a/src/lib/datepicker/calendar-body.ts b/src/lib/datepicker/calendar-body.ts new file mode 100644 index 000000000000..aebe993462bb --- /dev/null +++ b/src/lib/datepicker/calendar-body.ts @@ -0,0 +1,89 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + ViewEncapsulation +} 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, + public ariaLabel: string, + public enabled: boolean) {} +} + + +/** + * An internal component used to display calendar data in a table. + * @docs-private + */ +@Component({ + moduleId: module.id, + 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 MdCalendarBody { + /** 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; + + /** 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): void { + if (!this.allowDisabledSelection && !cell.enabled) { + return; + } + this.selectedValueChange.emit(cell.value); + } + + /** The number of blank cells to put at the beginning for the first row. */ + 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 new file mode 100644 index 000000000000..244a6640bda5 --- /dev/null +++ b/src/lib/datepicker/calendar.html @@ -0,0 +1,35 @@ +
+
+ +
+ + +
+
+ +
+ + + + + +
diff --git a/src/lib/datepicker/calendar.scss b/src/lib/datepicker/calendar.scss new file mode 100644 index 000000000000..ac208ce9e8c9 --- /dev/null +++ b/src/lib/datepicker/calendar.scss @@ -0,0 +1,122 @@ +$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 - 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; +} + +.mat-calendar-header { + padding: $mat-calendar-padding $mat-calendar-padding 0 $mat-calendar-padding; +} + +.mat-calendar-content { + padding: 0 $mat-calendar-padding $mat-calendar-padding $mat-calendar-padding; + outline: none; +} + +.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 { + flex: 1 1 auto; +} + +.mat-calendar-period-button { + font: inherit; + font-size: $mat-calendar-period-font-size; + font-weight: bold; + min-width: 0; +} + +.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-width: $mat-calendar-arrow-size; + border-top-style: solid; + margin: 0 0 0 $mat-calendar-arrow-size; + vertical-align: middle; + + &.mat-calendar-invert { + transform: rotate(180deg); + } +} + +.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; +} + +.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 new file mode 100644 index 000000000000..f632b4f204e1 --- /dev/null +++ b/src/lib/datepicker/calendar.spec.ts @@ -0,0 +1,622 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {Component} from '@angular/core'; +import {MdCalendar} from './calendar'; +import {By} from '@angular/platform-browser'; +import {MdMonthView} from './month-view'; +import {MdYearView} from './year-view'; +import {MdCalendarBody} from './calendar-body'; +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'; +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 +// 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', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + MdNativeDateModule, + ], + declarations: [ + MdCalendar, + MdCalendarBody, + MdMonthView, + MdYearView, + + // Test components. + StandardCalendar, + CalendarWithMinMax, + CalendarWithDateFilter, + ], + providers: [ + MdDatepickerIntl, + ], + }); + + 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 active', () => { + expect(calendarInstance._monthView).toBe(true, 'should be in month view'); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); + }); + + 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._activeDate).toEqual(new Date(2017, JAN, 31)); + + nextButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 28)); + + prevButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 28)); + }); + + it('should go to previous and next year', () => { + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(false, 'should be in year view'); + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); + + nextButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 31)); + + prevButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); + }); + + 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._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 Date(2017, DEC, 31)); + expect(testComponent.selected).toBeFalsy('no date should be selected yet'); + }); + + it('should select date in month view', () => { + 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(testComponent.selected).toEqual(new Date(2017, JAN, 31)); + }); + + describe('a11y', () => { + describe('calendar body', () => { + let calendarBodyEl: HTMLElement; + + beforeEach(() => { + calendarBodyEl = calendarElement.querySelector('.mat-calendar-content') as HTMLElement; + expect(calendarBodyEl).not.toBeNull(); + + dispatchFakeEvent(calendarBodyEl, 'focus'); + fixture.detectChanges(); + }); + + it('should initially set start date active', () => { + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); + }); + + describe('month view', () => { + it('should decrement date on left arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 30)); + + calendarInstance._activeDate = new Date(2017, JAN, 1); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + 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 Date(2017, FEB, 1)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + 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 Date(2017, JAN, 24)); + + calendarInstance._activeDate = new Date(2017, JAN, 7); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + 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 Date(2017, FEB, 7)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + 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 Date(2017, JAN, 1)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 1)); + }); + + it('should go to end of the month on end press', () => { + calendarInstance._activeDate = new Date(2017, JAN, 10); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + 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 Date(2016, DEC, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + 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 Date(2017, FEB, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, MAR, 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 Date(2017, JAN, 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 Date(2016, DEC, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + 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 Date(2017, FEB, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + 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 Date(2016, AUG, 31)); + + calendarInstance._activeDate = new Date(2017, JUL, 1); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2016, JUL, 1)); + + calendarInstance._activeDate = new Date(2017, DEC, 10); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + 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 Date(2017, AUG, 31)); + + calendarInstance._activeDate = new Date(2017, JUN, 1); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2018, JUN, 1)); + + calendarInstance._activeDate = new Date(2017, SEP, 30); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2018, FEB, 28)); + }); + + it('should go to first month of the year on home press', () => { + calendarInstance._activeDate = new Date(2017, SEP, 30); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 30)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + 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 Date(2017, DEC, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, DEC, 31)); + }); + + it('should go back one year on page up press', () => { + calendarInstance._activeDate = new Date(2016, FEB, 29); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2015, FEB, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2014, FEB, 28)); + }); + + it('should go forward one year on page down press', () => { + calendarInstance._activeDate = new Date(2016, FEB, 29); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2018, FEB, 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 Date(2017, FEB, 28)); + expect(testComponent.selected).toBeNull(); + }); + }); + }); + }); + }); + + describe('calendar with min and max date', () => { + let fixture: ComponentFixture; + let testComponent: CalendarWithMinMax; + let calendarElement: HTMLElement; + let prevButton: HTMLButtonElement; + let nextButton: HTMLButtonElement; + let calendarInstance: MdCalendar; + + beforeEach(() => { + fixture = TestBed.createComponent(CalendarWithMinMax); + + let calendarDebugElement = fixture.debugElement.query(By.directive(MdCalendar)); + calendarElement = calendarDebugElement.nativeElement; + prevButton = + calendarElement.querySelector('.mat-calendar-previous-button') as HTMLButtonElement; + nextButton = calendarElement.querySelector('.mat-calendar-next-button') as HTMLButtonElement; + calendarInstance = calendarDebugElement.componentInstance; + testComponent = fixture.componentInstance; + }); + + it('should clamp startAt value below min date', () => { + testComponent.startAt = new Date(2000, JAN, 1); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 1)); + }); + + it('should clamp startAt value above max date', () => { + testComponent.startAt = new Date(2020, JAN, 1); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 1)); + }); + + it('should not go back past min date', () => { + 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 Date(2016, FEB, 1)); + + prevButton.click(); + fixture.detectChanges(); + + expect(prevButton.disabled).toBe(true, 'previous button should be disabled'); + expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 1)); + + prevButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 1)); + }); + + it('should not go forward past max date', () => { + 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 Date(2017, DEC, 1)); + + nextButton.click(); + fixture.detectChanges(); + + expect(nextButton.disabled).toBe(true, 'next button should be disabled'); + expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 1)); + + nextButton.click(); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 1)); + }); + }); + + describe('calendar with date filter', () => { + let fixture: ComponentFixture; + let testComponent: CalendarWithDateFilter; + let calendarElement: HTMLElement; + let calendarInstance: MdCalendar; + + beforeEach(() => { + fixture = TestBed.createComponent(CalendarWithDateFilter); + fixture.detectChanges(); + + let calendarDebugElement = fixture.debugElement.query(By.directive(MdCalendar)); + calendarElement = calendarDebugElement.nativeElement; + calendarInstance = calendarDebugElement.componentInstance; + testComponent = fixture.componentInstance; + }); + + it('should disable and prevent selection of filtered dates', () => { + let cells = calendarElement.querySelectorAll('.mat-calendar-body-cell'); + (cells[0] as HTMLElement).click(); + fixture.detectChanges(); + + expect(testComponent.selected).toBeFalsy(); + + (cells[1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(testComponent.selected).toEqual(new Date(2017, JAN, 2)); + }); + + describe('a11y', () => { + let calendarBodyEl: HTMLElement; + + beforeEach(() => { + calendarBodyEl = calendarElement.querySelector('.mat-calendar-content') 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 Date(2017, JAN, 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 Date(2017, NOV, 1); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(false); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(true); + expect(testComponent.selected).toBeNull(); + }); + }); + }); +}); + + +@Component({ + template: `` +}) +class StandardCalendar { + selected: Date = null; + startDate = new Date(2017, JAN, 31); +} + + +@Component({ + 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 new file mode 100644 index 000000000000..9c6905a7f02d --- /dev/null +++ b/src/lib/datepicker/calendar.ts @@ -0,0 +1,321 @@ +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Inject, + Input, + NgZone, + Optional, + Output, + ViewEncapsulation +} from '@angular/core'; +import { + DOWN_ARROW, + END, + ENTER, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW +} 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'; + + +/** + * 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() startAt: D; + + /** Whether the calendar should be started in month or year view. */ + @Input() startView: 'month' | 'year' = 'month'; + + /** The currently selected date. */ + @Input() selected: D; + + /** The minimum selectable date. */ + @Input() minDate: D; + + /** The maximum selectable date. */ + @Input() maxDate: D; + + /** A function used to filter which dates are selectable. */ + @Input() dateFilter: (date: D) => boolean; + + /** Emits when the currently selected date changes. */ + @Output() selectedChange = new EventEmitter(); + + /** Date filter for the month and year views. */ + _dateFilterForViews = (date: D) => { + return !!date && + (!this.dateFilter || this.dateFilter(date)) && + (!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(): D { return this._clampedActiveDate; } + set _activeDate(value: D) { + this._clampedActiveDate = this._dateAdapter.clampDate(value, this.minDate, this.maxDate); + } + private _clampedActiveDate: D; + + /** Whether the calendar is in month view. */ + _monthView: boolean; + + /** The label for the current calendar view. */ + get _periodButtonText(): string { + return this._monthView ? + this._dateAdapter.format(this._activeDate, this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase() : + this._dateAdapter.getYearName(this._activeDate); + } + + get _periodButtonLabel(): string { + return this._monthView ? this._intl.switchToYearViewLabel : this._intl.switchToMonthViewLabel; + } + + /** The label for the the previous button. */ + get _prevButtonLabel(): string { + return this._monthView ? this._intl.prevMonthLabel : this._intl.prevYearLabel; + } + + /** The label for the the next button. */ + get _nextButtonLabel(): string { + return this._monthView ? this._intl.nextMonthLabel : this._intl.nextYearLabel; + } + + 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) { + throw createMissingDateImplError('DateAdapter'); + } + if (!this._dateFormats) { + throw createMissingDateImplError('MD_DATE_FORMATS'); + } + } + + ngAfterContentInit() { + this._activeDate = this.startAt || this._dateAdapter.today(); + this._focusActiveCell(); + this._monthView = this.startView != 'year'; + } + + /** Handles date selection in the month view. */ + _dateSelected(date: D): void { + if (!this._dateAdapter.sameDate(date, this.selected)) { + this.selectedChange.emit(date); + } + } + + /** Handles month selection in the year view. */ + _monthSelected(month: D): void { + this._activeDate = month; + this._monthView = true; + } + + /** Handles user clicks on the period label. */ + _currentPeriodClicked(): void { + this._monthView = !this._monthView; + } + + /** Handles user clicks on the previous button. */ + _previousClicked(): void { + this._activeDate = this._monthView ? + 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._dateAdapter.addCalendarMonths(this._activeDate, 1) : + this._dateAdapter.addCalendarYears(this._activeDate, 1); + } + + /** Whether the previous period button is enabled. */ + _previousEnabled(): boolean { + if (!this.minDate) { + return true; + } + return !this.minDate || !this._isSameView(this._activeDate, this.minDate); + } + + /** Whether the next period button is enabled. */ + _nextEnabled(): boolean { + return !this.maxDate || !this._isSameView(this._activeDate, this.maxDate); + } + + /** 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); + } + } + + /** 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) { + case LEFT_ARROW: + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, -1); + break; + case RIGHT_ARROW: + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, 1); + break; + case UP_ARROW: + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, -7); + break; + case DOWN_ARROW: + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, 7); + break; + case HOME: + this._activeDate = this._dateAdapter.addCalendarDays(this._activeDate, + 1 - this._dateAdapter.getDate(this._activeDate)); + break; + case END: + 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._dateAdapter.addCalendarYears(this._activeDate, -1) : + this._dateAdapter.addCalendarMonths(this._activeDate, -1); + break; + case PAGE_DOWN: + this._activeDate = event.altKey ? + this._dateAdapter.addCalendarYears(this._activeDate, 1) : + this._dateAdapter.addCalendarMonths(this._activeDate, 1); + break; + case ENTER: + if (this._dateFilterForViews(this._activeDate)) { + this._dateSelected(this._activeDate); + // Prevent unexpected default actions such as form submission. + event.preventDefault(); + } + return; + default: + // 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(); + } + + /** 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._dateAdapter.addCalendarMonths(this._activeDate, -1); + break; + case RIGHT_ARROW: + this._activeDate = this._dateAdapter.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._dateAdapter.addCalendarMonths(this._activeDate, + -this._dateAdapter.getMonth(this._activeDate)); + break; + case END: + this._activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, + 11 - this._dateAdapter.getMonth(this._activeDate)); + break; + case PAGE_UP: + this._activeDate = + this._dateAdapter.addCalendarYears(this._activeDate, event.altKey ? -10 : -1); + break; + case PAGE_DOWN: + this._activeDate = + this._dateAdapter.addCalendarYears(this._activeDate, event.altKey ? 10 : 1); + break; + case ENTER: + this._monthSelected(this._activeDate); + break; + default: + // 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(); + } + + /** + * Determine the date for the month that comes before the given month in the same column in the + * calendar table. + */ + 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 = 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: D): D { + // Determine how many months to jump forward given that there are 2 empty slots at the beginning + // of each year. + 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-content.html b/src/lib/datepicker/datepicker-content.html new file mode 100644 index 000000000000..0f3a70ec7dff --- /dev/null +++ b/src/lib/datepicker/datepicker-content.html @@ -0,0 +1,10 @@ + + diff --git a/src/lib/datepicker/datepicker-content.scss b/src/lib/datepicker/datepicker-content.scss new file mode 100644 index 000000000000..63a81951a071 --- /dev/null +++ b/src/lib/datepicker/datepicker-content.scss @@ -0,0 +1,46 @@ +@import '../core/style/elevation'; + + +$md-datepicker-calendar-padding: 8px; +$md-datepicker-non-touch-calendar-cell-size: 40px; +$md-datepicker-non-touch-calendar-width: + $md-datepicker-non-touch-calendar-cell-size * 7 + $md-datepicker-calendar-padding * 2; + +// Ideally the calendar would have a constant aspect ratio, no matter its size, and we would base +// these measurements off the aspect ratio. Unfortunately, the aspect ratio does change a little as +// 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: 64vmin; +$md-datepicker-touch-height: 80vmin; +$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 { + width: $md-datepicker-non-touch-calendar-width; + @include mat-elevation(8); +} + +.mat-datepicker-content-touch { + display: block; + // make sure the dialog scrolls rather than being cropped on ludicrously small screens + max-height: 80vh; + overflow: auto; + + // TODO(mmalerba): hack to offset the padding of the dialog. Can be removed when we switch away + // from using dialog. + margin: -24px; + + .mat-calendar { + width: $md-datepicker-touch-width; + height: $md-datepicker-touch-height; + min-width: $md-datepicker-touch-min-width; + min-height: $md-datepicker-touch-min-height; + max-width: $md-datepicker-touch-max-width; + max-height: $md-datepicker-touch-max-height; + @include mat-elevation(0); + } +} 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 new file mode 100644 index 000000000000..5dc2304436d7 --- /dev/null +++ b/src/lib/datepicker/datepicker-input.ts @@ -0,0 +1,232 @@ +import { + AfterContentInit, + Directive, + ElementRef, + EventEmitter, + forwardRef, + Inject, + Input, + OnDestroy, + Optional, + Renderer2 +} from '@angular/core'; +import {MdDatepicker} from './datepicker'; +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'; +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 = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MdDatepickerInput), + multi: true +}; + + +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, MD_DATEPICKER_VALIDATORS], + host: { + '[attr.aria-expanded]': '_datepicker?.opened || "false"', + '[attr.aria-haspopup]': 'true', + '[attr.aria-owns]': '_datepicker?.id', + '[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, + Validator { + /** The datepicker that this input is associated with. */ + @Input() + set mdDatepicker(value: MdDatepicker) { + if (value) { + this._datepicker = value; + this._datepicker._registerInput(this); + } + } + _datepicker: MdDatepicker; + + @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() + get value(): D { + return this._dateAdapter.parse(this._elementRef.nativeElement.value, + this._dateFormats.parse.dateInput); + } + set value(value: D) { + let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); + let oldDate = this.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); + } + } + + /** The minimum valid date. */ + @Input() + get min(): D { return this._min; } + set min(value: D) { + this._min = value; + this._validatorOnChange(); + } + private _min: D; + + /** The maximum valid date. */ + @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(); + + _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: Renderer2, + @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) { + this._datepickerSubscription = + this._datepicker.selectedChanged.subscribe((selected: D) => { + this.value = selected; + this._cvaOnChange(selected); + }); + } + } + + ngOnDestroy() { + if (this._datepickerSubscription) { + this._datepickerSubscription.unsubscribe(); + } + } + + 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. + */ + getPopupConnectionElementRef(): ElementRef { + return this._mdInputContainer ? this._mdInputContainer.underlineRef : this._elementRef; + } + + // Implemented as part of ControlValueAccessor + writeValue(value: D): void { + this.value = value; + } + + // Implemented as part of ControlValueAccessor + registerOnChange(fn: (value: any) => void): void { + this._cvaOnChange = fn; + } + + // Implemented as part of ControlValueAccessor + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + // Implemented as part of ControlValueAccessor + setDisabledState(disabled: boolean): void { + this._renderer.setProperty(this._elementRef.nativeElement, 'disabled', disabled); + } + + _onKeydown(event: KeyboardEvent) { + if (event.altKey && event.keyCode === DOWN_ARROW) { + this._datepicker.open(); + event.preventDefault(); + } + } + + _onInput(value: string) { + let date = this._dateAdapter.parse(value, this._dateFormats.parse.dateInput); + this._cvaOnChange(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..973bf29cccef --- /dev/null +++ b/src/lib/datepicker/datepicker-intl.ts @@ -0,0 +1,30 @@ +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'; +} diff --git a/src/lib/datepicker/datepicker-toggle.scss b/src/lib/datepicker/datepicker-toggle.scss new file mode 100644 index 000000000000..dd394dd25f7c --- /dev/null +++ b/src/lib/datepicker/datepicker-toggle.scss @@ -0,0 +1,14 @@ +$mat-datepicker-toggle-icon-size: 24px !default; + + +.mat-datepicker-toggle { + display: inline-block; + // 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; + 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..c00f6da5686f --- /dev/null +++ b/src/lib/datepicker/datepicker-toggle.ts @@ -0,0 +1,34 @@ +import {ChangeDetectionStrategy, Component, Input, ViewEncapsulation} from '@angular/core'; +import {MdDatepicker} from './datepicker'; +import {MdDatepickerIntl} from './datepicker-intl'; + + +@Component({ + moduleId: module.id, + selector: 'button[mdDatepickerToggle], button[matDatepickerToggle]', + template: '', + styleUrls: ['datepicker-toggle.css'], + host: { + '[class.mat-datepicker-toggle]': 'true', + '[attr.aria-label]': '_intl.openCalendarLabel', + '(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; } + + constructor(public _intl: MdDatepickerIntl) {} + + _open(event: Event): void { + if (this.datepicker) { + this.datepicker.open(); + event.stopPropagation(); + } + } +} diff --git a/src/lib/datepicker/datepicker.md b/src/lib/datepicker/datepicker.md new file mode 100644 index 000000000000..ec235cfcc3dc --- /dev/null +++ b/src/lib/datepicker/datepicker.md @@ -0,0 +1,199 @@ +The datepicker allows users to enter a date either through text input, or by choosing a date from +the calendar. It is made up of several components and directives that work together: + + + +### 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 +... + +``` + +### 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 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 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 +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. 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 +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.spec.ts b/src/lib/datepicker/datepicker.spec.ts new file mode 100644 index 000000000000..19d91acfbc2e --- /dev/null +++ b/src/lib/datepicker/datepicker.spec.ts @@ -0,0 +1,626 @@ +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MdDatepickerModule} from './index'; +import {Component, ViewChild} from '@angular/core'; +import {MdDatepicker} from './datepicker'; +import {MdDatepickerInput} from './datepicker-input'; +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'; +import {MdNativeDateModule} 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('MdDatepicker', () => { + describe('with MdNativeDateModule', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MdDatepickerModule, + MdInputModule, + MdNativeDateModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [ + DatepickerWithFilterAndValidation, + DatepickerWithFormControl, + DatepickerWithMinAndMax, + DatepickerWithNgModel, + DatepickerWithStartAt, + DatepickerWithToggle, + InputContainerDatepicker, + MultiInputDatepicker, + NoInputDatepicker, + StandardDatepicker, + ], + }); + + TestBed.compileComponents(); + })); + + describe('standard datepicker', () => { + let fixture: ComponentFixture; + let testComponent: StandardDatepicker; + + 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', 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', async(() => { + testComponent.touch = true; + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).toBeNull(); + + testComponent.datepicker.open(); + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + })); + + 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); + + testComponent.datepicker.close(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(parseInt(getComputedStyle(popup).height)).toBe(0); + }); + })); + + it('close should close dialog', async(() => { + testComponent.touch = true; + fixture.detectChanges(); + + testComponent.datepicker.open(); + fixture.detectChanges(); + + expect(document.querySelector('md-dialog-container')).not.toBeNull(); + + testComponent.datepicker.close(); + fixture.detectChanges(); + + 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(); + + testComponent.datepicker.open(); + fixture.detectChanges(); + + 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(); + + 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('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 no inputs', () => { + let fixture: ComponentFixture; + let testComponent: NoInputDatepicker; + + 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', async(() => { + expect(() => testComponent.datepicker.open()).toThrow(); + })); + }); + + describe('datepicker with startAt', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithStartAt; + + 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 Date(2010, JAN, 1)); + }); + }); + + describe('datepicker with ngModel', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithNgModel; + + beforeEach(async(() => { + fixture = TestBed.createComponent(DatepickerWithNgModel); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + testComponent = fixture.componentInstance; + }); + })); + + 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 Date(2017, JAN, 1); + testComponent.selected = selected; + fixture.detectChanges(); + + 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', async(() => { + expect(testComponent.selected).toBeNull(); + expect(testComponent.datepickerInput.value).toBeNull(); + + let selected = new Date(2017, JAN, 1); + testComponent.datepicker._selectAndClose(selected); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + 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', async(() => { + let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; + + expect(inputEl.classList).toContain('ng-pristine'); + + testComponent.datepicker._selectAndClose(new Date(2017, JAN, 1)); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + expect(inputEl.classList).toContain('ng-dirty'); + }); + })); + + 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 Date(2017, JAN, 1); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + fixture.detectChanges(); + + 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(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(); + expect(testComponent.datepicker._selected).toBeNull(); + + let selected = new Date(2017, JAN, 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 Date(2017, JAN, 1); + testComponent.datepicker._selectAndClose(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); + }); + }); + + describe('datepicker with mdDatepickerToggle', () => { + let fixture: ComponentFixture; + let testComponent: DatepickerWithToggle; + + 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', async(() => { + 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(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(); + expect(attachToRef.nativeElement.classList.contains('mat-input-underline')) + .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 Date(2010, JAN, 1)); + 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', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MdDatepickerModule, + MdInputModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [StandardDatepicker], + }); + + TestBed.compileComponents(); + })); + + it('should throw when created', () => { + expect(() => TestBed.createComponent(StandardDatepicker)) + .toThrowError(/MdDatepicker: No provider found for .*/); + }); + }); +}); + + +@Component({ + template: ` + + + `, +}) +class StandardDatepicker { + touch = false; + date = new Date(2020, JAN, 1); + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; +} + + +@Component({ + template: ` + + `, +}) +class MultiInputDatepicker {} + + +@Component({ + template: ``, +}) +class NoInputDatepicker { + @ViewChild('d') datepicker: MdDatepicker; +} + + +@Component({ + template: ` + + + `, +}) +class DatepickerWithStartAt { + date = new Date(2020, JAN, 1); + startDate = new Date(2010, JAN, 1); + @ViewChild('d') datepicker: MdDatepicker; +} + + +@Component({ + template: ``, +}) +class DatepickerWithNgModel { + selected: Date = null; + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; +} + + +@Component({ + template: ` + + + `, +}) +class DatepickerWithFormControl { + formControl = new FormControl(); + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; +} + + +@Component({ + template: ` + + + + `, +}) +class DatepickerWithToggle { + @ViewChild('d') datepicker: MdDatepicker; +} + + +@Component({ + template: ` + + + + + `, +}) +class InputContainerDatepicker { + @ViewChild('d') datepicker: MdDatepicker; + @ViewChild(MdDatepickerInput) datepickerInput: MdDatepickerInput; +} + + +@Component({ + template: ` + + + `, +}) +class DatepickerWithMinAndMax { + minDate = new Date(2010, JAN, 1); + 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 new file mode 100644 index 000000000000..4ba113254244 --- /dev/null +++ b/src/lib/datepicker/datepicker.ts @@ -0,0 +1,280 @@ +import { + AfterContentInit, + ChangeDetectionStrategy, + Component, + ComponentRef, + EventEmitter, + Input, + OnDestroy, + Optional, + Output, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import {Overlay} from '../core/overlay/overlay'; +import {OverlayRef} from '../core/overlay/overlay-ref'; +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'; +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'; +import {MdDatepickerInput} from './datepicker-input'; +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 {ESCAPE} from '../core/keyboard/keycodes'; +import {MdCalendar} from './calendar'; + + +/** Used to generate a unique ID for each datepicker instance. */ +let datepickerUid = 0; + + +/** + * 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-content', + templateUrl: 'datepicker-content.html', + styleUrls: ['datepicker-content.css'], + host: { + 'class': 'mat-datepicker-content', + '[class.mat-datepicker-content-touch]': 'datepicker.touchUi', + '(keydown)': '_handleKeydown($event)', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MdDatepickerContent implements AfterContentInit { + datepicker: MdDatepicker; + + @ViewChild(MdCalendar) _calendar: MdCalendar; + + ngAfterContentInit() { + 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(); + } +} + + +// 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() + 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: D) { this._startAt = date; } + private _startAt: D; + + /** 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; + + /** 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. */ + _selected: D = null; + + /** The minimum selectable date. */ + get _minDate(): D { + return this._datepickerInput && this._datepickerInput.min; + } + + /** The maximum selectable date. */ + get _maxDate(): D { + 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; + + /** 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: ComponentPortal>; + + /** The input element this datepicker is associated with. */ + private _datepickerInput: MdDatepickerInput; + + private _inputSubscription: Subscription; + + constructor(private _dialog: MdDialog, private _overlay: Overlay, + private _viewContainerRef: ViewContainerRef, + @Optional() private _dateAdapter: DateAdapter, + @Optional() private _dir: Dir) { + if (!this._dateAdapter) { + throw createMissingDateImplError('DateAdapter'); + } + + } + + ngOnDestroy() { + this.close(); + 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: D): void { + let oldValue = this._selected; + this._selected = date; + if (!this._dateAdapter.sameDate(oldValue, this._selected)) { + this.selectedChanged.emit(date); + } + this.close(); + } + + /** + * Register an input with this datepicker. + * @param input The datepicker input to register with this datepicker. + */ + _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: D) => this._selected = value); + } + + /** Open the calendar. */ + open(): void { + if (this.opened) { + return; + } + if (!this._datepickerInput) { + throw new MdError('Attempted to open an MdDatepicker with no associated input.'); + } + + this.touchUi ? this._openAsDialog() : this._openAsPopup(); + this.opened = true; + } + + /** Close the calendar. */ + close(): void { + if (!this.opened) { + return; + } + 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(); + } + this.opened = false; + } + + /** Open the calendar as a dialog. */ + private _openAsDialog(): void { + 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()) { + let componentRef: ComponentRef> = + this._popupRef.attach(this._calendarPortal); + componentRef.instance.datepicker = this; + } + + this._popupRef.backdropClick().first().subscribe(() => this.close()); + } + + /** Create the popup. */ + private _createPopup(): void { + const overlayState = new OverlayState(); + overlayState.positionStrategy = this._createPopupPositionStrategy(); + 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._datepickerInput.getPopupConnectionElementRef(), origin, overlay); + } +} diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts new file mode 100644 index 000000000000..186757dcc51f --- /dev/null +++ b/src/lib/datepicker/index.ts @@ -0,0 +1,58 @@ +import {NgModule} from '@angular/core'; +import {MdMonthView} from './month-view'; +import {CommonModule} from '@angular/common'; +import {MdCalendarBody} from './calendar-body'; +import {MdYearView} from './year-view'; +import {OverlayModule} from '../core/overlay/overlay-directives'; +import {MdDatepicker, MdDatepickerContent} from './datepicker'; +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'; +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'; + + +@NgModule({ + imports: [ + CommonModule, + MdButtonModule, + MdDialogModule, + OverlayModule, + StyleModule, + ], + exports: [ + MdDatepicker, + MdDatepickerContent, + MdDatepickerInput, + MdDatepickerToggle, + ], + declarations: [ + MdCalendar, + MdCalendarBody, + MdDatepicker, + MdDatepickerContent, + MdDatepickerInput, + MdDatepickerToggle, + MdMonthView, + MdYearView, + ], + providers: [ + MdDatepickerIntl, + ], + entryComponents: [ + MdDatepickerContent, + ] +}) +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..4688b323ee8f --- /dev/null +++ b/src/lib/datepicker/month-view.html @@ -0,0 +1,16 @@ + + + + + + + +
{{day.narrow}}
diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts new file mode 100644 index 000000000000..9cef08b5897f --- /dev/null +++ b/src/lib/datepicker/month-view.spec.ts @@ -0,0 +1,127 @@ +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 {MdCalendarBody} from './calendar-body'; +import {MdNativeDateModule} 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({ + imports: [ + MdNativeDateModule, + ], + declarations: [ + MdCalendarBody, + MdMonthView, + + // Test components. + StandardMonthView, + MonthViewWithDateFilter, + ], + }); + + 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 month 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-body-cell'); + expect(cellEls.length).toBe(31); + }); + + it('shows selected date if in same month', () => { + let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-body-selected'); + expect(selectedEl.innerHTML.trim()).toBe('10'); + }); + + it('does not show selected date if in different month', () => { + testComponent.selected = new Date(2017, MAR, 10); + fixture.detectChanges(); + + 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-body-cell'); + (cellEls[cellEls.length - 1] as HTMLElement).click(); + fixture.detectChanges(); + + 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-body-cell'); + expect((cellEls[4] as HTMLElement).innerText.trim()).toBe('5'); + expect(cellEls[4].classList).toContain('mat-calendar-body-active'); + }); + }); + + 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 disable filtered dates', () => { + 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'); + }); + }); +}); + + +@Component({ + template: ``, +}) +class StandardMonthView { + date = new Date(2017, JAN, 5); + selected = new Date(2017, JAN, 10); +} + + +@Component({ + 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 new file mode 100644 index 000000000000..19c7570d204d --- /dev/null +++ b/src/lib/datepicker/month-view.ts @@ -0,0 +1,171 @@ +import { + AfterContentInit, + 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; + + +/** + * 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 activeDate(): D { return this._activeDate; } + set activeDate(value: D) { + let oldActiveDate = this._activeDate; + this._activeDate = value || this._dateAdapter.today(); + if (!this._hasSameMonthAndYear(oldActiveDate, this._activeDate)) { + this._init(); + } + } + private _activeDate: D; + + /** The currently selected date. */ + @Input() + get selected(): D { return this._selected; } + set selected(value: D) { + this._selected = value; + this._selectedDate = this._getDateInCurrentMonth(this.selected); + } + private _selected: D; + + /** A function used to filter which dates are selectable. */ + @Input() dateFilter: (date: D) => boolean; + + /** 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; + + /** The names of the weekdays. */ + _weekdays: {long: string, narrow: string}[]; + + 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 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(); + } + + ngAfterContentInit(): void { + this._init(); + } + + /** Handles when a new date is selected. */ + _dateSelected(date: number) { + if (this._selectedDate == date) { + return; + } + 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(this._dateAdapter.today()); + this._monthLabel = + this._dateAdapter.getMonthNames('short')[this._dateAdapter.getMonth(this.activeDate)] + .toLocaleUpperCase(); + + let firstOfMonth = this._dateAdapter.createDate(this._dateAdapter.getYear(this.activeDate), + this._dateAdapter.getMonth(this.activeDate), 1); + this._firstWeekOffset = + (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 = 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) { + 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(date); + let ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel); + this._weeks[this._weeks.length - 1] + .push(new MdCalendarCell(i + 1, dateNames[i], ariaLabel, enabled)); + } + } + + /** + * 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: 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: 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 new file mode 100644 index 000000000000..537db7e82634 --- /dev/null +++ b/src/lib/datepicker/year-view.html @@ -0,0 +1,16 @@ + + + + + + +
diff --git a/src/lib/datepicker/year-view.spec.ts b/src/lib/datepicker/year-view.spec.ts new file mode 100644 index 000000000000..496938f7842d --- /dev/null +++ b/src/lib/datepicker/year-view.spec.ts @@ -0,0 +1,134 @@ +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 {MdCalendarBody} from './calendar-body'; +import {MdNativeDateModule} 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({ + imports: [ + MdNativeDateModule, + ], + declarations: [ + MdCalendarBody, + MdYearView, + + // Test components. + StandardYearView, + YearViewWithDateFilter, + ], + }); + + 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-body-label'); + expect(labelEl.innerHTML.trim()).toBe('2017'); + }); + + it('has 12 months', () => { + 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-body-selected'); + expect(selectedEl.innerHTML.trim()).toBe('MAR'); + }); + + it('does not show selected month if in different year', () => { + testComponent.selected = new Date(2016, MAR, 10); + fixture.detectChanges(); + + 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-body-cell'); + (cellEls[cellEls.length - 1] as HTMLElement).click(); + fixture.detectChanges(); + + 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-body-cell'); + expect((cellEls[0] as HTMLElement).innerText.trim()).toBe('JAN'); + expect(cellEls[0].classList).toContain('mat-calendar-body-active'); + }); + }); + + 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-body-cell'); + expect(cells[0].classList).not.toContain('mat-calendar-body-disabled'); + expect(cells[1].classList).toContain('mat-calendar-body-disabled'); + }); + }); +}); + + +@Component({ + template: ` + `, +}) +class StandardYearView { + date = new Date(2017, JAN, 5); + selected = new Date(2017, MAR, 10); +} + + +@Component({ + template: `` +}) +class YearViewWithDateFilter { + activeDate = new Date(2017, JAN, 1); + dateFilter(date: Date) { + if (date.getMonth() == JAN) { + return date.getDate() == 10; + } + if (date.getMonth() == FEB) { + return false; + } + return true; + } +} diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts new file mode 100644 index 000000000000..16532964571f --- /dev/null +++ b/src/lib/datepicker/year-view.ts @@ -0,0 +1,144 @@ +import { + AfterContentInit, + 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'; + + +/** + * 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 activeDate(): D { return this._activeDate; } + set activeDate(value: D) { + let oldActiveDate = this._activeDate; + this._activeDate = value || this._dateAdapter.today(); + if (this._dateAdapter.getYear(oldActiveDate) != this._dateAdapter.getYear(this._activeDate)) { + this._init(); + } + } + private _activeDate: D; + + /** The currently selected date. */ + @Input() + get selected(): D { return this._selected; } + set selected(value: D) { + this._selected = value; + this._selectedMonth = this._getMonthInCurrentYear(this.selected); + } + private _selected: D; + + /** A function used to filter which dates are selectable. */ + @Input() dateFilter: (date: D) => boolean; + + /** 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(@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(); + } + + ngAfterContentInit() { + this._init(); + } + + /** Handles when a new month is selected. */ + _monthSelected(month: number) { + 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(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, 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: 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, 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(), ariaLabel, this._isMonthEnabled(month)); + } + + /** Whether the given month is enabled. */ + private _isMonthEnabled(month: number) { + if (!this.dateFilter) { + 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 = firstOfMonth; this._dateAdapter.getMonth(date) == month; + date = this._dateAdapter.addCalendarDays(date, 1)) { + if (this.dateFilter(date)) { + return true; + } + } + + return false; + } +} 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 @@ -