Search code examples
angularjsangularjs-directiveangularjs-ng-transclude

Watching for changes in $pristine or $touched in transcluded input


I'm trying to build a directive around an input element that responds when the model is dirtied or touched. The required ngModel seems to reflect the changes in the value and view of the input model, but none of the other attributes.

I'm suspecting it has something to do with the fact that I'm including ng-model in two elements, but I haven't figured out how to use it just once.

Ideally, I would like something that is created like this:

<input test-directive label="'My Label'" type="text" ng-model="testObject.text"/>

And results in something like:

<label>
    <div>My Label</div>
    <input ng-model="testObject.text" ng-blur="input.focus=false" ng-focus="input.focus=true"/>
    Focused: true (input.focus)
    Pristine: false (ngModel.$pristine)
</label>

Here is what I have so far: fiddle

<div test-directive ng-model="testObject.text" l="'Test Input'" f="testObject.focus">
    <input type="text" ng-model="testObject.text" ng-blur="testObject.focus=false" ng-focus="testObject.focus=true" />
</div>

The directive watches ngModel.

app.directive('testDirective', ['$compile',
    function ($compile) {
    'use strict';
    return {
        restrict: 'A',
    require: "ngModel",
    scope: {
        l: '=',
        f: '='
    },
    link: function (scope, element, attr, ngModel) {
        var input = element.find('input');
        scope.$watch(function () {
            return ngModel;
        }, function (modelView) {
            scope.modelView = modelView
        });
    },
    template:
        '<div>' +

        '<label>' +
        '{{l}}' +
        '<div class="iwc-input" ng-transclude></div>' +
        '</label>' +
        'focus: {{f}}' +
        '<pre>{{modelView|json}}</pre>' +
        '</div>',
    transclude: true,
    replace: false
    };

}]);

Solution

  • I found that in Angular it is rather complex to have a directive "self-wrap", while still having other directives work properly alongside it. So, the answer below works, and I will try to explain why it is more complicated than it ought to be.

    There are a number of ways to approach this. I will use the approach with transclude: "element" - this transcludes the entire element and allows you to place it anywhere (including wrapping).

    .directive("wrapper", function($compile){
      return {
        scope: { l: "@" },
        transclude: "element",
        require: ["ngModel"],
        link: function(scope, element, attrs, ctrls, transclude)
          scope.ngModel = ctrls[0];
    
          // transclude: "element" ignores the template property, so compile it manually
          var template = '<label ng-class="{dirty: ngModel.$dirty}">{{l}}: \
                            <placeholder></placeholder>\
                          </label>';
    
          $compile(template)(scope, function(prelinkedTemplate){        
             transclude(function (clonedElement){
                prelinkedTemplate.find("placeholder").replaceWith(clonedElement);
    
                // element here is only a comment after transclusion
                // so, need to use .after() - not .append()
                element.after(prelinkedTemplate);
             });
          })
      }
    })
    

    So, the above compiles the template and links against the isolate scope (where $scope.l and $scope.ngModel are available), and then trascludes the element and replaces the <placeholder>.

    That should have been enough, but there is a problem. When Angular compiled our directive, the element has been transcluded and is now a comment <!-- wrapper -->, and not <input> - this is what ngModel directive "sees" in its prelink function, so things start to break.

    To fix, our directive needs to have a higher priority than ngModel (which is 1), and in fact, higher priority than ngAttributeDirective (which is 100) for things like ng-maxlength to work. But if we did that, then we could not just require: "ngModel", since it would not yet be available at our priority level.

    One way to fix this is to make 2 passes - one with higher priority and one with lower. The lower priority pass will "hang" the captured ngModel controller on to the directive's controller. Here's how:

    // first pass
    app.directive("wrapper", function($compile) {
      return {
        priority: 101,
        scope: {
          l: "@"
        },
        transclude: "element",
        controller: angular.noop, // just a noop controller to attach properties to
        controllerAs: "ctrl", // expose controller properties as "ctrl"
        link: function(scope, element, attrs, ctrls, transclude) {
    
          // notice the change to "ctrl.ngModel"
          var template = '<label ng-class="{dirty: ctrl.ngModel.$dirty}">{{l}}: \
                            <placeholder></placeholder>\
                          </label>';
    
          $compile(template)(scope, function(prelinkedTemplate) {
            transclude(function(clonedElement) {
              prelinkedTemplate.find("placeholder").replaceWith(clonedElement);
              element.after(prelinkedTemplate);
            });
          });
        }
      };
    })
    
    // second pass
    .directive("wrapper", function($compile) {
      return {
        priority: -1,
        require: ["wrapper", "ngModel"],
        link: function(scope, element, attrs, ctrls, transclude) {
          var wrapperCtrl = ctrls[0],
              ngModel = ctrls[1];
    
          // "hang" ngModel as a property of the controller
          wrapperCtrl.ngModel = ngModel;
        }
      };
    });
    

    Demo

    There are other approaches as well. For example, we could have made this directive with very high priority (say, priority: 10000) and terminal: true. Then, we could then take the element, wrap it, apply another directive that has require: "ngModel" to actually keep track of $pristine, $touched, etc... and recompile the contents (not forgetting to remove the original directive to avoid an infinite loop).