Skip to content

feat(mat-step): support Signal Forms in MatStep / ErrorStateMatcher #33447

Description

@pkurcx

Feature Description

The error-state machinery in Angular Material is hardwired to AbstractControl, with no path for the new Signal Forms:

  • ErrorStateMatcher.isErrorState(control, form) accepts only AbstractControl | null.
  • CdkStep.stepControl is typed @Input() stepControl!: AbstractControl;.
  • MatStep registers itself as the ErrorStateMatcher ({ provide: ErrorStateMatcher, useExisting: MatStep }) and ORs the in-scope matcher with its own control.invalid && this.interacted rule.

Signal Forms expose field state as a Field / FieldTree<T> / FieldState, not an AbstractControl. Angular does build an adapter internally — InteropNgControl (created by the Field
directive, wrapping FieldState to look like NgControl/AbstractControl) — but it is not exported from @angular/forms/signals, so consumers can't reuse it.

The result: there is no supported way to drive Material's error state (and MatStep in particular) from a Signal Forms field. Today the only option is to fake an AbstractControl:

// userland workaround — wrap a signal-forms FieldTree so ErrorStateMatcher accepts it
class FieldAbstractControl<T> extends AbstractControl<T> {
  constructor(private readonly field: FieldTree<T>) { super(null, null); }
  override get invalid() { return this.field().invalid(); }
  override get touched() { return this.field().touched(); }
  override get dirty()   { return this.field().dirty(); }
  // setValue/patchValue/reset → no-ops; state lives in the FieldTree
}

This works only because the built-in matchers and MatStep happen to read just invalid/touched/dirty. It's brittle (a future matcher reading status/errors/value would get
super(null, null) defaults) and forces every Signal Forms user to re-discover the same hack.

Use Case

I'm migrating reactive forms to Signal Forms in an app that uses MatStepper. I want each step's header to show the error indicator using the same rules
Material already applies — i.e. honoring MatStep's invalid && interacted behavior so a step only flags an error after the user has moved past it, rather than reading
field().invalid() directly and re-implementing that logic myself.

Because MatStep decides this through ErrorStateMatcher.isErrorState(control, form) and that only accepts an AbstractControl, I have to wrap my FieldTree in a fake AbstractControl
to feed it in. The same need shows up anywhere error state is shown for a signal-forms field — mat-error, custom validity indicators in accordions/lists, etc. A supported Field
AbstractControl bridge (or a matcher overload that accepts a field) would let Signal Forms integrate with Material's error-state components without faking control instances,
and keep error semantics defined in exactly one place.

Environment: Angular v21.2.x

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: material/stepperfeatureLabel used to distinguish feature request from other issuesgemini-triagedLabel noting that an issue has been triaged by geminineeds triageThis issue needs to be triaged by the team

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions