32

I'm trying to create a directive that would allow an element to be defined as clickable or not, and would be defined like this:

<page is-clickable="true">
    transcluded elements...
</page>

I want the resulting HTML to be:

<page is-clickable="true" ng-click="onHandleClick()">
    transcluded elements...
</page>

My directive implementation looks like this:

app.directive('page', function() {
    return {
        restrict: 'E',
        template: '<div ng-transclude></div>',
        transclude: true,
        link: function(scope, element, attrs) {
            var isClickable = angular.isDefined(attrs.isClickable) && scope.$eval(attrs.isClickable) === true ? true : false;

            if (isClickable) {
                attrs.$set('ngClick', 'onHandleClick()');
            }

            scope.onHandleClick = function() {
                console.log('onHandleClick');
            };
        }
    };
});

I can see that after adding the new attribute, Angular does not know about the ng-click, so it is not firing. I tried adding a $compile after the attribute is set, but it causes an infinite link/compile loop.

I know I can just check inside the onHandleClick() function if the isClickable value is true, but I'm curious how one would go about doing this with dynamically adding an ng-click event because I may need to do with this with multiple other ng-* directives and I don't want to add unnecessary overhead. Any ideas?

DRiFTy
  • 11,269
  • 11
  • 61
  • 77
  • I'm just starting to learn angular, and from what I've learned adding stuff dynamically is more of a jQuery thing. I think you should use an alternative approach with a static template. I encountered the same situation yesterday and I just do an if else in the click handler and add a conditional ng-class for styling if the button is not clickable. I agree with angular that having a static template is easy to read and predict functionality. – Hans Mar 01 '14 at 16:13
  • Yeah I'm going to just check inside the click handler for `isClickable` value for the time being... I just feel like there must be a way to add this during the directive compile phase! No matter what I try in the compile function, Angular doesn't hook up the binding to the function. – DRiFTy Mar 01 '14 at 16:36
  • See my updated answer below for the solution I ended up using. – DRiFTy Mar 01 '14 at 17:48

7 Answers7

28

Better Solution (New):

After reading through the Angular docs I came across this:

You can specify template as a string representing the template or as a function which takes two arguments tElement and tAttrs (described in the compile function api below) and returns a string value representing the template.

So my new directive looks like this: (I believe this is the appropriate "Angular" way to go about this type of thing)

app.directive('page', function() {
    return {
        restrict: 'E',
        replace: true,
        template: function(tElement, tAttrs) {
            var isClickable = angular.isDefined(tAttrs.isClickable) && eval(tAttrs.isClickable) === true ? true : false;

            var clickAttr = isClickable ? 'ng-click="onHandleClick()"' : '';

            return '<div ' + clickAttr + ' ng-transclude></div>';
        },
        transclude: true,
        link: function(scope, element, attrs) {
            scope.onHandleClick = function() {
                console.log('onHandleClick');
            };
        }
    };
});

Notice the new template function. Now I am manipulating the template inside that function before it is compiled.

Alternative solution (Old):

Added replace: true to get rid of the infinite loop issue when recompiling the directive. And then in the link function I just recompile the element after adding the new attribute. One thing to note though, because I had an ng-transclude directive on my element, I needed to remove that so it doesn't try to transclude anything on the second compile, because there is nothing to transclude.

This is what my directive looks like now:

app.directive('page', function() {
    return {
        restrict: 'E',
        replace: true,
        template: '<div ng-transclude></div>',
        transclude: true,
        link: function(scope, element, attrs) {
            var isClickable = angular.isDefined(attrs.isClickable) && scope.$eval(attrs.isClickable) === true ? true : false;

            if (isClickable) {
                attrs.$set('ngClick', 'onHandleClick()');
                element.removeAttr('ng-transclude');
                $compile(element)(scope);
            }

            scope.onHandleClick = function() {
                console.log('onHandleClick');
            };
        }
    };
});

I don't think that recompiling the template a second time is ideal though, so I feel that there is still a way to do this before the template is compiled the first time.

Rubens Mariuzzo
  • 28,358
  • 27
  • 121
  • 148
DRiFTy
  • 11,269
  • 11
  • 61
  • 77
  • Good that you found a possibility, but i think that's quite messy. You should check out my updated answer. – Alp Mar 01 '14 at 17:04
  • I agree, and I'm not going to use this, but it does work at least. Still working on a better solution... – DRiFTy Mar 01 '14 at 17:09
  • That's cool (your new solution). So now the template is generated dynamically. But I kind of dislike that using templateUrl is no longer an option, in case the markup is more complex. – Hans Mar 01 '14 at 17:54
  • Yeah, I think this solution only pertains to inline templates... You could probably use `$http` to go get the template during the `compile` phase, manipulate it, and then use my alternative solution during the linking phase and `$compile` it again. Sounds ugly to just get rid of binding though haha. – DRiFTy Mar 01 '14 at 18:06
  • Your new approach is good. Although it has a little flaw (depending on your needs): It cannot change its clickable state dynamically. But if you don't need that feature, your solution is great! – Alp Mar 01 '14 at 22:36
  • Maybe I'm misunderstanding you, but clickable state could be easily added and controlled with a scope variable (as other answers have noted). It's no different than hardcoding a template with or without the `ng-click`. – DRiFTy Mar 01 '14 at 23:18
  • 4
    Another thing is that this will only work for an element but not attribute directive. – Archimedes Trajano Sep 03 '15 at 07:09
16

You could always just modify your ng-click to look like this:

ng-click="isClickable && someFunction()"

No custom directive required :)

Here is a JSFiddle demoing it: http://jsfiddle.net/robianmcd/5D4VR/

rob
  • 17,995
  • 12
  • 69
  • 94
  • That's pretty neat! How would you assign the isClickable property to each button? – Hans Mar 01 '14 at 17:35
  • @Hans isClickable would be a variable on the scope and you could manually add it or any other variable to any ng-click in your html. – rob Mar 01 '14 at 17:39
  • Although this works, it is not ideal because the `ng-click` directive is still being bound (and I'm trying to get rid of that, and declutter the bindings). See my updated answer for the correct way to solve my problem. – DRiFTy Mar 01 '14 at 17:41
  • In your fiddle, buttonIsClickable is a variable on the scope of myCtrl. So wouldn't buttonIsClickable be exposed to everything on the scope of myCtrl? Say you make another button, then you'll have to define two buttonIsClickable-like variables on the scope? – Hans Mar 01 '14 at 17:43
  • @Hans yeah if you wanted to control two buttons seperately you would use two variables. – rob Mar 01 '14 at 17:46
  • Thanks a lot @rob . Your solution is pretty simple and neat which suited my use case. – Gursharan Singh Aug 03 '18 at 06:52
3

Updated answer

"The Angular Way" would be no manual DOM manipulation at all. So, we need to get rid off adding and removing attributes.

DEMO

Change the template to:

template: '<div ng-click="onHandleClick()" ng-transclude></div>'

And in the directive check for the isClickable attribute to decide what to do when clicked:

    link: function(scope, element, attrs) {
        var isClickable = angular.isDefined(attrs.isClickable) && scope.$eval(attrs.isClickable) === true ? true : false;

        scope.onHandleClick = function() {
            if (!isClickable) return;
            console.log('onHandleClick');
        };
    }

You could also put the isClickable attribute in the directive scope so that it can dynamically change its behavior.

Old answer (wrong)

link is run after the template is compiled. Use controller for alterations on the template before compiling:

app.directive('page', function() {
    return {
        restrict: 'E',
        template: '<div ng-transclude></div>',
        transclude: true,
        controller: function(scope, element, attrs) {
            // your code
        }
    };
});
Community
  • 1
  • 1
Alp
  • 29,274
  • 27
  • 120
  • 198
  • Unfortunately this didn't work for me, good idea though. I have tried this in the `compile` function as well without luck... – DRiFTy Mar 01 '14 at 16:38
  • You are right. I created a new approach. Let me know how you like it. – Alp Mar 01 '14 at 17:03
  • Yeah that approach is my "fall back", and what I'm currently using as an alternative at the moment... I think there must be a way to manipulate the template in a directive before it is compiled the first time, don't you? – DRiFTy Mar 01 '14 at 17:08
  • I think the "fallback" has a huge advantage: no DOM manipulation. Altering the template is not what Angular is meant to be. Also see: http://stackoverflow.com/questions/14994391/how-do-i-think-in-angularjs-if-i-have-a-jquery-background – Alp Mar 01 '14 at 22:34
  • Don't get me wrong, in any normal situation I would (and should) use this approach. My question was about NOT using this though. I needed something slightly different to remove as many bindings as possible that weren't needed (I'm developing a complicated screen in a mobile app where performance was lacking). My example was very dumbed down. – DRiFTy Mar 01 '14 at 23:06
2

HTML

<div page is-clickable="true">hhhh</div>

JS

app.directive('page', function($compile) {
                return {
                    priority:1001, // compiles first
                    terminal:true, // prevent lower priority directives to compile after it
                    template: '<div ng-transclude></div>',
                    transclude: true,
                    compile: function(el,attr,transclude) {
                        el.removeAttr('page'); // necessary to avoid infinite compile loop
                        var contents = el.contents().remove();
                        var compiledContents;
                        return function(scope){
                            var isClickable = angular.isDefined(attr.isClickable)?scope.$eval(attr.isClickable):false;
                            if(isClickable){
                                el.attr('ng-click','onHandleClick()');
                                var fn = $compile(el);
                                fn(scope);
                                scope.onHandleClick = function() {
                                    console.log('onHandleClick');
                                };
                            }
                            if(!compiledContents) {
                                compiledContents = $compile(contents, transclude);
                            }
                            compiledContents(scope, function(clone, scope) {
                                el.append(clone); 
                            });

                        };
                    },
                    link:function(scope){

                    }


                };
            });

credit to Erstad.Stephen and Ilan Frumer

BTW with restrict: 'E' the browser crashed :(

Community
  • 1
  • 1
Whisher
  • 31,320
  • 32
  • 120
  • 201
1

This is my version of the @DiscGolfer solution where I added support for attributes as well.

.directive("page", function() {

  return {
    transclude: true,
    replace: true,
    template: function(tElement, tAttr) {

      var isClickable = angular.isDefined(tAttrs.isClickable) && eval(tAttrs.isClickable) === true ? true : false;

      if (isClickable) {
        tElement.attr("ng-click", "onHandleClick()");
      }
      tElement.attr("ng-transclude", "");
      if (tAttr.$attr.page === undefined) {
        return "<" + tElement[0].outerHTML.replace(/(^<\w+|\w+>$)/g, 'div') + ">";
      } else {
        tElement.removeAttr(tAttr.$attr.page);
        return tElement[0].outerHTML;
      }
    }

  };

A more generic and full sample is provided http://plnkr.co/edit/4PcMnpq59ebZr2VrOI07?p=preview

The only problem with this solution is that replace is deprecated in AngularJS.

Archimedes Trajano
  • 35,625
  • 19
  • 175
  • 265
0

I think it should be better like this:

app.directive('page', function() {
    return {
        restrict: 'E',
        template: '<div ng-transclude></div>',
        transclude: true,
        link: function(scope, element, attrs) {
            var isClickable = angular.isDefined(attrs.isClickable) && scope.$eval(attrs.isClickable) === true ? true : false;

            if (isClickable) {
                angular.element(element).on('click', scope.onHandleClick);
            }

            scope.onHandleClick = function() {
                console.log('onHandleClick');
            };
        }
    };
});
rneves
  • 2,013
  • 26
  • 35
-1
module.factory("ibDirectiveHelpers", ["ngClickDirective", function (ngClick) {
        return {
            click: function (scope, element, fn) {
                var attr = {ngClick: fn};
                ngClick[0].compile(element, attr)(scope, element, attr);
            }
        };
    }]);

use:

module.controller("demoController",["$scope","$element","ibDirectiveHelpers",function($scope,$element,ibDirectiveHelpers){

$scope.demoMethod=function(){console.log("demoMethod");};
ibDirectiveHelpers.click($scope,$element,"demoMethod()");//uses html notation
 //or
ibDirectiveHelpers.click($scope,$element,function(){$scope.demoMethod();});//uses inline notation
}]
Delagen
  • 5
  • 2