How to Create Custom Form Control for the Phone Number Input Field Using Angular

Written by prantiksaha | Published 2023/07/20
Tech Story Tags: programming | angular | phone-number-input-field | custom-form-control | web-development | html | css | coding

TLDRAngular developers often find it difficult to integrate a phone number input field. You would not find any input field that can accept user data in the phone number format and validate the same. Therefore, you have to custom-make the form control for the phone number field and then embed intl-tel-input, synchronizing the entire operations. Here, I have proposed a solution to create a custom form control for the phone number input field using intl-tel-input that supports mat-form-field in Angular.via the TL;DR App

Angular developers often find it difficult to integrate a phone number input field.

You would not find any input field that can accept user data in the phone number format and validate the same. Therefore, you have to custom-make the form control for the phone number field and then embed intl-tel-input, synchronizing the entire operations.

Here, I have proposed a solution to create a custom form control for the phone number input field using intl-tel-input that supports mat-form-field in Angular.

Get Started With the Solution

The given solution implements a custom Angular component for a phone number input field that provides validation and formatting of international phone numbers using the intl-tel-input npm package. Also, it explains the feature inheritance from mat-form-field.

Here’s the basic text input field representation that accepts a user’s phone number. It has some animation.

Integrating the Validation Logic

Now, to add the validation logic for the user phone number, install the intl-tel-input npm package in your angular project.

Note: Here, the validation of the given phone number is based on the unique code of different countries.

Once integrated, the field looks something like this:

The text input field is wrapped using ‘Mat Form Field’. It has nothing to do with the phone number validation logic.

It complements the functionality of the custom phone number input component, providing a well-designed and user-friendly form field for phone number inputs.

How To Integrate the Input Field

First, install the package in your angular project using the below-given command.

npm i intl-tel-input

Intl-tel-input-  It is a JS plugin used to accept and validate international phone numbers. It adds the flag icon to a specific country and adds a placeholder for the users to add a phone number.

Note: We have set the showFlags option to false. You won’t notice the country flag icon in this current solution.

Once installed, add the following lines to the ‘angular.json’ file.

To reduce the development time, intl-tel-input is quite effective. It imports another library, google/libphonenumber, making it easier to embed the logic behind phone number validation. You don't have to put in extra effort and brainstorm its logic.

Refer to the highlighted section from the above image:

"./node_modules/intl-tel-input/build/css/intlTelInput.min.css"

"./node_modules/intl-tel-input/build/js/utils.js"

Here the code imports a style file (intlTelInput.min.css) from the npm package and the utils file (utils.js) containing the validations from google/libphonenumber.

Note: If you don’t add the following code snippet, the logic behind phone number validation won’t work. Hence, users will not receive any alerts, indicating the validity of their input.

Creating Module for the Custom Form Field

Next, create a module in your project for the custom form field using the ng command:

  1. Create a module using the command: ng generate module custom-modules/tel-input

  1. Run the given command on the project terminal to create a component for the module:  ng generate component custom-modules/tel-input/tel-input --flat

Once you are done, you will find the following files already created in your project.

The following given snippet is the content of the tel-input.module.ts file

 import { NgModule } from '@angular/core';

import { CommonModule } from '@angular/common';

import { TelInputComponent } from './tel-input.component';

@NgModule({

declarations: [

TelInputComponent

],

imports: [

CommonModule,

],

exports: [

TelInputComponent

]

})

export class TelInputModule { }

Here, it declares and exports the tel-input component  from the module.

Let's check what the html file of the project holds.

 <input

type="tel"

#telInput

(input)="onInput()"

(focus)="onFocus()"

(focusout)="onFocusOut($event)"

(countrychange)="onCountryChange()"

[disabled]="disabled"

[attr.aria-describedby]="userAriaDescribedBy"

[attr.aria-labelledby]="parentFormField.getLabelId()"

>

The snippet above defines an input element with an id #telInput to pass the element reference to create the intl-tel-input.

The #telInput allows the custom phone number input component to obtain a reference to this input element in the component's code. It includes event handlers to handle user input, focus, and country changes.

It also defines different functions to handle various user input, focus and country changes events.

Additionally, it supports the disabled state and has ARIA attributes for accessibility. When integrated with the custom phone number input component, it enables proper behavior and seamless interaction within the mat-form-field container.

What’s in the CSS File?

Next we have the css file with the following code syntax.

Make some small modifications, as shown in the snippet to adjust the package css and make it compatible with the ‘mat form field’.

span {

opacity: 0;

transition: opacity 200ms;

}

:host.floating span {

opacity: 1;

}

.iti {

width: 100%;

max-width: 100%;

}

input {

border: none;

}

::ng-deep .iti.iti--allow-dropdown input {

margin-left: 8px

}

::ng-deep .iti .iti__flag-container .iti__selected-flag, ::ng-deep .iti .iti__flag-container .iti__selected-flag:hover {

background-color: transparent;

}

::ng-deep .iti .iti__flag-container .iti__selected-flag {

padding-right: 6px;

border-right: 1px solid #e3e3e3;

height: 18px;

}

::ng-deep .iti .iti__flag-container .iti__selected-flag .iti_custom_arrow i {

margin-top: 4px;

margin-left: 4px;

font-size: 16px;

}

Finally, you have the component file.  It implements the ControlValueAccessor, Validator and MatFormFieldControl<T> interface.

The following customizations are specific to the phone number input field. This makes the mat-form-field work seamlessly with the custom form control that uses the intl-tel-input package.

import { BooleanInput, coerceBooleanProperty } from '@angular/cdk/coercion';

import { AfterViewInit, Component, ElementRef, EventEmitter, HostBinding, Input, OnDestroy, Optional, Output, Self, ViewChild } from '@angular/core';

import { AbstractControl, ControlValueAccessor, NgControl, ValidationErrors, Validator } from '@angular/forms';

import { MatFormField, MatFormFieldControl } from '@angular/material/form-field';

import * as intlTelInput from 'intl-tel-input';

import { Subject } from 'rxjs';

@Component({

selector: 'app-tel-input',

templateUrl: './tel-input.component.html',

styleUrls: ['./tel-input.component.css'],

providers: [

{

provide: MatFormFieldControl,

useExisting: TelInputComponent

}

],

})

export class TelInputComponent implements AfterViewInit, OnDestroy, ControlValueAccessor, Validator, MatFormFieldControl<string> {

@ViewChild('telInput') telInput!: ElementRef;

iti: intlTelInput.Plugin | undefined;

touched: boolean = false;

disabled = false;

@Output() inputEvent: EventEmitter<string> = new EventEmitter<string>();

@Output() countryChangeEvent: EventEmitter<intlTelInput.CountryData> = new EventEmitter<intlTelInput.CountryData>();

value: string = '';

stateChanges = new Subject<void>();

static nextId = 0;

@HostBinding() id = `app-tel-input-${TelInputComponent.nextId++}`;

focused: boolean = false;

controlType = 'app-tel-input';

@Input('aria-describedby') userAriaDescribedBy: string = '';

constructor(

@Optional() @Self() public ngControl: NgControl,

@Optional() public parentFormField: MatFormField,

) {

if (this.ngControl != null) {

// Setting the value accessor directly (instead of using

// the providers) to avoid running into a circular import.

this.ngControl.valueAccessor = this;

}

}

ngAfterViewInit(): void {

this.iti = intlTelInput(this.telInput.nativeElement, {

formatOnDisplay: true,

separateDialCode: true,

initialCountry: 'US',

showFlags: false,

utilsScript: 'assets/js/intl/utils.js'

} as any);

this.iti.promise.then(() => this.setupField());

}

setupField() {

// manually registering the validation function to overcome circular dependency for validator

this.ngControl.control?.setValidators([this.validate.bind(this)]);

this.ngControl.control?.updateValueAndValidity();

this.replaceSelecArrowDesign();

this.iti?.setNumber(this.value);

this.updateValue();

}

replaceSelecArrowDesign() {

const arrowElement = document.querySelector('.iti .iti__flag-container .iti__selected-flag .iti__arrow');

if (arrowElement) {

const element = document.createElement('span');

element.setAttribute('class', 'iti_custom_arrow');

element.innerHTML = '<i class="material-icons">keyboard_arrow_down</i>';

arrowElement.replaceWith(element);

}

}

onInput() {

this.updateValue();

this.inputEvent.emit(this.iti?.getNumber());

}

onFocus() {

if (!this.focused) {

this.focused = true;

this.stateChanges.next();

}

}

onFocusOut(event: FocusEvent) {

if (!this.telInput.nativeElement.contains(event.relatedTarget as Element)) {

this.focused = false;

this.markAsTouched();

this.stateChanges.next();

}

}

onCountryChange() {

this.updateValue();

this.countryChangeEvent.emit(this.iti?.getSelectedCountryData());

}

updateValue() {

if (this.iti) {

this.iti.setNumber(this.iti.getNumber());

this.value = this.iti.getNumber();

this.onChange(this.iti.getNumber());

this.stateChanges.next();

}

}

writeValue(phnNumber: string) {

this.value = phnNumber;

if (this.iti) {

this.iti.setNumber(phnNumber);

}

}

onChange = (phnNumber: string) => {};

registerOnChange(onChange: any) {

this.onChange = onChange;

}

onTouched = () => {};

registerOnTouched(onTouched: any) {

this.onTouched = onTouched;

}

markAsTouched() {

if (!this.touched) {

this.onTouched();

this.touched = true;

}

}

setDisabledState(disabled: boolean) {

this.disabled = disabled;

this.stateChanges.next();

}

validate(control: AbstractControl): ValidationErrors | null {

if (this.iti) {

if (!this.iti.isValidNumber()) {

return {

invalidNumber: {

reason: this.iti?.getValidationError()

}

};

} else {

return null;

}

} else {

return null;

}

}

@Input()

get placeholder() {

return this._placeholder;

}

set placeholder(plh) {

this._placeholder = plh;

this.stateChanges.next();

}

private _placeholder: string = '';

get empty() {

let n = '';

if (this.iti) {

n = this.iti.getNumber();

}

return n.length===0;

}

@HostBinding('class.floating')

get shouldLabelFloat() {

// return this.focused || !this.empty;

return true; // Label will always float as placeholder is dynamically shown by intl-tel-input based on country code

}

@Input()

get required(): boolean {

return this._required;

}

set required(req: BooleanInput) {

this._required = coerceBooleanProperty(req);

this.stateChanges.next();

}

private _required = false;

get errorState(): boolean {

return !this.iti?.isValidNumber() && this.touched;

}

setDescribedByIds(ids: string[]) {

const controlElement = this.telInput?.nativeElement.querySelector('.app-tel-input-container');

controlElement?.setAttribute('aria-describedby', ids.join(' '));

}

onContainerClick(event: MouseEvent) {

if ((event.target as Element).tagName.toLowerCase() != 'input') {

this.telInput.nativeElement.focus();

}

}

ngOnDestroy(): void {

this.iti?.destroy();

this.stateChanges.complete();

}

}

Once you have written the logic, import the built custom module and use the custom form control <app-tel-input> in the following way.

<mat-form-field appearance="outline" class="input-default w-100">

<mat-label>Phone Number</mat-label>

<app-tel-input formControlName="phoneNumber" required></app-tel-input>

<mat-error *ngIf="Step1.controls.phoneNumber.errors" class="mt-3">

Please enter a valid phone number

</mat-error>

</mat-form-field>

Note: You can specify any other name to this built custom form control. Here, I have named it as ‘app-tel-input’.

To Brief

The given solution explains how to create a custom form control for a phone number input field in Angular. It uses the intl-tel-input library. It also uses the mat-form-field from Angular Material,  providing a well-designed form field.

Moreover, the custom field supports validation and seamlessly integrates with Angular's form control ecosystem.

In any related scenario, you can use this custom form control where you may need to integrate the phone number input field.


Written by prantiksaha | I am passionate about adding new custom features to website and web app.
Published by HackerNoon on 2023/07/20