Skip to main content

Angular - How to create custom form controls

Greetings!

If you are working in an Angular project, you surely have created many UI components. We can easily use @Input, @Output to communicate data between parent to child and child to parent. However, we are unable to bind those values directly to angular forms. But, do we realy need to do it that way?

Yes! we need, utilizing the form modules is more powerful way to get user input. Then, can't we use our 'normal' components to do the job? No! we can't.

I was ashamed to know that I didn't know the existence of the ControlValueAccessor but better late than never ;)

Complete code custom-form-control

What is ControlValueAccessor?

When you are working with forms in Angular, whether it is template driven or reactive form, NgModel, formControl, formControlName, Angular creates FormControl to track the value, validations etc. Then Angular should have a way to communicate data between actual html element and the FormControl. Angular is smarter enough not to create one to one element mappers, instead it uses ControlValueAccessor.
Defines an interface that acts as a bridge between the Angular forms API and a native element in the DOM. (documentation)

interface ControlValueAccessor {
  writeValue(obj: any): void
  registerOnChange(fn: any): void
  registerOnTouched(fn: any): void
  setDisabledState(isDisabled: boolean)?: void
}
Angular has implemeted this interface for available elements. For example, for input text, text there is DefaultValueAccessor default_value_accessor

There are limited amount of html elements available. However, with modern rich user interfaces amount of elements we actually need is unlimited. This is where we use ControlValueAccessor.
Implement this interface to create a custom form control directive that integrates with Angular forms. (documentation)

writeValue

This method is called by the forms API to write to the view when programmatic changes from model to view are requested. Which means, we use the value passed to this method to update our view.

registerOnChange

Registers a callback function that is called when the control's value changes in the UI. This is a callback given to us so that when the value is changed in view, we use this callback to inform form control.

registerOnTouched

Registers a callback function that is called by the forms API on initialization to update the form model on blur. That is, we need to use this callback function to inform the form contol about when our view is started to change. This is needed to handle error styling.

setDisabledState

Enable, disable the element based on the value passed.

Let's create a JIRA like input field

When you open a ticket in JIRA it doesnt show you any edit button. Instead you can click on the text to make it editable. It also gives you save/cancel buttons to choose whether to save or cancel. Let's build this element in Angular! (my css is poor, hence i'll skip styling).

View Mode

Edit Mode

This is how we want to use our custom control.
<mat-card class="example-input">
  <mat-card-content>
    <form [formGroup]="form" (ngSubmit)="onSubmit()">
      <app-view-edit-input formControlName="title" veTitle="Title"></app-view-edit-input>
      <app-view-edit-input formControlName="summary" veTitle="Summary"></app-view-edit-input>
    </form>
  </mat-card-content>
  {{ form.value | json }}
</mat-card>
As you can see, we are using formControlName as a normal input element. If you do like this without implementing ControlValueAccessor, it will give you an error.

This is our component's template.

<div class="example-container">
  <mat-label class="label">{{ veTitle }}</mat-label>
  <mat-form-field appearance="outline" (click)="startEdit()">
    <input matInput [formControl]="viewEdit" required autocomplete="off"/>
  </mat-form-field>
  <ng-container *ngIf="editing">
    <div class="buttons">
      <span></span>
      <button mat-icon-button color="primary" (click)="onSave()">
        <mat-icon>done</mat-icon>
      </button>
      <button mat-icon-button color="primary" (click)="onCancel()">
        <mat-icon>cancel</mat-icon>
      </button>
    </div>
  </ng-container>
</div>

It is just normal template html. We have an input field and two buttons. What I want to do is, bind this input fields value to parent formControl when save button is clicked. Reset the value the cancel button is clicked. I also want to accept values when initializing this (remember, this is default to view mode). Buttons are visible only when clicked on the input field. (best is to remove styling in view mode, which my poor css didnt help)

Implement the ControlValueAccessor

When we implement the interface methods, we will think that Angular will handle the rest of the work. There is one missing part. Still, Angular doesn't know about our custrom control. Hence we need provide it to existing value accessor list. The way to do is add providers array in component.


  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: ViewEditInputComponent
    }
  ]

Note multi: true here as key NG_VALUE_ACCESSOR has multiple values including Angular's ControlValueAccessor implementations and our custom controls.

As a normal component, we use @Input to get initial value and value for the title.

  @Input('value') originalText = '';
  @Input('veTitle') veTitle = '';

As registerOnChange and registerOnTouched give us callback functions, we save it in variables.

  _onChange = (value: string) => {};
  _onTouched = () => {};

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

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

When there is a value initially or programatically change the value, form API will call writeValue with new value. We need to update our template's input field with this value. We also keep the reference for edit mode.

  writeValue(value: string): void {
    this.originalText = value;
    this.viewEdit.setValue(this.originalText);
  }

With that forms API to custom control synchronization is completed. Now we want to update forms value with custom controls' value. As we want to do this only when the save button is clicked, we will call the callback handler inside save function.

  onSave() {
    this._onChange(this.originalText);
  }

This is the complete code for the custom component.

import { Component, Input, OnInit } from '@angular/core';
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR } from '@angular/forms';

@Component({
  selector: 'app-view-edit-input',
  templateUrl: './view-edit-input.component.html',
  styleUrls: ['./view-edit-input.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      multi: true,
      useExisting: ViewEditInputComponent
    }
  ]
})
export class ViewEditInputComponent implements ControlValueAccessor, OnInit {

  @Input('value') originalText = '';
  @Input('veTitle') veTitle = '';

  viewEdit = new FormControl('');
  editing = false;

  _onChange = (value: string) => {};
  _onTouched = () => {};
  disabled = false;

  constructor() {
  }

  ngOnInit(): void {}

  writeValue(value: string): void {
    this.originalText = value;
    this.viewEdit.setValue(this.originalText);
  }

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

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

  setDisabledState(disabled: boolean): void {
    this.disabled = disabled;
    if (disabled) {
      this.viewEdit.disable({ onlySelf: true, emitEvent: false });
    }
  }

  startEdit() {
    if (!this.disabled) {
      this.editing = true;
      this._onTouched();
    }
  }

  onSave() {
    this.originalText = this.viewEdit.value;
    this.editing = false;
    this._onChange(this.originalText);
  }

  onCancel() {
    this.viewEdit.setValue(this.originalText);
    this.editing = false;
  }
}

This is fully Angular form compatible custom control. We can use validations as well. However, we can further improve this by adding inbuilt validations for the control itself.

References


Happy coding ;)

Comments