Skip to content

Commit 5bc01c3

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. (cherry picked from commit 9c018d1)
1 parent 4048687 commit 5bc01c3

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)