Revolutionize Your Angular Forms: Mastering Custom Inputs with and without Reactive Form Control

Revolutionize Your Angular Forms: Mastering Custom Inputs with and without Reactive Form Control

Table of contents

Custom inputs can be a powerful tool in Angular applications, allowing you to create unique user interfaces and tailor the user experience to your specific needs. With the @angular/forms package, you can create custom inputs that work seamlessly with the built-in form control functionality in Angular. In this guide, we will walk you through the process of creating custom inputs with and without form control, providing you with the knowledge you need to take your Angular applications to the next level. We'll cover everything from the basics of creating custom inputs to more advanced techniques for creating complex interfaces that are easy to use and maintain. By the end of this guide, you'll have a solid foundation in custom input development in Angular and be able to create custom inputs that meet your specific needs.

Reactive forms in Angular offer a more robust way to build complex and dynamic forms. It uses the power of observables to manage the state of form data and track changes in real time. In this post, we will take a look at how to build a reactive form with input fields in Angular.

Using ReactiveFormsModule to build a basic reactive form can be done by:

import { FormGroup, FormControl, Validators } from '@angular/forms';

In the FormComponent class, create a new form group by adding the following code:

formGroup: FormGroup;

Then, in the constructor() method, create a new form group, and add form controls to it as follows:

constructor(private formBuilder: FormBuilder) {
  this.formGroup = this.formBuilder.group({
    name: ['', Validators.required],
    email: ['', [Validators.required, Validators.email]]
  });
}

In the above code, we are creating a form group with four form controls: nameand email. We have also added some validators to each control to ensure that the user enters valid data.

Next, let's bind our form controls to the input fields in the template. Open the form.component.html file and add the following code:

<form [formGroup]="formGroup" (ngSubmit)="onSubmit()">
  <label>
    Name:
    <input type="text" formControlName="name">
  </label>
  <label>
    Email:
    <input type="email" formControlName="email">
  </label>
  <button type="submit" [disabled]="!formGroup.valid">Submit</button>
</form>

Create the onSubmit() method to handle the form submission.

onSubmit() {
  if (this.formGroup.valid) {
    // Handle form submission
    console.log('Form submitted successfully');
    console.log(this.formGroup.value);
  } else {
    // Display error message
    console.log('Form validation failed');
  }
}

We would be creating a tailwind css enabled Input with mat-icon prefix and suffix when the input is of type password.

<label>
    <p 
    class="font-medium text-primaryText pb-1 text-sm {{labelClass }}"
        >
            {{ label }}
        </p>
        <div class="relative {{ wrapperClass }}">
            <div
              class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"
                *ngIf="icon">
         <mat-icon class="w-5 h-5 text-gray-500">{{ icon }}</mat-icon>
            </div>
            <input
                name="{{ name }}"
                type="{{
            type == 'text' ? type : showPassword ? 'text' :'password'
                }}"
                (input)="
            onChange($any($event.target).value);getChangeEvent($event)
                "
                (blur)="onTouched(); blur.emit()"
                [disabled]="disabled"
                [placeholder]="placeholder"
                class="{{
                    inputClass
                }} w-full py-3 border placeholder:text-sm text-primaryText border-border rounded-lg focus:outline-none focus:border-slate-500 hover:shadow"
                [ngClass]="{
                    '!border-critical !border':
                        formControls?.touched && !formControls?.valid,
                    'pl-10 pr-3': icon,
                    'pl-3 pr-10': type == 'password',
                    'px-3': !icon
                }"
                [value]="formControls?.value || value"
            />
            <div
             class="absolute inset-y-0 right-0 flex items-center pr-3"
                *ngIf="type == 'password'" >
                <mat-icon
                    (click)="togglePasswordVisibility()"
                    class="w-5 h-5 cursor-pointer text-primaryText"
                    >
                    {{
                       !showPassword ? "visibility_off" : "visibility"
                    }}
                </mat-icon>
            </div>
        </div>
    </label>
    <div
        *ngIf="formControls?.invalid && formControls?.touched"
        class="text-critical mt-1 italic text-xs"
    >
        This field is required!
    </div>

To enable the easy customization of the Input we have extended come class names that can be passed in as props from the component where it would be used.

  • label: This binding is used to display the label text for the input field. It is of type string and is passed as an input to the component.

  • name: This binding is used to set the name attribute of the input field. It is of type string and is passed as an input to the component.

  • placeholder: This binding is used to set the placeholder text for the input field. It is of type string and is passed as an input to the component.

  • type: This binding is used to set the type of the input field. It is of type string and is passed as an input to the component. The default value is password but it can be set to text to display a regular text input field.

  • icon: This binding is used to display an icon on the left-hand side of the input field. It is of type string and is passed as an input to the component.

  • value: This binding is used to set the initial value of the input field. It is of type string and is passed as an input to the component.

  • disabled: This binding is used to disable the input field. It is of type boolean and is passed as an input to the component.

  • wrapperClass: This binding is used to set the class for the input field wrapper. It is of type string and is passed as an input to the component.

  • inputClass: This binding is used to set the class for the input field. It is of type string and is passed as an input to the component.

  • labelClass: This binding is used to set the class for the label of the input field. It is of type string and is passed as an input to the component.

  • onChange: This binding is used to emit an event when the value of the input field changes. It is of type EventEmitter and is passed as an output from the component.

  • onTouched: This binding is used to emit an event when the input field loses focus. It is of type EventEmitter and is passed as an output from the component.

  • blur: This binding is used to emit an event when the input field loses focus. It is of type EventEmitter and is passed as an output from the component.

  • formControls: This binding is used to access the form control for the input field. It is of type AbstractControl and is passed as an input to the component.

First, we create out togglePasswordVisibility function in the component.ts

showPassword: boolean = false;

togglePasswordVisibility() {
        this.showPassword = !this.showPassword;
}

Then we proceed to the constructor.

  • Self: This decorator tells Angular to look for dependencies only in the local injector.

  • Optional: This decorator indicates that the dependency is optional.

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

Constructor:

The constructor of the InputComponent class takes an optional NgControl argument. The @Self() decorator tells Angular to look for the NgControl dependency only in the local injector, and the @Optional() decorator indicates that the dependency is optional.

In the constructor, if the NgControl argument is provided, the component's value accessor is set to this instance of the InputComponent. The value accessor is responsible for reading and writing the value of the input field.

Then we proceed to mount the component after injecting the dependencies.

 ngOnInit(): void {
        if (!this.controlDir) {
            this.writeValue(this.value);
        } else {
            const control = this.controlDir.control;
            const validators = control?.validator
                ? [control.validator, Validators.required]
                : Validators.required;
            control?.setValidators(validators);
        }
}

writeValue(value: any): void {
   if (this.controlDir) {
      value &&
      this.controlDir.control?.setValue(value, { emitEvent: false });
   }
}

Lifecycle hooks:

The ngOnInit() method is a lifecycle hook that is called after the component's inputs have been initialized. In this method, the writeValue() method is called if there is no NgControl directive, and the Validators.required validator is added if there is a NgControl directive.

The writeValue() method is responsible for writing the initial value of the input field. If there is an NgControl directive.

get formControls() {
        if (!this.controlDir) {
            return null;
        }
        return this.controlDir.control;
    }

The get formControls returns the form control.

 onChange: (value: any) => void = () => {};
 onTouched: () => void = () => {};

onChange and onTouched are callback functions that are used to interact with Angular's form control directives, specifically when creating custom form controls that integrate with Angular's FormControl API.

  • onChange is a function that will be called whenever the value of the custom form control changes. It takes in a parameter value of type any, which represents the new value of the control. If no value is passed in, the default value of an empty function is assigned using the syntax = () => {}.

  • onTouched is a function that will be called when the custom form control is blurred, indicating that the user has interacted with the control and then moved away from it. It takes in no parameters and, like onChange, has a default value of an empty function if no value is passed in.

By setting these functions as class properties, they can be used to interact with Angular's form control directives and provide custom behavior for the custom form control. For example, when the user interacts with the custom form control, the onChange function can be called to update the value of the control, and when the control is blurred, the onTouched function can be called to update the control's touched state.

registerOnChange(onChange: (value: any) => void): void {
        this.onChange = onChange;
}

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

These are two methods required by Angular's ControlValueAccessor interface which is used to create a custom form control that can be used with Angular's reactive forms.

The registerOnChange method registers a callback function that should be called by the custom form control whenever its value changes. The callback function takes a single argument, which is the new value of the custom form control. In this component, the onChange method is set to the function passed in as an argument to registerOnChange. Whenever the custom form control's value changes, this method will be called with the new value.

The registerOnTouched method registers a callback function that should be called by the custom form control whenever it is touched by the user. In this component, the onTouched method is set to the function passed in as an argument to registerOnTouched. Whenever the user interacts with the custom form control, this method will be called to signal that the control has been touched.

These methods are necessary for the custom form control to communicate with Angular's reactive forms, and are used to update the model value and the control's validity status.

Conclusion:

Our component.ts should look like this.

import {
    Component,
    EventEmitter,
    Input,
    Output,
    Self,
    Optional,
} from "@angular/core";
import { NgControl, Validators } from "@angular/forms";

@Component({
    selector: "app-input",
    templateUrl: "./input.component.html",
    styleUrls: ["./input.component.scss"],
    providers: [],
})
export class InputComponent {
    @Input() placeholder: string = "";
    @Input() wrapperClass: string = "";
    @Input() inputClass: string = "";
    @Input() labelClass: string = "";
    @Input() icon: string | undefined;
    @Input() name: string = "";
    @Input() type: string = "text";
    @Input() label: string = "";
    @Input() value: string = "";
    @Output() blur: EventEmitter<void> = new EventEmitter<void>();
    @Output() onChangeEvent: EventEmitter<string> = new EventEmitter<string>();
    @Input() disabled!: boolean;

    showPassword: boolean = false;

    onChange: (value: any) => void = () => {};
    onTouched: () => void = () => {};

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

    ngOnInit(): void {
        if (!this.controlDir) {
            this.writeValue(this.value);
        } else {
            const control = this.controlDir.control;
            const validators = control?.validator
                ? [control.validator, Validators.required]
                : Validators.required;
            control?.setValidators(validators);
        }
    }

    writeValue(value: any): void {
        if (this.controlDir) {
            value &&
                this.controlDir.control?.setValue(value, { emitEvent: false });
        }
    }

    registerOnChange(onChange: (value: any) => void): void {
        this.onChange = onChange;
    }

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

    getChangeEvent(event: Event) {
        let value = (event.target as HTMLInputElement).value;
        this.onChangeEvent.emit(value);
    }

    togglePasswordVisibility() {
        this.showPassword = !this.showPassword;
    }

    get formControls() {
        if (!this.controlDir) {
            return null;
        }
        return this.controlDir.control;
    }
}

Usage:

<form [formGroup]="form" (submit)="onSubmit()" novalidate>
       <div>
         <app-input
            formControlName="email"
            placeholder="e.g example@mail.com"
            label="Email address"
         ></app-input>
        </div>
        <div>
          <app-input
            formControlName="password"
            placeholder="*********"
            label="Enter password"
            [type]="'password'"
          ></app-input>
        </div>
    <button>Submit</button>
</form>

Or without form control like this

 <app-input
        placeholder="e.g mail.com"
        label="Enter mail"
        icon="mail"
        (onChangeEvent)="getEvent($event)"
 ></app-input>