I implemented a custom mat form field with control value accessor based on the Angular Material documentation and github repo.
The problem is when I use this custom control with a mat-form-field and reactive form binding the formControlName
and the user enters an invalid value according to the customControlValidator
and/or the customFormValidator
, the control becomes red as it should but the error is not propagated to the (outer) control, meaning the error message not shows and the outer control is considered valid.
Everything else (value changes, using with ngmodel, disabling input etc) seems to work fine.
Any idea how to propagate the error from the custom input control to the outer control?
I removed most of the MatFormFieldControl implementation and other class variables, minimal code:
Custom input:
@Component({
selector: 'my-custom-input',
templateUrl: './my-custom-input.component.html',
styleUrl: './my-custom-input.component.scss',
providers: [{ provide: MatFormFieldControl, useExisting: MyCustomInputComponent }],
standalone: true,
imports: [FormsModule, ReactiveFormsModule],
})
export class MyCustomInputComponent implements ControlValueAccessor, MatFormFieldControl<any>, OnInit, OnDestroy {
parts: FormGroup<{
pt1: FormControl<any>;
pt2: FormControl<any>;
pt3: FormControl<any>;
}>;
onChange = (_: any) => {};
onTouched = () => {};
@Input()
get value(): any {
// some logic here
// returns the value
}
set value(value: any) {
// some logic here
this.parts.setValue({ pt1: someValue1, pt2: someValue2, pt3: someValue3 });
}
@Input() errorStateMatcher?: ErrorStateMatcher;
get errorState(): boolean {
if (this.errorStateMatcher) {
return this.errorStateMatcher.isErrorState(this.ngControl.control, null);
}
return this.parts.invalid && this.touched;
}
writeValue(value: any): void {
this.value = value;
}
registerOnChange(fn: any): void {
this.onChange = fn;
}
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
setDisabledState(isDisabled: boolean): void {
this.disabled = isDisabled;
}
handleInput(control: AbstractControl): void {
this.onChange(this.value);
}
constructor(
formBuilder: FormBuilder,
private _focusMonitor: FocusMonitor,
private _elementRef: ElementRef<HTMLElement>,
@Optional() @Inject(MAT_FORM_FIELD) public formField: MatFormField,
@Optional() @Self() public ngControl: NgControl
) {
if (this.ngControl != null) {
this.ngControl.valueAccessor = this;
}
this.parts = formBuilder.group({
pt1: ['', [Validators.maxLength(10), customControlValidator]],
pt2: ['', [Validators.maxLength(2), customControlValidator]],
pt3: ['', [Validators.maxLength(2), customContolValidator]],
});
}
ngOnInit(): void {
this.parts.setValidators([customFormValidator]);
}
ngOnDestroy() {
this.stateChanges.complete();
this._focusMonitor.stopMonitoring(this._elementRef);
}
}
Usage:
@Component({
selector: 'my-form-field',
standalone: true,
template: `<form class="custom" [formGroup]="form">
<mat-form-field>
<my-custom-input formControlName="myValueCtrl" />
@for (error of form.get('myValueCtrl')?.errors | keyvalue; track error[key]) {
<mat-error>{{ error.key }}</mat-error>
}
</mat-form-field>
ctrl errors: {{ form.get('myValueCtrl').errors | json }} <!-- only the required error, does not see the custom input errors -->
ctrl valid: {{ form.get('myValueCtrl').valid }} <!-- only invalid if the required validator returns error, does not see the custom input errors -->
</form>`,
imports: [
MatFormField,
ReactiveFormsModule,
FormsModule,
JsonPipe,
MatInputModule,
MatError,
JsonPipe,
KeyValuePipe,
],
})
class MyFormFieldComponent {
form = new FormGroup({
myValueCtrl: new FormControl<any>(null, { validators: Validators.required }),
});
}
I tried to provide the NG_VALIDATORS
token with multi to implement the validate
method and somehow merge the validators but it caused circular dependency error because I inject the ngControl in the constructor and set the value accessor there.
To my understanding I need to do this as the MatFormFieldControl implementation needs an ngControl class variable. If I remove it and try to inject the NG_VALUE_ACCESSOR
without injecting the control, it throws an error that the abstract class is not implemented correctly.