12

The following snippet does what I want to an input, i.e., it removes all non-alphanumerical characters, converts to uppercase, and preserves the cursor position.

element = $(element);

element.keyup(function() {
    var x = element.val();
    var y = x && x.toUpperCase().replace(/[^A-Z\d]/g, '');
    if (x===y) return;
    var start = this.selectionStart;
    var end = this.selectionEnd + y.length - x.length;
    element.val(y);
    this.setSelectionRange(start, end);
});

I placed this snippet in the link of a directive and it works.... mostly.

The problem is that the angular model sees the value before the change gets applied. I tried to Google for how to use $apply or $digest or whatever here, but nothing worked.

(Actually, I somehow managed it, but then the content was re-rendered and I lost the position. I can't reproduce it, but it wasn't good enough, anyway.)

maaartinus
  • 44,714
  • 32
  • 161
  • 320

2 Answers2

21

A way of doing this where

  • The input is only cleaned once
  • ngChange on the input is then only fired once

is to use the $parsers array that the ngModelController provides. It's designed as a place to affect the model value (via its return value), but it also can be used as a listener to input events.

app.directive('cleanInput', function() {
  return {
    require: 'ngModel',
    link: function(scope, element, attrs, ngModelController) {
      var el = element[0];

      function clean(x) {
        return x && x.toUpperCase().replace(/[^A-Z\d]/g, '');
      }

      ngModelController.$parsers.push(function(val) {
        var cleaned = clean(val);

        // Avoid infinite loop of $setViewValue <-> $parsers
        if (cleaned === val) return val;

        var start = el.selectionStart;
        var end = el.selectionEnd + cleaned.length - val.length;

        // element.val(cleaned) does not behave with
        // repeated invalid elements
        ngModelController.$setViewValue(cleaned);
        ngModelController.$render();

        el.setSelectionRange(start, end);
        return cleaned;
      });
    }
  }
});

However, I'm not sure if this usage of $parsers is a bit of a hack. The directive can be used as:

<input type="text" clean-input ng-model="name">

or if you would like an ngChange function:

<input type="text" clean-input ng-model="name" ng-change="onChange()">

This can be seen in-action at http://plnkr.co/edit/dAJ46XmmC49wqTgdp2qz?p=preview

Michal Charemza
  • 25,940
  • 14
  • 98
  • 165
  • great solution, thanks! all the other ways I tried to set the caret position caused the caret jumping back and forth, and your solution works just perfect – trushkevich Mar 30 '16 at 17:56
  • @michal-charemza It is possible to maintain different modelValue and viewValue. For example, I want to format input on the fly like XXX-XXX-XXXX but the model should contains XXXXXXXXXX only. – Vivek Kumar Nov 04 '16 at 16:58
1

The main things that are needed are:

  • Require ngModelController to be able to call its methods and get/set its values. Specifically...

  • Replace the call element.val(y) with

    ngModelController.$setViewValue(y);
    ngModelController.$render();
    

    I think the I should admit, I'm not entirely sure on the inner workings of ngModelController to understand why this is necessary.

  • Optional, but getting the existing value in the view by element.val() can be instead done by:

    ngModelController.$viewValue;
    

    which is at least a more consistent with the way of setting the view value.

  • Again optional, but listening to the input event makes the interface a bit nicer, as it seems to fire a bit before the keyup event, so you don't get a flash of the unprocessed input.

  • Adding to the $parsers array to process the input seems to stop any ngChange callbacks being fired for the un-processed version of the input.

    ngModelController.$parsers.push(function(val) {
      // Return the processed value
    })
    

Putting all this together as a custom directive:

app.directive('cleanInput', function() {
  return {
    require: 'ngModel',
    link: function(scope, element, attrs, ngModelController) {
      function clean(x) {
        return x && x.toUpperCase().replace(/[^A-Z\d]/g, '');
      }

      ngModelController.$parsers.push(function(val) {
        return clean(val);
      })

      element.on('input', function() {
        var x = ngModelController.$viewValue;
        var y = clean(x);

        var start = this.selectionStart;
        var end = this.selectionEnd + y.length - x.length;

        ngModelController.$setViewValue(y);
        ngModelController.$render();
        this.setSelectionRange(start, end);
      });
    }
  }
});

which can be used as:

<input type="text" clean-input ng-model="name">

or if you would like an ngChange function:

<input type="text" clean-input ng-model="name ng-change="onChange()">

and seen in action at http://plnkr.co/edit/FymZ8QEKwj2xXTmaExrH?p=preview

Edit: add the part about $parsers array. I should admit, it was @Engineer's answer that made me think of it.

Michal Charemza
  • 25,940
  • 14
  • 98
  • 165
  • This seems to work fine, it just doesn't feel right: I need to clean it twice, once in `oninput` and once in `$parsers`. – maaartinus Apr 19 '14 at 21:54
  • I agree about not feeling right. Trying to convince myself/yourself that it's ok, I realised ngModelController controls/interacts with 2 things. The model value, and the view value. So then cleaning input once for each could be seen as ok. However, I have now thought of an alternative where it's cleaned once, but not sure if it's a bit of a back. Will post as a separate answer. – Michal Charemza Apr 20 '14 at 06:39