0

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
    };

}]);
C1pher
  • 1,933
  • 6
  • 33
  • 52
Tantelope
  • 597
  • 1
  • 8
  • 24
  • That is, in fact, because you have 2 `ng-model`s. The second one - the one you are watching - is only updated from the model side (because the first one changes the model). It's not clear why you need to have a transcluded content - is it because you want to allow the user to template it? Then, what if the user adds 2 `` elements? You need to accurately define what this directive enables and what it doesn't – New Dev Jul 26 '15 at 06:34
  • I want this directive to essentially be a fancy wrapper for an input element. So if the input is an email field, a regular text field, has a placeholder or any other attributes of an input element, I don't want to disturb the default functionality of the input. What I do want, for example, is if the input is dirty or focused, to change the label colour. – Tantelope Jul 27 '15 at 17:16
  • Will this directive always be applied to an input field, or could be applied to some root node that contains (possibly more than 1) input field? And in any case, you should not be applied an `ng-model` unless you are implementing a custom input control – New Dev Jul 27 '15 at 17:23
  • Ideally I just want the directive to be applied directly to the input field. The current situation with the transclusion is the only way I've figured out how to keep the input intact. If I put the directive directly on the input, I'd have to somehow replace it with the template that includes the input itself, and I don't know if that's even allowed. – Tantelope Jul 28 '15 at 15:15

1 Answers1

2

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).

New Dev
  • 48,427
  • 12
  • 87
  • 129
  • Thank you. This provides a lot of insight into a lot of things I haven't considered, like the concept of the same directive name with a different priority. – Tantelope Jul 30 '15 at 16:47