Decimal number is not a number!

You have an input field, where user can enter decimal values. Problem is, that some cultures use decimal point “.” and some uses decimal comma “,”. So what is a valid number in one culture, is not a number at all in a different culture. You can see the full list of differences on wikipedia.

There is no built-in validator for numbers in Angular, so we have built our own like this:

import { AbstractControl, ValidatorFn } from '@angular/forms';

export class NumberValidators {

  static isNumber(): ValidatorFn {
    return (c: AbstractControl): { [key: string]: boolean } | null => {
      if (isNaN(c.value)) {
        return { 'isNumber': true };
      }
      return null;
    };
  }
}

Can you see, where the problem is? It uses isNaN() to check if given value is number or not. But isNaN() does not accept number with decimal comma as valid.

isNaN('10.0');
// logs 'false'
isNaN('10,0')
// logs 'true'

As usual there are many ways how to do validate numeric values, I could rewrite the validator to allow both variants, but then I would have to handle both variants on server. Which I do not want. So the solution is to use a directive, which will automagically convert decimal comma to decimal point before validation kicks in.

The directive works like this:

  1. Subscribe to keydown and paste events
  2. Check if the new value contains decimal comma
  3. If yes, suppress the event and
  4. Replace comma with point
  5. Then triggers native DOM event again

I use customRenderer to dispatch native DOM events, which naturally bubbles up the DOM tree. Angular then reacts to that event and validation kicks in. Here is the source code.

import { AfterViewInit, Directive, ElementRef, OnDestroy } from '@angular/core';
import { CustomRenderer } from 'app/utils/custom-renderer';
import { replaceAll } from 'app/utils/replace-string';
import { fromEvent } from 'rxjs/observable/fromEvent';

/**
 * Detects when user press key "decimal point"
 * which depends on user's locale.
 * In some locales it prints decimal point "."
 * and in other it prints decimal comma ","
 * This directive forces that decimal comma is always translated to decimal point.
 */
// Full list of countries using either decimal point or comma can be found here https://en.wikipedia.org/wiki/Decimal_separator
@Directive({
  selector: '[decimalPoint]'
})
export class DecimalPointDirective implements AfterViewInit, OnDestroy {

  private input: HTMLInputElement;
  private alive = true;

  constructor(
    private el: ElementRef,
    private customRenderer: CustomRenderer
  ) {
  }

  ngAfterViewInit() {
    this.input = this.el.nativeElement;

    fromEvent(this.input, 'keydown')
    .takeWhile(() => this.alive)
    .subscribe((event: KeyboardEvent) => {
      if (event.key === ',') {
        event.preventDefault();
        event.stopPropagation();
        this.input.value = this.input.value + '.';
        this.triggerEvent('input');
      }
    });

    fromEvent(this.input, 'paste')
    .takeWhile(() => this.alive)
    .subscribe((event: Event) => {
      const pastedValue = <string>(<any>event).clipboardData.getData('text/plain');

      if (pastedValue.indexOf(',')) {
        event.preventDefault();
        event.stopPropagation();
        const fixedValue = replaceAll(pastedValue, ',', '.');
        this.input.value = fixedValue;
        this.triggerEvent('input');
      }
    });
  }

  // Trigger dom native event, which bubbles up as usual.
  private triggerEvent(eventName: string) {
    this.customRenderer.invokeElementMethod(this.el, 'dispatchEvent', [new CustomEvent(eventName, {bubbles: true})]);
  }

  ngOnDestroy() {
    this.alive = false;
  }
}

Example usage

<input type="number" formControlName="amount" decimalPoint>