Applying Validation in Custom Form Components

Introduction

Angular platform ships Angular Material library that contains quite a bunch of UI components which helps to develop Single Page Apps easier. The material components are opinionated and they follows Google's Material Spec. Sometimes we've to develop components from scratch either wrapping over the native HTML elements or third party components. For custom components created in such ways, Angular provides support for them to bind data and synchronize the state through interfaces. It also a good idea to encapsulate the validation and displaying error messages work inside the custom component.

In this article, I'll show you how we can leverage the built-in validation and display the error messages inside a custom component.

Simple Custom Component

Let's create a simple custom textbox component that wraps the native input element.

@Component({
   selector: 'app-custom-textbox',
   templateUrl: './custom-textbox.html',
   styleUrls: ['./_custom-textbox.scss']
})
export class CustomTextboxComponent {

  @Input()
  public label: string;

  @Input()
  public placeholder: string;

  @Input()
  public required = false;

  @Input()
  public disabled = false;

  @Input()
  public data: string;

  @Input()
  public minlength = 0;
}

Our custom component accepts quite a bunch of inputs like label, placeholder, data and others.

Below is it's template and the SCSS file.

custom-textbox.html

<div class="input-wrapper" [class.required]="required">
 <label>{{label}}</label>
 <input type="text"
    [required]="required"
    [disabled]="disabled"
    [minlength]="minlength"
    [placeholder]="placeholder"
    [(ngModel)]="data"
    [ngModelOptions]="{standalone: true}"/>
</div>

_custom.textbox.scss

.input-wrapper {
 display: flex;
 flex-direction: column;
 padding: 1rem 0;
 position: relative;

 label {
   font-size: 0.85rem;
   margin-bottom: 0.25rem;
   font-weight: bold;
 }

 input {
   border: none;
   border-bottom: solid 1px #000;
   font-size: inherit;
   line-height: 1;
   padding: 0.25rem 0;

   &:focus {
     outline: none;
     border-width: 2px;
   }

   &:disabled {
     border-bottom: solid 1px gray;
   }
 }

 &.required {
   label::after {
     content: '*';
     position: relative;
     top: -3px;
     left: 3px;
     font-size: 0.85rem;
     color: red;
   }
 }
}

Implementing ControlValueAccessor

To make our custom component communicate with the Angular Forms API we need to do couple of things. First, implement the ControlValueAccessor interface, this interface provides methods to synchronize state between our custom component and the Angular Forms API. Second, set a provider for NG_VALUE_ACCESSOR to return our custom component.

@Component({
   selector: 'app-custom-textbox',
   ...
   providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => CustomTextboxComponent),
      multi: true
    }
  ]
})
export class CustomTextboxComponent implements ControlValueAccessor {

  public onChangeFn = (_: any) => {};

  public onTouchedFn = () => {};

  public registerOnChange(fn: any): void {
     this.onChangeFn = fn;
  }

  public registerOnTouched(fn: any): void {
     this.onTouchedFn = fn;
  }

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

  public writeValue(obj: any): void {
     this.data = obj;
  }

  public onChange() {
     this.onChangeFn(this.data);
  }
}

Whenever the component data changes we need to call the change handler and when the focus gets out we need to call the touched handler. Let's update the template to subscribe to the native input element events and invoke the registered methods.

<input type="text"
  ...
  (input)="onChange()"
  (change)="onChange()"
  (blur)="onTouchedFn()"/>
</div>

Before implementing error messages let's quickly test our custom textbox. In the below example, we've dropped our custom component inside an Angular form and the submit button is enabled only when the form is valid. Note, since we've implemented ControlValueAccessor we can directly apply ngModel to our custom component. After entering text with more than three characters in the textbox the submit button should be enabled, if that works then the communication between our custom component and Angular works just fine.

<form #form="ngForm">
 <app-custom-textbox
   name="userName"
   label="User Name"
   placeholder="Please enter your user name"
   minlength="3"
   [required]="true"
   ngModel>
 </app-custom-textbox>

 <p>
   <button type="submit" [disabled]="form.invalid">Submit</button>
 </p>
</form>

Let's see how we can display the error messages.

FormControl and NgControl

FormControl is one of the important type in Angular Forms API that stores all the information of a form component like it's value, validation status etc. In the case of Reactive Forms, we create FormControl explicitly and in case of template forms (ngModel) the FormControl is instantiated implicitly. If we acquire the FormControl of our custom component then we can know the validation status and display the error messages inside the component itself. To acquire the FormControl, we need to acquire the NgControl directive. All the form directives (both ngModel and reactive) in Angular derives from NgControl When we try to acquire NgControl through DI Angular throws a circular dependency error and to avoid instead of providing our component through NG_VALUE_ACCESSOR we need to set the valueAccessor in the constructor.

@Component({
   selector: 'app-custom-textbox',
   templateUrl: './custom-textbox.html',
   styleUrls: ['./_custom-textbox.scss']
})
export class CustomTextboxComponent implements ControlValueAccessor {

  constructor(@Self() @Optional() public control: NgControl) {
     this.control && (this.control.valueAccessor = this);
  }
}

Once we acquire the NgControl in the component the remaining work is easy. Let's create methods to know the validation status and display errors. Let's store the error messages in a map.

constructor(@Self() @Optional() public control: NgControl) {
   this.control && (this.control.valueAccessor = this);
   this.errorMessages.set('required', () => `${this.label} is required.`);
   this.errorMessages.set('minlength', () => `The no. of characters should not be less than ${this.minlength}.`);
}

public get invalid(): boolean {
   return this.control ? this.control.invalid : false;
}

public get showError(): boolean {
   if (!this.control) {
       return false;
   }

   const { dirty, touched } = this.control;

   return this.invalid ? (dirty || touched) : false;
}

public get errors(): Array {
   if (!this.control) {
       return [];
   }

   const { errors } = this.control;
   return Object.keys(errors).map(key => this.errorMessages.has(key) ? this.errorMessages.get(key)() : errors[key] || key);
}

Let's update the template to display the first error.

<div class="input-wrapper" [class.required]="required">
 <label>{{label}}</label>
 <input type="text"
        [required]="required"
        [disabled]="disabled"
        [minlength]="minlength"
        [placeholder]="placeholder"
        [(ngModel)]="data"
        [ngModelOptions]="{standalone: true}"
        (input)="onChange()"
        (change)="onChange()"
        (blur)="onTouchedFn()"/>
 <div class="error-message" *ngIf="showError">
   {{errors[0]}}
 </div>
</div>

Finally, let's add some style to the error-message in the SCSS file.

.error-message {
 font-size: 0.85rem;
 color: red;
 position: absolute;
 bottom: 0;
}

Let's test how the validation and error messages work in our previous form. If we leave our custom component without entering any text it displays the required error message. On entering text with less than three characters it displays the minlength error message as shown in below screenshots.

You can find the complete source code in Git and the demo in StackBlitz. Hope you learnt something from this article. Feel free to ask questions and share your feedback.

blog comments powered by Disqus