Skip to content

Commit c5aa548

Browse files
authored
fix(material/core): rework error state matcher to handle signal forms (#33500)
Reworks the `ErrorStateMatcher` to work with signal forms. I ended up introducing a new method in order to keep backwards compatibility.
1 parent 8205e24 commit c5aa548

11 files changed

Lines changed: 88 additions & 8 deletions

File tree

goldens/material/chips/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { ControlValueAccessor } from '@angular/forms';
1313
import { DoCheck } from '@angular/core';
1414
import { ElementRef } from '@angular/core';
1515
import { EventEmitter } from '@angular/core';
16+
import { Field } from '@angular/forms/signals';
1617
import { FocusKeyManager } from '@angular/cdk/a11y';
1718
import { FormGroupDirective } from '@angular/forms';
1819
import * as i0 from '@angular/core';

goldens/material/core/index.api.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import { AfterViewChecked } from '@angular/core';
99
import { ChangeDetectorRef } from '@angular/core';
1010
import { ElementRef } from '@angular/core';
1111
import { EventEmitter } from '@angular/core';
12+
import { Field } from '@angular/forms/signals';
1213
import { FocusableOption } from '@angular/cdk/a11y';
1314
import { FocusOrigin } from '@angular/cdk/a11y';
15+
import { FormField } from '@angular/forms/signals';
1416
import { FormGroupDirective } from '@angular/forms';
1517
import * as i0 from '@angular/core';
1618
import * as i2 from '@angular/cdk/bidi';
@@ -95,17 +97,19 @@ export class ErrorStateMatcher {
9597
// (undocumented)
9698
isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean;
9799
// (undocumented)
100+
isSignalErrorState?(field: Field<unknown> | null): boolean;
101+
// (undocumented)
98102
static ɵfac: i0.ɵɵFactoryDeclaration<ErrorStateMatcher, never>;
99103
// (undocumented)
100104
static ɵprov: i0.ɵɵInjectableDeclaration<ErrorStateMatcher>;
101105
}
102106

103107
// @public
104108
export class _ErrorStateTracker {
105-
constructor(_defaultMatcher: ErrorStateMatcher_2 | null, ngControl: NgControl | null, _parentFormGroup: FormGroupDirective | null, _parentForm: NgForm | null, _stateChanges: Subject<void>);
109+
constructor(_defaultMatcher: ErrorStateMatcher_2 | null, directive: NgControl | FormField<unknown> | null, _parentFormGroup: FormGroupDirective | null, _parentForm: NgForm | null, _stateChanges: Subject<void>);
106110
errorState: boolean;
111+
formField: FormField<unknown> | null;
107112
matcher: ErrorStateMatcher_2;
108-
// (undocumented)
109113
ngControl: NgControl | null;
110114
updateErrorState(): void;
111115
}
@@ -519,6 +523,8 @@ export class ShowOnDirtyErrorStateMatcher implements ErrorStateMatcher {
519523
// (undocumented)
520524
isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean;
521525
// (undocumented)
526+
isSignalErrorState(field: Field<unknown> | null): boolean;
527+
// (undocumented)
522528
static ɵfac: i0.ɵɵFactoryDeclaration<ShowOnDirtyErrorStateMatcher, never>;
523529
// (undocumented)
524530
static ɵprov: i0.ɵɵInjectableDeclaration<ShowOnDirtyErrorStateMatcher>;

goldens/material/datepicker/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { Directionality } from '@angular/cdk/bidi';
1616
import { DoCheck } from '@angular/core';
1717
import { ElementRef } from '@angular/core';
1818
import { EventEmitter } from '@angular/core';
19+
import { Field } from '@angular/forms/signals';
1920
import { FocusOrigin } from '@angular/cdk/a11y';
2021
import { FormGroupDirective } from '@angular/forms';
2122
import * as i0 from '@angular/core';

goldens/material/input/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AfterViewInit } from '@angular/core';
1212
import { BooleanInput } from '@angular/cdk/coercion';
1313
import { DoCheck } from '@angular/core';
1414
import { ElementRef } from '@angular/core';
15+
import { Field } from '@angular/forms/signals';
1516
import { FormGroupDirective } from '@angular/forms';
1617
import * as i0 from '@angular/core';
1718
import * as i1 from '@angular/cdk/observers';

goldens/material/paginator/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { Directionality } from '@angular/cdk/bidi';
2121
import { DoCheck } from '@angular/core';
2222
import { ElementRef } from '@angular/core';
2323
import { EventEmitter } from '@angular/core';
24+
import { Field } from '@angular/forms/signals';
2425
import { FlexibleOverlayPopoverLocation } from '@angular/cdk/overlay';
2526
import { FocusableOption } from '@angular/cdk/a11y';
2627
import { FocusOrigin } from '@angular/cdk/a11y';

goldens/material/select/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ControlValueAccessor } from '@angular/forms';
2020
import { DoCheck } from '@angular/core';
2121
import { ElementRef } from '@angular/core';
2222
import { EventEmitter } from '@angular/core';
23+
import { Field } from '@angular/forms/signals';
2324
import { FlexibleOverlayPopoverLocation } from '@angular/cdk/overlay';
2425
import { FocusableOption } from '@angular/cdk/a11y';
2526
import { FocusOrigin } from '@angular/cdk/a11y';

goldens/material/stepper/index.api.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { CdkStepperNext } from '@angular/cdk/stepper';
1616
import { CdkStepperPrevious } from '@angular/cdk/stepper';
1717
import { ElementRef } from '@angular/core';
1818
import { EventEmitter } from '@angular/core';
19+
import { Field } from '@angular/forms/signals';
1920
import { FocusOrigin } from '@angular/cdk/a11y';
2021
import { FormGroupDirective } from '@angular/forms';
2122
import * as i0 from '@angular/core';
@@ -36,6 +37,8 @@ import { TemplateRef } from '@angular/core';
3637
export class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentInit, OnDestroy {
3738
color: ThemePalette;
3839
isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean;
40+
// (undocumented)
41+
isSignalErrorState(field: Field<unknown> | null): boolean;
3942
_lazyContent: MatStepContent;
4043
// (undocumented)
4144
ngAfterContentInit(): void;

pkg-externals.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ PKG_EXTERNALS = [
2424
"@angular/core/rxjs-interop",
2525
"@angular/core/testing",
2626
"@angular/forms",
27+
"@angular/forms/signals",
2728
"@angular/platform-browser",
2829
"@angular/platform-browser/animations",
2930
"@angular/platform-server",

src/material/core/common-behaviors/error-state.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import {AbstractControl, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
1010
import {Subject} from 'rxjs';
1111
import {ErrorStateMatcher as _ErrorStateMatcher} from '../error/error-options';
12+
import {FormField} from '@angular/forms/signals';
13+
import {isSignal} from '@angular/core';
1214

1315
// Declare ErrorStateMatcher as an interface to have compatibility with Closure Compiler.
1416
interface ErrorStateMatcher extends _ErrorStateMatcher {}
@@ -24,21 +26,58 @@ export class _ErrorStateTracker {
2426
/** User-defined matcher for the error state. */
2527
matcher!: ErrorStateMatcher;
2628

29+
/** Reactive or template-based control directive. */
30+
ngControl: NgControl | null;
31+
32+
/** Signal-based form field directive. */
33+
formField: FormField<unknown> | null;
34+
2735
constructor(
2836
private _defaultMatcher: ErrorStateMatcher | null,
29-
public ngControl: NgControl | null,
37+
directive: NgControl | FormField<unknown> | null,
3038
private _parentFormGroup: FormGroupDirective | null,
3139
private _parentForm: NgForm | null,
3240
private _stateChanges: Subject<void>,
33-
) {}
41+
) {
42+
if (!directive) {
43+
this.ngControl = this.formField = null;
44+
} else if (
45+
isSignal((directive as {field?: unknown}).field) &&
46+
// Avoid false positives for interop controls.
47+
!(directive as {updateValueAndValidity?: unknown}).updateValueAndValidity
48+
) {
49+
this.formField = directive as FormField<unknown>;
50+
this.ngControl = null;
51+
} else {
52+
this.formField = null;
53+
this.ngControl = directive as NgControl;
54+
}
55+
}
3456

3557
/** Updates the error state based on the provided error state matcher. */
3658
updateErrorState() {
3759
const oldState = this.errorState;
38-
const parent = this._parentFormGroup || this._parentForm;
3960
const matcher = this.matcher || this._defaultMatcher;
40-
const control = this.ngControl ? (this.ngControl.control as AbstractControl) : null;
41-
const newState = matcher?.isErrorState(control, parent) ?? false;
61+
let newState: boolean;
62+
63+
if (this.formField) {
64+
if (
65+
(typeof ngDevMode === 'undefined' || ngDevMode) &&
66+
matcher &&
67+
!matcher.isSignalErrorState
68+
) {
69+
throw new Error(
70+
'Current error state matcher does not support signal forms. ' +
71+
'Please implement the `isSignalErrorState` method.',
72+
);
73+
}
74+
75+
newState = matcher?.isSignalErrorState?.(this.formField.field()) ?? false;
76+
} else {
77+
const parent = this._parentFormGroup || this._parentForm;
78+
const control = this.ngControl ? (this.ngControl.control as AbstractControl) : null;
79+
newState = matcher?.isErrorState(control, parent) ?? false;
80+
}
4281

4382
if (newState !== oldState) {
4483
this.errorState = newState;

src/material/core/error/error-options.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,24 @@
77
*/
88

99
import {Service} from '@angular/core';
10-
import {FormGroupDirective, NgForm, AbstractControl} from '@angular/forms';
10+
import type {FormGroupDirective, NgForm, AbstractControl} from '@angular/forms';
11+
import type {Field} from '@angular/forms/signals';
1112

1213
/** Error state matcher that matches when a control is invalid and dirty. */
1314
@Service({autoProvided: false})
1415
export class ShowOnDirtyErrorStateMatcher implements ErrorStateMatcher {
1516
isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean {
1617
return !!(control && control.invalid && (control.dirty || (form && form.submitted)));
1718
}
19+
20+
isSignalErrorState(field: Field<unknown> | null): boolean {
21+
if (!field) {
22+
return false;
23+
}
24+
const invalid = field().invalid();
25+
const dirty = field().dirty();
26+
return invalid && dirty;
27+
}
1828
}
1929

2030
/** Provider that defines how form controls behave with regards to displaying error messages. */
@@ -23,4 +33,13 @@ export class ErrorStateMatcher {
2333
isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean {
2434
return !!(control && control.invalid && (control.touched || (form && form.submitted)));
2535
}
36+
37+
isSignalErrorState?(field: Field<unknown> | null): boolean {
38+
if (!field) {
39+
return false;
40+
}
41+
const invalid = field().invalid();
42+
const touched = field().touched();
43+
return invalid && touched;
44+
}
2645
}

0 commit comments

Comments
 (0)