0
votes

I recently spent hours going over alot of resources which none seems to work exactly out of the box as expected so I cam,e up with this example on stackblitz to the best of my ability.

Could some one please tell me if this is the best way to accomplish the following?

  1. Create a custom form control using angular material, errorStateMatcher, and ngControl.
  2. Have this component work as "out of the box" or natively as possible intertwining any parent validations put on the custom formControl as well as custom validations inside the custom form control.
  3. GOAL: please help remove or add any code needed to make this function better. Thanks in advance.

Link to stackblitz: https://stackblitz.com/edit/angular-mat-reactive-form-control-ddssy1

Custom Control Html:

<mat-form-field appearance="outline" [floatLabel]="'always'" class="example-full-width">
  <mat-label>{{label}}</mat-label>
  <input matInput [id]="id" #input [formControl]="control" [placeholder]="placeholder"/>
  <mat-hint>Required</mat-hint>
  <mat-error>{{errorMessage}}</mat-error>
</mat-form-field>

Custom Control TS:

import { coerceBooleanProperty } from "@angular/cdk/coercion";
import {
  Component,
  ViewChild,
  HostBinding,
  Input,
  ChangeDetectionStrategy,
  Optional,
  Self,
  DoCheck,
  OnInit,
} from "@angular/core";
import {
  ControlValueAccessor,
  NgControl,
  FormControlName,
  FormControl,
} from "@angular/forms";
import {
  MatFormFieldControl,
  ErrorStateMatcher, MatInput
} from "@angular/material";
import { Subject } from "rxjs";

export class MyErrorStateMatcher implements ErrorStateMatcher {
  isErrorState(control: FormControl | null): boolean {
    return !!(control && control.invalid && (control.dirty || control.touched));
  }
}

@Component({
  host: {
    '(focusout)': 'onTouch()',
    "[id]": "id",
    "[attr.aria-describedby]": "describedBy"
  },
  selector: "custom-input",
  templateUrl: "./custom-select.component.html",
  styleUrls: ["./custom-select.component.scss"],
  providers: [
    {
      provide: MatFormFieldControl,
      useExisting: CustomSelectComponent
    }
  ],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomSelectComponent
  implements ControlValueAccessor, OnInit, DoCheck {

  static nextId = 0;
  @HostBinding() id = `input-${CustomSelectComponent.nextId++}`;
  @HostBinding("attr.aria-describedby") describedBy = "";
  @ViewChild("Input") input: MatInput;
  @Input() placeholder: string;
  @Input() label: string;
  @Input() disabled: boolean;
  @Input('value') _value: any
  get value() {
    return this._value || null;
  }
  set value(val) {
    this._value = val;
  }
  public control: FormControl;
  public errorMessage: string;

  get errorState(){
    console.log('error state!');
    return this.errorMatcher.isErrorState(this.ngControl.control as FormControl, null);
  }

  onChange: (value: any) => void;
  onTouch: () => void;

  constructor(
    @Optional() @Self() public ngControl: NgControl,
    @Optional() private _controlName: FormControlName,
    private errorMatcher: ErrorStateMatcher,
  ) {
    if (ngControl) {
      ngControl.valueAccessor = this;
    }
  }

  ngOnInit(): void {
    this.control = this._controlName.control;    
    this.control.valueChanges.subscribe(res=>{
      if(res){
        this.validate();
      }
    })
    this.control.markAsTouched();
    this.validate();
  }

  ngDoCheck(): void {
    if(this.control){
      this.validate();   
    }
  }

  writeValue(obj: any): void {
    this._value = obj;
  }
  registerOnChange(fn: any): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouch = fn;
  }

  validate(){
    console.log(this.control);
    this.errorMessage = null;
    if(this.control.errors && this.control.errors.required && !this.control.value){
      this.errorMessage = "Required";
      return;
    }
    if(this.control?.value?.length < 3){
      this.control.setErrors({ invalid: true});
      this.errorMessage = 'Length must be at least 3 characters.';
      return
    }   
  }
}

PARENT HTML:

<div style="text-align:center">
  <form class="example-form" [formGroup]="myForm" (submit)="submitForm()">
    <custom-input placeholder="Favorite Food" label="Food" formControlName="food" [required]="true"></custom-input>
    <button>Submit</button>
  </form>
</div>

<div>
  Form is valid? {{myForm.valid}}
</div>

PARENT TS:

import { coerceBooleanProperty } from "@angular/cdk/coercion";
import {
  Component,
  ViewChild,
  HostBinding,
  Input,
  ChangeDetectionStrategy,
  Optional,
  Self,
  DoCheck,
  OnInit,
} from "@angular/core";
import {
  ControlValueAccessor,
  NgControl,
  FormControlName,
  FormControl,
} from "@angular/forms";
import {
  MatFormFieldControl,
  ErrorStateMatcher, MatInput
} from "@angular/material";
import { Subject } from "rxjs";

export class MyErrorStateMatcher implements ErrorStateMatcher {
  isErrorState(control: FormControl | null): boolean {
    return !!(control && control.invalid && (control.dirty || control.touched));
  }
}

@Component({
  host: {
    '(focusout)': 'onTouch()',
    "[id]": "id",
    "[attr.aria-describedby]": "describedBy"
  },
  selector: "custom-input",
  templateUrl: "./custom-select.component.html",
  styleUrls: ["./custom-select.component.scss"],
  providers: [
    {
      provide: MatFormFieldControl,
      useExisting: CustomSelectComponent
    }
  ],
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class CustomSelectComponent
  implements ControlValueAccessor, OnInit, DoCheck {

  static nextId = 0;
  @HostBinding() id = `input-${CustomSelectComponent.nextId++}`;
  @HostBinding("attr.aria-describedby") describedBy = "";
  @ViewChild("Input") input: MatInput;
  @Input() placeholder: string;
  @Input() label: string;
  @Input() disabled: boolean;
  @Input('value') _value: any
  get value() {
    return this._value || null;
  }
  set value(val) {
    this._value = val;
  }
  public control: FormControl;
  public errorMessage: string;

  get errorState(){
    console.log('error state!');
    return this.errorMatcher.isErrorState(this.ngControl.control as FormControl, null);
  }

  onChange: (value: any) => void;
  onTouch: () => void;

  constructor(
    @Optional() @Self() public ngControl: NgControl,
    @Optional() private _controlName: FormControlName,
    private errorMatcher: ErrorStateMatcher,
  ) {
    if (ngControl) {
      ngControl.valueAccessor = this;
    }
  }

  ngOnInit(): void {
    this.control = this._controlName.control;    
    this.control.valueChanges.subscribe(res=>{
      if(res){
        this.validate();
      }
    })
    this.control.markAsTouched();
    this.validate();
  }

  ngDoCheck(): void {
    if(this.control){
      this.validate();   
    }
  }

  writeValue(obj: any): void {
    this._value = obj;
  }
  registerOnChange(fn: any): void {
    this.onChange = fn;
  }
  registerOnTouched(fn: any): void {
    this.onTouch = fn;
  }

  validate(){
    console.log(this.control);
    this.errorMessage = null;
    if(this.control.errors && this.control.errors.required && !this.control.value){
      this.errorMessage = "Required";
      return;
    }
    if(this.control?.value?.length < 3){
      this.control.setErrors({ invalid: true});
      this.errorMessage = 'Length must be at least 3 characters.';
      return
    }   
  }
}
1

1 Answers

0
votes

what you did is good, but you may want to add these too:

  /** Adding these just to update the component a bit */
  @Input() name: string;
  @Input() readOnly: boolean;
  @Input() type: string;
  @Input() required: boolean;
  @Input() maxLength: number;
  @Input() hint: string;
  @Input() errMessage: string;
  @Output() blur: EventEmitter<any> = new EventEmitter<any>();

   
  onBlur(event) {
    if (event && event.target && event.target.value) {
      this.value = event.target.value;
      this.blur.emit(event);
    }
  }

In the HTML, you can bind them like this:

<input
    matInput
    [id]="id"
    #input
    [formControl]="control"
    [placeholder]="placeholder"
    [name]="formControlName"
    [readonly]="readOnly"
    [type]="type"
    [required]="required"
    [maxLength]="maxLength"
    (blur)="onBlur($event)"
  />
  <mat-hint>{{ hint ? hint : 'Required' }}</mat-hint>
  <mat-error>{{ errMessage ? errMessage : errorMessage }}</mat-error>