Customizing an Angular Material directive by adding commas to digit groups in matInput numbers and support reactive Angular forms.

commified numbers

AngularInDepth is moving away from Medium. More recent articles are hosted on the new platform inDepth.dev. Thanks for being part of indepth movement!

Every Angular developer knows Angular Material (You don't know it? Then definitely take a look). Like many other UI libraries it provides a set of form controls and among them matInput.

It is a regular text input with the possibility to display placeholder in a nice way, to add a clear button, provide custom error messages, text length hint, and even suffixes and prefixes to improve user experience. You can look at examples here.

The problem

The task I got from a customer was to display input values in digit groups for account amount form field, like this:

Commified input

Surprisingly searching though the internet didn't provide some solid solutions so I decided to create my own one. I found only two things that may help:

This interesting paragraph is on the matInput API page:

MAT_INPUT_VALUE_ACCESSOR This token is used to inject the object whose value should be set into MatInput . If none is provided, the native HTMLInputElement is used. Directives like MatDatepickerInput can provide themselves for this token, in order to make MatInput delegate the getting and setting of the value to them

Angular Material’s MatDatePicker expects to be applied on top of matInput so we can use its code as an example of extending matInput.

Solution

What can we take from matDatePicker code (and not only) for our goals?:

1. It redefines MAT_INPUT_VALUE_ACCESSOR provider so we can display some text value in the DOM input field (since we need digit grouping), but return numeric value for matInput validation.

2. To apply our formatting on Angular FormControl operations (like set value to form field) we also have to implement the ControlValueAccessor interface (you can read more custom form controls in Angular here).

3. And a few more things we need to implement:

onBlur input field event should format value

onFocus event should unformat the value so that we can edit it as a regular number

onInput event should send a numeric representation of value with _onChange handler (ControlValueAccessor implementation)

Since we’re going to display commas, the input type should be set as text (not a number) but we have to make validation work (for example, min and max value)

Before we start, let's create the reactive Angular form where we are going to use our form control directive:

<!-- app.component.html -->

<form [formGroup]="myForm">

<mat-form-field appearance="outline">

<mat-label>Deposit Amount</mat-label>

<input matInput <- Material input

matInputCommified <--our directive

formControlName="deposit"

type="text"/>

</mat-form-field>

</form>

<button [disabled]="myForm.invalid"

mat-raised-button

color="primary"

(click)="onSubmit()"

>Submit</button> // app.component.ts

export class AppComponent {

title = 'ngx-material-tools-demo';



constructor(private formBuilder: FormBuilder) {

}



myForm = this.formBuilder.group({

deposit: ['', [

Validators.required, // Validators

Validators.min(1),

Validators.max(1000000)

]],

});



onSubmit() {

// some operations here

}

}

Supporting the MAT_INPUT_ACCESSOR API

If we take a look at MAT_INPUT_ACCESSOR, it requires a very simple interface:

export declare const MAT_INPUT_VALUE_ACCESSOR: InjectionToken<{

value: any;

}>;

If we declare a MAT_INPUT_VALUE_ACCESSOR provider the matInput directive will start working with DOM input element through our directive. In fact, it will think that our directive instance is a DOM input element which has a value property.

Why do we need it? Because when we set a value we need to apply formatting with commas to separate digit groups. When we get the value we have to remove commas and return a number to the matInput instance to support Angular validators could work correctly.

So all we have to do is to provide value as both setter and getter. Let's do this:



//mat-input-commified.directive.ts

import {Directive, ElementRef, forwardRef, HostListener, Input} from '@angular/core';

import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material';

import {NG_VALUE_ACCESSOR} from '@angular/forms';

import {numberWithCommas} from './helpers';



@Directive({

selector: 'input[matInputCommified]',

providers: [

{provide: MAT_INPUT_VALUE_ACCESSOR, useExisting: MatInputCommifiedDirective}

})

export class MatInputCommifiedDirective {



private _value: string | null;



constructor(private elementRef: ElementRef<HTMLInputElement>,

) {}





get value(): string | null {

return this._value;

}



@Input('value')

set value(value: string | null) {

this._value = value;

this.formatValue(value);

}



private formatValue(value: string | null) {

if (value !== null) {

this.elementRef.nativeElement.value = numberWithCommas(value);

} else {

this.elementRef.nativeElement.value = '';

}

}





}

You may think that setter here works even if we assign formControl value but no:

this.myForm.get('deposit').setValue(10) // setter is not called ... <input matInput <- Material input

matInputCommified

formControlName="deposit"

[value]="10" // setter is called in that case

type="text"/>

The setter is called only when we assign some value with [value] property binding for the matInput directive (you can check matInput source code here and run reproduce here — thanks to Alexey Zuev for providing that info!).

To make formatting work even for formControl.setValue call we have to implement the CONTROL_VALUE_ACCESSOR interface (we will do that later).

Adding onBlur, onFocus, and onInput

OK, now we have to implement the remaining behavior:

Format on input blur event (when we finish editing). Unformat text on focus event (when we start editing we don't need commas). On text typing (onInput event), we should take the input field value and save it to the internal this._value property without any non-number symbols (only 0–9, period and minus are allowed).

Let's do that:

import {Directive, ElementRef, forwardRef, HostListener, Input} from '@angular/core';

import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material';

import {NG_VALUE_ACCESSOR} from '@angular/forms';

import {numberWithCommas} from './helpers';



@Directive({

selector: 'input[matInputCommified]',

providers: [

{provide: MAT_INPUT_VALUE_ACCESSOR, useExisting: MatInputCommifiedDirective}

]

})

export class MatInputCommifiedDirective {

// tslint:disable-next-line:variable-name

private _value: string | null;



constructor(private elementRef: ElementRef<HTMLInputElement>,

) {

console.log('created directive');

}





get value(): string | null {

return this._value;

}



@Input('value')

set value(value: string | null) {

this._value = value;

this.formatValue(value);

}



private formatValue(value: string | null) {

if (value !== null) {

this.elementRef.nativeElement.value = numberWithCommas(value);

} else {

this.elementRef.nativeElement.value = '';

}

}



private unFormatValue() {

const value = this.elementRef.nativeElement.value;

this._value = value.replace(/[^\d.-]/g, '');

if (value) {

this.elementRef.nativeElement.value = this._value;

} else {

this.elementRef.nativeElement.value = '';

}

}



@HostListener('input', ['$event.target.value'])

onInput(value) {

// here we cut any non numerical symbols

this._value = value.replace(/[^\d.-]/g, '');

}



@HostListener('blur')

_onBlur() {

this.formatValue(this._value); // add commas

}



@HostListener('focus')

onFocus() {

this.unFormatValue(); // remove commas for editing purpose

}



}

Ok, let's check how it works now:

Nice!

Now let's implement the CONTROL_VALUE_ACCESSOR interface to format the value even if we assign it with the FormControl#setValue method.

Adding CONTROL_VALUE_ACCESSOR

In the official Angular docs we see that this interface is quite simple:

I will not discuss the details of implementing a custom reactive Angular form control implementations — you can read about that here.

All we have to do is to define our writeValue method (it should apply formatting on value assignment) and register our directive as a NG_VALUE_ACCESSOR Angular provider. Here’s how it will look:

import {Directive, ElementRef, forwardRef, HostListener, Input} from '@angular/core';

import {MAT_INPUT_VALUE_ACCESSOR} from '@angular/material';

import {NG_VALUE_ACCESSOR} from '@angular/forms';

import {numberWithCommas} from './helpers';



@Directive({

selector: 'input[matInputCommified]',

providers: [

{provide: MAT_INPUT_VALUE_ACCESSOR, useExisting: MatInputCommifiedDirective},

{

provide: NG_VALUE_ACCESSOR,

useExisting: forwardRef(() => MatInputCommifiedDirective),

multi: true,

}

]

})

export class MatInputCommifiedDirective {

// tslint:disable-next-line:variable-name

private _value: string | null;



constructor(private elementRef: ElementRef<HTMLInputElement>,

) {

console.log('created directive');

}





get value(): string | null {

return this._value;

}



@Input('value')

set value(value: string | null) {

this._value = value;

this.formatValue(value);

}



private formatValue(value: string | null) {

if (value !== null) {

this.elementRef.nativeElement.value = numberWithCommas(value);

} else {

this.elementRef.nativeElement.value = '';

}

}



private unFormatValue() {

const value = this.elementRef.nativeElement.value;

this._value = value.replace(/[^\d.-]/g, '');

if (value) {

this.elementRef.nativeElement.value = this._value;

} else {

this.elementRef.nativeElement.value = '';

}

}



@HostListener('input', ['$event.target.value'])

onInput(value) {

this._value = value.replace(/[^\d.-]/g, '');

this._onChange(this._value); // here to notify Angular Validators

}



@HostListener('blur')

_onBlur() {

this.formatValue(this._value);

}



@HostListener('focus')

onFocus() {

this.unFormatValue();

}



_onChange(value: any): void {

}



writeValue(value: any) {

this._value = value;

this.formatValue(this._value); // format Value

}



registerOnChange(fn: (value: any) => void) {

this._onChange = fn;

}



registerOnTouched() {

}



}

The code is quite self-explanatory but please note that to make Angular run validators against the entered value we should run the registered _onChange handler every time the value is changed. So we call it inside onInput method.

Let's check how our directive works now:

LGTM!

Publishing matInputCommified directive in the ngx-material-tools library

To be able to use our directive in other projects I've published it as part of the ngx-material-tools package.

You can use it this way:

npm i matInputCommified

.... // in app.module.ts

...

imports: [

BrowserModule,

BrowserAnimationsModule,

MatCardModule,

MatInputModule,

MatFormFieldModule,

MatButtonModule,

NgxMaterialToolsModule, // <-- here is our module

ReactiveFormsModule

],

... <!-- app.component.html -->

<form [formGroup]="myForm" style="margin-top: 20px">

<mat-form-field appearance="outline">

<mat-label>Deposit Amount</mat-label>

<input matInput

matInputCommified

formControlName="deposit"

type="text"/>

</mat-form-field>

</form>

You can check how it works in this playground. Voila!

Did you like this article? Clap 🤓

Further reading

Conclusion

Hope someone read this text at the end of the article — if you’re one of those peeps then Merry Christmas to you and happy holidays!🎅

Special thanks to Alexey Zuev and Lars Gyrup Brink Nielsen for helping with and reviewing the article!