From c900d2dc9bcef0da8e87520b097c4039e827f5b5 Mon Sep 17 00:00:00 2001 From: xlou978 <51772166+xlou978@users.noreply.github.com> Date: Fri, 11 Oct 2019 11:05:06 -0700 Subject: [PATCH 1/5] Adds a date selection model (#17363) * Adds a date selection model * Adds a date selection model --- .../core/datetime/date-selection-model.ts | 136 ++++++++++++++++++ src/material/core/datetime/index.ts | 1 + tools/public_api_guard/material/core.d.ts | 37 +++++ 3 files changed, 174 insertions(+) create mode 100644 src/material/core/datetime/date-selection-model.ts 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..1bc4df380bfc --- /dev/null +++ b/src/material/core/datetime/date-selection-model.ts @@ -0,0 +1,136 @@ +/** + * @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 {DateAdapter} from '@angular/material/core'; +import {Subject, Observable} from 'rxjs'; + +export abstract class MatDateSelectionModel { + protected _valueChangesSubject = new Subject(); + valueChanges: Observable = this._valueChangesSubject.asObservable(); + + constructor(protected readonly adapter: DateAdapter) {} + + destroy() { + this._valueChangesSubject.complete(); + } + + abstract add(date: D | null): void; + abstract isComplete(): boolean; + abstract isSame(other: MatDateSelectionModel): boolean; + abstract isValid(): boolean; +} + +export interface DateRange { + start: D | null; + end: D | null; +} + +/** + * Concrete implementation of a MatDateSelectionModel that holds a single date. + */ +export class MatSingleDateSelectionModel extends MatDateSelectionModel { + private _date: D | null = null; + + constructor(adapter: DateAdapter, date?: D | null) { + super(adapter); + this._date = date === undefined ? null : date; + } + + add(date: D | null) { + this._date = date; + this._valueChangesSubject.next(); + } + + compareDate(other: MatSingleDateSelectionModel) { + const date = this.asDate(); + const otherDate = other.asDate(); + if (date != null && otherDate != null) { + return this.adapter.compareDate(date, otherDate); + } + return date === otherDate; + } + + isComplete() { return this._date != null; } + + isSame(other: MatDateSelectionModel): boolean { + return other instanceof MatSingleDateSelectionModel && + this.adapter.sameDate(other.asDate(), this._date); + } + + isValid(): boolean { + return this._date != null && this.adapter.isDateInstance(this._date) && + this.adapter.isValid(this._date); + } + + asDate(): D | null { + return this.isValid() ? this._date : null; + } +} + +/** + * Concrete implementation of a MatDateSelectionModel that holds a date range, represented by + * a start date and an end date. + */ +export class MatRangeDateSelectionModel extends MatDateSelectionModel { + private _start: D | null = null; + private _end: D | null = null; + + constructor(adapter: DateAdapter, start?: D | null, end?: D | null) { + super(adapter); + this._start = start === undefined ? null : start; + this._end = end === undefined ? null : end; + } + + /** + * Adds an additional date to the range. If no date is set thus far, it will set it to the + * beginning. If the beginning is set, it will set it to the end. + * If add is called on a complete selection, it will empty the selection and set it as the start. + */ + add(date: D | null): void { + if (this._start == null) { + this._start = date; + } else if (this._end == null) { + this._end = date; + } else { + this._start = date; + this._end = null; + } + + this._valueChangesSubject.next(); + } + + setRange(start: D | null, end: D | null) { + this._start = start; + this._end = end; + } + + isComplete(): boolean { + return this._start != null && this._end != null; + } + + isSame(other: MatDateSelectionModel): boolean { + if (other instanceof MatRangeDateSelectionModel) { + const otherRange = other.asRange(); + return this.adapter.sameDate(this._start, otherRange.start) && + this.adapter.sameDate(this._end, otherRange.end); + } + return false; + } + + isValid(): boolean { + return this._start != null && this._end != null && + this.adapter.isValid(this._start!) && this.adapter.isValid(this._end!); + } + + asRange(): DateRange { + return { + start: this._start, + end: this._end, + }; + } +} 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/tools/public_api_guard/material/core.d.ts b/tools/public_api_guard/material/core.d.ts index 4e39f0d4e2ee..1790719cf91c 100644 --- a/tools/public_api_guard/material/core.d.ts +++ b/tools/public_api_guard/material/core.d.ts @@ -79,6 +79,11 @@ export declare abstract class DateAdapter { abstract today(): D; } +export interface DateRange { + end: D | null; + start: D | null; +} + 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: { @@ -218,6 +223,18 @@ export declare type MatDateFormats = { }; }; +export declare abstract class MatDateSelectionModel { + protected _valueChangesSubject: Subject; + protected readonly adapter: DateAdapter; + valueChanges: Observable; + constructor(adapter: DateAdapter); + abstract add(date: D | null): void; + destroy(): void; + abstract isComplete(): boolean; + abstract isSame(other: MatDateSelectionModel): boolean; + abstract isValid(): boolean; +} + export declare const MATERIAL_SANITY_CHECKS: InjectionToken; export declare class MatLine { @@ -312,6 +329,16 @@ export declare class MatPseudoCheckboxModule { export declare type MatPseudoCheckboxState = 'unchecked' | 'checked' | 'indeterminate'; +export declare class MatRangeDateSelectionModel extends MatDateSelectionModel { + constructor(adapter: DateAdapter, start?: D | null, end?: D | null); + add(date: D | null): void; + asRange(): DateRange; + isComplete(): boolean; + isSame(other: MatDateSelectionModel): boolean; + isValid(): boolean; + setRange(start: D | null, end: D | null): void; +} + export declare class MatRipple implements OnInit, OnDestroy, RippleTarget { animation: RippleAnimationConfig; centered: boolean; @@ -337,6 +364,16 @@ export declare class MatRippleModule { static ɵmod: i0.ɵɵNgModuleDefWithMeta; } +export declare class MatSingleDateSelectionModel extends MatDateSelectionModel { + constructor(adapter: DateAdapter, date?: D | null); + add(date: D | null): void; + asDate(): D | null; + compareDate(other: MatSingleDateSelectionModel): number | boolean; + isComplete(): boolean; + isSame(other: MatDateSelectionModel): boolean; + isValid(): boolean; +} + 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; From e2ad9911c4f13c9ffe49f54db6a0a905aa0ef085 Mon Sep 17 00:00:00 2001 From: jaguima Date: Mon, 11 Nov 2019 16:15:48 -0800 Subject: [PATCH 2/5] Use MatDateSelectionModel to model the selected value in MatDatepickerInput (#17497) --- .../core/datetime/date-selection-model.ts | 5 +++ src/material/datepicker/datepicker-input.ts | 38 ++++++++++++------- tools/public_api_guard/material/core.d.ts | 1 + 3 files changed, 30 insertions(+), 14 deletions(-) diff --git a/src/material/core/datetime/date-selection-model.ts b/src/material/core/datetime/date-selection-model.ts index 1bc4df380bfc..39c6c94535b9 100644 --- a/src/material/core/datetime/date-selection-model.ts +++ b/src/material/core/datetime/date-selection-model.ts @@ -70,6 +70,11 @@ export class MatSingleDateSelectionModel extends MatDateSelectionModel { asDate(): D | null { return this.isValid() ? this._date : null; } + + setDate(date: D | null) { + this._date = date; + this._valueChangesSubject.next(); + } } /** diff --git a/src/material/datepicker/datepicker-input.ts b/src/material/datepicker/datepicker-input.ts index bb74cceab489..f5a2c913cb20 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, + MatSingleDateSelectionModel, + MatDateFormats, + ThemePalette, +} from '@angular/material/core'; import {MatFormField} from '@angular/material/form-field'; import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material/input'; import {Subscription} from 'rxjs'; @@ -123,20 +129,22 @@ 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._selection.asDate(); } 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._formatValue(value); - - if (!this._dateAdapter.sameDate(oldDate, value)) { - this._valueChange.emit(value); + value = this._dateAdapter.deserialize(value); + const oldDate = this._selection.asDate(); + const isDifferent = !this._dateAdapter.sameDate(oldDate, value); + this._selection.setDate(value); + + this._lastValueValid = this._selection.isValid(); + + this._formatValue(this._selection.asDate()); + + if (isDifferent) { + this._valueChange.emit(this.value); } } - private _value: D | null; + private _selection: MatSingleDateSelectionModel; /** The minimum valid date. */ @Input() @@ -251,6 +259,8 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V throw createMissingDateImplError('MAT_DATE_FORMATS'); } + this._selection = new MatSingleDateSelectionModel(this._dateAdapter, null); + // Update the displayed date when the locale changes. this._localeSubscription = _dateAdapter.localeChanges.subscribe(() => { this.value = this.value; @@ -324,8 +334,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._selection.asDate())) { + this._selection.setDate(date); this._cvaOnChange(date); this._valueChange.emit(date); this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement)); diff --git a/tools/public_api_guard/material/core.d.ts b/tools/public_api_guard/material/core.d.ts index 1790719cf91c..738f107e4024 100644 --- a/tools/public_api_guard/material/core.d.ts +++ b/tools/public_api_guard/material/core.d.ts @@ -372,6 +372,7 @@ export declare class MatSingleDateSelectionModel extends MatDateSelectionMode isComplete(): boolean; isSame(other: MatDateSelectionModel): boolean; isValid(): boolean; + setDate(date: D | null): void; } 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; From 1fad46c05aacaad3c3ccd2f2eb82c44e156a5ed3 Mon Sep 17 00:00:00 2001 From: xlou978 <51772166+xlou978@users.noreply.github.com> Date: Thu, 21 Nov 2019 14:10:16 -0800 Subject: [PATCH 3/5] Make the selection model injectable and add an `overlaps` method (#17766) * Adds a date selection model (#17363) * Adds a date selection model * Adds a date selection model * Add overlaps function to date selection model --- .../core/datetime/date-selection-model.ts | 49 +++++++++++++++++++ tools/public_api_guard/material/core.d.ts | 11 +++++ 2 files changed, 60 insertions(+) diff --git a/src/material/core/datetime/date-selection-model.ts b/src/material/core/datetime/date-selection-model.ts index 39c6c94535b9..60dce0276a5d 100644 --- a/src/material/core/datetime/date-selection-model.ts +++ b/src/material/core/datetime/date-selection-model.ts @@ -7,6 +7,7 @@ */ import {DateAdapter} from '@angular/material/core'; +import {FactoryProvider, Optional, SkipSelf, Injectable} from '@angular/core'; import {Subject, Observable} from 'rxjs'; export abstract class MatDateSelectionModel { @@ -23,6 +24,7 @@ export abstract class MatDateSelectionModel { abstract isComplete(): boolean; abstract isSame(other: MatDateSelectionModel): boolean; abstract isValid(): boolean; + abstract overlaps(range: DateRange): boolean; } export interface DateRange { @@ -33,6 +35,7 @@ export interface DateRange { /** * Concrete implementation of a MatDateSelectionModel that holds a single date. */ +@Injectable() export class MatSingleDateSelectionModel extends MatDateSelectionModel { private _date: D | null = null; @@ -75,12 +78,23 @@ export class MatSingleDateSelectionModel extends MatDateSelectionModel { this._date = date; this._valueChangesSubject.next(); } + + /** + * Determines if the single date is within a given date range. Retuns false if either dates of + * the range is null or if the selection is undefined. + */ + overlaps(range: DateRange): boolean { + return !!(this._date && range.start && range.end && + this.adapter.compareDate(range.start, this._date) <= 0 && + this.adapter.compareDate(this._date, range.end) <= 0); + } } /** * Concrete implementation of a MatDateSelectionModel that holds a date range, represented by * a start date and an end date. */ +@Injectable() export class MatRangeDateSelectionModel extends MatDateSelectionModel { private _start: D | null = null; private _end: D | null = null; @@ -138,4 +152,39 @@ export class MatRangeDateSelectionModel extends MatDateSelectionModel { end: this._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._start && this._end && range.start && range.end)) { + return false; + } + + return ( + this._isBetween(range.start, this._start, this._end) || + this._isBetween(range.end, this._start, this._end) || + ( + this.adapter.compareDate(range.start, this._start) <= 0 && + this.adapter.compareDate(this._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; + } } + +export function MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY(parent: + MatSingleDateSelectionModel, + adapter: DateAdapter) { + return parent || new MatSingleDateSelectionModel(adapter); +} + +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/tools/public_api_guard/material/core.d.ts b/tools/public_api_guard/material/core.d.ts index 738f107e4024..f7ff5ad7c665 100644 --- a/tools/public_api_guard/material/core.d.ts +++ b/tools/public_api_guard/material/core.d.ts @@ -205,6 +205,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; @@ -233,6 +237,7 @@ export declare abstract class MatDateSelectionModel { abstract isComplete(): boolean; abstract isSame(other: MatDateSelectionModel): boolean; abstract isValid(): boolean; + abstract overlaps(range: DateRange): boolean; } export declare const MATERIAL_SANITY_CHECKS: InjectionToken; @@ -336,7 +341,10 @@ export declare class MatRangeDateSelectionModel extends MatDateSelectionModel isComplete(): boolean; isSame(other: MatDateSelectionModel): boolean; isValid(): boolean; + overlaps(range: DateRange): boolean; setRange(start: D | null, end: D | null): void; + static ɵfac: i0.ɵɵFactoryDef>; + static ɵprov: i0.ɵɵInjectableDef>; } export declare class MatRipple implements OnInit, OnDestroy, RippleTarget { @@ -372,7 +380,10 @@ export declare class MatSingleDateSelectionModel extends MatDateSelectionMode isComplete(): boolean; isSame(other: MatDateSelectionModel): boolean; isValid(): boolean; + overlaps(range: DateRange): boolean; setDate(date: D | null): void; + 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; From b757aee6f629c4cec06aa3c23adae40622443194 Mon Sep 17 00:00:00 2001 From: xlou978 <51772166+xlou978@users.noreply.github.com> Date: Thu, 19 Dec 2019 16:04:35 -0800 Subject: [PATCH 4/5] Trying to incorporate the new type definition (#17942) * incoporate new type definition * fix test and clean up the types a bit * update api gold * fix some nits * fix lint Co-authored-by: mmalerba --- .../core/datetime/date-selection-model.ts | 225 ++++++++++-------- src/material/datepicker/datepicker-input.ts | 40 ++-- tools/public_api_guard/material/core.d.ts | 40 ++-- 3 files changed, 164 insertions(+), 141 deletions(-) diff --git a/src/material/core/datetime/date-selection-model.ts b/src/material/core/datetime/date-selection-model.ts index 60dce0276a5d..f5c8731fa476 100644 --- a/src/material/core/datetime/date-selection-model.ts +++ b/src/material/core/datetime/date-selection-model.ts @@ -6,151 +6,173 @@ * found in the LICENSE file at https://angular.io/license */ +import {FactoryProvider, Injectable, OnDestroy, Optional, SkipSelf} from '@angular/core'; import {DateAdapter} from '@angular/material/core'; -import {FactoryProvider, Optional, SkipSelf, Injectable} from '@angular/core'; -import {Subject, Observable} from 'rxjs'; +import {Observable, Subject} from 'rxjs'; -export abstract class MatDateSelectionModel { - protected _valueChangesSubject = new Subject(); +/** 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; + + /** The start date of the range. */ + readonly start: D | null; + + /** The end date of the range. */ + readonly end: D | null; + + constructor(range?: {start?: D | null, end?: D | null} | null) { + this.start = range && range.start || null; + this.end = range && range.end || null; + } +} + +type ExtractDateTypeFromSelection = T extends DateRange ? D : NonNullable; + +/** A selection model containing a date selection. */ +export abstract class MatDateSelectionModel> + implements OnDestroy { + /** Subject used to emit value change events. */ + private _valueChangesSubject = new Subject(); + + /** Observable of value change events. */ valueChanges: Observable = this._valueChangesSubject.asObservable(); - constructor(protected readonly adapter: DateAdapter) {} + /** The current selection. */ + get selection (): S { return this._selection; } + set selection(s: S) { + this._selection = s; + this._valueChangesSubject.next(); + } - destroy() { + protected constructor(protected readonly adapter: DateAdapter, private _selection: S) {} + + ngOnDestroy() { this._valueChangesSubject.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 the same as the selection in the given selection model. + */ abstract isSame(other: MatDateSelectionModel): boolean; + + /** Checks whether the current selection is valid. */ abstract isValid(): boolean; - abstract overlaps(range: DateRange): boolean; -} -export interface DateRange { - start: D | null; - end: D | null; + /** Checks whether the current selection overlaps with the given range. */ + abstract overlaps(range: DateRange): boolean; } -/** - * Concrete implementation of a MatDateSelectionModel that holds a single date. - */ +/** A selection model that contains a single date. */ @Injectable() -export class MatSingleDateSelectionModel extends MatDateSelectionModel { - private _date: D | null = null; - +export class MatSingleDateSelectionModel extends MatDateSelectionModel { constructor(adapter: DateAdapter, date?: D | null) { - super(adapter); - this._date = date === undefined ? null : date; + super(adapter, date || 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) { - this._date = date; - this._valueChangesSubject.next(); + this.selection = date; } - compareDate(other: MatSingleDateSelectionModel) { - const date = this.asDate(); - const otherDate = other.asDate(); - if (date != null && otherDate != null) { - return this.adapter.compareDate(date, otherDate); - } - return date === otherDate; - } - - isComplete() { return this._date != null; } + /** + * 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; } - isSame(other: MatDateSelectionModel): boolean { + /** + * Checks whether the current selection is the same as the selection in the given selection model. + */ + isSame(other: MatDateSelectionModel): boolean { return other instanceof MatSingleDateSelectionModel && - this.adapter.sameDate(other.asDate(), this._date); + this.adapter.sameDate(other.selection, 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._date != null && this.adapter.isDateInstance(this._date) && - this.adapter.isValid(this._date); + return this.selection != null && this.adapter.isDateInstance(this.selection) && + this.adapter.isValid(this.selection); } - asDate(): D | null { - return this.isValid() ? this._date : null; - } - - setDate(date: D | null) { - this._date = date; - this._valueChangesSubject.next(); - } - - /** - * Determines if the single date is within a given date range. Retuns false if either dates of - * the range is null or if the selection is undefined. - */ + /** Checks whether the current selection overlaps with the given range. */ overlaps(range: DateRange): boolean { - return !!(this._date && range.start && range.end && - this.adapter.compareDate(range.start, this._date) <= 0 && - this.adapter.compareDate(this._date, range.end) <= 0); + return !!(this.selection && range.start && range.end && + this.adapter.compareDate(range.start, this.selection) <= 0 && + this.adapter.compareDate(this.selection, range.end) <= 0); } } -/** - * Concrete implementation of a MatDateSelectionModel that holds a date range, represented by - * a start date and an end date. - */ +/** A selection model that contains a date range. */ @Injectable() -export class MatRangeDateSelectionModel extends MatDateSelectionModel { - private _start: D | null = null; - private _end: D | null = null; - - constructor(adapter: DateAdapter, start?: D | null, end?: D | null) { - super(adapter); - this._start = start === undefined ? null : start; - this._end = end === undefined ? null : end; +export class MatRangeDateSelectionModel extends MatDateSelectionModel, D> { + constructor(adapter: DateAdapter, range?: {start?: D | null, end?: D | null} | null) { + super(adapter, new DateRange(range)); } /** - * Adds an additional date to the range. If no date is set thus far, it will set it to the - * beginning. If the beginning is set, it will set it to the end. - * If add is called on a complete selection, it will empty the selection and set it as the start. + * 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 { - if (this._start == null) { - this._start = date; - } else if (this._end == null) { - this._end = date; + let {start, end} = this.selection; + + if (start == null) { + start = date; + } else if (end == null) { + end = date; } else { - this._start = date; - this._end = null; + start = date; + end = null; } - this._valueChangesSubject.next(); - } - - setRange(start: D | null, end: D | null) { - this._start = start; - this._end = end; + this.selection = new DateRange({start, end}); } + /** + * 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._start != null && this._end != null; + return this.selection.start != null && this.selection.end != null; } - isSame(other: MatDateSelectionModel): boolean { + /** + * Checks whether the current selection is the same as the selection in the given selection model. + */ + isSame(other: MatDateSelectionModel): boolean { if (other instanceof MatRangeDateSelectionModel) { - const otherRange = other.asRange(); - return this.adapter.sameDate(this._start, otherRange.start) && - this.adapter.sameDate(this._end, otherRange.end); + return this.adapter.sameDate(this.selection.start, other.selection.start) && + this.adapter.sameDate(this.selection.end, other.selection.end); } return false; } + /** + * 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._start != null && this._end != null && - this.adapter.isValid(this._start!) && this.adapter.isValid(this._end!); - } - - asRange(): DateRange { - return { - start: this._start, - end: this._end, - }; + return this.selection.start != null && this.selection.end != null && + this.adapter.isValid(this.selection.start!) && this.adapter.isValid(this.selection.end!); } /** @@ -158,17 +180,17 @@ export class MatRangeDateSelectionModel extends MatDateSelectionModel { * includes incomplete selections or ranges. */ overlaps(range: DateRange): boolean { - if (!(this._start && this._end && range.start && range.end)) { + if (!(this.selection.start && this.selection.end && range.start && range.end)) { return false; } return ( - this._isBetween(range.start, this._start, this._end) || - this._isBetween(range.end, this._start, this._end) || - ( - this.adapter.compareDate(range.start, this._start) <= 0 && - this.adapter.compareDate(this._end, range.end) <= 0 - ) + 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 + ) ); } @@ -177,9 +199,8 @@ export class MatRangeDateSelectionModel extends MatDateSelectionModel { } } -export function MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY(parent: - MatSingleDateSelectionModel, - adapter: DateAdapter) { +export function MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY( + parent: MatSingleDateSelectionModel, adapter: DateAdapter) { return parent || new MatSingleDateSelectionModel(adapter); } diff --git a/src/material/datepicker/datepicker-input.ts b/src/material/datepicker/datepicker-input.ts index f5a2c913cb20..7940a54823aa 100644 --- a/src/material/datepicker/datepicker-input.ts +++ b/src/material/datepicker/datepicker-input.ts @@ -32,8 +32,8 @@ import { import { DateAdapter, MAT_DATE_FORMATS, - MatSingleDateSelectionModel, MatDateFormats, + MatSingleDateSelectionModel, ThemePalette, } from '@angular/material/core'; import {MatFormField} from '@angular/material/form-field'; @@ -67,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; } } @@ -129,22 +129,20 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V /** The value of the input. */ @Input() - get value(): D | null { return this._selection.asDate(); } + get value(): D | null { return this._selectionModel.selection; } set value(value: D | null) { - value = this._dateAdapter.deserialize(value); - const oldDate = this._selection.asDate(); - const isDifferent = !this._dateAdapter.sameDate(oldDate, value); - this._selection.setDate(value); - - this._lastValueValid = this._selection.isValid(); - - this._formatValue(this._selection.asDate()); - - if (isDifferent) { - this._valueChange.emit(this.value); + value = this._dateAdapter.deserialize(value); + this._lastValueValid = !value || this._dateAdapter.isValid(value); + value = this._getValidDateOrNull(value); + const oldDate = this._selectionModel.selection; + this._selectionModel.selection = value; + this._formatValue(value); + + if (!this._dateAdapter.sameDate(oldDate, value)) { + this._valueChange.emit(value); } } - private _selection: MatSingleDateSelectionModel; + private _selectionModel: MatSingleDateSelectionModel; /** The minimum valid date. */ @Input() @@ -259,7 +257,7 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V throw createMissingDateImplError('MAT_DATE_FORMATS'); } - this._selection = new MatSingleDateSelectionModel(this._dateAdapter, null); + this._selectionModel = new MatSingleDateSelectionModel(this._dateAdapter); // Update the displayed date when the locale changes. this._localeSubscription = _dateAdapter.localeChanges.subscribe(() => { @@ -334,8 +332,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._selection.asDate())) { - this._selection.setDate(date); + if (!this._dateAdapter.sameDate(date, this._selectionModel.selection)) { + this._selectionModel.selection = date; this._cvaOnChange(date); this._valueChange.emit(date); this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement)); diff --git a/tools/public_api_guard/material/core.d.ts b/tools/public_api_guard/material/core.d.ts index f7ff5ad7c665..f2a2a8ef9518 100644 --- a/tools/public_api_guard/material/core.d.ts +++ b/tools/public_api_guard/material/core.d.ts @@ -79,9 +79,13 @@ export declare abstract class DateAdapter { abstract today(): D; } -export interface DateRange { - end: D | null; - start: D | null; +export declare class DateRange { + readonly end: D | null; + readonly start: D | null; + constructor(range?: { + start?: D | null; + end?: D | null; + } | null); } 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; @@ -205,7 +209,7 @@ export declare const MAT_OPTION_PARENT_COMPONENT: InjectionToken; -export declare function MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY(parent: MatSingleDateSelectionModel, adapter: DateAdapter): MatSingleDateSelectionModel; +export declare function MAT_SINGLE_DATE_SELECTION_MODEL_FACTORY(parent: MatSingleDateSelectionModel, adapter: DateAdapter): MatSingleDateSelectionModel; export declare const MAT_SINGLE_DATE_SELECTION_MODEL_PROVIDER: FactoryProvider; @@ -227,17 +231,19 @@ export declare type MatDateFormats = { }; }; -export declare abstract class MatDateSelectionModel { - protected _valueChangesSubject: Subject; +export declare abstract class MatDateSelectionModel> implements OnDestroy { protected readonly adapter: DateAdapter; + selection: S; valueChanges: Observable; - constructor(adapter: DateAdapter); + protected constructor(adapter: DateAdapter, _selection: S); abstract add(date: D | null): void; - destroy(): void; abstract isComplete(): boolean; abstract isSame(other: MatDateSelectionModel): boolean; abstract isValid(): boolean; + ngOnDestroy(): void; abstract overlaps(range: DateRange): boolean; + static ɵdir: i0.ɵɵDirectiveDefWithMeta, never, never, {}, {}, never>; + static ɵfac: i0.ɵɵFactoryDef>; } export declare const MATERIAL_SANITY_CHECKS: InjectionToken; @@ -334,15 +340,16 @@ export declare class MatPseudoCheckboxModule { export declare type MatPseudoCheckboxState = 'unchecked' | 'checked' | 'indeterminate'; -export declare class MatRangeDateSelectionModel extends MatDateSelectionModel { - constructor(adapter: DateAdapter, start?: D | null, end?: D | null); +export declare class MatRangeDateSelectionModel extends MatDateSelectionModel, D> { + constructor(adapter: DateAdapter, range?: { + start?: D | null; + end?: D | null; + } | null); add(date: D | null): void; - asRange(): DateRange; isComplete(): boolean; - isSame(other: MatDateSelectionModel): boolean; + isSame(other: MatDateSelectionModel): boolean; isValid(): boolean; overlaps(range: DateRange): boolean; - setRange(start: D | null, end: D | null): void; static ɵfac: i0.ɵɵFactoryDef>; static ɵprov: i0.ɵɵInjectableDef>; } @@ -372,16 +379,13 @@ export declare class MatRippleModule { static ɵmod: i0.ɵɵNgModuleDefWithMeta; } -export declare class MatSingleDateSelectionModel extends MatDateSelectionModel { +export declare class MatSingleDateSelectionModel extends MatDateSelectionModel { constructor(adapter: DateAdapter, date?: D | null); add(date: D | null): void; - asDate(): D | null; - compareDate(other: MatSingleDateSelectionModel): number | boolean; isComplete(): boolean; - isSame(other: MatDateSelectionModel): boolean; + isSame(other: MatDateSelectionModel): boolean; isValid(): boolean; overlaps(range: DateRange): boolean; - setDate(date: D | null): void; static ɵfac: i0.ɵɵFactoryDef>; static ɵprov: i0.ɵɵInjectableDef>; } From a6faba733d8cd7a94f68052a7e896cc9dedfb453 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 7 Jan 2020 20:35:43 +0100 Subject: [PATCH 5/5] refactor(datepicker): implement date selection model provider Reworks the datepicker-related directives to move the source of the value into the new datepicker selection provider. --- .../core/datetime/date-selection-model.ts | 100 +++++++++--------- src/material/datepicker/calendar.ts | 36 +++++-- .../datepicker/datepicker-content.html | 2 - src/material/datepicker/datepicker-input.ts | 70 +++++++----- src/material/datepicker/datepicker.spec.ts | 46 +++++--- src/material/datepicker/datepicker.ts | 32 ++---- tools/public_api_guard/material/core.d.ts | 34 +++--- .../public_api_guard/material/datepicker.d.ts | 8 +- 8 files changed, 188 insertions(+), 140 deletions(-) diff --git a/src/material/core/datetime/date-selection-model.ts b/src/material/core/datetime/date-selection-model.ts index f5c8731fa476..270550605071 100644 --- a/src/material/core/datetime/date-selection-model.ts +++ b/src/material/core/datetime/date-selection-model.ts @@ -6,8 +6,8 @@ * found in the LICENSE file at https://angular.io/license */ -import {FactoryProvider, Injectable, OnDestroy, Optional, SkipSelf} from '@angular/core'; -import {DateAdapter} from '@angular/material/core'; +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. */ @@ -19,40 +19,52 @@ export class DateRange { // tslint:disable-next-line:no-unused-variable private _disableStructuralEquivalency: never; - /** The start date of the range. */ - readonly start: D | null; - - /** The end date of the range. */ - readonly end: D | null; - - constructor(range?: {start?: D | null, end?: D | null} | null) { - this.start = range && range.start || null; - this.end = range && range.end || null; - } + 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 { - /** Subject used to emit value change events. */ - private _valueChangesSubject = new Subject(); + private _selectionChanged = new Subject>(); - /** Observable of value change events. */ - valueChanges: Observable = this._valueChangesSubject.asObservable(); + /** Emits when the selection has changed. */ + selectionChanged: Observable> = this._selectionChanged.asObservable(); - /** The current selection. */ - get selection (): S { return this._selection; } - set selection(s: S) { - this._selection = s; - this._valueChangesSubject.next(); + 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; } - protected constructor(protected readonly adapter: DateAdapter, private _selection: S) {} + /** + * 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._valueChangesSubject.complete(); + this._selectionChanged.complete(); } /** Adds a date to the current selection. */ @@ -61,10 +73,8 @@ export abstract class MatDateSelectionModel): 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; @@ -76,8 +86,8 @@ export abstract class MatDateSelectionModel extends MatDateSelectionModel { - constructor(adapter: DateAdapter, date?: D | null) { - super(adapter, date || null); + constructor(adapter: DateAdapter) { + super(adapter, null); } /** @@ -85,7 +95,7 @@ export class MatSingleDateSelectionModel extends MatDateSelectionModel extends MatDateSelectionModel): boolean { - return other instanceof MatSingleDateSelectionModel && - this.adapter.sameDate(other.selection, this.selection); + /** Checks whether the current selection is identical to the passed-in selection. */ + isSame(other: D): boolean { + return this.adapter.sameDate(other, this.selection); } /** @@ -122,8 +129,8 @@ export class MatSingleDateSelectionModel extends MatDateSelectionModel extends MatDateSelectionModel, D> { - constructor(adapter: DateAdapter, range?: {start?: D | null, end?: D | null} | null) { - super(adapter, new DateRange(range)); + constructor(adapter: DateAdapter) { + super(adapter, new DateRange(null, null)); } /** @@ -143,7 +150,7 @@ export class MatRangeDateSelectionModel extends MatDateSelectionModel({start, end}); + super.updateSelection(new DateRange(start, end), this); } /** @@ -154,15 +161,10 @@ export class MatRangeDateSelectionModel extends MatDateSelectionModel): boolean { - if (other instanceof MatRangeDateSelectionModel) { - return this.adapter.sameDate(this.selection.start, other.selection.start) && - this.adapter.sameDate(this.selection.end, other.selection.end); - } - return false; + /** 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); } /** @@ -199,11 +201,13 @@ export class MatRangeDateSelectionModel extends MatDateSelectionModel, 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], 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 7940a54823aa..4539aa567f7d 100644 --- a/src/material/datepicker/datepicker-input.ts +++ b/src/material/datepicker/datepicker-input.ts @@ -33,8 +33,8 @@ import { DateAdapter, MAT_DATE_FORMATS, MatDateFormats, - MatSingleDateSelectionModel, ThemePalette, + MatDateSelectionModel, } from '@angular/material/core'; import {MatFormField} from '@angular/material/form-field'; import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material/input'; @@ -100,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; @@ -129,20 +135,20 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V /** The value of the input. */ @Input() - get value(): D | null { return this._selectionModel.selection; } + 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._selectionModel.selection; - this._selectionModel.selection = value; + const oldDate = this.value; + this._assignValue(value); this._formatValue(value); if (!this._dateAdapter.sameDate(oldDate, value)) { this._valueChange.emit(value); } } - private _selectionModel: MatSingleDateSelectionModel; + private _model: MatDateSelectionModel | undefined; /** The minimum valid date. */ @Input() @@ -201,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 ? @@ -257,8 +267,6 @@ export class MatDatepickerInput implements ControlValueAccessor, OnDestroy, V throw createMissingDateImplError('MAT_DATE_FORMATS'); } - this._selectionModel = new MatSingleDateSelectionModel(this._dateAdapter); - // Update the displayed date when the locale changes. this._localeSubscription = _dateAdapter.localeChanges.subscribe(() => { this.value = this.value; @@ -266,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(); @@ -332,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._selectionModel.selection)) { - this._selectionModel.selection = 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)); @@ -375,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 f2a2a8ef9518..891bf797d10e 100644 --- a/tools/public_api_guard/material/core.d.ts +++ b/tools/public_api_guard/material/core.d.ts @@ -82,10 +82,14 @@ export declare abstract class DateAdapter { export declare class DateRange { readonly end: D | null; readonly start: D | null; - constructor(range?: { - start?: D | null; - end?: D | null; - } | 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; @@ -233,15 +237,18 @@ export declare type MatDateFormats = { export declare abstract class MatDateSelectionModel> implements OnDestroy { protected readonly adapter: DateAdapter; - selection: S; - valueChanges: Observable; - protected constructor(adapter: DateAdapter, _selection: S); + readonly selection: S; + selectionChanged: Observable>; + protected constructor( + adapter: DateAdapter, + selection: S); abstract add(date: D | null): void; abstract isComplete(): boolean; - abstract isSame(other: MatDateSelectionModel): 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>; } @@ -341,13 +348,10 @@ export declare class MatPseudoCheckboxModule { export declare type MatPseudoCheckboxState = 'unchecked' | 'checked' | 'indeterminate'; export declare class MatRangeDateSelectionModel extends MatDateSelectionModel, D> { - constructor(adapter: DateAdapter, range?: { - start?: D | null; - end?: D | null; - } | null); + constructor(adapter: DateAdapter); add(date: D | null): void; isComplete(): boolean; - isSame(other: MatDateSelectionModel): boolean; + isSame(other: DateRange): boolean; isValid(): boolean; overlaps(range: DateRange): boolean; static ɵfac: i0.ɵɵFactoryDef>; @@ -380,10 +384,10 @@ export declare class MatRippleModule { } export declare class MatSingleDateSelectionModel extends MatDateSelectionModel { - constructor(adapter: DateAdapter, date?: D | null); + constructor(adapter: DateAdapter); add(date: D | null): void; isComplete(): boolean; - isSame(other: MatDateSelectionModel): boolean; + isSame(other: D): boolean; isValid(): boolean; overlaps(range: DateRange): boolean; static ɵfac: i0.ɵɵFactoryDef>; 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;