diff --git a/src/material/datepicker/calendar-header.spec.ts b/src/material/datepicker/calendar-header.spec.ts index 3138015744a..7a4e71c69ce 100644 --- a/src/material/datepicker/calendar-header.spec.ts +++ b/src/material/datepicker/calendar-header.spec.ts @@ -19,6 +19,7 @@ describe('MatCalendarHeader', () => { declarations: [ // Test components. StandardCalendar, + CalendarWithMinMaxDate, ], providers: [ MatDatepickerIntl, @@ -148,7 +149,174 @@ describe('MatCalendarHeader', () => { expect(calendarInstance.activeDate).toEqual(new Date(2016, DEC, 31)); expect(testComponent.selected).toBeFalsy('no date should be selected yet'); }); + }); + describe('calendar with minDate only', () => { + let fixture: ComponentFixture; + let testComponent: CalendarWithMinMaxDate; + let calendarElement: HTMLElement; + let periodButton: HTMLButtonElement; + let prevButton: HTMLButtonElement; + let nextButton: HTMLButtonElement; + let calendarInstance: MatCalendar; + + beforeEach(() => { + fixture = TestBed.createComponent(CalendarWithMinMaxDate); + fixture.detectChanges(); + + let calendarDebugElement = fixture.debugElement.query(By.directive(MatCalendar)); + calendarElement = calendarDebugElement.nativeElement; + periodButton = + calendarElement.querySelector('.mat-calendar-period-button') as HTMLButtonElement; + 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 start the first page with minDate', () => { + testComponent.minDate = new Date(2010, JAN, 1); + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(periodButton.innerText.trim()).toEqual('2010 \u2013 2033'); + }); + + + it('should disable the page before the one showing minDate', () => { + testComponent.minDate = new Date(2010, JAN, 1); + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(prevButton.disabled).toBe(true); + }); + + it('should enable the page after the one showing minDate', () => { + testComponent.minDate = new Date(2010, JAN, 1); + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(nextButton.disabled).toBe(false); + }); + }); + + describe('calendar with maxDate only', () => { + let fixture: ComponentFixture; + let testComponent: CalendarWithMinMaxDate; + let calendarElement: HTMLElement; + let periodButton: HTMLButtonElement; + let prevButton: HTMLButtonElement; + let nextButton: HTMLButtonElement; + let calendarInstance: MatCalendar; + + beforeEach(() => { + fixture = TestBed.createComponent(CalendarWithMinMaxDate); + fixture.detectChanges(); + + let calendarDebugElement = fixture.debugElement.query(By.directive(MatCalendar)); + calendarElement = calendarDebugElement.nativeElement; + periodButton = + calendarElement.querySelector('.mat-calendar-period-button') as HTMLButtonElement; + 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 end the last page with maxDate', () => { + testComponent.maxDate = new Date(2020, JAN, 1); + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(periodButton.innerText.trim()).toEqual('1997 \u2013 2020'); + }); + + it('should disable the page after the one showing maxDate', () => { + testComponent.maxDate = new Date(2020, JAN, 1); + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(nextButton.disabled).toBe(true); + }); + + it('should enable the page before the one showing maxDate', () => { + testComponent.maxDate = new Date(2020, JAN, 1); + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(prevButton.disabled).toBe(false); + }); + }); + + describe('calendar with minDate and maxDate', () => { + let fixture: ComponentFixture; + let testComponent: CalendarWithMinMaxDate; + let calendarElement: HTMLElement; + let periodButton: HTMLButtonElement; + let prevButton: HTMLButtonElement; + let nextButton: HTMLButtonElement; + let calendarInstance: MatCalendar; + + beforeEach(() => { + fixture = TestBed.createComponent(CalendarWithMinMaxDate); + fixture.detectChanges(); + + let calendarDebugElement = fixture.debugElement.query(By.directive(MatCalendar)); + calendarElement = calendarDebugElement.nativeElement; + periodButton = + calendarElement.querySelector('.mat-calendar-period-button') as HTMLButtonElement; + 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 end the last page with maxDate', () => { + testComponent.minDate = new Date(1993, JAN, 1); + testComponent.maxDate = new Date(2020, JAN, 1); + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(periodButton.innerText.trim()).toEqual('1997 \u2013 2020'); + }); + + it('should disable the page after the one showing maxDate', () => { + testComponent.minDate = new Date(1993, JAN, 1); + testComponent.maxDate = new Date(2020, JAN, 1); + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + expect(nextButton.disabled).toBe(true); + }); + + it('should disable the page before the one showing minDate', () => { + testComponent.minDate = new Date(1993, JAN, 1); + testComponent.maxDate = new Date(2020, JAN, 1); + periodButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.currentView).toBe('multi-year'); + + prevButton.click(); + fixture.detectChanges(); + + expect(calendarInstance.activeDate).toEqual(new Date(2018 - yearsPerPage, JAN, 1)); + expect(prevButton.disabled).toBe(true); + }); }); }); @@ -167,3 +335,18 @@ class StandardCalendar { selectedMonth: Date; startDate = new Date(2017, JAN, 31); } + +@Component({ + template: ` + + + ` +}) +class CalendarWithMinMaxDate { + startAt = new Date(2018, JAN, 1); + minDate: Date | null; + maxDate: Date | null; +} diff --git a/src/material/datepicker/calendar.ts b/src/material/datepicker/calendar.ts index d7f336baf66..105a0bfb3f1 100644 --- a/src/material/datepicker/calendar.ts +++ b/src/material/datepicker/calendar.ts @@ -27,12 +27,17 @@ import { } from '@angular/core'; import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core'; import {Subject, Subscription} from 'rxjs'; +import {MatCalendarCellCssClasses} from './calendar-body'; import {createMissingDateImplError} from './datepicker-errors'; import {MatDatepickerIntl} from './datepicker-intl'; import {MatMonthView} from './month-view'; -import {MatMultiYearView, yearsPerPage} from './multi-year-view'; +import { + getActiveOffset, + isSameMultiYearView, + MatMultiYearView, + yearsPerPage +} from './multi-year-view'; import {MatYearView} from './year-view'; -import {MatCalendarCellCssClasses} from './calendar-body'; /** * Possible views for the calendar. @@ -69,12 +74,15 @@ export class MatCalendarHeader { if (this.calendar.currentView == 'year') { return this._dateAdapter.getYearName(this.calendar.activeDate); } + + // The offset from the active year to the "slot" for the starting year is the + // *actual* first rendered year in the multi-year view, and the last year is + // just yearsPerPage - 1 away. 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}`; + const minYearOfPage = activeYear - getActiveOffset( + this._dateAdapter, this.calendar.activeDate, this.calendar.minDate, this.calendar.maxDate); + const maxYearOfPage = minYearOfPage + yearsPerPage - 1; + return `${minYearOfPage} \u2013 ${maxYearOfPage}`; } get periodButtonLabel(): string { @@ -149,8 +157,8 @@ export class MatCalendarHeader { 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); + return isSameMultiYearView( + this._dateAdapter, date1, date2, this.calendar.minDate, this.calendar.maxDate); } } diff --git a/src/material/datepicker/multi-year-view.spec.ts b/src/material/datepicker/multi-year-view.spec.ts index 62aac26d110..bf4e374d58f 100644 --- a/src/material/datepicker/multi-year-view.spec.ts +++ b/src/material/datepicker/multi-year-view.spec.ts @@ -33,6 +33,7 @@ describe('MatMultiYearView', () => { // Test components. StandardMultiYearView, MultiYearViewWithDateFilter, + MultiYearViewWithMinMaxDate, ], providers: [ {provide: Directionality, useFactory: () => dir = {value: 'ltr'}} @@ -247,8 +248,87 @@ describe('MatMultiYearView', () => { expect(cells[1].classList).toContain('mat-calendar-body-disabled'); }); }); -}); + describe('multi year view with minDate only', () => { + let fixture: ComponentFixture; + let testComponent: MultiYearViewWithMinMaxDate; + let multiYearViewNativeElement: Element; + + beforeEach(() => { + fixture = TestBed.createComponent(MultiYearViewWithMinMaxDate); + + const multiYearViewDebugElement = fixture.debugElement.query(By.directive(MatMultiYearView)); + multiYearViewNativeElement = multiYearViewDebugElement.nativeElement; + testComponent = fixture.componentInstance; + }); + + it('should begin first page with minDate', () => { + testComponent.minDate = new Date(2014, JAN, 1); + testComponent.maxDate = null; + fixture.detectChanges(); + + const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + expect((cells[0] as HTMLElement).innerText.trim()).toBe('2014'); + }); + }); + + describe('multi year view with maxDate only', () => { + let fixture: ComponentFixture; + let testComponent: MultiYearViewWithMinMaxDate; + let multiYearViewNativeElement: Element; + + beforeEach(() => { + fixture = TestBed.createComponent(MultiYearViewWithMinMaxDate); + + const multiYearViewDebugElement = fixture.debugElement.query(By.directive(MatMultiYearView)); + multiYearViewNativeElement = multiYearViewDebugElement.nativeElement; + testComponent = fixture.componentInstance; + }); + + it('should end last page with maxDate', () => { + testComponent.minDate = null; + testComponent.maxDate = new Date(2020, JAN, 1); + fixture.detectChanges(); + + const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + expect((cells[cells.length - 1] as HTMLElement).innerText.trim()).toBe('2020'); + }); + }); + + describe('multi year view with minDate and maxDate', () => { + let fixture: ComponentFixture; + let testComponent: MultiYearViewWithMinMaxDate; + let multiYearViewNativeElement: Element; + + beforeEach(() => { + fixture = TestBed.createComponent(MultiYearViewWithMinMaxDate); + + const multiYearViewDebugElement = fixture.debugElement.query(By.directive(MatMultiYearView)); + multiYearViewNativeElement = multiYearViewDebugElement.nativeElement; + testComponent = fixture.componentInstance; + }); + + it('should end last page with maxDate', () => { + testComponent.minDate = new Date(2006, JAN, 1); + testComponent.maxDate = new Date (2020, JAN, 1); + fixture.detectChanges(); + + const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + expect((cells[cells.length - 1] as HTMLElement).innerText.trim()).toBe('2020'); + }); + + it('should disable dates before minDate', () => { + testComponent.minDate = new Date(2006, JAN, 1); + testComponent.maxDate = new Date (2020, JAN, 1); + fixture.detectChanges(); + + const cells = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell'); + expect(cells[0].classList).toContain('mat-calendar-body-disabled'); + expect(cells[8].classList).toContain('mat-calendar-body-disabled'); + expect(cells[9].classList).not.toContain('mat-calendar-body-disabled'); + }); + }); +}); @Component({ template: ` @@ -265,7 +345,8 @@ class StandardMultiYearView { @Component({ template: ` - + + ` }) class MultiYearViewWithDateFilter { @@ -274,3 +355,15 @@ class MultiYearViewWithDateFilter { return date.getFullYear() !== 2017; } } + +@Component({ + template: ` + + + ` +}) +class MultiYearViewWithMinMaxDate { + activeDate = new Date(2019, JAN, 1); + minDate: Date | null; + maxDate: Date | null; +} diff --git a/src/material/datepicker/multi-year-view.ts b/src/material/datepicker/multi-year-view.ts index 91c1ccddd93..443c50ab599 100644 --- a/src/material/datepicker/multi-year-view.ts +++ b/src/material/datepicker/multi-year-view.ts @@ -39,7 +39,6 @@ export const yearsPerPage = 24; export const yearsPerRow = 4; - /** * An internal component used to display a year selector in the datepicker. * @docs-private @@ -61,8 +60,9 @@ export class MatMultiYearView implements AfterContentInit { const validDate = this._getValidDateOrNull(this._dateAdapter.deserialize(value)) || this._dateAdapter.today(); this._activeDate = this._dateAdapter.clampDate(validDate, this.minDate, this.maxDate); - if (Math.floor(this._dateAdapter.getYear(oldActiveDate) / yearsPerPage) != - Math.floor(this._dateAdapter.getYear(this._activeDate) / yearsPerPage)) { + + if (!isSameMultiYearView( + this._dateAdapter, oldActiveDate, this._activeDate, this.minDate, this.maxDate)) { this._init(); } } @@ -134,11 +134,20 @@ export class MatMultiYearView implements AfterContentInit { /** Initializes this multi-year view. */ _init() { this._todayYear = this._dateAdapter.getYear(this._dateAdapter.today()); - let activeYear = this._dateAdapter.getYear(this._activeDate); - let activeOffset = activeYear % yearsPerPage; + + // We want a range years such that we maximize the number of + // enabled dates visible at once. This prevents issues where the minimum year + // is the last item of a page OR the maximum year is the first item of a page. + + // The offset from the active year to the "slot" for the starting year is the + // *actual* first rendered year in the multi-year view. + const activeYear = this._dateAdapter.getYear(this._activeDate); + const minYearOfPage = activeYear - getActiveOffset( + this._dateAdapter, this.activeDate, this.minDate, this.maxDate); + this._years = []; for (let i = 0, row: number[] = []; i < yearsPerPage; i++) { - row.push(activeYear - activeOffset + i); + row.push(minYearOfPage + i); if (row.length == yearsPerRow) { this._years.push(row.map(year => this._createCellForYear(year))); row = []; @@ -159,10 +168,6 @@ export class MatMultiYearView implements AfterContentInit { /** Handles keydown events on the calendar body when calendar is in multi-year view. */ _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. - const oldActiveDate = this._activeDate; const isRtl = this._isRtl(); @@ -181,11 +186,12 @@ export class MatMultiYearView implements AfterContentInit { break; case HOME: this.activeDate = this._dateAdapter.addCalendarYears(this._activeDate, - -this._dateAdapter.getYear(this._activeDate) % yearsPerPage); + -getActiveOffset(this._dateAdapter, this.activeDate, this.minDate, this.maxDate)); break; case END: this.activeDate = this._dateAdapter.addCalendarYears(this._activeDate, - yearsPerPage - this._dateAdapter.getYear(this._activeDate) % yearsPerPage - 1); + yearsPerPage - getActiveOffset( + this._dateAdapter, this.activeDate, this.minDate, this.maxDate) - 1); break; case PAGE_UP: this.activeDate = @@ -205,7 +211,6 @@ export class MatMultiYearView implements AfterContentInit { // Don't prevent default or focus active cell on keys that we don't explicitly handle. return; } - if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) { this.activeDateChange.emit(this.activeDate); } @@ -216,7 +221,7 @@ export class MatMultiYearView implements AfterContentInit { } _getActiveCell(): number { - return this._dateAdapter.getYear(this.activeDate) % yearsPerPage; + return getActiveOffset(this._dateAdapter, this.activeDate, this.minDate, this.maxDate); } /** Focuses the active cell after the microtask queue is empty. */ @@ -270,3 +275,45 @@ export class MatMultiYearView implements AfterContentInit { return this._dir && this._dir.value === 'rtl'; } } + +export function isSameMultiYearView( + dateAdapter: DateAdapter, date1: D, date2: D, minDate: D | null, maxDate: D | null): boolean { + const year1 = dateAdapter.getYear(date1); + const year2 = dateAdapter.getYear(date2); + const startingYear = getStartingYear(dateAdapter, minDate, maxDate); + return Math.floor((year1 - startingYear) / yearsPerPage) === + Math.floor((year2 - startingYear) / yearsPerPage); +} + +/** + * When the multi-year view is first opened, the active year will be in view. + * So we compute how many years are between the active year and the *slot* where our + * "startingYear" will render when paged into view. + */ +export function getActiveOffset( + dateAdapter: DateAdapter, activeDate: D, minDate: D | null, maxDate: D | null): number { + const activeYear = dateAdapter.getYear(activeDate); + return euclideanModulo((activeYear - getStartingYear(dateAdapter, minDate, maxDate)), + yearsPerPage); +} + +/** + * We pick a "starting" year such that either the maximum year would be at the end + * or the minimum year would be at the beginning of a page. + */ +function getStartingYear( + dateAdapter: DateAdapter, minDate: D | null, maxDate: D | null): number { + let startingYear = 0; + if (maxDate) { + const maxYear = dateAdapter.getYear(maxDate); + startingYear = maxYear - yearsPerPage + 1; + } else if (minDate) { + startingYear = dateAdapter.getYear(minDate); + } + return startingYear; +} + +/** Gets remainder that is non-negative, even if first number is negative */ +function euclideanModulo (a: number, b: number): number { + return (a % b + b) % b; +} diff --git a/src/material/datepicker/public-api.ts b/src/material/datepicker/public-api.ts index f796de12f3e..59cff4b77f8 100644 --- a/src/material/datepicker/public-api.ts +++ b/src/material/datepicker/public-api.ts @@ -16,4 +16,4 @@ export * from './datepicker-intl'; export * from './datepicker-toggle'; export * from './month-view'; export * from './year-view'; -export * from './multi-year-view'; +export {MatMultiYearView, yearsPerPage, yearsPerRow} from './multi-year-view';