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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P3An issue that is relevant to core functions, but does not impede progress. Important, but not urgentarea: material/stepperfeatureLabel used to distinguish feature request from other issuesgemini-triagedLabel noting that an issue has been triaged by gemini

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions