diff --git a/package-lock.json b/package-lock.json index b6149e63f9c4..a7bf587e3031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8136,7 +8136,7 @@ "github": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/github/-/github-12.1.0.tgz", - "integrity": "sha512-HhWjhd/OATC4Hjj7xfGjGRtwWzo/fzTc55EkvsRatI9G6Vp47mVcdBIt1lQ56A9Qit/yVQRX1+M9jbWlcJvgug==", + "integrity": "sha1-8qLcvUQReBVZQiV0kaS8CL9mHdc=", "dev": true, "requires": { "dotenv": "4.0.0", diff --git a/src/demo-app/datepicker/custom-header.html b/src/demo-app/datepicker/custom-header.html new file mode 100644 index 000000000000..2c3f30415c0a --- /dev/null +++ b/src/demo-app/datepicker/custom-header.html @@ -0,0 +1,7 @@ +
+ + + {{periodLabel}} + + +
diff --git a/src/demo-app/datepicker/custom-header.scss b/src/demo-app/datepicker/custom-header.scss new file mode 100644 index 000000000000..2d928abd5ed8 --- /dev/null +++ b/src/demo-app/datepicker/custom-header.scss @@ -0,0 +1,10 @@ +.custom-header { + padding: 1em 1.5em; + display: flex; + align-items: center; +} + +.custom-header-label { + flex: 1; + text-align: center; +} diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index e5771e8fa609..03af10a442b3 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -144,3 +144,14 @@

Datepicker with value property binding

[startView]="yearView ? 'year' : 'month'">

+ +

Datepicker with custom header

+

+ + Custom calendar header + + + + +

diff --git a/src/demo-app/datepicker/datepicker-demo.ts b/src/demo-app/datepicker/datepicker-demo.ts index 61d6a5146883..f01da8398345 100644 --- a/src/demo-app/datepicker/datepicker-demo.ts +++ b/src/demo-app/datepicker/datepicker-demo.ts @@ -6,12 +6,13 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectionStrategy, Component} from '@angular/core'; +import {ChangeDetectionStrategy, Component, Host} from '@angular/core'; import {FormControl} from '@angular/forms'; import {MatDatepickerInputEvent} from '@angular/material/datepicker'; +import {DateAdapter} from '@angular/material/core'; +import {MatCalendar} from '@angular/material'; import {ThemePalette} from '@angular/material/core'; - @Component({ moduleId: module.id, selector: 'datepicker-demo', @@ -40,4 +41,38 @@ export class DatepickerDemo { onDateInput = (e: MatDatepickerInputEvent) => this.lastDateInput = e.value; onDateChange = (e: MatDatepickerInputEvent) => this.lastDateChange = e.value; + + // pass custom header component type as input + customHeader = CustomHeader; +} + +// Custom header component for datepicker +@Component({ + moduleId: module.id, + selector: 'custom-header', + templateUrl: 'custom-header.html', + styleUrls: ['custom-header.css'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CustomHeader { + constructor(@Host() private _calendar: MatCalendar, + private _dateAdapter: DateAdapter) {} + + get periodLabel() { + const year = this._dateAdapter.getYearName(this._calendar.activeDate); + const month = (this._dateAdapter.getMonth(this._calendar.activeDate) + 1); + return `${month}/${year}`; + } + + previousClicked(mode: 'month' | 'year') { + this._calendar.activeDate = mode == 'month' ? + this._dateAdapter.addCalendarMonths(this._calendar.activeDate, -1) : + this._dateAdapter.addCalendarYears(this._calendar.activeDate, -1); + } + + nextClicked(mode: 'month' | 'year') { + this._calendar.activeDate = mode == 'month' ? + this._dateAdapter.addCalendarMonths(this._calendar.activeDate, 1) : + this._dateAdapter.addCalendarYears(this._calendar.activeDate, 1); + } } diff --git a/src/demo-app/demo-app/demo-module.ts b/src/demo-app/demo-app/demo-module.ts index 9d116439a02d..919b4e1e2629 100644 --- a/src/demo-app/demo-app/demo-module.ts +++ b/src/demo-app/demo-app/demo-module.ts @@ -19,7 +19,7 @@ import {ButtonDemo} from '../button/button-demo'; import {CardDemo} from '../card/card-demo'; import {CheckboxDemo, MatCheckboxDemoNestedChecklist} from '../checkbox/checkbox-demo'; import {ChipsDemo} from '../chips/chips-demo'; -import {DatepickerDemo} from '../datepicker/datepicker-demo'; +import {CustomHeader, DatepickerDemo} from '../datepicker/datepicker-demo'; import {DemoMaterialModule} from '../demo-material-module'; import {ContentElementDialog, DialogDemo, IFrameDialog, JazzDialog} from '../dialog/dialog-demo'; import {DrawerDemo} from '../drawer/drawer-demo'; @@ -88,6 +88,7 @@ import {ConnectedOverlayDemo, DemoOverlay} from '../connected-overlay/connected- ChipsDemo, ContentElementDialog, DatepickerDemo, + CustomHeader, DemoApp, DialogDemo, DrawerDemo, @@ -148,6 +149,7 @@ import {ConnectedOverlayDemo, DemoOverlay} from '../connected-overlay/connected- ScienceJoke, SpagettiPanel, ExampleBottomSheet, + CustomHeader, DemoOverlay, ], }) diff --git a/src/lib/datepicker/calendar-header.html b/src/lib/datepicker/calendar-header.html new file mode 100644 index 000000000000..c11663b6227e --- /dev/null +++ b/src/lib/datepicker/calendar-header.html @@ -0,0 +1,21 @@ +
+
+ + +
+ + + + +
+
diff --git a/src/lib/datepicker/calendar-header.spec.ts b/src/lib/datepicker/calendar-header.spec.ts new file mode 100644 index 000000000000..a143b10f5868 --- /dev/null +++ b/src/lib/datepicker/calendar-header.spec.ts @@ -0,0 +1,170 @@ +import {Direction, Directionality} from '@angular/cdk/bidi'; +import {MatDatepickerModule} from './datepicker-module'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatDatepickerIntl} from './datepicker-intl'; +import {DEC, FEB, JAN, MatNativeDateModule} from '@angular/material/core'; +import {Component} from '@angular/core'; +import {MatCalendar} from './calendar'; +import {By} from '@angular/platform-browser'; +import {yearsPerPage} from './multi-year-view'; + +describe('MatCalendarHeader', () => { + let dir: { value: Direction }; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + MatNativeDateModule, + MatDatepickerModule, + ], + declarations: [ + // Test components. + StandardCalendar, + ], + providers: [ + MatDatepickerIntl, + {provide: Directionality, useFactory: () => dir = {value: 'ltr'}} + ], + }); + + 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: MatCalendar; + + beforeEach(() => { + fixture = TestBed.createComponent(StandardCalendar); + fixture.detectChanges(); + + let calendarDebugElement = fixture.debugElement.query(By.directive(MatCalendar)); + 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.currentView).toBe('month'); + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 31)); + }); + + it('should toggle view when period clicked', () => { + expect(calendarInstance.currentView).toBe('month'); + + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('month'); + }); + + 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.currentView).toBe('multi-year'); + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 31)); + + (calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('year'); + + 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 to previous and next multi-year range', () => { + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 31)); + + nextButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.activeDate).toEqual(new Date(2017 + yearsPerPage, JAN, 31)); + + prevButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 31)); + }); + + it('should go back to month view after selecting year and month', () => { + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 31)); + + let yearCells = calendarElement.querySelectorAll('.mat-calendar-body-cell'); + (yearCells[0] as HTMLElement).click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('year'); + expect(calendarInstance.activeDate).toEqual(new Date(2016, JAN, 31)); + + let monthCells = calendarElement.querySelectorAll('.mat-calendar-body-cell'); + (monthCells[monthCells.length - 1] as HTMLElement).click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('month'); + expect(calendarInstance.activeDate).toEqual(new Date(2016, DEC, 31)); + expect(testComponent.selected).toBeFalsy('no date should be selected yet'); + }); + + }); +}); + +@Component({ + template: ` + + ` +}) +class StandardCalendar { + selected: Date; + selectedYear: Date; + selectedMonth: Date; + startDate = new Date(2017, JAN, 31); +} diff --git a/src/lib/datepicker/calendar.html b/src/lib/datepicker/calendar.html index 1245f7a2f263..284affeb69f9 100644 --- a/src/lib/datepicker/calendar.html +++ b/src/lib/datepicker/calendar.html @@ -1,29 +1,10 @@ -
-
- -
+ - - - -
-
- -
+
{ let dir: {value: Direction}; @@ -23,16 +18,10 @@ describe('MatCalendar', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ - MatButtonModule, MatNativeDateModule, + MatDatepickerModule, ], declarations: [ - MatCalendar, - MatCalendarBody, - MatMonthView, - MatYearView, - MatMultiYearView, - // Test components. StandardCalendar, CalendarWithMinMax, @@ -52,8 +41,6 @@ describe('MatCalendar', () => { let testComponent: StandardCalendar; let calendarElement: HTMLElement; let periodButton: HTMLElement; - let prevButton: HTMLElement; - let nextButton: HTMLElement; let calendarInstance: MatCalendar; beforeEach(() => { @@ -63,108 +50,14 @@ describe('MatCalendar', () => { let calendarDebugElement = fixture.debugElement.query(By.directive(MatCalendar)); 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._currentView).toBe('month'); - expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); - }); - - it('should toggle view when period clicked', () => { - expect(calendarInstance._currentView).toBe('month'); - - periodButton.click(); - fixture.detectChanges(); - - expect(calendarInstance._currentView).toBe('multi-year'); - - periodButton.click(); - fixture.detectChanges(); - - expect(calendarInstance._currentView).toBe('month'); - }); - - 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._currentView).toBe('multi-year'); - expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); - - (calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click(); - fixture.detectChanges(); - - expect(calendarInstance._currentView).toBe('year'); - - 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 to previous and next multi-year range', () => { - periodButton.click(); - fixture.detectChanges(); - - expect(calendarInstance._currentView).toBe('multi-year'); - expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); - - nextButton.click(); - fixture.detectChanges(); - - expect(calendarInstance._activeDate).toEqual(new Date(2017 + yearsPerPage, JAN, 31)); - - prevButton.click(); - fixture.detectChanges(); - - expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); - }); - - it('should go back to month view after selecting year and month', () => { - periodButton.click(); - fixture.detectChanges(); - - expect(calendarInstance._currentView).toBe('multi-year'); - expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); - - let yearCells = calendarElement.querySelectorAll('.mat-calendar-body-cell'); - (yearCells[0] as HTMLElement).click(); - fixture.detectChanges(); - - expect(calendarInstance._currentView).toBe('year'); - expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 31)); - - let monthCells = calendarElement.querySelectorAll('.mat-calendar-body-cell'); - (monthCells[monthCells.length - 1] as HTMLElement).click(); - fixture.detectChanges(); - - expect(calendarInstance._currentView).toBe('month'); - expect(calendarInstance._activeDate).toEqual(new Date(2016, DEC, 31)); - expect(testComponent.selected).toBeFalsy('no date should be selected yet'); + expect(calendarInstance.currentView).toBe('month'); + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 31)); }); it('should select date in month view', () => { @@ -172,7 +65,7 @@ describe('MatCalendar', () => { (monthCells[monthCells.length - 1] as HTMLElement).click(); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('month'); + expect(calendarInstance.currentView).toBe('month'); expect(testComponent.selected).toEqual(new Date(2017, JAN, 31)); }); @@ -180,14 +73,14 @@ describe('MatCalendar', () => { periodButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('multi-year'); - expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); + expect(calendarInstance.currentView).toBe('multi-year'); + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 31)); (calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click(); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('year'); + expect(calendarInstance.currentView).toBe('year'); (calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click(); @@ -199,8 +92,8 @@ describe('MatCalendar', () => { periodButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('multi-year'); - expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); + expect(calendarInstance.currentView).toBe('multi-year'); + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 31)); (calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click(); @@ -237,7 +130,7 @@ describe('MatCalendar', () => { }); it('should initially set start date active', () => { - expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31)); + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 31)); }); it('should make the calendar body focusable', () => { @@ -249,12 +142,12 @@ describe('MatCalendar', () => { dispatchMouseEvent(periodButton, 'click'); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('multi-year'); + expect(calendarInstance.currentView).toBe('multi-year'); (calendarBodyEl.querySelector('.mat-calendar-body-active') as HTMLElement).click(); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('year'); + expect(calendarInstance.currentView).toBe('year'); }); it('should return to month view on enter', () => { @@ -266,8 +159,8 @@ describe('MatCalendar', () => { dispatchKeyboardEvent(tableBodyEl, 'keydown', ENTER); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('month'); - expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 28)); + expect(calendarInstance.currentView).toBe('month'); + expect(calendarInstance.activeDate).toEqual(new Date(2017, FEB, 28)); expect(testComponent.selected).toBeUndefined(); }); }); @@ -277,7 +170,7 @@ describe('MatCalendar', () => { dispatchMouseEvent(periodButton, 'click'); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('multi-year'); + expect(calendarInstance.currentView).toBe('multi-year'); }); it('should go to year view on enter', () => { @@ -289,8 +182,8 @@ describe('MatCalendar', () => { dispatchKeyboardEvent(tableBodyEl, 'keydown', ENTER); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('year'); - expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 31)); + expect(calendarInstance.currentView).toBe('year'); + expect(calendarInstance.activeDate).toEqual(new Date(2018, JAN, 31)); expect(testComponent.selected).toBeUndefined(); }); }); @@ -319,14 +212,14 @@ describe('MatCalendar', () => { testComponent.startAt = new Date(2000, JAN, 1); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 1)); + 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)); + expect(calendarInstance.activeDate).toEqual(new Date(2018, JAN, 1)); }); it('should not go back past min date', () => { @@ -337,18 +230,18 @@ describe('MatCalendar', () => { calendarElement.querySelector('.mat-calendar-previous-button') as HTMLButtonElement; expect(prevButton.disabled).toBe(false, 'previous button should not be disabled'); - expect(calendarInstance._activeDate).toEqual(new Date(2016, FEB, 1)); + expect(calendarInstance.activeDate).toEqual(new Date(2016, FEB, 1)); prevButton.click(); fixture.detectChanges(); expect(prevButton.disabled).toBe(true, 'previous button should be disabled'); - expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 1)); + expect(calendarInstance.activeDate).toEqual(new Date(2016, JAN, 1)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 1)); + expect(calendarInstance.activeDate).toEqual(new Date(2016, JAN, 1)); }); it('should not go forward past max date', () => { @@ -359,18 +252,18 @@ describe('MatCalendar', () => { calendarElement.querySelector('.mat-calendar-next-button') as HTMLButtonElement; expect(nextButton.disabled).toBe(false, 'next button should not be disabled'); - expect(calendarInstance._activeDate).toEqual(new Date(2017, DEC, 1)); + expect(calendarInstance.activeDate).toEqual(new Date(2017, DEC, 1)); nextButton.click(); fixture.detectChanges(); expect(nextButton.disabled).toBe(true, 'next button should be disabled'); - expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 1)); + expect(calendarInstance.activeDate).toEqual(new Date(2018, JAN, 1)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 1)); + expect(calendarInstance.activeDate).toEqual(new Date(2018, JAN, 1)); }); it('should re-render the month view when the minDate changes', () => { @@ -501,8 +394,8 @@ describe('MatCalendar', () => { }); it('should not allow selection of disabled date in month view', () => { - expect(calendarInstance._currentView).toBe('month'); - expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 1)); + expect(calendarInstance.currentView).toBe('month'); + expect(calendarInstance.activeDate).toEqual(new Date(2017, JAN, 1)); dispatchKeyboardEvent(tableBodyEl, 'keydown', ENTER); fixture.detectChanges(); @@ -519,16 +412,16 @@ describe('MatCalendar', () => { (calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click(); fixture.detectChanges(); - calendarInstance._activeDate = new Date(2017, NOV, 1); + calendarInstance.activeDate = new Date(2017, NOV, 1); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('year'); + expect(calendarInstance.currentView).toBe('year'); tableBodyEl = calendarElement.querySelector('.mat-calendar-body') as HTMLElement; dispatchKeyboardEvent(tableBodyEl, 'keydown', ENTER); fixture.detectChanges(); - expect(calendarInstance._currentView).toBe('month'); + expect(calendarInstance.currentView).toBe('month'); expect(testComponent.selected).toBeUndefined(); }); }); diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index 1d25bd2b86bf..966718f193eb 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -6,12 +6,15 @@ * found in the LICENSE file at https://angular.io/license */ +import {ComponentPortal, ComponentType, Portal} from '@angular/cdk/portal'; import { AfterContentInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, + forwardRef, + Host, Inject, Input, OnChanges, @@ -23,6 +26,9 @@ import { ViewEncapsulation, } from '@angular/core'; import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core'; +import {Observable} from 'rxjs/Observable'; +import {takeUntil} from 'rxjs/operators/takeUntil'; +import {Subject} from 'rxjs/Subject'; import {Subscription} from 'rxjs/Subscription'; import {createMissingDateImplError} from './datepicker-errors'; import {MatDatepickerIntl} from './datepicker-intl'; @@ -30,6 +36,127 @@ import {MatMonthView} from './month-view'; import {MatMultiYearView, yearsPerPage} from './multi-year-view'; import {MatYearView} from './year-view'; +/** Default header for MatCalendar */ +@Component({ + moduleId: module.id, + selector: 'mat-calendar-header', + templateUrl: 'calendar-header.html', + encapsulation: ViewEncapsulation.None, + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MatCalendarHeader implements OnDestroy { + /** Subject that emits when the component has been destroyed. */ + private _destroyed = new Subject(); + + constructor(private _intl: MatDatepickerIntl, + @Host() @Inject(forwardRef(() => MatCalendar)) public calendar: MatCalendar, + @Optional() private _dateAdapter: DateAdapter, + @Optional() @Inject(MAT_DATE_FORMATS) private _dateFormats: MatDateFormats, + changeDetectorRef: ChangeDetectorRef) { + this.calendar.stateChanges.pipe(takeUntil(this._destroyed)) + .subscribe(() => changeDetectorRef.markForCheck()); + } + + /** The label for the current calendar view. */ + get periodButtonText(): string { + if (this.calendar.currentView == 'month') { + return this._dateAdapter + .format(this.calendar.activeDate, this._dateFormats.display.monthYearLabel) + .toLocaleUpperCase(); + } + if (this.calendar.currentView == 'year') { + return this._dateAdapter.getYearName(this.calendar.activeDate); + } + const activeYear = this._dateAdapter.getYear(this.calendar.activeDate); + const firstYearInView = this._dateAdapter.getYearName( + this._dateAdapter.createDate(activeYear - activeYear % 24, 0, 1)); + const lastYearInView = this._dateAdapter.getYearName( + this._dateAdapter.createDate(activeYear + yearsPerPage - 1 - activeYear % 24, 0, 1)); + return `${firstYearInView} \u2013 ${lastYearInView}`; + } + + get periodButtonLabel(): string { + return this.calendar.currentView == 'month' ? + this._intl.switchToMultiYearViewLabel : this._intl.switchToMonthViewLabel; + } + + /** The label for the the previous button. */ + get prevButtonLabel(): string { + return { + 'month': this._intl.prevMonthLabel, + 'year': this._intl.prevYearLabel, + 'multi-year': this._intl.prevMultiYearLabel + }[this.calendar.currentView]; + } + + /** The label for the the next button. */ + get nextButtonLabel(): string { + return { + 'month': this._intl.nextMonthLabel, + 'year': this._intl.nextYearLabel, + 'multi-year': this._intl.nextMultiYearLabel + }[this.calendar.currentView]; + } + + /** Handles user clicks on the period label. */ + currentPeriodClicked(): void { + this.calendar.currentView = this.calendar.currentView == 'month' ? 'multi-year' : 'month'; + } + + /** Handles user clicks on the previous button. */ + previousClicked(): void { + this.calendar.activeDate = this.calendar.currentView == 'month' ? + this._dateAdapter.addCalendarMonths(this.calendar.activeDate, -1) : + this._dateAdapter.addCalendarYears( + this.calendar.activeDate, this.calendar.currentView == 'year' ? -1 : -yearsPerPage + ); + } + + /** Handles user clicks on the next button. */ + nextClicked(): void { + this.calendar.activeDate = this.calendar.currentView == 'month' ? + this._dateAdapter.addCalendarMonths(this.calendar.activeDate, 1) : + this._dateAdapter.addCalendarYears( + this.calendar.activeDate, + this.calendar.currentView == 'year' ? 1 : yearsPerPage + ); + } + + /** Whether the previous period button is enabled. */ + previousEnabled(): boolean { + if (!this.calendar.minDate) { + return true; + } + return !this.calendar.minDate || + !this._isSameView(this.calendar.activeDate, this.calendar.minDate); + } + + /** Whether the next period button is enabled. */ + nextEnabled(): boolean { + return !this.calendar.maxDate || + !this._isSameView(this.calendar.activeDate, this.calendar.maxDate); + } + + /** Whether the two dates represent the same view in the current view mode (month or year). */ + private _isSameView(date1: D, date2: D): boolean { + if (this.calendar.currentView == 'month') { + return this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2) && + this._dateAdapter.getMonth(date1) == this._dateAdapter.getMonth(date2); + } + if (this.calendar.currentView == 'year') { + return this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2); + } + // Otherwise we are in 'multi-year' view. + return Math.floor(this._dateAdapter.getYear(date1) / yearsPerPage) == + Math.floor(this._dateAdapter.getYear(date2) / yearsPerPage); + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } +} /** * A calendar that is used as part of the datepicker. @@ -48,6 +175,12 @@ import {MatYearView} from './year-view'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { + /** An input indicating the type of the header component, if set. */ + @Input() headerComponent: ComponentType; + + /** A portal containing the header component type for this calendar. */ + _calendarHeaderPortal: Portal; + private _intlChanges: Subscription; /** A date representing the period (month or year) to start the calendar in. */ @@ -119,56 +252,26 @@ export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { * 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) { + get activeDate(): D { return this._clampedActiveDate; } + set activeDate(value: D) { this._clampedActiveDate = this._dateAdapter.clampDate(value, this.minDate, this.maxDate); + this._stateChanges.next(); } private _clampedActiveDate: D; /** Whether the calendar is in month view. */ - _currentView: 'month' | 'year' | 'multi-year'; - - /** The label for the current calendar view. */ - get _periodButtonText(): string { - if (this._currentView == 'month') { - return this._dateAdapter.format(this._activeDate, this._dateFormats.display.monthYearLabel) - .toLocaleUpperCase(); - } - if (this._currentView == 'year') { - return this._dateAdapter.getYearName(this._activeDate); - } - const activeYear = this._dateAdapter.getYear(this._activeDate); - const firstYearInView = this._dateAdapter.getYearName( - this._dateAdapter.createDate(activeYear - activeYear % 24, 0, 1)); - const lastYearInView = this._dateAdapter.getYearName( - this._dateAdapter.createDate(activeYear + yearsPerPage - 1 - activeYear % 24, 0, 1)); - return `${firstYearInView} \u2013 ${lastYearInView}`; - } + currentView: 'month' | 'year' | 'multi-year'; - get _periodButtonLabel(): string { - return this._currentView == 'month' ? - this._intl.switchToMultiYearViewLabel : this._intl.switchToMonthViewLabel; - } - - /** The label for the the previous button. */ - get _prevButtonLabel(): string { - return { - 'month': this._intl.prevMonthLabel, - 'year': this._intl.prevYearLabel, - 'multi-year': this._intl.prevMultiYearLabel - }[this._currentView]; - } - - /** The label for the the next button. */ - get _nextButtonLabel(): string { - return { - 'month': this._intl.nextMonthLabel, - 'year': this._intl.nextYearLabel, - 'multi-year': this._intl.nextMultiYearLabel - }[this._currentView]; + /** + * An observable that emits whenever there is a state change that the header may need to respond + * to. + */ + get stateChanges(): Observable { + return this._stateChanges.asObservable(); } + private _stateChanges = new Subject(); - constructor(private _intl: MatDatepickerIntl, + constructor(_intl: MatDatepickerIntl, @Optional() private _dateAdapter: DateAdapter, @Optional() @Inject(MAT_DATE_FORMATS) private _dateFormats: MatDateFormats, changeDetectorRef: ChangeDetectorRef) { @@ -181,12 +284,17 @@ export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { throw createMissingDateImplError('MAT_DATE_FORMATS'); } - this._intlChanges = _intl.changes.subscribe(() => changeDetectorRef.markForCheck()); + this._intlChanges = _intl.changes.subscribe(() => { + changeDetectorRef.markForCheck(); + this._stateChanges.next(); + }); } ngAfterContentInit() { - this._activeDate = this.startAt || this._dateAdapter.today(); - this._currentView = this.startView; + this._calendarHeaderPortal = new ComponentPortal(this.headerComponent || MatCalendarHeader); + + this.activeDate = this.startAt || this._dateAdapter.today(); + this.currentView = this.startView; } ngOnDestroy() { @@ -203,6 +311,8 @@ export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { view._init(); } } + + this._stateChanges.next(); } /** Handles date selection in the month view. */ @@ -228,56 +338,8 @@ export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { /** Handles year/month selection in the multi-year/year views. */ _goToDateInView(date: D, view: 'month' | 'year' | 'multi-year'): void { - this._activeDate = date; - this._currentView = view; - } - - /** Handles user clicks on the period label. */ - _currentPeriodClicked(): void { - this._currentView = this._currentView == 'month' ? 'multi-year' : 'month'; - } - - /** Handles user clicks on the previous button. */ - _previousClicked(): void { - this._activeDate = this._currentView == 'month' ? - this._dateAdapter.addCalendarMonths(this._activeDate, -1) : - this._dateAdapter.addCalendarYears( - this._activeDate, this._currentView == 'year' ? -1 : -yearsPerPage); - } - - /** Handles user clicks on the next button. */ - _nextClicked(): void { - this._activeDate = this._currentView == 'month' ? - this._dateAdapter.addCalendarMonths(this._activeDate, 1) : - this._dateAdapter.addCalendarYears( - this._activeDate, this._currentView == 'year' ? 1 : yearsPerPage); - } - - /** 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); - } - - /** Whether the two dates represent the same view in the current view mode (month or year). */ - private _isSameView(date1: D, date2: D): boolean { - if (this._currentView == 'month') { - return this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2) && - this._dateAdapter.getMonth(date1) == this._dateAdapter.getMonth(date2); - } - if (this._currentView == 'year') { - return this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2); - } - // Otherwise we are in 'multi-year' view. - return Math.floor(this._dateAdapter.getYear(date1) / yearsPerPage) == - Math.floor(this._dateAdapter.getYear(date2) / yearsPerPage); + this.activeDate = date; + this.currentView = view; } /** diff --git a/src/lib/datepicker/datepicker-content.html b/src/lib/datepicker/datepicker-content.html index 3ade43b79336..ea90dbc71087 100644 --- a/src/lib/datepicker/datepicker-content.html +++ b/src/lib/datepicker/datepicker-content.html @@ -6,6 +6,7 @@ [minDate]="datepicker._minDate" [maxDate]="datepicker._maxDate" [dateFilter]="datepicker._dateFilter" + [headerComponent]="datepicker.calendarHeaderComponent" [selected]="datepicker._selected" [@fadeInCalendar]="'enter'" (selectedChange)="datepicker._select($event)" diff --git a/src/lib/datepicker/datepicker-module.ts b/src/lib/datepicker/datepicker-module.ts index 2e9460f77301..4efd6d18a4c5 100644 --- a/src/lib/datepicker/datepicker-module.ts +++ b/src/lib/datepicker/datepicker-module.ts @@ -13,6 +13,7 @@ import {NgModule} from '@angular/core'; import {MatButtonModule} from '@angular/material/button'; import {MatDialogModule} from '@angular/material/dialog'; import {MatCalendar} from './calendar'; +import {MatCalendarHeader} from './calendar'; import {MatCalendarBody} from './calendar-body'; import {MatDatepicker, MatDatepickerContent} from './datepicker'; import {MatDatepickerInput} from './datepicker-input'; @@ -21,15 +22,17 @@ import {MatDatepickerToggle, MatDatepickerToggleIcon} from './datepicker-toggle' import {MatMonthView} from './month-view'; import {MatMultiYearView} from './multi-year-view'; import {MatYearView} from './year-view'; +import {PortalModule} from '@angular/cdk/portal'; @NgModule({ imports: [ - A11yModule, CommonModule, MatButtonModule, MatDialogModule, OverlayModule, + A11yModule, + PortalModule, ], exports: [ MatCalendar, @@ -54,12 +57,14 @@ import {MatYearView} from './year-view'; MatMonthView, MatYearView, MatMultiYearView, + MatCalendarHeader ], providers: [ MatDatepickerIntl, ], entryComponents: [ MatDatepickerContent, + MatCalendarHeader, ] }) export class MatDatepickerModule {} diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 0a5707b7bb7a..036bb17701d2 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -17,7 +17,7 @@ import { PositionStrategy, ScrollStrategy, } from '@angular/cdk/overlay'; -import {ComponentPortal} from '@angular/cdk/portal'; +import {ComponentPortal, ComponentType} from '@angular/cdk/portal'; import {DOCUMENT} from '@angular/common'; import { AfterContentInit, @@ -176,6 +176,9 @@ export class MatDatepickerContent extends _MatDatepickerContentMixinBase encapsulation: ViewEncapsulation.None, }) export class MatDatepicker implements OnDestroy, CanColor { + /** An input indicating the type of the custom header component for the calendar, if set. */ + @Input() calendarHeaderComponent: ComponentType; + /** The date to open the calendar to initially. */ @Input() get startAt(): D | null {