Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions goldens/material/chips/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
10 changes: 8 additions & 2 deletions goldens/material/core/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -95,17 +97,19 @@ export class ErrorStateMatcher {
// (undocumented)
isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean;
// (undocumented)
isSignalErrorState?(field: Field<unknown> | null): boolean;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<ErrorStateMatcher, never>;
// (undocumented)
static ɵprov: i0.ɵɵInjectableDeclaration<ErrorStateMatcher>;
}

// @public
export class _ErrorStateTracker {
constructor(_defaultMatcher: ErrorStateMatcher_2 | null, ngControl: NgControl | null, _parentFormGroup: FormGroupDirective | null, _parentForm: NgForm | null, _stateChanges: Subject<void>);
constructor(_defaultMatcher: ErrorStateMatcher_2 | null, directive: NgControl | FormField<unknown> | null, _parentFormGroup: FormGroupDirective | null, _parentForm: NgForm | null, _stateChanges: Subject<void>);
errorState: boolean;
formField: FormField<unknown> | null;
matcher: ErrorStateMatcher_2;
// (undocumented)
ngControl: NgControl | null;
updateErrorState(): void;
}
Expand Down Expand Up @@ -519,6 +523,8 @@ export class ShowOnDirtyErrorStateMatcher implements ErrorStateMatcher {
// (undocumented)
isErrorState(control: AbstractControl | null, form: FormGroupDirective | NgForm | null): boolean;
// (undocumented)
isSignalErrorState(field: Field<unknown> | null): boolean;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<ShowOnDirtyErrorStateMatcher, never>;
// (undocumented)
static ɵprov: i0.ɵɵInjectableDeclaration<ShowOnDirtyErrorStateMatcher>;
Expand Down
1 change: 1 addition & 0 deletions goldens/material/datepicker/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions goldens/material/input/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions goldens/material/paginator/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions goldens/material/select/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
3 changes: 3 additions & 0 deletions goldens/material/stepper/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<unknown> | null): boolean;
_lazyContent: MatStepContent;
// (undocumented)
ngAfterContentInit(): void;
Expand Down
1 change: 1 addition & 0 deletions pkg-externals.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 44 additions & 5 deletions src/material/core/common-behaviors/error-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand All @@ -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<unknown> | null;

constructor(
private _defaultMatcher: ErrorStateMatcher | null,
public ngControl: NgControl | null,
directive: NgControl | FormField<unknown> | null,
private _parentFormGroup: FormGroupDirective | null,
private _parentForm: NgForm | null,
private _stateChanges: Subject<void>,
) {}
) {
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<unknown>;
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;
Expand Down
21 changes: 20 additions & 1 deletion src/material/core/error/error-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,24 @@
*/

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})
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<unknown> | 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. */
Expand All @@ -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<unknown> | null): boolean {
if (!field) {
return false;
}
const invalid = field().invalid();
const touched = field().touched();
return invalid && touched;
}
}
7 changes: 7 additions & 0 deletions src/material/stepper/stepper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -112,6 +113,12 @@ export class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentI

return originalErrorState || customErrorState;
}

isSignalErrorState(field: Field<unknown> | null): boolean {
const originalErrorState = this._errorStateMatcher.isSignalErrorState?.(field) ?? false;
const customErrorState = !!(field && field().invalid() && this.interacted);
return originalErrorState || customErrorState;
}
}

@Component({
Expand Down
Loading