left-icon

AngularJS Succinctly®
by Frederik Dietz

Previous
Chapter

of
A
A
A

CHAPTER 3

Directives

Directives


Directives are one of the most powerful concepts in Angular since they let you create custom HTML elements specific to your application. This allows you to develop reusable components, which encapsulate complex DOM structures, style sheets, and even behavior.

Enabling/Disabling DOM Elements Conditionally

Problem

You wish to disable a button depending on a checkbox state.

Solution

Use the ng-disabled directive and bind its condition to the checkbox state:

<body ng-app>
  <label><input type="checkbox" ng-model="checked"/>Toggle Button</label>
  <button ng-disabled="checked">Press me</button>
</body>

You can find the complete example on GitHub.

Discussion

The ng-disabled directive is a direct translation from the disabled HTML attribute, without you needing to worry about browser incompatibilities. It is bound to the checked model using an attribute value as is the checkbox using the ng-model directive. In fact, the checked attribute value is again an Angular expression. You could, for example, invert the logic and use !checked instead.

This is just one example of a directive shipped with Angular. There are many others, for example, ng-hide, ng-checked, or ng-mouseenter. I encourage you to go through the Application Programming Interface (API) Reference  and explore all the directives Angular has to offer.

In the next recipes, we will focus on implementing directives.

Changing the DOM in Response to User Actions

Problem

You wish to change the CSS of an HTML element on a mouse click and encapsulate this behavior in a reusable component.

Solution

Implement a directive my-widget that contains an example paragraph of text you want to style:

<body ng-app="MyApp">
  <my-widget>
    <p>Hello World</p>
  </my-widget>
</body>

Use a link function in the directive implementation to change the CSS of the paragraph:

var app = angular.module("MyApp", []);

app.directive("myWidget", function() {
  var linkFunction = function(scope, element, attributes) {
    var paragraph = element.children()[0];
    $(paragraph).on("click", function() {
      $(this).css({ "background-color": "red" });
    });
  };

  return {
    restrict: "E",
    link: linkFunction
  };
});

When clicking on the paragraph, the background color changes to red.

You can find the complete example on GitHub.

Discussion

In the HTML document, use the new directive as an HTML element my-widget, which can be found in the JavaScript code as myWidget again. The directive function returns a restriction and a link function.

The restriction means that this directive can only be used as an HTML element and not, for example, an HTML attribute. If you want to use it as an HTML attribute, change the restrict to return A instead. The usage would then have to be adapted to:

<div my-widget>
  <p>Hello World</p>
</div>

Whether or not you use the attribute or element mechanism will depend on your use case. Generally speaking, one would use the element mechanism to define a custom reusable component. The attribute mechanism would be used whenever you want to configure some element or enhance it with more behavior. Other available options are using the directive as a class attribute or a comment.

The directive method expects a function that can be used for initialization and injection of dependencies:

app.directive("myWidget", function factory(injectables) {
  // ...
}

The link function is much more interesting since it defines the actual behavior. The scope, the actual HTML element my-widget, and the HTML attributes are passed as params. Note that this has nothing to do with Angular's dependency injection mechanism. Ordering of the parameters is important!

First, we select the paragraph element, which is a child of the my-widget element using Angular's children() function as defined by element. In the second step, we use jQuery to bind to the click event and modify the css property on click. This is of particular interest since we have a mixture of Angular element functions and jQuery here. In fact, under the hood Angular will use jQuery in the children() function if it is defined and will fall back to jqLite (shipped with Angular) otherwise. You can find all supported methods in the API Reference of element.

Following a slightly altered version of the code, using jQuery only:

element.on("click", function() {
  $(this).css({ "background-color": "red" });
});

In this case, element is already a jQuery element, and we can directly use the on function.

Rendering an HTML Snippet in a Directive

Problem

You wish to render an HTML snippet as a reusable component.

Solution

Implement a directive and use the template attribute to define the HTML:

<body ng-app="MyApp">
  <my-widget/>
</body>

var app = angular.module("MyApp", []);

app.directive("myWidget", function() {
  return {
    restrict: "E",
    template: "<p>Hello World</p>"
  };
});

You can find the complete example on GitHub.

Discussion

This will render the Hello World paragraph as a child node of your my-widget element. If you want to replace the element entirely with the paragraph, you will also have to return the replace attribute:

app.directive("myWidget", function() {
  return {
    restrict: "E",
    replace: true,
    template: "<p>Hello World</p>"
  };
});

Another option would be to use a file for the HTML snippet. In this case, you will need to use the templateUrl attribute, for example, as follows:

app.directive("myWidget", function() {
  return {
    restrict: "E",
    replace: true,
    templateUrl: "widget.html"
  };
});

The widget.html should reside in the same directory as the index.html file. This will only work if you use a web server to host the file. The example on GitHub uses angular-seed as a bootstrap again.

Rendering a Directive's DOM Node Children

Problem

Your widget uses the child nodes of the directive element to create a combined rendering.

Solution

Use the transclude attribute together with the ng-transclude directive:

<my-widget>
  <p>This is my paragraph text.</p>
</my-widget>

var app = angular.module("MyApp", []);

app.directive("myWidget", function() {
  return {
    restrict: "E",
    transclude: true,
    template: "<div ng-transclude><h3>Heading</h3></div>"
  };
});

This will render a div element containing an h3 element and append the directive's child node with the paragraph element below.

You can find the complete example on GitHub.

Discussion

In this context, transclusion refers to the inclusion of a part of a document into another document by reference. The ng-transclude attribute should be positioned depending on where you want your child nodes to be appended.

Passing Configuration Params Using HTML Attributes

Problem

You wish to pass a configuration param to change the rendered output.

Solution

Use the attribute-based directive and pass an attribute value for the configuration. The attribute is passed as a parameter to the link function:

<body ng-app="MyApp">
  <div my-widget="Hello World"></div>
</body>

var app = angular.module("MyApp", []);

app.directive("myWidget", function() {
  var linkFunction = function(scope, element, attributes) {
    scope.text = attributes["myWidget"];
  };

  return {
    restrict: "A",
    template: "<p>{{text}}</p>",
    link: linkFunction
  };
});

This renders a paragraph with the text passed as the param.

You can find the complete example on GitHub.

Discussion

The link function has access to the element and its attributes. It is, therefore, straightforward to set the scope to the text passed as the attributes value and use this in the template evaluation.

The scope context is important though. The text model we changed might already be defined in the parent scope and used in another part of your app. In order to isolate the context and thereby use it only locally inside your directive, we have to return an additional scope attribute:

return {
  restrict: "A",
  template: "<p>{{text}}</p>",
  link: linkFunction,
  scope: {}
};

In Angular, this is called an isolate scope. It does not prototypically inherit from the parent scope and is especially useful when creating reusable components.

Let's look at another way of passing params to the directive. This time we will define an HTML element my-widget2:

<my-widget2 text="Hello World"></my-widget2>

app.directive("myWidget2", function() {
  return {
    restrict: "E",
    template: "<p>{{text}}</p>",
    scope: {
      text: "@text"
    }
  };
});

The scope definition using @text is binding the text model to the directive's attribute. Note that any changes to the parent scope text will change the local scope text but not the other way around.

If you want instead to have a bi-directional binding between the parent scope and the local scope, you should use the = equality character:

scope: {
  text: "=text"
}

Changes to the local scope will also change the parent scope.

Another option would be to pass an expression as a function to the directive using the & character:

<my-widget-expr fn="count = count + 1"></my-widget-expr>

app.directive("myWidgetExpr", function() {
  var linkFunction = function(scope, element, attributes) {
    scope.text = scope.fn({ count: 5 });
  };

  return {
    restrict: "E",
    template: "<p>{{text}}</p>",
    link: linkFunction,
    scope: {
      fn: "&fn"
    }
  };
});

We pass the attribute fn to the directive and, since the local scope defines fn accordingly, we can call the function in the linkFunction and pass in the expression arguments as a hash.

Repeatedly Rendering Directive's DOM Node Children

Problem

You wish to render an HTML snippet repeatedly using the directive's child nodes as the -stamp content.

Solution

Implement a compile function in your directive:

<repeat-ntimes repeat="10">
  <h1>Header 1</h1>
  <p>This is the paragraph.</p>
</repeat-n-times>

var app = angular.module("MyApp", []);

app.directive("repeatNtimes", function() {
  return {
    restrict: "E",
    compile: function(tElement, attrs) {
      var content = tElement.children();
      for (var i=1; i<attrs.repeat; i++) {
        tElement.append(content.clone());
      }
    }
  };
});

This will render the header and paragraph 10 times.

You can find the complete example on GitHub.

Discussion

The directive repeats the child nodes as often as configured in the repeat attribute. It works similarly to the ng-repeat directive. The implementation uses Angular's element methods to append the child nodes in a loop.

Note that the compile method only has access to the templates element tElement and template attributes. It has no access to the scope, and you therefore can't use $watch to add behavior either. This is in comparison to the link function that has access to the DOM instance (after the compile phase) and has access to the scope to add behavior.

Use the compile function for template DOM manipulation only. Use the link function whenever you want to add behavior.

Note that you can use both compile and link function combined. In this case, the compile function must return the link function. As an example, you want to react to a click on the header:

compile: function(tElement, attrs) {
  var content = tElement.children();
  for (var i=1; i<attrs.repeat; i++) {
    tElement.append(content.clone());
  }

  return function (scope, element, attrs) {
    element.on("click", "h1", function() {
      $(this).css({ "background-color": "red" });
    });
  };
}

Clicking the header will change the background color to red.

Directive-to-Directive Communication

Problem

You wish a directive to communicate with another directive and augment each other's behavior using a well-definedAPI.

Solution

We implement a directive basket with a controller function and two other directives, orange and apple, which require this controller. Our example starts with an apple and orange directive used as attributes:

<body ng-app="MyApp">
  <basket apple orange>Roll over me and check the console!</basket>
</body>

The basket directive manages an array to which one can add apples and oranges:

var app = angular.module("MyApp", []);

app.directive("basket", function() {
  return {
    restrict: "E",
    controller: function($scope, $element, $attrs) {
      $scope.content = [];

      this.addApple = function() {
        $scope.content.push("apple");
      };

      this.addOrange = function() {
        $scope.content.push("orange");
      };
    },
    link: function(scope, element) {
      element.bind("mouseenter", function() {
        console.log(scope.content);
      });
    }
  };
});

And finally, the apple and orange directives, which add themselves to the basket using the basket's controller:

app.directive("apple", function() {
  return {
    require: "basket",
    link: function(scope, element, attrs, basketCtrl) {
      basketCtrl.addApple();
    }
  };
});

app.directive("orange", function() {
  return {
    require: "basket",
    link: function(scope, element, attrs, basketCtrl) {
      basketCtrl.addOrange();
    }
  };
});

If you hover with the mouse over the rendered text, the console should print and the basket's content as well.

You can find the complete example on GitHub.

Discussion

Basket is the example directive that demonstrates an API using the controller function, whereas the apple and orange directives augment the basket directive. They both define a dependency to the basket controller with the require attribute. The link function then gets basketCtrl injected.

Note how the basket directive is defined as an HTML element and the apple and orange directives are defined as HTML attributes (the default for directives). This demonstrates the typical use case of a reusable component augmented by other directives.

Now, there might be other ways of passing data back and forth between directives; we have seen the different semantics of using the (isolated) context in directives in previous recipes. But what's especially great about the controller is the clear API contract it lets you define.

Testing Directives

Problem

You wish to test your directive with a unit test. As an example, we will use a tab component directive implementation, which can easily be used in your HTML document:

<tabs>
  <pane title="First Tab">First pane.</pane>
  <pane title="Second Tab">Second pane.</pane>
</tabs>

The directive implementation is split into the tabs and the pane directive. Let us start with the tabs directive:

app.directive("tabs", function() {
  return {
    restrict: "E",
    transclude: true,
    scope: {},
    controller: function($scope, $element) {
      var panes = $scope.panes = [];

      $scope.select = function(pane) {
        angular.forEach(panes, function(pane) {
          pane.selected = false;
        });
        pane.selected = true;
        console.log("selected pane: ", pane.title);
      };

      this.addPane = function(pane) {
        if (!panes.length) $scope.select(pane);
        panes.push(pane);
      };
    },
    template:
      '<div class="tabbable">' +
        '<ul class="nav nav-tabs">' +
          '<li ng-repeat="pane in panes"' +
              'ng-class="{active:pane.selected}">'+
            '<a href="" ng-click="select(pane)">{{pane.title}}</a>' +
          '</li>' +
        '</ul>' +
        '<div class="tab-content" ng-transclude></div>' +
      '</div>',
    replace: true
  };
});

It manages a list of panes and the selected state of the panes. The template definition makes use of the selection to change the class and responds on the click event to change the selection.

The pane directive depends on the tabs directive to add itself to it:

app.directive("pane", function() {
  return {
    require: "^tabs",
    restrict: "E",
    transclude: true,
    scope: {
      title: "@"
    },
    link: function(scope, element, attrs, tabsCtrl) {
      tabsCtrl.addPane(scope);
    },
    template:
      '<div class="tab-pane" ng-class="{active: selected}"' +
        'ng-transclude></div>',
    replace: true
  };
});

Solution

Using the angular-seed in combination with jasmine and jasmine-jquery, you can implement a unit test:

describe('MyApp Tabs', function() {
  var elm, scope;

  beforeEach(module('MyApp'));

  beforeEach(inject(function($rootScope, $compile) {
    elm = angular.element(
      '<div>' +
        '<tabs>' +
          '<pane title="First Tab">' +
            'First content is {{first}}' +
          '</pane>' +
          '<pane title="Second Tab">' +
            'Second content is {{second}}' +
          '</pane>' +
        '</tabs>' +
      '</div>');

    scope = $rootScope;
    $compile(elm)(scope);
    scope.$digest();
  }));

  it('should create clickable titles', function() {
    console.log(elm.find('ul.nav-tabs'));
    var titles = elm.find('ul.nav-tabs li a');

    expect(titles.length).toBe(2);
    expect(titles.eq(0).text()).toBe('First Tab');
    expect(titles.eq(1).text()).toBe('Second Tab');
  });

  it('should set active class on title', function() {
    var titles = elm.find('ul.nav-tabs li');

    expect(titles.eq(0)).toHaveClass('active');
    expect(titles.eq(1)).not.toHaveClass('active');
  });

  it('should change active pane when title clicked', function() {
    var titles = elm.find('ul.nav-tabs li');
    var contents = elm.find('div.tab-content div.tab-pane');

    titles.eq(1).find('a').click();

    expect(titles.eq(0)).not.toHaveClass('active');
    expect(titles.eq(1)).toHaveClass('active');

    expect(contents.eq(0)).not.toHaveClass('active');
    expect(contents.eq(1)).toHaveClass('active');
  });
});

You can find the complete example on GitHub.

Discussion

Combining jasmine with jasmine-jquery gives you useful assertions like toHaveClass and actions like click, which are used extensively in the example above.

To prepare the template, we use $compile and $digest in the beforeEach function and then access the resulting Angular element in our tests.

The angular-seed project was slightly extended to add jquery and jasmine-jquery to the project.

The example code was extracted from Vojta Jina's GitHub example, the author of the awesome Testacular.

Scroll To Top
Disclaimer
DISCLAIMER: Web reader is currently in beta. Please report any issues through our support system. PDF and Kindle format files are also available for download.

Previous

Next



You are one step away from downloading ebooks from the Succinctly® series premier collection!
A confirmation has been sent to your email address. Please check and confirm your email subscription to complete the download.