Skip to content

Commit 4b16025

Browse files
authored
fix(material/datepicker): range input controls dirty on init (#21223)
Fixes that the date range input controls were marking each other as dirty on initialization. This seems like an easy fix on the surface, but it's somewhat tricky, because while we usually don't want the inputs to respond each other's events, we still want it to happen if an end date before the start date is assigned. These changes resolve the issue by not having the inputs respond to any events from inside the input, but notify each other when a value is assigned programmatically. I've also cleaned up some unnecessary code and added more test cases for things that tripped me up while fixing the issue. Fixes #20213.
1 parent deead4c commit 4b16025

File tree

5 files changed

+105
-65
lines changed

5 files changed

+105
-65
lines changed

src/material/datepicker/date-range-input-parts.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -159,15 +159,20 @@ abstract class MatDateRangeInputPartBase<D>
159159
return this._rangeInput.dateFilter;
160160
}
161161

162-
protected _outsideValueChanged = () => {
163-
// Whenever the value changes outside the input we need to revalidate, because
164-
// the validation state of each of the inputs depends on the other one.
165-
this._validatorOnChange();
166-
}
167-
168162
protected _parentDisabled() {
169163
return this._rangeInput._groupDisabled;
170164
}
165+
166+
protected _shouldHandleChangeEvent({source}: DateSelectionModelChange<DateRange<D>>): boolean {
167+
return source !== this._rangeInput._startInput && source !== this._rangeInput._endInput;
168+
}
169+
170+
protected _assignValueProgrammatically(value: D | null) {
171+
super._assignValueProgrammatically(value);
172+
const opposite = (this === this._rangeInput._startInput ? this._rangeInput._endInput :
173+
this._rangeInput._startInput) as MatDateRangeInputPartBase<D> | undefined;
174+
opposite?._validatorOnChange();
175+
}
171176
}
172177

173178
const _MatDateRangeInputBase:
@@ -259,14 +264,9 @@ export class MatStartDate<D> extends _MatDateRangeInputBase<D> implements
259264
if (this._model) {
260265
const range = new DateRange(value, this._model.selection.end);
261266
this._model.updateSelection(range, this);
262-
this._cvaOnChange(value);
263267
}
264268
}
265269

266-
protected _canEmitChangeEvent = (event: DateSelectionModelChange<DateRange<D>>): boolean => {
267-
return event.source !== this._rangeInput._endInput;
268-
}
269-
270270
protected _formatValue(value: D | null) {
271271
super._formatValue(value);
272272

@@ -367,14 +367,9 @@ export class MatEndDate<D> extends _MatDateRangeInputBase<D> implements
367367
if (this._model) {
368368
const range = new DateRange(this._model.selection.start, value);
369369
this._model.updateSelection(range, this);
370-
this._cvaOnChange(value);
371370
}
372371
}
373372

374-
protected _canEmitChangeEvent = (event: DateSelectionModelChange<DateRange<D>>): boolean => {
375-
return event.source !== this._rangeInput._startInput;
376-
}
377-
378373
_onKeydown(event: KeyboardEvent) {
379374
// If the user is pressing backspace on an empty end input, move focus back to the start.
380375
if (event.keyCode === BACKSPACE && !this._elementRef.nativeElement.value) {

src/material/datepicker/date-range-input.spec.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import {Type, Component, ViewChild, ElementRef, Directive} from '@angular/core';
2-
import {ComponentFixture, TestBed, inject, fakeAsync, tick} from '@angular/core/testing';
2+
import {ComponentFixture, TestBed, inject, fakeAsync, tick, flush} from '@angular/core/testing';
33
import {
44
FormsModule,
55
ReactiveFormsModule,
66
FormGroup,
77
FormControl,
88
NG_VALIDATORS,
99
Validator,
10+
NgModel,
1011
} from '@angular/forms';
1112
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
1213
import {OverlayContainer} from '@angular/cdk/overlay';
@@ -251,6 +252,9 @@ describe('MatDateRangeInput', () => {
251252
tick();
252253
const {start, end} = fixture.componentInstance.range.controls;
253254

255+
// The default error state matcher only checks if the controls have been touched.
256+
// Set it manually here so we can assert `rangeInput.errorState` correctly.
257+
fixture.componentInstance.range.markAllAsTouched();
254258
expect(fixture.componentInstance.rangeInput.errorState).toBe(false);
255259
expect(start.errors?.matStartDateInvalid).toBeFalsy();
256260
expect(end.errors?.matEndDateInvalid).toBeFalsy();
@@ -262,6 +266,13 @@ describe('MatDateRangeInput', () => {
262266
expect(fixture.componentInstance.rangeInput.errorState).toBe(true);
263267
expect(start.errors?.matStartDateInvalid).toBeTruthy();
264268
expect(end.errors?.matEndDateInvalid).toBeTruthy();
269+
270+
end.setValue(new Date(2020, 3, 2));
271+
fixture.detectChanges();
272+
273+
expect(fixture.componentInstance.rangeInput.errorState).toBe(false);
274+
expect(start.errors?.matStartDateInvalid).toBeFalsy();
275+
expect(end.errors?.matEndDateInvalid).toBeFalsy();
265276
}));
266277

267278
it('should pass the minimum date from the range input to the inner inputs', () => {
@@ -569,6 +580,62 @@ describe('MatDateRangeInput', () => {
569580
assignAndAssert(new Date(2020, 2, 2), new Date(2020, 2, 5));
570581
}));
571582

583+
it('should not be dirty on init when there is no value', fakeAsync(() => {
584+
const fixture = createComponent(RangePickerNgModel);
585+
fixture.detectChanges();
586+
flush();
587+
const {startModel, endModel} = fixture.componentInstance;
588+
589+
expect(startModel.dirty).toBe(false);
590+
expect(startModel.touched).toBe(false);
591+
expect(endModel.dirty).toBe(false);
592+
expect(endModel.touched).toBe(false);
593+
}));
594+
595+
it('should not be dirty on init when there is a value', fakeAsync(() => {
596+
const fixture = createComponent(RangePickerNgModel);
597+
fixture.componentInstance.start = new Date(2020, 1, 2);
598+
fixture.componentInstance.end = new Date(2020, 2, 2);
599+
fixture.detectChanges();
600+
flush();
601+
const {startModel, endModel} = fixture.componentInstance;
602+
603+
expect(startModel.dirty).toBe(false);
604+
expect(startModel.touched).toBe(false);
605+
expect(endModel.dirty).toBe(false);
606+
expect(endModel.touched).toBe(false);
607+
}));
608+
609+
it('should mark the input as dirty once the user types in it', fakeAsync(() => {
610+
const fixture = createComponent(RangePickerNgModel);
611+
fixture.componentInstance.start = new Date(2020, 1, 2);
612+
fixture.componentInstance.end = new Date(2020, 2, 2);
613+
fixture.detectChanges();
614+
flush();
615+
const {startModel, endModel, startInput, endInput} = fixture.componentInstance;
616+
617+
expect(startModel.dirty).toBe(false);
618+
expect(endModel.dirty).toBe(false);
619+
620+
endInput.nativeElement.value = '30/12/2020';
621+
dispatchFakeEvent(endInput.nativeElement, 'input');
622+
fixture.detectChanges();
623+
flush();
624+
fixture.detectChanges();
625+
626+
expect(startModel.dirty).toBe(false);
627+
expect(endModel.dirty).toBe(true);
628+
629+
startInput.nativeElement.value = '12/12/2020';
630+
dispatchFakeEvent(startInput.nativeElement, 'input');
631+
fixture.detectChanges();
632+
flush();
633+
fixture.detectChanges();
634+
635+
expect(startModel.dirty).toBe(true);
636+
expect(endModel.dirty).toBe(true);
637+
}));
638+
572639
it('should move focus to the start input when pressing backspace on an empty end input', () => {
573640
const fixture = createComponent(StandardRangePicker);
574641
fixture.detectChanges();
@@ -848,6 +915,10 @@ class RangePickerNoEnd {}
848915
`
849916
})
850917
class RangePickerNgModel {
918+
@ViewChild(MatStartDate, {read: NgModel}) startModel: NgModel;
919+
@ViewChild(MatEndDate, {read: NgModel}) endModel: NgModel;
920+
@ViewChild(MatStartDate, {read: ElementRef}) startInput: ElementRef<HTMLInputElement>;
921+
@ViewChild(MatEndDate, {read: ElementRef}) endInput: ElementRef<HTMLInputElement>;
851922
start: Date | null = null;
852923
end: Date | null = null;
853924
}

src/material/datepicker/datepicker-input-base.ts

Lines changed: 18 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -76,16 +76,7 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
7676
return this._model ? this._getValueFromModel(this._model.selection) : this._pendingValue;
7777
}
7878
set value(value: D | null) {
79-
value = this._dateAdapter.deserialize(value);
80-
this._lastValueValid = this._isValidValue(value);
81-
value = this._dateAdapter.getValidDateOrNull(value);
82-
const oldDate = this.value;
83-
this._assignValue(value);
84-
this._formatValue(value);
85-
86-
if (!this._dateAdapter.sameDate(oldDate, value)) {
87-
this._valueChange.emit(value);
88-
}
79+
this._assignValueProgrammatically(value);
8980
}
9081
protected _model: MatDateSelectionModel<S, D> | undefined;
9182

@@ -122,16 +113,13 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
122113
@Output() readonly dateInput: EventEmitter<MatDatepickerInputEvent<D, S>> =
123114
new EventEmitter<MatDatepickerInputEvent<D, S>>();
124115

125-
/** Emits when the value changes (either due to user input or programmatic change). */
126-
_valueChange = new EventEmitter<D | null>();
127-
128116
/** Emits when the internal state has changed */
129117
stateChanges = new Subject<void>();
130118

131119
_onTouched = () => {};
132120
_validatorOnChange = () => {};
133121

134-
protected _cvaOnChange: (value: any) => void = () => {};
122+
private _cvaOnChange: (value: any) => void = () => {};
135123
private _valueChangesSubscription = Subscription.EMPTY;
136124
private _localeSubscription = Subscription.EMPTY;
137125

@@ -200,24 +188,14 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
200188
}
201189

202190
this._valueChangesSubscription = this._model.selectionChanged.subscribe(event => {
203-
if (event.source !== this) {
191+
if (this._shouldHandleChangeEvent(event)) {
204192
const value = this._getValueFromModel(event.selection);
205193
this._lastValueValid = this._isValidValue(value);
206194
this._cvaOnChange(value);
207195
this._onTouched();
208196
this._formatValue(value);
209-
210-
// Note that we can't wrap the entire block with this logic, because for the range inputs
211-
// we want to revalidate whenever either one of the inputs changes and we don't have a
212-
// good way of distinguishing it at the moment.
213-
if (this._canEmitChangeEvent(event)) {
214-
this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
215-
this.dateChange.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
216-
}
217-
218-
if (this._outsideValueChanged) {
219-
this._outsideValueChanged();
220-
}
197+
this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
198+
this.dateChange.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
221199
}
222200
});
223201
}
@@ -234,14 +212,8 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
234212
/** Combined form control validator for this input. */
235213
protected abstract _validator: ValidatorFn | null;
236214

237-
/**
238-
* Callback that'll be invoked when the selection model is changed
239-
* from somewhere that's not the current datepicker input.
240-
*/
241-
protected abstract _outsideValueChanged?: () => void;
242-
243-
/** Predicate that determines whether we're allowed to emit a particular change event. */
244-
protected abstract _canEmitChangeEvent(event: DateSelectionModelChange<S>): boolean;
215+
/** Predicate that determines whether the input should handle a particular change event. */
216+
protected abstract _shouldHandleChangeEvent(event: DateSelectionModelChange<S>): boolean;
245217

246218
/** Whether the last value set on the input was valid. */
247219
protected _lastValueValid = false;
@@ -262,7 +234,7 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
262234

263235
// Update the displayed date when the locale changes.
264236
this._localeSubscription = _dateAdapter.localeChanges.subscribe(() => {
265-
this.value = this.value;
237+
this._assignValueProgrammatically(this.value);
266238
});
267239
}
268240

@@ -279,7 +251,6 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
279251
ngOnDestroy() {
280252
this._valueChangesSubscription.unsubscribe();
281253
this._localeSubscription.unsubscribe();
282-
this._valueChange.complete();
283254
this.stateChanges.complete();
284255
}
285256

@@ -295,7 +266,7 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
295266

296267
// Implemented as part of ControlValueAccessor.
297268
writeValue(value: D): void {
298-
this.value = value;
269+
this._assignValueProgrammatically(value);
299270
}
300271

301272
// Implemented as part of ControlValueAccessor.
@@ -331,7 +302,6 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
331302
if (!this._dateAdapter.sameDate(date, this.value)) {
332303
this._assignValue(date);
333304
this._cvaOnChange(date);
334-
this._valueChange.emit(date);
335305
this.dateInput.emit(new MatDatepickerInputEvent(this, this._elementRef.nativeElement));
336306
} else {
337307
// Call the CVA change handler for invalid values
@@ -391,6 +361,15 @@ export abstract class MatDatepickerInputBase<S, D = ExtractDateTypeFromSelection
391361
return false;
392362
}
393363

364+
/** Programmatically assigns a value to the input. */
365+
protected _assignValueProgrammatically(value: D | null) {
366+
value = this._dateAdapter.deserialize(value);
367+
this._lastValueValid = this._isValidValue(value);
368+
value = this._dateAdapter.getValidDateOrNull(value);
369+
this._assignValue(value);
370+
this._formatValue(value);
371+
}
372+
394373
/** Gets whether a value matches the current date filter. */
395374
_matchesFilter(value: D | null): boolean {
396375
const filter = this._getDateFilter();

src/material/datepicker/datepicker-input.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {MatFormField, MAT_FORM_FIELD} from '@angular/material/form-field';
3030
import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material/input';
3131
import {MatDatepickerInputBase, DateFilterFn} from './datepicker-input-base';
3232
import {MatDatepickerControl, MatDatepickerPanel} from './datepicker-base';
33+
import {DateSelectionModelChange} from './date-selection-model';
3334

3435
/** @docs-private */
3536
export const MAT_DATEPICKER_VALUE_ACCESSOR: any = {
@@ -183,13 +184,10 @@ export class MatDatepickerInput<D> extends MatDatepickerInputBase<D | null, D>
183184
return this._dateFilter;
184185
}
185186

186-
protected _canEmitChangeEvent() {
187-
return true;
187+
protected _shouldHandleChangeEvent(event: DateSelectionModelChange<D>) {
188+
return event.source !== this;
188189
}
189190

190-
// Unnecessary when selecting a single date.
191-
protected _outsideValueChanged: undefined;
192-
193191
// Accept `any` to avoid conflicts with other directives on `<input>` that
194192
// may accept different types.
195193
static ngAcceptInputType_value: any;

tools/public_api_guard/material/datepicker.d.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,6 @@ export declare class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>
212212

213213
export declare class MatDatepickerInput<D> extends MatDatepickerInputBase<D | null, D> implements MatDatepickerControl<D | null> {
214214
_datepicker: MatDatepickerPanel<MatDatepickerControl<D>, D | null, D>;
215-
protected _outsideValueChanged: undefined;
216215
protected _validator: ValidatorFn | null;
217216
get dateFilter(): DateFilterFn<D | null>;
218217
set dateFilter(value: DateFilterFn<D | null>);
@@ -223,12 +222,12 @@ export declare class MatDatepickerInput<D> extends MatDatepickerInputBase<D | nu
223222
set min(value: D | null);
224223
constructor(elementRef: ElementRef<HTMLInputElement>, dateAdapter: DateAdapter<D>, dateFormats: MatDateFormats, _formField: MatFormField);
225224
protected _assignValueToModel(value: D | null): void;
226-
protected _canEmitChangeEvent(): boolean;
227225
protected _getDateFilter(): DateFilterFn<D | null>;
228226
_getMaxDate(): D | null;
229227
_getMinDate(): D | null;
230228
protected _getValueFromModel(modelValue: D | null): D | null;
231229
protected _openPopup(): void;
230+
protected _shouldHandleChangeEvent(event: DateSelectionModelChange<D>): boolean;
232231
getConnectedOverlayOrigin(): ElementRef;
233232
getStartValue(): D | null;
234233
getThemePalette(): ThemePalette;
@@ -373,7 +372,6 @@ export declare abstract class MatDateSelectionModel<S, D = ExtractDateTypeFromSe
373372
}
374373

375374
export declare class MatEndDate<D> extends _MatDateRangeInputBase<D> implements CanUpdateErrorState, DoCheck, OnInit {
376-
protected _canEmitChangeEvent: (event: DateSelectionModelChange<DateRange<D>>) => boolean;
377375
protected _validator: ValidatorFn | null;
378376
constructor(rangeInput: MatDateRangeInputParent<D>, elementRef: ElementRef<HTMLInputElement>, defaultErrorStateMatcher: ErrorStateMatcher, injector: Injector, parentForm: NgForm, parentFormGroup: FormGroupDirective, dateAdapter: DateAdapter<D>, dateFormats: MatDateFormats);
379377
protected _assignValueToModel(value: D | null): void;
@@ -483,7 +481,6 @@ export declare class MatSingleDateSelectionModel<D> extends MatDateSelectionMode
483481
}
484482

485483
export declare class MatStartDate<D> extends _MatDateRangeInputBase<D> implements CanUpdateErrorState, DoCheck, OnInit {
486-
protected _canEmitChangeEvent: (event: DateSelectionModelChange<DateRange<D>>) => boolean;
487484
protected _validator: ValidatorFn | null;
488485
constructor(rangeInput: MatDateRangeInputParent<D>, elementRef: ElementRef<HTMLInputElement>, defaultErrorStateMatcher: ErrorStateMatcher, injector: Injector, parentForm: NgForm, parentFormGroup: FormGroupDirective, dateAdapter: DateAdapter<D>, dateFormats: MatDateFormats);
489486
protected _assignValueToModel(value: D | null): void;

0 commit comments

Comments
 (0)