Angular Material custom MatFormFieldControl – How to manage error state

angularangular-material2angular6

I am trying to make a custom MatFormFieldControl, with the version 7 of Angular Material and Angular 6. The custom input is a weight input, wich has a value (input type="number") and a unit (select "kg","g",…). It has to be placed inside a mat-form-field-control, work with reactive forms (formControlName="weight") and support error states (<mat-error *ngIf="weightControl.hasError('required')">error<...>), even with custom validators.

I wrote this implementation:

weight-input.component.html

<div [formGroup]="weightForm">
  <input fxFlex formControlName="value" type="number" placeholder="Valore" min="0" #value>
  <select formControlName="unit" [style.color]="getUnselectedColor()" (change)="setUnselected(unit)" #unit>
    <option value="" selected> Unità </option>
    <option *ngFor="let unit of units" style="color: black;">{{ unit }}</option>
  </select>
</div>

weight-input.component.css

.container {
  display: flex;
}

input, select {
  border: none;
  background: none;
  padding: 0;
  opacity: 0;
  outline: none;
  font: inherit;
  transition: 200ms opacity ease-in-out;
}

:host.weight-floating input {
  opacity: 1;
}

:host.weight-floating select {
  opacity: 1;
}

weight-input.component.ts

import { Component, OnInit, Input, OnDestroy, HostBinding, ElementRef, forwardRef, Optional, Self } from '@angular/core';
import { FormGroup, FormBuilder, ControlValueAccessor, NgControl, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MatFormFieldControl } from '@angular/material';
import { Subject } from 'rxjs';
import { FocusMonitor } from '@angular/cdk/a11y';

export class Weight {
  constructor(public value: number, public unit: string) { };
}

@Component({
  selector: 'weight-input',
  templateUrl: './weight-input.component.html',
  styleUrls: ['./weight-input.component.css'],
  providers: [
    { provide: MatFormFieldControl, useExisting: WeightInput }
  ],
})
export class WeightInput implements OnInit, OnDestroy, MatFormFieldControl<Weight>, ControlValueAccessor {

  stateChanges = new Subject<void>();

  @Input() 
  get units(): string[] {
    return this._units;
  }
  set units(value: string[]) {
    this._units = value;
    this.stateChanges.next();
  }
  private _units: string[];

  unselected = true;
  weightForm: FormGroup;

  @Input()
  get value(): Weight | null {
    const value: Weight = this.weightForm.value;
    return ((value.value || value.value == 0) && !!value.unit) ? value : null;
  }
  set value(value: Weight | null) {
    value = value || new Weight(null, '');
    this.weightForm.setValue({ value: value.value, unit: value.unit });
    if(this._onChange) this._onChange(value);
    this.stateChanges.next();
  }

  static nextId = 0;
  @HostBinding() id = `weight-input-${WeightInput.nextId++}`;

  @Input()
  get placeholder() {
    return this._placeholder;
  }
  set placeholder(placeholder) {
    this._placeholder = placeholder;
    this.stateChanges.next();
  }
  private _placeholder: string;

  focused = false;

  get empty() {
    const value = this.weightForm.value as Weight;
    return (!value.value && value.value != 0) || !!!value.unit;
  }

  @HostBinding('class.weight-floating')
  get shouldLabelFloat() {
    return this.focused || !this.empty;
  }

  @Input()
  get required(): boolean {
    return this._required;
  }
  set required(required: boolean) {
    const temp: any = required;
    required = (temp != "true");
    this._required = required;
    this.stateChanges.next();
  }
  private _required = false;

  @Input()
  get disabled(): boolean {
    return this._disabled;
  }
  set disabled(disabled: boolean) {
    const temp: any = disabled;
    disabled = (temp != "true");
    this._disabled = disabled;
    this.setDisable();
    this.stateChanges.next();
  }
  private _disabled = false;

  errorState = false;
  controlType = 'weight-input';

  @HostBinding('attr.aria-describedby') describedBy = '';
  setDescribedByIds(ids: string[]) {
    this.describedBy = ids.join(' ');
  }

  onContainerClick(event: MouseEvent) {
    if(!this.disabled) {
      this._onTouched();
    }
   }

  constructor(
    @Optional() @Self() public ngControl: NgControl, 
    private fb: FormBuilder, 
    private fm: FocusMonitor,
    private elRef: ElementRef<HTMLElement>
  ) {
    if(this.ngControl != null) { 
      this.ngControl.valueAccessor = this; 
    }
    fm.monitor(elRef.nativeElement, true).subscribe(origin => {
      this.focused = !!origin;
      this.stateChanges.next();
    });
  }

  ngOnInit() {
    this.weightForm = this.fb.group({
      value: null,
      unit: ''
    });
    this.setDisable();
    this.weightForm.valueChanges.subscribe(
      () => {
        const value = this.value;
        if(this._onChange) this._onChange(value);
        this.stateChanges.next();
      }
    );
  }

  ngOnDestroy() {
    this.stateChanges.complete();
    this.fm.stopMonitoring(this.elRef.nativeElement);
  }

  writeValue(value: Weight): void {
    if(value instanceof Weight) {
      this.weightForm.setValue(value);
    }
  }

  _onChange: (_: any) => void;
  registerOnChange(fn: (_: any) => void): void {
    this._onChange = fn;
  }

  _onTouched: () => void;
  registerOnTouched(fn: () => void): void {
    this._onTouched = fn;
  }

  setDisabledState(isDisabled: boolean): void {
    this.disabled = isDisabled;
  }

  private setDisable(): void {
    if(this.disabled && this.weightForm) {
      this.weightForm.disable();
    }
    else if(this.weightForm) {
      this.weightForm.enable();
    }
  }

  getUnselectedColor(): string {
    return this.unselected ? '#999' : '#000';
  }

  setUnselected(select): void {
    this.unselected = !!!select.value;
  }

}

And here is where it has to go:

app.component.html

<mat-form-field fxFlexAlign="stretch">
        <weight-input formControlName="peso" [units]="units" placeholder="Peso" required></weight-input>
        <mat-error *ngIf="peso.invalid">errore</mat-error>
      </mat-form-field>

(peso means weight in italian, the units are customs so you bind them in an input [units])

app.component.ts (partial)

units = [ 'Kg', 'g', 'T', 'hg' ];
ngOnInit() {
    this.initForm();
  } 

private initForm(): void {
    this.scheda = this.fb.group({
      diametro: [ null, Validators.required ],
      peso: [ null, Validators.required ], //There will be custom validators, for instance for unit control (Validators.unitsIn(units: string[]))
      contorno: [ null, Validators.required ],
      fornitore: null,
      note: null
    });
  }

get diametro(): FormControl | undefined {
    return this.scheda.get('diametro') as FormControl;
  }
  get peso(): FormControl | undefined {
    return this.scheda.get('peso') as FormControl;
  }

So what I need is:

-Is this a good implementation of MatFormFieldControl and ControlValueAccessor? Has it problems, bugs?

-Mainly: how manage the errorState of the input, so that it behaves as a normal mat form field cotrol and how to detect/relate it with the external form control validators? (for instance, if the control "peso" has Validators.required errorState is true if the custom input is empty, else is false, the same with eventual custom validators)

Update: I corrected the empty method from this (!value.value && value.value != 0) || !!!value.unit to this (!value.value && value.value != 0) && !!!value.unit

I changed the select input with a mat-select input, but it is still functionally the same

<div [formGroup]="weightForm">
 <input fxFlex formControlName="value" type="number" placeholder="Valore" min="0" #value>
  <mat-select fxFlex="10" id="mat-select" formControlName="unit">
    <mat-option value="" selected> Unità </mat-option>  
    <mat-option *ngFor="let unit of units" [value]="unit">
        {{ unit }}
      </mat-option>
    </mat-select>
</div>

Best Answer

Probably one should use the Validator interface, but unfortunately it creates that pesky cyclic error dependency. So instead, just add an errorState property to your custom component that checks the ngControl that was injected into the constructor, like this:

get errorState() {
  return this.ngControl.errors !== null && !!this.ngControl.touched;
}

This should respect your normal Angular validators in the parent component, like this line in the formGroup:

peso: [ null, Validators.required ],