Skip to content

Instantly share code, notes, and snippets.

@gund
Last active March 20, 2024 09:21
Show Gist options
  • Star 11 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save gund/46b5f30865f7ee0d62daa4f848a9f3fc to your computer and use it in GitHub Desktop.
Save gund/46b5f30865f7ee0d62daa4f848a9f3fc to your computer and use it in GitHub Desktop.
Simple Angular mask directive

Simple Angular mask directive

This directive does not create it's own value accessor - it simply reuses whatever element is using already and just hooks in.

Also it is fully abstracted off of the HTML implementation and so can be safely used in WebWorker and server side environment.

Usage

<input type="text" name="masked-value" ngModel appMask="000-AAA-0">

Implementation

mask.directive.ts

import { Directive, Injector, Input, OnInit } from '@angular/core';
import { NgControl } from '@angular/forms';

import { valueToFormat, unmaskValue } from './mask';

@Directive({
  selector: '[appMask]',
})
export class MaskDirective implements OnInit {

  @Input() appMask: string;

  private control: NgControl;
  private _lastMaskedValue = '';

  constructor(
    private injector: Injector,
  ) { }

  ngOnInit() {
    this.control = this.injector.get(NgControl);

    if (!this.control || !this.control.valueAccessor) {
      return;
    }

    const originalWriteVal = this.control.valueAccessor.writeValue.bind(this.control.valueAccessor);
    this.control.valueAccessor.writeValue = (val: any) => originalWriteVal(this._maskValue(val));

    const originalChange = (<any>this.control.valueAccessor)['onChange'].bind(this.control.valueAccessor);
    this.control.valueAccessor.registerOnChange((val: any) => originalChange(this._unmaskValue(val)));

    this._setVal(this._maskValue(this.control.value));
  }

  private _maskValue(val: string): string {
    if (!this.appMask || val === this._lastMaskedValue) {
      return val;
    }

    const maskedVal = this._lastMaskedValue =
      valueToFormat(
        val,
        this.appMask, this._lastMaskedValue.length > val.length,
        this._lastMaskedValue);

    return maskedVal;
  }

  private _unmaskValue(val: string): string {
    const maskedVal = this._maskValue(val);
    const unmaskedVal = unmaskValue(maskedVal);

    if (maskedVal !== val) {
      this._setVal(maskedVal);
    }

    return maskedVal ? unmaskedVal : '';
  }

  private _setVal(val: string) {
    if (this.control.control) {
      this.control.control.setValue(val, { emitEvent: false });
    }
  }

}

mask.ts

import { StringHashMap } from '../../core/facade/types';

const _formatToRegExp: StringHashMap<RegExp> = {
  '0': /[0-9]/, 'a': /[a-z]/, 'A': /[A-Z]/, 'B': /[a-zA-Z]/,
};

const _allFormatsStr = '(' +
  Object.keys(_formatToRegExp)
    .map(key => _formatToRegExp[key].toString())
    .map(regexStr => regexStr.substr(1, regexStr.length - 2))
    .join('|')
  + ')';

const _allFormatsGlobal = getAllFormatRegexp('g');

/**
 * Apply format to a value string
 *
 * Format can be constructed from next symbols:
 *  - '0': /[0-9]/,
 *  - 'a': /[a-z]/,
 *  - 'A': /[A-Z]/,
 *  - 'B': /[a-zA-Z]/
 *
 * Example: 'AAA-00BB-aaaa'
 * will accept 'COD-12Rt-efww'
 *
 * @param value Current value
 * @param format Format
 * @param goingBack Indicates if change was done by BackSpace
 * @param prevValue Pass to precisely detect formatter chars
 */
export function valueToFormat(value: string, format: string, goingBack = false, prevValue?: string): string {
  let maskedValue = '';
  const unmaskedValue = unmaskValue(value);
  const isLastCharFormatter = !getAllFormatRegexp().test(value[value.length - 1]);
  const isPrevLastCharFormatter = prevValue && !getAllFormatRegexp().test(prevValue[prevValue.length - 1]);

  let formatOffset = 0;
  for (let i = 0, maxI = Math.min(unmaskedValue.length, format.length); i < maxI; ++i) {
    const valueChar = unmaskedValue[i];
    let formatChar = format[formatOffset + i];
    let formatRegex = getFormatRegexp(formatChar);

    if (formatChar && !formatRegex) {
      maskedValue += formatChar;
      formatChar = format[++formatOffset + i];
      formatRegex = getFormatRegexp(formatChar);
    }

    if (valueChar && formatRegex) {
      if (formatRegex.test(valueChar)) {
        maskedValue += valueChar;
      } else {
        break;
      }
    }

    const nextFormatChar = format[formatOffset + i + 1];
    const nextFormatRegex = getFormatRegexp(nextFormatChar);
    const isLastIteration = i === maxI - 1;

    if (isLastIteration && nextFormatChar && !nextFormatRegex) {
      if (!isLastCharFormatter && goingBack) {
        if (prevValue && !isPrevLastCharFormatter) {
          continue;
        }
        maskedValue = maskedValue.substr(0, formatOffset + i);
      } else {
        maskedValue += nextFormatChar;
      }
    }
  }

  return maskedValue;
}

export function unmaskValue(value: string): string {
  const unmaskedMathes = value.match(_allFormatsGlobal);
  return unmaskedMathes ? unmaskedMathes.join('') : '';
}

function getAllFormatRegexp(flags?: string) {
  return new RegExp(_allFormatsStr, flags);
}

function getFormatRegexp(formatChar: string): RegExp | null {
  return formatChar && _formatToRegExp[formatChar] ? _formatToRegExp[formatChar] : null;
}
@ygordanniel
Copy link

ygordanniel commented Jun 20, 2018

I'm getting the following error:

ConfirmationPage.html:61 ERROR RangeError: Maximum call stack size exceeded
    at String.match (<anonymous>)
    at unmaskValue (mask.ts:74)
    at MaskDirective.Array.concat.MaskDirective._unmaskValue (mask.directive.ts:55)
    at TextInput._onChanged (mask.directive.ts:31)
    at TextInput.BaseInput.onChange (base-input.js:217)
    at TextInput._onChanged (mask.directive.ts:31)
    at TextInput.BaseInput.onChange (base-input.js:217)
    at TextInput._onChanged (mask.directive.ts:31)
    at TextInput.BaseInput.onChange (base-input.js:217)
    at TextInput._onChanged (mask.directive.ts:31)

Any idea why? I Just copied you code and added it into my app.
I'm using Ionic 4.7.0 and Angular 5.2.10

@ParatechX
Copy link

Thanks for posting this! Was able to modify it slightly and use it in Ionic. Will post the final version once I polish/test it a bit more.

@HudsonAfonso
Copy link

Thanks for posting this! Was able to modify it slightly and use it in Ionic. Will post the final version once I polish/test it a bit more.

news?

@JankiGohel-Inteloom
Copy link

@ParateckX would you mind posting the final version

@Scrini
Copy link

Scrini commented Apr 26, 2019

This is the inteface used by mask.ts

export interface StringHashMap<T> { [key: string]: T; }

@ekzGuille
Copy link

Yes.. still getting the same error

@rami-alloush
Copy link

My fork seems to be working fine with me. I modified the listening methodology and removed a lot of duplicate code.
https://gist.github.com/rami-alloush/3ee792fd0647b73de5f863a2719c78c6

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment