Skip to content

Commit 9c018d1

Browse files
committed
fix(material/datepicker): resolve change after checked errors
Fixes several "changed after checked" errors in the datepicker that were largely due to components depending on state from other components.
1 parent baa5191 commit 9c018d1

File tree

4 files changed

+62
-56
lines changed

4 files changed

+62
-56
lines changed

src/material/datepicker/calendar.spec.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
dispatchMouseEvent,
66
provideFakeDirectionality,
77
} from '@angular/cdk/testing/private';
8-
import {Component, provideCheckNoChangesConfig} from '@angular/core';
8+
import {Component} from '@angular/core';
99
import {ComponentFixture, TestBed, waitForAsync} from '@angular/core/testing';
1010
import {By} from '@angular/platform-browser';
1111
import {DateAdapter, MatNativeDateModule} from '../core';
@@ -21,11 +21,7 @@ describe('MatCalendar', () => {
2121
beforeEach(waitForAsync(() => {
2222
TestBed.configureTestingModule({
2323
imports: [MatNativeDateModule, MatDatepickerModule],
24-
providers: [
25-
MatDatepickerIntl,
26-
provideFakeDirectionality('ltr'),
27-
provideCheckNoChangesConfig({exhaustive: false}),
28-
],
24+
providers: [MatDatepickerIntl, provideFakeDirectionality('ltr')],
2925
declarations: [
3026
// Test components.
3127
StandardCalendar,

src/material/datepicker/calendar.ts

Lines changed: 51 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -64,68 +64,47 @@ export class MatCalendarHeader<D> {
6464
calendar = inject<MatCalendar<D>>(MatCalendar);
6565
private _dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
6666
private _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
67+
private _periodButtonText: string;
68+
private _periodButtonDescription: string;
69+
private _periodButtonLabel: string;
70+
private _prevButtonLabel: string;
71+
private _nextButtonLabel: string;
6772

6873
constructor(...args: unknown[]);
6974

7075
constructor() {
7176
inject(_CdkPrivateStyleLoader).load(_VisuallyHiddenLoader);
7277
const changeDetectorRef = inject(ChangeDetectorRef);
73-
this.calendar.stateChanges.subscribe(() => changeDetectorRef.markForCheck());
78+
this._updateLabels();
79+
this.calendar.stateChanges.subscribe(() => {
80+
this._updateLabels();
81+
changeDetectorRef.markForCheck();
82+
});
7483
}
7584

7685
/** The display text for the current calendar view. */
7786
get periodButtonText(): string {
78-
if (this.calendar.currentView == 'month') {
79-
return this._dateAdapter
80-
.format(this.calendar.activeDate, this._dateFormats.display.monthYearLabel)
81-
.toLocaleUpperCase();
82-
}
83-
if (this.calendar.currentView == 'year') {
84-
return this._dateAdapter.getYearName(this.calendar.activeDate);
85-
}
86-
87-
return this._intl.formatYearRange(...this._formatMinAndMaxYearLabels());
87+
return this._periodButtonText;
8888
}
8989

9090
/** The aria description for the current calendar view. */
9191
get periodButtonDescription(): string {
92-
if (this.calendar.currentView == 'month') {
93-
return this._dateAdapter
94-
.format(this.calendar.activeDate, this._dateFormats.display.monthYearLabel)
95-
.toLocaleUpperCase();
96-
}
97-
if (this.calendar.currentView == 'year') {
98-
return this._dateAdapter.getYearName(this.calendar.activeDate);
99-
}
100-
101-
// Format a label for the window of years displayed in the multi-year calendar view. Use
102-
// `formatYearRangeLabel` because it is TTS friendly.
103-
return this._intl.formatYearRangeLabel(...this._formatMinAndMaxYearLabels());
92+
return this._periodButtonDescription;
10493
}
10594

10695
/** The `aria-label` for changing the calendar view. */
10796
get periodButtonLabel(): string {
108-
return this.calendar.currentView == 'month'
109-
? this._intl.switchToMultiYearViewLabel
110-
: this._intl.switchToMonthViewLabel;
97+
return this._periodButtonLabel;
11198
}
11299

113100
/** The label for the previous button. */
114101
get prevButtonLabel(): string {
115-
return {
116-
'month': this._intl.prevMonthLabel,
117-
'year': this._intl.prevYearLabel,
118-
'multi-year': this._intl.prevMultiYearLabel,
119-
}[this.calendar.currentView];
102+
return this._prevButtonLabel;
120103
}
121104

122105
/** The label for the next button. */
123106
get nextButtonLabel(): string {
124-
return {
125-
'month': this._intl.nextMonthLabel,
126-
'year': this._intl.nextYearLabel,
127-
'multi-year': this._intl.nextMultiYearLabel,
128-
}[this.calendar.currentView];
107+
return this._nextButtonLabel;
129108
}
130109

131110
/** Handles user clicks on the period label. */
@@ -172,6 +151,41 @@ export class MatCalendarHeader<D> {
172151
);
173152
}
174153

154+
/** Updates the labels for the various sections of the header. */
155+
private _updateLabels() {
156+
const calendar = this.calendar;
157+
const intl = this._intl;
158+
const adapter = this._dateAdapter;
159+
160+
if (calendar.currentView === 'month') {
161+
this._periodButtonText = adapter
162+
.format(calendar.activeDate, this._dateFormats.display.monthYearLabel)
163+
.toLocaleUpperCase();
164+
this._periodButtonDescription = adapter
165+
.format(calendar.activeDate, this._dateFormats.display.monthYearLabel)
166+
.toLocaleUpperCase();
167+
this._periodButtonLabel = intl.switchToMultiYearViewLabel;
168+
this._prevButtonLabel = intl.prevMonthLabel;
169+
this._nextButtonLabel = intl.nextMonthLabel;
170+
} else if (calendar.currentView === 'year') {
171+
this._periodButtonText = adapter.getYearName(calendar.activeDate);
172+
this._periodButtonDescription = adapter.getYearName(calendar.activeDate);
173+
this._periodButtonLabel = intl.switchToMonthViewLabel;
174+
this._prevButtonLabel = intl.prevYearLabel;
175+
this._nextButtonLabel = intl.nextYearLabel;
176+
} else {
177+
this._periodButtonText = intl.formatYearRange(...this._formatMinAndMaxYearLabels());
178+
// Format a label for the window of years displayed in the multi-year calendar view. Use
179+
// `formatYearRangeLabel` because it is TTS friendly.
180+
this._periodButtonDescription = intl.formatYearRangeLabel(
181+
...this._formatMinAndMaxYearLabels(),
182+
);
183+
this._periodButtonLabel = intl.switchToMonthViewLabel;
184+
this._prevButtonLabel = intl.prevMultiYearLabel;
185+
this._nextButtonLabel = intl.nextMultiYearLabel;
186+
}
187+
}
188+
175189
/** Whether the two dates represent the same view in the current view mode (month or year). */
176190
private _isSameView(date1: D, date2: D): boolean {
177191
if (this.calendar.currentView == 'month') {
@@ -387,6 +401,7 @@ export class MatCalendar<D> implements AfterContentInit, AfterViewChecked, OnDes
387401
this._moveFocusOnNextTick = true;
388402
this._changeDetectorRef.markForCheck();
389403
if (viewChangedResult) {
404+
this.stateChanges.next();
390405
this.viewChanged.emit(viewChangedResult);
391406
}
392407
}

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

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
Input,
1818
OnInit,
1919
inject,
20+
signal,
2021
} from '@angular/core';
2122
import {
2223
AbstractControl,
@@ -47,6 +48,7 @@ abstract class MatDateRangeInputPartBase<D>
4748
override _elementRef = inject<ElementRef<HTMLInputElement>>(ElementRef);
4849
_defaultErrorStateMatcher = inject(ErrorStateMatcher);
4950
private _injector = inject(Injector);
51+
private _rawValue = signal('');
5052
_parentForm = inject(NgForm, {optional: true});
5153
_parentFormGroup = inject(FormGroupDirective, {optional: true});
5254

@@ -120,11 +122,13 @@ abstract class MatDateRangeInputPartBase<D>
120122
// that whatever logic is in here has to be super lean or we risk destroying the performance.
121123
this.updateErrorState();
122124
}
125+
126+
this._rawValue.set(this._elementRef.nativeElement.value);
123127
}
124128

125129
/** Gets whether the input is empty. */
126130
isEmpty(): boolean {
127-
return this._elementRef.nativeElement.value.length === 0;
131+
return this._rawValue().length === 0;
128132
}
129133

130134
/** Gets the placeholder of the input. */
@@ -139,9 +143,8 @@ abstract class MatDateRangeInputPartBase<D>
139143

140144
/** Gets the value that should be used when mirroring the input's size. */
141145
getMirrorValue(): string {
142-
const element = this._elementRef.nativeElement;
143-
const value = element.value;
144-
return value.length > 0 ? value : element.placeholder;
146+
const value = this._rawValue();
147+
return value.length > 0 ? value : this._getPlaceholder();
145148
}
146149

147150
/** Refreshes the error state of the input. */
@@ -191,6 +194,7 @@ abstract class MatDateRangeInputPartBase<D>
191194
: this._rangeInput._startInput
192195
) as MatDateRangeInputPartBase<D> | undefined;
193196
opposite?._validatorOnChange();
197+
this._rawValue.set(this._elementRef.nativeElement.value);
194198
}
195199

196200
protected override _formatValue(value: D | null) {

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

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,7 @@ import {
66
dispatchKeyboardEvent,
77
provideFakeDirectionality,
88
} from '@angular/cdk/testing/private';
9-
import {
10-
Component,
11-
Directive,
12-
ElementRef,
13-
provideCheckNoChangesConfig,
14-
Provider,
15-
Type,
16-
ViewChild,
17-
} from '@angular/core';
9+
import {Component, Directive, ElementRef, Provider, Type, ViewChild} from '@angular/core';
1810
import {ComponentFixture, fakeAsync, flush, TestBed, tick} from '@angular/core/testing';
1911
import {
2012
FormControl,
@@ -48,7 +40,6 @@ describe('MatDateRangeInput', () => {
4840
component,
4941
],
5042
providers: [
51-
provideCheckNoChangesConfig({exhaustive: false}),
5243
...providers,
5344
{provide: MATERIAL_ANIMATIONS, useValue: {animationsDisabled: true}},
5445
],

0 commit comments

Comments
 (0)