diff --git a/src/material/core/datetime/date-selection-model.ts b/src/material/core/datetime/date-selection-model.ts new file mode 100644 index 000000000000..270550605071 --- /dev/null +++ b/src/material/core/datetime/date-selection-model.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {FactoryProvider, Injectable, Optional, SkipSelf, OnDestroy} from '@angular/core'; +import {DateAdapter} from './date-adapter'; +import {Observable, Subject} from 'rxjs'; + +/** A class representing a range of dates. */ +export class DateRange { + /** + * Ensures that objects with a `start` and `end` property can't be assigned to a variable that + * expects a `DateRange` + */ + // tslint:disable-next-line:no-unused-variable + private _disableStructuralEquivalency: never; + + constructor( + /** The start date of the range. */ + readonly start: D | null, + /** The end date of the range. */ + readonly end: D | null) {} +} + +type ExtractDateTypeFromSelection = T extends DateRange ? D : NonNullable; + +/** Event emitted by the date selection model when its selection changes. */ +export interface DateSelectionModelChange { + /** New value for the selection. */ + selection: S; + + /** Object that triggered the change. */ + source: unknown; +} + +/** A selection model containing a date selection. */ +export abstract class MatDateSelectionModel> + implements OnDestroy { + private _selectionChanged = new Subject>(); + + /** Emits when the selection has changed. */ + selectionChanged: Observable> = this._selectionChanged.asObservable(); + + protected constructor( + /** Date adapter used when interacting with dates in the model. */ + protected readonly adapter: DateAdapter, + /** The current selection. */ + readonly selection: S) { + this.selection = selection; + } + + /** + * Updates the current selection in the model. + * @param value New selection that should be assigned. + * @param source Object that triggered the selection change. + */ + updateSelection(value: S, source: unknown) { + (this as {selection: S}).selection = value; + this._selectionChanged.next({selection: value, source}); + } + + ngOnDestroy() { + this._selectionChanged.complete(); + } + + /** Adds a date to the current selection. */ + abstract add(date: D | null): void; + + /** Checks whether the current selection is complete. */ + abstract isComplete(): boolean; + + /** Checks whether the current selection is identical to the passed-in selection. */ + abstract isSame(other: S): boolean; + + /** Checks whether the current selection is valid. */ + abstract isValid(): boolean; + + /** Checks whether the current selection overlaps with the given range. */ + abstract overlaps(range: DateRange): boolean; +} + +/** A selection model that contains a single date. */ +@Injectable() +export class MatSingleDateSelectionModel extends MatDateSelectionModel { + constructor(adapter: DateAdapter) { + super(adapter, null); + } + + /** + * Adds a date to the current selection. In the case of a single date selection, the added date + * simply overwrites the previous selection + */ + add(date: D | null) { + super.updateSelection(date, this); + } + + /** + * Checks whether the current selection is complete. In the case of a single date selection, this + * is true if the current selection is not null. + */ + isComplete() { return this.selection != null; } + + /** Checks whether the current selection is identical to the passed-in selection. */ + isSame(other: D): boolean { + return this.adapter.sameDate(other, this.selection); + } + + /** + * Checks whether the current selection is valid. In the case of a single date selection, this + * means that the current selection is not null and is a valid date. + */ + isValid(): boolean { + return this.selection != null && this.adapter.isDateInstance(this.selection) && + this.adapter.isValid(this.selection); + } + + /** Checks whether the current selection overlaps with the given range. */ + overlaps(range: DateRange): boolean { + return !!(this.selection && range.start && range.end && + this.adapter.compareDate(range.start, this.selection) <= 0 && + this.adapter.compareDate(this.selection, range.end) <= 0); + } +} + +/** A selection model that contains a date range. */ +@Injectable() +export class MatRangeDateSelectionModel extends MatDateSelectionModel, D> { + constructor(adapter: DateAdapter) { + super(adapter, new DateRange(null, null)); + } + + /** + * Adds a date to the current selection. In the case of a date range selection, the added date + * fills in the next `null` value in the range. If both the start and the end already have a date, + * the selection is reset so that the given date is the new `start` and the `end` is null. + */ + add(date: D | null): void { + let {start, end} = this.selection; + + if (start == null) { + start = date; + } else if (end == null) { + end = date; + } else { + start = date; + end = null; + } + + super.updateSelection(new DateRange(start, end), this); + } + + /** + * Checks whether the current selection is complete. In the case of a date range selection, this + * is true if the current selection has a non-null `start` and `end`. + */ + isComplete(): boolean { + return this.selection.start != null && this.selection.end != null; + } + + /** Checks whether the current selection is identical to the passed-in selection. */ + isSame(other: DateRange): boolean { + return this.adapter.sameDate(this.selection.start, other.start) && + this.adapter.sameDate(this.selection.end, other.end); + } + + /** + * Checks whether the current selection is valid. In the case of a date range selection, this + * means that the current selection has a `start` and `end` that are both non-null and valid + * dates. + */ + isValid(): boolean { + return this.selection.start != null && this.selection.end != null && + this.adapter.isValid(this.selection.start!) && this.adapter.isValid(this.selection.end!); + } + + /** + * Returns true if the given range and the selection overlap in any way. False if otherwise, that + * includes incomplete selections or ranges. + */ + overlaps(range: DateRange): boolean { + if (!(this.selection.start && this.selection.end && range.start && range.end)) { + return false; + } + + return ( + this._isBetween(range.start, this.selection.start, this.selection.end) || + this._isBetween(range.end, this.selection.start, this.selection.end) || + ( + this.adapter.compareDate(range.start, this.selection.start) <= 0 && + this.adapter.compareDate(this.selection.end, range.end) <= 0 + ) + ); + } + + private _isBetween(value: D, from: D, to: D): boolean { + return this.adapter.compareDate(from, value) <= 0 && this.adapter.compareDate(value, to) <= 0; + } +} + +/** @docs-private */ +export function MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY( + parent: MatSingleDateSelectionModel, adapter: DateAdapter) { + return parent || new MatSingleDateSelectionModel(adapter); +} + +/** Used to provide a single selection model to a component. */ +export const MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER: FactoryProvider = { + provide: MatDateSelectionModel, + deps: [[new Optional(), new SkipSelf(), MatDateSelectionModel], DateAdapter], + useFactory: MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY, +}; diff --git a/src/material/core/datetime/index.ts b/src/material/core/datetime/index.ts index 5c94050408a0..3bf1ea2459e1 100644 --- a/src/material/core/datetime/index.ts +++ b/src/material/core/datetime/index.ts @@ -17,6 +17,7 @@ export * from './date-adapter'; export * from './date-formats'; export * from './native-date-adapter'; export * from './native-date-formats'; +export * from './date-selection-model'; @NgModule({ diff --git a/src/material/datepicker/calendar.ts b/src/material/datepicker/calendar.ts index 9b911d58edcf..4672f159fd15 100644 --- a/src/material/datepicker/calendar.ts +++ b/src/material/datepicker/calendar.ts @@ -25,7 +25,13 @@ import { ViewChild, ViewEncapsulation, } from '@angular/core'; -import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MatDateFormats, + MatDateSelectionModel, + MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER, +} from '@angular/material/core'; import {Subject, Subscription} from 'rxjs'; import {MatCalendarCellCssClasses} from './calendar-body'; import {createMissingDateImplError} from './datepicker-errors'; @@ -179,6 +185,7 @@ export class MatCalendarHeader { exportAs: 'matCalendar', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, + providers: [MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER] }) export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDestroy, OnChanges { /** An input indicating the type of the header component, if set. */ @@ -188,6 +195,7 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes _calendarHeaderPortal: Portal; private _intlChanges: Subscription; + private _selectedChanges: Subscription; /** * Used for scheduling that focus should be moved to the active cell on the next tick. @@ -209,11 +217,11 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes /** The currently selected date. */ @Input() - get selected(): D | null { return this._selected; } + get selected(): D | null { return this._model.selection; } set selected(value: D | null) { - this._selected = this._getValidDateOrNull(this._dateAdapter.deserialize(value)); + const newValue = this._getValidDateOrNull(this._dateAdapter.deserialize(value)); + this._model.updateSelection(newValue, this); } - private _selected: D | null; /** The minimum selectable date. */ @Input() @@ -237,7 +245,10 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes /** Function that can be used to add custom CSS classes to dates. */ @Input() dateClass: (date: D) => MatCalendarCellCssClasses; - /** Emits when the currently selected date changes. */ + /** + * Emits when the currently selected date changes. + * @breaking-change 11.0.0 Emitted value to change to `D | null`. + */ @Output() readonly selectedChange: EventEmitter = new EventEmitter(); /** @@ -293,7 +304,8 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes constructor(_intl: MatDatepickerIntl, @Optional() private _dateAdapter: DateAdapter, @Optional() @Inject(MAT_DATE_FORMATS) private _dateFormats: MatDateFormats, - private _changeDetectorRef: ChangeDetectorRef) { + private _changeDetectorRef: ChangeDetectorRef, + private _model: MatDateSelectionModel) { if (!this._dateAdapter) { throw createMissingDateImplError('DateAdapter'); @@ -307,6 +319,13 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes _changeDetectorRef.markForCheck(); this.stateChanges.next(); }); + + this._selectedChanges = _model.selectionChanged.subscribe(event => { + // @breaking-change 11.0.0 Remove null check once `event.selection` is allowed to be null. + if (event.selection) { + this.selectedChange.emit(event.selection); + } + }); } ngAfterContentInit() { @@ -325,6 +344,7 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes } ngOnDestroy() { + this._selectedChanges.unsubscribe(); this._intlChanges.unsubscribe(); this.stateChanges.complete(); } @@ -361,9 +381,7 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes /** Handles date selection in the month view. */ _dateSelected(date: D | null): void { - if (date && !this._dateAdapter.sameDate(date, this.selected)) { - this.selectedChange.emit(date); - } + this._model.add(date); } /** Handles year selection in the multiyear view. */ diff --git a/src/material/datepicker/datepicker-content.html b/src/material/datepicker/datepicker-content.html index fbca010906e8..6fbe45f34ab9 100644 --- a/src/material/datepicker/datepicker-content.html +++ b/src/material/datepicker/datepicker-content.html @@ -7,10 +7,8 @@ [maxDate]="datepicker._maxDate" [dateFilter]="datepicker._dateFilter" [headerComponent]="datepicker.calendarHeaderComponent" - [selected]="datepicker._selected" [dateClass]="datepicker.dateClass" [@fadeInCalendar]="'enter'" - (selectedChange)="datepicker.select($event)" (yearSelected)="datepicker._selectYear($event)" (monthSelected)="datepicker._selectMonth($event)" (_userSelection)="datepicker.close()"> diff --git a/src/material/datepicker/datepicker-input.ts b/src/material/datepicker/datepicker-input.ts index bb74cceab489..4539aa567f7d 100644 --- a/src/material/datepicker/datepicker-input.ts +++ b/src/material/datepicker/datepicker-input.ts @@ -29,7 +29,13 @@ import { ValidatorFn, Validators, } from '@angular/forms'; -import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats, ThemePalette} from '@angular/material/core'; +import { + DateAdapter, + MAT_DATE_FORMATS, + MatDateFormats, + ThemePalette, + MatDateSelectionModel, +} from '@angular/material/core'; import {MatFormField} from '@angular/material/form-field'; import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material/input'; import {Subscription} from 'rxjs'; @@ -61,10 +67,10 @@ export class MatDatepickerInputEvent { value: D | null; constructor( - /** Reference to the datepicker input component that emitted the event. */ - public target: MatDatepickerInput, - /** Reference to the native input element associated with the datepicker input. */ - public targetElement: HTMLElement) { + /** Reference to the datepicker input component that emitted the event. */ + public target: MatDatepickerInput, + /** Reference to the native input element associated with the datepicker input. */ + public targetElement: HTMLElement) { this.value = this.target.value; } } @@ -94,21 +100,27 @@ export class MatDatepickerInputEvent { export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, Validator { /** The datepicker that this input is associated with. */ @Input() - set matDatepicker(value: MatDatepicker) { - if (!value) { + set matDatepicker(datepicker: MatDatepicker) { + if (!datepicker) { return; } - this._datepicker = value; - this._datepicker._registerInput(this); - this._datepickerSubscription.unsubscribe(); + this._datepicker = datepicker; + this._model = this._datepicker._registerInput(this); + this._valueChangesSubscription.unsubscribe(); - this._datepickerSubscription = this._datepicker._selectedChanged.subscribe((selected: D) => { - this.value = selected; - this._cvaOnChange(selected); - this._onTouched(); - this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement)); - this.dateChange.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement)); + if (this._pendingValue) { + this._assignValue(this._pendingValue); + } + + this._valueChangesSubscription = this._model.selectionChanged.subscribe(event => { + if (event.source !== this) { + this._cvaOnChange(event.selection); + this._onTouched(); + this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement)); + this.dateChange.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement)); + this._formatValue(event.selection); + } }); } _datepicker: MatDatepicker; @@ -123,20 +135,20 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V /** The value of the input. */ @Input() - get value(): D | null { return this._value; } + get value(): D | null { return this._model ? this._model.selection : this._pendingValue; } set value(value: D | null) { value = this._dateAdapter.deserialize(value); this._lastValueValid = !value || this._dateAdapter.isValid(value); value = this._getValidDateOrNull(value); const oldDate = this.value; - this._value = value; + this._assignValue(value); this._formatValue(value); if (!this._dateAdapter.sameDate(oldDate, value)) { this._valueChange.emit(value); } } - private _value: D | null; + private _model: MatDateSelectionModel | undefined; /** The minimum valid date. */ @Input() @@ -195,13 +207,17 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V _onTouched = () => {}; private _cvaOnChange: (value: any) => void = () => {}; - private _validatorOnChange = () => {}; - - private _datepickerSubscription = Subscription.EMPTY; - + private _valueChangesSubscription = Subscription.EMPTY; private _localeSubscription = Subscription.EMPTY; + /** + * Since the value is kept on the datepicker which is assigned in an Input, + * we might get a value before we have a datepicker. This property keeps track + * of the value until we have somewhere to assign it. + */ + private _pendingValue: D | null; + /** The form control validator for whether the input parses. */ private _parseValidator: ValidatorFn = (): ValidationErrors | null => { return this._lastValueValid ? @@ -258,7 +274,7 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V } ngOnDestroy() { - this._datepickerSubscription.unsubscribe(); + this._valueChangesSubscription.unsubscribe(); this._localeSubscription.unsubscribe(); this._valueChange.complete(); this._disabledChange.complete(); @@ -324,8 +340,8 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V this._lastValueValid = !date || this._dateAdapter.isValid(date); date = this._getValidDateOrNull(date); - if (!this._dateAdapter.sameDate(date, this._value)) { - this._value = date; + if (!this._dateAdapter.sameDate(date, this.value)) { + this._assignValue(date); this._cvaOnChange(date); this._valueChange.emit(date); this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement)); @@ -367,6 +383,18 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V return (this._dateAdapter.isDateInstance(obj) && this._dateAdapter.isValid(obj)) ? obj : null; } + /** Assigns a value to the model. */ + private _assignValue(value: D | null) { + // We may get some incoming values before the datepicker was + // assigned. Save the value so that we can assign it later. + if (this._model) { + this._model.updateSelection(value, this); + this._pendingValue = null; + } else { + this._pendingValue = value; + } + } + // Accept `any` to avoid conflicts with other directives on `` that // may accept different types. static ngAcceptInputType_value: any; diff --git a/src/material/datepicker/datepicker.spec.ts b/src/material/datepicker/datepicker.spec.ts index 23784b20251b..f55306e7e747 100644 --- a/src/material/datepicker/datepicker.spec.ts +++ b/src/material/datepicker/datepicker.spec.ts @@ -12,7 +12,12 @@ import { import {Component, FactoryProvider, Type, ValueProvider, ViewChild} from '@angular/core'; import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing'; import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms'; -import {MAT_DATE_LOCALE, MatNativeDateModule, NativeDateModule} from '@angular/material/core'; +import { + MAT_DATE_LOCALE, + MatNativeDateModule, + NativeDateModule, + MatDateSelectionModel, +} from '@angular/material/core'; import {MatFormField, MatFormFieldModule} from '@angular/material/form-field'; import {DEC, JAN, JUL, JUN, SEP} from '@angular/material/testing'; import {By} from '@angular/platform-browser'; @@ -66,12 +71,15 @@ describe('MatDatepicker', () => { describe('standard datepicker', () => { let fixture: ComponentFixture; let testComponent: StandardDatepicker; + let model: MatDateSelectionModel; beforeEach(fakeAsync(() => { fixture = createComponent(StandardDatepicker, [MatNativeDateModule]); fixture.detectChanges(); testComponent = fixture.componentInstance; + model = fixture.debugElement.query(By.directive(MatDatepicker)) + .injector.get(MatDateSelectionModel); })); afterEach(fakeAsync(() => { @@ -274,8 +282,8 @@ describe('MatDatepicker', () => { it('clicking the currently selected date should close the calendar ' + 'without firing selectedChanged', fakeAsync(() => { - const selectedChangedSpy = - spyOn(testComponent.datepicker._selectedChanged, 'next').and.callThrough(); + const spy = jasmine.createSpy('selectionChanged spy'); + const selectedSubscription = model.selectionChanged.subscribe(spy); for (let changeCount = 1; changeCount < 3; changeCount++) { const currentDay = changeCount; @@ -291,15 +299,16 @@ describe('MatDatepicker', () => { flush(); } - expect(selectedChangedSpy.calls.count()).toEqual(1); + expect(spy).toHaveBeenCalledTimes(1); expect(document.querySelector('mat-dialog-container')).toBeNull(); expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 2)); + selectedSubscription.unsubscribe(); })); it('pressing enter on the currently selected date should close the calendar without ' + 'firing selectedChanged', () => { - const selectedChangedSpy = - spyOn(testComponent.datepicker._selectedChanged, 'next').and.callThrough(); + const spy = jasmine.createSpy('selectionChanged spy'); + const selectedSubscription = model.selectionChanged.subscribe(spy); testComponent.datepicker.open(); fixture.detectChanges(); @@ -312,9 +321,10 @@ describe('MatDatepicker', () => { fixture.detectChanges(); fixture.whenStable().then(() => { - expect(selectedChangedSpy.calls.count()).toEqual(0); + expect(spy).not.toHaveBeenCalled(); expect(document.querySelector('mat-dialog-container')).toBeNull(); expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1)); + selectedSubscription.unsubscribe(); }); }); @@ -507,11 +517,13 @@ describe('MatDatepicker', () => { const fixture = createComponent(DelayedDatepicker, [MatNativeDateModule]); const testComponent: DelayedDatepicker = fixture.componentInstance; const toSelect = new Date(2017, JAN, 1); - fixture.detectChanges(); + const model = fixture.debugElement.query(By.directive(MatDatepicker)) + .injector.get(MatDateSelectionModel); + expect(testComponent.datepickerInput.value).toBeNull(); - expect(testComponent.datepicker._selected).toBeNull(); + expect(model.selection).toBeNull(); testComponent.assignedDatepicker = testComponent.datepicker; fixture.detectChanges(); @@ -522,7 +534,7 @@ describe('MatDatepicker', () => { fixture.detectChanges(); expect(testComponent.datepickerInput.value).toEqual(toSelect); - expect(testComponent.datepicker._selected).toEqual(toSelect); + expect(model.selection).toEqual(toSelect); })); }); @@ -672,6 +684,7 @@ describe('MatDatepicker', () => { describe('datepicker with ngModel', () => { let fixture: ComponentFixture; let testComponent: DatepickerWithNgModel; + let model: MatDateSelectionModel; beforeEach(fakeAsync(() => { fixture = createComponent(DatepickerWithNgModel, [MatNativeDateModule]); @@ -681,6 +694,8 @@ describe('MatDatepicker', () => { fixture.detectChanges(); testComponent = fixture.componentInstance; + model = fixture.debugElement.query(By.directive(MatDatepicker)) + .injector.get(MatDateSelectionModel); }); })); @@ -691,7 +706,7 @@ describe('MatDatepicker', () => { it('should update datepicker when model changes', fakeAsync(() => { expect(testComponent.datepickerInput.value).toBeNull(); - expect(testComponent.datepicker._selected).toBeNull(); + expect(model.selection).toBeNull(); let selected = new Date(2017, JAN, 1); testComponent.selected = selected; @@ -700,7 +715,7 @@ describe('MatDatepicker', () => { fixture.detectChanges(); expect(testComponent.datepickerInput.value).toEqual(selected); - expect(testComponent.datepicker._selected).toEqual(selected); + expect(model.selection).toEqual(selected); })); it('should update model when date is selected', fakeAsync(() => { @@ -820,12 +835,15 @@ describe('MatDatepicker', () => { describe('datepicker with formControl', () => { let fixture: ComponentFixture; let testComponent: DatepickerWithFormControl; + let model: MatDateSelectionModel; beforeEach(fakeAsync(() => { fixture = createComponent(DatepickerWithFormControl, [MatNativeDateModule]); fixture.detectChanges(); testComponent = fixture.componentInstance; + model = fixture.debugElement.query(By.directive(MatDatepicker)) + .injector.get(MatDateSelectionModel); })); afterEach(fakeAsync(() => { @@ -835,14 +853,14 @@ describe('MatDatepicker', () => { it('should update datepicker when formControl changes', () => { expect(testComponent.datepickerInput.value).toBeNull(); - expect(testComponent.datepicker._selected).toBeNull(); + expect(model.selection).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); + expect(model.selection).toEqual(selected); }); it('should update formControl when date is selected', () => { diff --git a/src/material/datepicker/datepicker.ts b/src/material/datepicker/datepicker.ts index 3574a78ff034..b5e5dd1d28a0 100644 --- a/src/material/datepicker/datepicker.ts +++ b/src/material/datepicker/datepicker.ts @@ -42,9 +42,11 @@ import { DateAdapter, mixinColor, ThemePalette, + MatDateSelectionModel, + MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER, } from '@angular/material/core'; import {MatDialog, MatDialogRef} from '@angular/material/dialog'; -import {merge, Subject, Subscription} from 'rxjs'; +import {merge, Subject} from 'rxjs'; import {filter, take} from 'rxjs/operators'; import {MatCalendar} from './calendar'; import {matDatepickerAnimations} from './datepicker-animations'; @@ -136,6 +138,7 @@ export class MatDatepickerContent extends _MatDatepickerContentMixinBase exportAs: 'matDatepicker', changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, + providers: [MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER] }) export class MatDatepicker implements OnDestroy, CanColor { private _scrollStrategy: () => ScrollStrategy; @@ -230,11 +233,6 @@ export class MatDatepicker implements OnDestroy, CanColor { /** The id for the datepicker calendar. */ id: string = `mat-datepicker-${datepickerUid++}`; - /** The currently selected date. */ - get _selected(): D | null { return this._validSelected; } - set _selected(value: D | null) { this._validSelected = value; } - private _validSelected: D | null = null; - /** The minimum selectable date. */ get _minDate(): D | null { return this._datepickerInput && this._datepickerInput.min; @@ -264,18 +262,12 @@ export class MatDatepicker implements OnDestroy, CanColor { /** The element that was focused before the datepicker was opened. */ private _focusedElementBeforeOpen: HTMLElement | null = null; - /** Subscription to value changes in the associated input element. */ - private _inputSubscription = Subscription.EMPTY; - /** The input element this datepicker is associated with. */ _datepickerInput: MatDatepickerInput; /** Emits when the datepicker is disabled. */ readonly _disabledChange = new Subject(); - /** Emits new selected date when selected date changes. */ - readonly _selectedChanged = new Subject(); - constructor(private _dialog: MatDialog, private _overlay: Overlay, private _ngZone: NgZone, @@ -283,7 +275,8 @@ export class MatDatepicker implements OnDestroy, CanColor { @Inject(MAT_DATEPICKER_SCROLL_STRATEGY) scrollStrategy: any, @Optional() private _dateAdapter: DateAdapter, @Optional() private _dir: Directionality, - @Optional() @Inject(DOCUMENT) private _document: any) { + @Optional() @Inject(DOCUMENT) private _document: any, + private _model: MatDateSelectionModel) { if (!this._dateAdapter) { throw createMissingDateImplError('DateAdapter'); } @@ -293,7 +286,6 @@ export class MatDatepicker implements OnDestroy, CanColor { ngOnDestroy() { this.close(); - this._inputSubscription.unsubscribe(); this._disabledChange.complete(); if (this._popupRef) { @@ -304,11 +296,7 @@ export class MatDatepicker implements OnDestroy, CanColor { /** Selects the given date */ select(date: D): void { - let oldValue = this._selected; - this._selected = date; - if (!this._dateAdapter.sameDate(oldValue, this._selected)) { - this._selectedChanged.next(date); - } + this._model.add(date); } /** Emits the selected year in multiyear view */ @@ -324,14 +312,14 @@ export class MatDatepicker implements OnDestroy, CanColor { /** * Register an input with this datepicker. * @param input The datepicker input to register with this datepicker. + * @returns Selection model that the input should hook itself up to. */ - _registerInput(input: MatDatepickerInput): void { + _registerInput(input: MatDatepickerInput): MatDateSelectionModel { if (this._datepickerInput) { throw Error('A MatDatepicker can only be associated with a single input.'); } this._datepickerInput = input; - this._inputSubscription = - this._datepickerInput._valueChange.subscribe((value: D | null) => this._selected = value); + return this._model; } /** Open the calendar. */ diff --git a/tools/public_api_guard/material/core.d.ts b/tools/public_api_guard/material/core.d.ts index 4e39f0d4e2ee..891bf797d10e 100644 --- a/tools/public_api_guard/material/core.d.ts +++ b/tools/public_api_guard/material/core.d.ts @@ -79,6 +79,19 @@ export declare abstract class DateAdapter { abstract today(): D; } +export declare class DateRange { + readonly end: D | null; + readonly start: D | null; + constructor( + start: D | null, + end: D | null); +} + +export interface DateSelectionModelChange { + selection: S; + source: unknown; +} + export declare 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; export declare const defaultRippleAnimationConfig: { @@ -200,6 +213,10 @@ export declare const MAT_OPTION_PARENT_COMPONENT: InjectionToken; +export declare function MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY(parent: MatSingleDateSelectionModel, adapter: DateAdapter): MatSingleDateSelectionModel; + +export declare const MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER: FactoryProvider; + export declare class MatCommonModule { constructor(highContrastModeDetector: HighContrastModeDetector, sanityChecks: any); static ɵinj: i0.ɵɵInjectorDef; @@ -218,6 +235,24 @@ export declare type MatDateFormats = { }; }; +export declare abstract class MatDateSelectionModel> implements OnDestroy { + protected readonly adapter: DateAdapter; + readonly selection: S; + selectionChanged: Observable>; + protected constructor( + adapter: DateAdapter, + selection: S); + abstract add(date: D | null): void; + abstract isComplete(): boolean; + abstract isSame(other: S): boolean; + abstract isValid(): boolean; + ngOnDestroy(): void; + abstract overlaps(range: DateRange): boolean; + updateSelection(value: S, source: unknown): void; + static ɵdir: i0.ɵɵDirectiveDefWithMeta, never, never, {}, {}, never>; + static ɵfac: i0.ɵɵFactoryDef>; +} + export declare const MATERIAL_SANITY_CHECKS: InjectionToken; export declare class MatLine { @@ -312,6 +347,17 @@ export declare class MatPseudoCheckboxModule { export declare type MatPseudoCheckboxState = 'unchecked' | 'checked' | 'indeterminate'; +export declare class MatRangeDateSelectionModel extends MatDateSelectionModel, D> { + constructor(adapter: DateAdapter); + add(date: D | null): void; + isComplete(): boolean; + isSame(other: DateRange): boolean; + isValid(): boolean; + overlaps(range: DateRange): boolean; + static ɵfac: i0.ɵɵFactoryDef>; + static ɵprov: i0.ɵɵInjectableDef>; +} + export declare class MatRipple implements OnInit, OnDestroy, RippleTarget { animation: RippleAnimationConfig; centered: boolean; @@ -337,6 +383,17 @@ export declare class MatRippleModule { static ɵmod: i0.ɵɵNgModuleDefWithMeta; } +export declare class MatSingleDateSelectionModel extends MatDateSelectionModel { + constructor(adapter: DateAdapter); + add(date: D | null): void; + isComplete(): boolean; + isSame(other: D): boolean; + isValid(): boolean; + overlaps(range: DateRange): boolean; + static ɵfac: i0.ɵɵFactoryDef>; + static ɵprov: i0.ɵɵInjectableDef>; +} + export declare 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; export declare function mixinColor>(base: T, defaultColor?: ThemePalette): CanColorCtor & T; diff --git a/tools/public_api_guard/material/datepicker.d.ts b/tools/public_api_guard/material/datepicker.d.ts index ab9d4defe69e..0daff5f48edd 100644 --- a/tools/public_api_guard/material/datepicker.d.ts +++ b/tools/public_api_guard/material/datepicker.d.ts @@ -32,7 +32,7 @@ export declare class MatCalendar implements AfterContentInit, AfterViewChecke stateChanges: Subject; readonly yearSelected: EventEmitter; yearView: MatYearView; - constructor(_intl: MatDatepickerIntl, _dateAdapter: DateAdapter, _dateFormats: MatDateFormats, _changeDetectorRef: ChangeDetectorRef); + constructor(_intl: MatDatepickerIntl, _dateAdapter: DateAdapter, _dateFormats: MatDateFormats, _changeDetectorRef: ChangeDetectorRef, _model: MatDateSelectionModel); _dateSelected(date: D | null): void; _goToDateInView(date: D, view: 'month' | 'year' | 'multi-year'): void; _monthSelectedInYearView(normalizedMonth: D): void; @@ -109,8 +109,6 @@ export declare class MatDatepicker implements OnDestroy, CanColor { readonly _maxDate: D | null; readonly _minDate: D | null; _popupRef: OverlayRef; - _selected: D | null; - readonly _selectedChanged: Subject; calendarHeaderComponent: ComponentType; closedStream: EventEmitter; color: ThemePalette; @@ -125,8 +123,8 @@ export declare class MatDatepicker implements OnDestroy, CanColor { startView: 'month' | 'year' | 'multi-year'; touchUi: boolean; readonly yearSelected: EventEmitter; - constructor(_dialog: MatDialog, _overlay: Overlay, _ngZone: NgZone, _viewContainerRef: ViewContainerRef, scrollStrategy: any, _dateAdapter: DateAdapter, _dir: Directionality, _document: any); - _registerInput(input: MatDatepickerInput): void; + constructor(_dialog: MatDialog, _overlay: Overlay, _ngZone: NgZone, _viewContainerRef: ViewContainerRef, scrollStrategy: any, _dateAdapter: DateAdapter, _dir: Directionality, _document: any, _model: MatDateSelectionModel); + _registerInput(input: MatDatepickerInput): MatDateSelectionModel; _selectMonth(normalizedMonth: D): void; _selectYear(normalizedYear: D): void; close(): void;