diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index bc2fceff84cb..8177083d6078 100644 --- a/goldens/material/chips/index.api.md +++ b/goldens/material/chips/index.api.md @@ -13,6 +13,7 @@ import { ControlValueAccessor } from '@angular/forms'; import { DoCheck } from '@angular/core'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; +import { Field } from '@angular/forms/signals'; import { FocusKeyManager } from '@angular/cdk/a11y'; import { FormGroupDirective } from '@angular/forms'; import * as i0 from '@angular/core'; diff --git a/goldens/material/core/index.api.md b/goldens/material/core/index.api.md index 5780910542d4..6a1b9cd40528 100644 --- a/goldens/material/core/index.api.md +++ b/goldens/material/core/index.api.md @@ -9,8 +9,10 @@ import { AfterViewChecked } from '@angular/core'; import { ChangeDetectorRef } from '@angular/core'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; +import { Field } from '@angular/forms/signals'; import { FocusableOption } from '@angular/cdk/a11y'; import { FocusOrigin } from '@angular/cdk/a11y'; +import { FormField } from '@angular/forms/signals'; import { FormGroupDirective } from '@angular/forms'; import * as i0 from '@angular/core'; import * as i2 from '@angular/cdk/bidi'; @@ -95,6 +97,8 @@ export class ErrorStateMatcher { // (undocumented) isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean; // (undocumented) + isSignalErrorState?(field: Field | null): boolean; + // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) static ɵprov: i0.ɵɵInjectableDeclaration; @@ -102,10 +106,10 @@ export class ErrorStateMatcher { // @public export class _ErrorStateTracker { - constructor(_defaultMatcher: ErrorStateMatcher_2 | null, ngControl: NgControl | null, _parentFormGroup: FormGroupDirective | null, _parentForm: NgForm | null, _stateChanges: Subject); + constructor(_defaultMatcher: ErrorStateMatcher_2 | null, directive: NgControl | FormField | null, _parentFormGroup: FormGroupDirective | null, _parentForm: NgForm | null, _stateChanges: Subject); errorState: boolean; + formField: FormField | null; matcher: ErrorStateMatcher_2; - // (undocumented) ngControl: NgControl | null; updateErrorState(): void; } @@ -519,6 +523,8 @@ export class ShowOnDirtyErrorStateMatcher implements ErrorStateMatcher { // (undocumented) isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean; // (undocumented) + isSignalErrorState(field: Field | null): boolean; + // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) static ɵprov: i0.ɵɵInjectableDeclaration; diff --git a/goldens/material/datepicker/index.api.md b/goldens/material/datepicker/index.api.md index da4b5977dba6..c2ae996fb88b 100644 --- a/goldens/material/datepicker/index.api.md +++ b/goldens/material/datepicker/index.api.md @@ -16,6 +16,7 @@ import { Directionality } from '@angular/cdk/bidi'; import { DoCheck } from '@angular/core'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; +import { Field } from '@angular/forms/signals'; import { FocusOrigin } from '@angular/cdk/a11y'; import { FormGroupDirective } from '@angular/forms'; import * as i0 from '@angular/core'; diff --git a/goldens/material/input/index.api.md b/goldens/material/input/index.api.md index 378d872cfbaa..8f32fa7d9afd 100644 --- a/goldens/material/input/index.api.md +++ b/goldens/material/input/index.api.md @@ -12,6 +12,7 @@ import { AfterViewInit } from '@angular/core'; import { BooleanInput } from '@angular/cdk/coercion'; import { DoCheck } from '@angular/core'; import { ElementRef } from '@angular/core'; +import { Field } from '@angular/forms/signals'; import { FormGroupDirective } from '@angular/forms'; import * as i0 from '@angular/core'; import * as i1 from '@angular/cdk/observers'; diff --git a/goldens/material/paginator/index.api.md b/goldens/material/paginator/index.api.md index f26a0c6b664b..cf6853addb2b 100644 --- a/goldens/material/paginator/index.api.md +++ b/goldens/material/paginator/index.api.md @@ -21,6 +21,7 @@ import { Directionality } from '@angular/cdk/bidi'; import { DoCheck } from '@angular/core'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; +import { Field } from '@angular/forms/signals'; import { FlexibleOverlayPopoverLocation } from '@angular/cdk/overlay'; import { FocusableOption } from '@angular/cdk/a11y'; import { FocusOrigin } from '@angular/cdk/a11y'; diff --git a/goldens/material/select/index.api.md b/goldens/material/select/index.api.md index 58da7072077a..9f92404f9399 100644 --- a/goldens/material/select/index.api.md +++ b/goldens/material/select/index.api.md @@ -20,6 +20,7 @@ import { ControlValueAccessor } from '@angular/forms'; import { DoCheck } from '@angular/core'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; +import { Field } from '@angular/forms/signals'; import { FlexibleOverlayPopoverLocation } from '@angular/cdk/overlay'; import { FocusableOption } from '@angular/cdk/a11y'; import { FocusOrigin } from '@angular/cdk/a11y'; diff --git a/goldens/material/stepper/index.api.md b/goldens/material/stepper/index.api.md index 3ec089342637..2e341d09a37c 100644 --- a/goldens/material/stepper/index.api.md +++ b/goldens/material/stepper/index.api.md @@ -16,6 +16,7 @@ import { CdkStepperNext } from '@angular/cdk/stepper'; import { CdkStepperPrevious } from '@angular/cdk/stepper'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; +import { Field } from '@angular/forms/signals'; import { FocusOrigin } from '@angular/cdk/a11y'; import { FormGroupDirective } from '@angular/forms'; import * as i0 from '@angular/core'; @@ -36,6 +37,8 @@ import { TemplateRef } from '@angular/core'; export class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentInit, OnDestroy { color: ThemePalette; isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean; + // (undocumented) + isSignalErrorState(field: Field | null): boolean; _lazyContent: MatStepContent; // (undocumented) ngAfterContentInit(): void; diff --git a/pkg-externals.bzl b/pkg-externals.bzl index 1a1dbaafef5e..9c091fb4030b 100644 --- a/pkg-externals.bzl +++ b/pkg-externals.bzl @@ -24,6 +24,7 @@ PKG_EXTERNALS = [ "@angular/core/rxjs-interop", "@angular/core/testing", "@angular/forms", + "@angular/forms/signals", "@angular/platform-browser", "@angular/platform-browser/animations", "@angular/platform-server", diff --git a/src/material/core/common-behaviors/error-state.ts b/src/material/core/common-behaviors/error-state.ts index e881a49f8449..33cfa9113c80 100644 --- a/src/material/core/common-behaviors/error-state.ts +++ b/src/material/core/common-behaviors/error-state.ts @@ -9,6 +9,8 @@ import {AbstractControl, FormGroupDirective, NgControl, NgForm} from '@angular/forms'; import {Subject} from 'rxjs'; import {ErrorStateMatcher as _ErrorStateMatcher} from '../error/error-options'; +import {FormField} from '@angular/forms/signals'; +import {isSignal} from '@angular/core'; // Declare ErrorStateMatcher as an interface to have compatibility with Closure Compiler. interface ErrorStateMatcher extends _ErrorStateMatcher {} @@ -24,21 +26,58 @@ export class _ErrorStateTracker { /** User-defined matcher for the error state. */ matcher!: ErrorStateMatcher; + /** Reactive or template-based control directive. */ + ngControl: NgControl | null; + + /** Signal-based form field directive. */ + formField: FormField | null; + constructor( private _defaultMatcher: ErrorStateMatcher | null, - public ngControl: NgControl | null, + directive: NgControl | FormField | null, private _parentFormGroup: FormGroupDirective | null, private _parentForm: NgForm | null, private _stateChanges: Subject, - ) {} + ) { + if (!directive) { + this.ngControl = this.formField = null; + } else if ( + isSignal((directive as {field?: unknown}).field) && + // Avoid false positives for interop controls. + !(directive as {updateValueAndValidity?: unknown}).updateValueAndValidity + ) { + this.formField = directive as FormField; + this.ngControl = null; + } else { + this.formField = null; + this.ngControl = directive as NgControl; + } + } /** Updates the error state based on the provided error state matcher. */ updateErrorState() { const oldState = this.errorState; - const parent = this._parentFormGroup || this._parentForm; const matcher = this.matcher || this._defaultMatcher; - const control = this.ngControl ? (this.ngControl.control as AbstractControl) : null; - const newState = matcher?.isErrorState(control, parent) ?? false; + let newState: boolean; + + if (this.formField) { + if ( + (typeof ngDevMode === 'undefined' || ngDevMode) && + matcher && + !matcher.isSignalErrorState + ) { + throw new Error( + 'Current error state matcher does not support signal forms. ' + + 'Please implement the `isSignalErrorState` method.', + ); + } + + newState = matcher?.isSignalErrorState?.(this.formField.field()) ?? false; + } else { + const parent = this._parentFormGroup || this._parentForm; + const control = this.ngControl ? (this.ngControl.control as AbstractControl) : null; + newState = matcher?.isErrorState(control, parent) ?? false; + } if (newState !== oldState) { this.errorState = newState; diff --git a/src/material/core/error/error-options.ts b/src/material/core/error/error-options.ts index d30a3e8f963d..df3cd11301ca 100644 --- a/src/material/core/error/error-options.ts +++ b/src/material/core/error/error-options.ts @@ -7,7 +7,8 @@ */ import {Service} from '@angular/core'; -import {FormGroupDirective, NgForm, AbstractControl} from '@angular/forms'; +import type {FormGroupDirective, NgForm, AbstractControl} from '@angular/forms'; +import type {Field} from '@angular/forms/signals'; /** Error state matcher that matches when a control is invalid and dirty. */ @Service({autoProvided: false}) @@ -15,6 +16,15 @@ export class ShowOnDirtyErrorStateMatcher implements ErrorStateMatcher { isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean { return !!(control && control.invalid && (control.dirty || (form && form.submitted))); } + + isSignalErrorState(field: Field | null): boolean { + if (!field) { + return false; + } + const invalid = field().invalid(); + const dirty = field().dirty(); + return invalid && dirty; + } } /** Provider that defines how form controls behave with regards to displaying error messages. */ @@ -23,4 +33,13 @@ export class ErrorStateMatcher { isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean { return !!(control && control.invalid && (control.touched || (form && form.submitted))); } + + isSignalErrorState?(field: Field | null): boolean { + if (!field) { + return false; + } + const invalid = field().invalid(); + const touched = field().touched(); + return invalid && touched; + } } diff --git a/src/material/stepper/stepper.ts b/src/material/stepper/stepper.ts index 394966d9968e..a22a9c1e554e 100644 --- a/src/material/stepper/stepper.ts +++ b/src/material/stepper/stepper.ts @@ -41,6 +41,7 @@ import {MatStepHeader} from './step-header'; import {MatStepLabel} from './step-label'; import {MatStepperIcon, MatStepperIconContext} from './stepper-icon'; import {MatStepContent} from './step-content'; +import type {Field} from '@angular/forms/signals'; @Component({ selector: 'mat-step', @@ -112,6 +113,12 @@ export class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentI return originalErrorState || customErrorState; } + + isSignalErrorState(field: Field | null): boolean { + const originalErrorState = this._errorStateMatcher.isSignalErrorState?.(field) ?? false; + const customErrorState = !!(field && field().invalid() && this.interacted); + return originalErrorState || customErrorState; + } } @Component({