Creating a Custom Directive with AngularJS:

I am going to start with a simple example to demonstrate how custom directives are created, just to outline the basic features and to set the scene for later examples in the chapter. My initial goal will be to create and apply a directive that will generate an ul element that contains an li element for each object in the products array. I’ll walk through the process step-by-step in the sections that follow.

1. Defining the Directive

Directives are created using the Module.directive method, and the arguments are the name of the new directive and a factory function that creates the directive. In Listing 15-2, you can see how I have added a directive called unorderedList to the directives.html file. This directive doesn’t do anything at the moment, but it allows me to explain some important points as I build the functionality in the sections that follow.

Listing 15-2. Adding a Directive to the directives.html File

<script>

angular.module(“exampleApp”, [])

.directive(“unorderedList”, function () {

return function (scope, element, attrs) {

// implementation code will go here

}

};

.controller(“defaultCtrl”, function ($scope) {

$scope.products = [

{ name: “Apples”, category: “Fruit”, price: 1.20, expiry: 10 },

{ name: “Bananas”, category: “Fruit”, price: 2.42, expiry: 7 },

{ name: “Pears”, category: “Fruit”, price: 2.02, expiry: 6 }

];

})

</script>

Tip I have defined the directive before the controller in the listing, but that is not a requirement, and in larger projects you would generally define directives in one or more separate files, much as I did for the SportsStore application in Chapters 6-8.

The first argument that I passed to the directive method set the name of the new directive to unorderedList. Notice that I have used the standard JavaScript case convention, meaning that the u of unordered is lowercase and the L of list is uppercase. AngularJS is particular when it comes to names with mixed capitalization. You can see what I mean in Listing 15-3, which shows how I have applied the unorderedList directive to an HTML element.

Listing 15-3. Applying a Custom Directive in the directives.html File

<body ng-controller=”defaultCtrl”>

<div class=”panel panel-default”>

<div class=”panel-heading”>

<h3>Products</h3>

</div>

<div class=”panel-body”>

<div unordered-list=”products”x/div>

</div>

</div>

</body>

I have applied the directive as an attribute on a div element, but notice how the name of the attribute is different from the argument I passed to the directive method: unordered-list instead of unorderedList. Each uppercase letter in the argument passed to the method is treated as a separate word in the attribute name, where each word is separated by a hyphen.

Tip I have applied the directive as an attribute in this example, but in Chapter 16 I show you how to create and apply elements that can be used as HTML elements, as values for the class attributes, and even as comments.

I have set the value of the attribute to the name of the array whose objects I want to list, which is products in this case. Directives are intended to be reusable within and across applications, so you avoid creating hardwired dependencies, including references to data created by specific controllers.

2. Implementing the Link Function

The worker function in the directive I created is called the link junction, and it provides the means to link the directive with the HTML in the document and the data in the scope. (There is another kind of function associated with directives, called the compile junction, which I describe in Chapter 17.)

The link function is invoked when AngularJS sets up each instance of the directive and receives three arguments: the scope for the view in which the directive has been applied, the HTML element that the directive has been applied to, and the attributes of that HTML element. The convention is to define the link function with arguments called scope, element, and attrs. In the sections that follow, I’ll walk through the process of implementing the link function for my example directive.

Tip The scope, element, and attrs arguments are regular JavaScript arguments and are not provided via dependency injection. This means the order in which the objects are passed to the link function is fixed.

2.1. Getting Data from the Scope

The first step I need to take to implement my custom directive is to get the data I am going to display from the scope. Unlike AngularJS controllers, directives don’t declare a dependency on the $scope service; instead, they are passed the scope created by the controller that supports the view in which the directive is applied. This is important because it allows a single directive to be applied multiple times in an application, where each application may be operating on a different scope in the hierarchy (I explained the scope hierarchy in Chapter 13).

In Listing 15-3, I applied my custom directive to a div element as an attribute and used the attribute value to specify the name of the array in the scope that I wanted to process, like this:

<div unordered-list=”products”></div>

To get the data from the scope, I need first to get the value of the attribute. The third argument to the link function is a collection of attributes, indexed by name. There is no special support for getting the name of the attribute used to apply the directive, which means I use the incantation in Listing 15-4 to get the data from the scope.

Listing 15-4. Getting the Data from the Scope in the directives.html File

angular.module(“exampleApp”, [])

.directive(“unorderedList”, function () {

return function (scope, element, attrs) {

vax data = scope[attxs[“unoxdexedList”]];

if (angular.isArray(data)) {

fox (vax i = 0; i < data.length; i++) {

console.log(“Item: ” + data[i].name);

}

}

}

})

I get the value associated with the unorderedList key from the attrs collection and then pass the result to the scope object to get the data, like this:

var data = scope[attrs[“unorderedList”]];

Tip Notice that I use unorderedList to get the value of the unordered-list attribute. AngularJS automatically maps between the two naming formats. The form unorderedList is an example of a normalized name and is used because of the different ways in which directives can be applied to HTML.

Once I get the data, I use the angular.isArray method to check that I really am working with an array and then use a for loop to write the name property of each object to the console. (This would be a poor design in a real project because it assumes that all of the objects that the directive will process have a name attribute, which hampers reuse. I’ll show you how to be more flexible in the “Evaluating Expressions” section.) If you load the directives.html file into the browser, you’ll see the following output in the JavaScript console:

Item: Apples

Item: Bananas

Item: Pears

2.2. Generating the HTML Elements

The next step is to generate the elements I need from the data objects. AngularJS includes a cut-down version of jQuery called jqLite. It doesn’t have all of the features of jQuery, but it has sufficient functionality for working with directives. I describe jqLite in detail in the “Working with jqLite” section later in the chapter, but for now I am just going to show you the changes I need to make for my custom directive, as shown in Listing 15-5.

Listing 15-5. Generating Elements in a Custom Directive in the directives.html File

angular.module(“exampleApp”, [])

.directive(“unorderedList”, function () {

return function (scope, element, attrs) {

var data = scope[attrs[“unorderedList”]];

if (angular.isArray(data)) {

var listElem = angulax.element(“<ul>”);

element.append(listElem);

fox (var i = 0; i < data.length; i++) {

listElem.append(angulax.element(‘<li>’).text(data[i].name));

}

}

}

})

The jqLite functionality is exposed through the element argument that is passed to the link function. First, I call the angular.element method to create a new element and use the append method on the element argument to insert the new element into the document, like this:

var listElem = angular.element(“<ul>”);

element.append(listElem);

The result from most jqLite methods is another object that provides access to the jqLite functionality, much as the methods in the full jQuery library return jQuery objects. AngularJS doesn’t expose the DOM API provided by the browser, and any time you are working with elements, you can expect to receive a jqLite object. I’ll refer to the results that jqLite methods return as jqLite objects.

If you don’t have a jqLite object but need one—because you want to create a new element, for example—then you can use the angular.element method, like this:

angular.element(‘<li>’).text(data[i].name)

Both approaches return jqLite objects, which you can then use to call other jqLite methods, a technique known as method chaining. I included an example of method chaining in the example when I create the li elements and then call the text method to set their contents, like this:

angular.element(‘<li>’).text(data[i].name)

A library that provides support for method chaining is said to provide a fluent API, and jQuery, from which jqLite is derived, is one of the most widely used fluent APIs.

Tip You will recognize the purpose and nature of the jqLite methods if you have used jQuery, but don’t worry if they don’t make sense. I’ll introduce jqLite properly in the “Working with jqLite” section shortly.

The result of my jqLite additions is that my custom directive will add an ul element to the element to which it has been applied—the div in this case—and create a nested li element for each object in the data array obtained from the scope, as shown in Figure 15-2.

3. Breaking the Data Property Dependency

My custom directive works, but it has a dependency on the objects in the array that it uses to generate list items:

It assumes that they have a name property. This kind of dependency ties the directive to a specific set of data objects and means that it can’t be used elsewhere in the application or in other applications. There are several ways that I can address this, which I describe in the following sections.

3.1. Adding a Support Attribute

The first approach is the simplest and requires defining an attribute that specifies the property whose values will be displayed in the li items. This is easy to do, because the link function is passed a collection of all the attributes that have been defined on the element to which the directive has been applied. In Listing 15-6, you can see how I have added support for a list-property attribute.

Listing 15-6. Adding Support for a Directive Attribute in the directives.html File

<html ng-app=”exampleApp”>

<head>

<title>Directives</title>

<script src=”angular.js”></script>

<link href=”bootstrap.css” rel=”stylesheet” />

<link href=”bootstrap-theme.css” rel=”stylesheet” />

<script>

angular.module(“exampleApp”, [])

.directive(“unorderedList”, function () {

return function (scope, element, attrs) {

var data = scope[attrs[“unorderedList”]];

var propertyName = attrs[“listProperty”];

if (angular.isArray(data)) {

var listElem = angular.element(“<ul>”);

element.append(listElem);

for (var i = 0; i < data.length; i++) {

listElem.append(angular.element(‘<li>’)

•text(data[i][propertyName]))j

}

}

}

})

.controller(“defaultCtrl”, function ($scope) {

$scope.products = [

{ name: “Apples”, category: “Fruit”, price: 1.20, expiry: 10 },

{ name: “Bananas”, category: “Fruit”, price: 2.42, expiry: 7 },

{ name: “Pears”, category: “Fruit”, price: 2.02, expiry: 6 }

];

})

</script>

</head>

<body ng-controller=”defaultCtrl”>

<div class=”panel panel-default”>

<div class=”panel-heading”>

<h3>Products</h3>

</div>

<div class=”panel-body”>

<div unordered-list=”products” list-property=”name”></div>

</div>

</div>

</body>

</html>

I obtain the value of the list-property attribute through the attrs argument to the link function, using the key listProperty. Once again, AngularJS has normalized the attribute name. I use the value of the listProperty attribute to obtain a value from each of the data objects, like this:

listElem.append(angular.element(‘<li>’).text(data[i][propertyName]));

Tip If you prefix your property name with data-, then AngularJS will remove the prefix when it builds the set of attributes passed to the link function. This means, for example, that an attribute of data-list-property and list-property will both be presented as listProperty when the name is normalized and passed to the link function.

3.2. Evaluating Expressions

Adding another attribute has helped, but I still have some problems. For example, consider the effect of the change in Listing 15-7, in which I apply a filter to the property I want to display.

Listing 15-7. Adding a Filter to the Attribute Value in the directives.html File

<body ng-controller=”defaultCtrl”>

<div class=”panel panel-default”>

<div class=”panel-heading”>

<h3>Products</h3>

</div>

<div class=”panel-body”>

<div unordered-list=”products” list-property=”price \ currency”></div>

</div>

</div>

</body>

This change breaks my custom directive because I read the value of the attribute and use the value as the name of the property I am going to display in each li element I generate. The solution to this problem is to have the scope evaluate the attribute value as an expression, which is done through the scope.$eval method, the arguments to which are the expression to evaluate and any local data required to perform the evaluation. In Listing 15-8, you can see how I have used $eval to support the kind of expression shown in Listing 15-7.

Listing 15-8. Evaluating Expressions in the directives.html File

angular.module(“exampleApp”, [])

.directive(“unorderedList”, function () {

return function (scope, element, attrs) {

var data = scope[attrs[“unorderedList”]];

var propertyExpression = attrs[“listProperty”];

if (angular.isArray(data)) {

var listElem = angular.element(“<ul>”);

element.append(listElem);

for (var i = 0; i < data.length; i++) {

listElem.append(angular.element(‘<li>’)

.text(scope.$eval(propertyExpression, data[i])));

}

}

}

})

I obtain the value of the listProperty attribute, which gives me the string that I need to evaluate as an expression. When I create the li elements, I call the $eval method on the scope argument passed to the link function, passing in the expression and the current data object as a source for the properties required to evaluate the expression. AngularJS takes care of the rest, and you can see the effect in Figure 15-3, which illustrates how the li elements contain the value of the price property for each data object, formatted by the currency filter.

4. Handling Data Changes

The next feature I am going to add to this introductory directive is the ability to respond to data changes in the scope. At the moment, the contents of the li elements are set when the HTML page is processed by AngularJS and don’t automatically update when the underlying data values change. In Listing 15-9, you can see the changes that I have made to the directives.html file to change the price properties of the product objects.

Tip I am going to break down the process of handling changes because I want to demonstrate a common problem that arises between AngularJS and JavaScript in directives and explain the solution.

Listing 15-9. Changing Values in the directives.html File

<html ng-app=”exampleApp”>

<head>

<title>Directives</title>

<script src=”angular.js”></script>

<link href=”bootstrap.css” rel=”stylesheet” />

<link href=”bootstrap-theme.css” rel=”stylesheet” />

<script>

angular.module(“exampleApp”, [])

.directive(“unorderedList”, function () {

return function (scope, element, attrs) {

var data = scope[attrs[“unorderedList”]];

var propertyExpression = attrs[“listProperty”];

if (angular.isArray(data)) {

var listElem = angular.element(“<ul>”); element.append(listElem);

for (var i = 0; i < data.length; i++) {

listElem.append(angular.element(‘<li>’)

.text(scope.$eval(propertyExpression, data[i])));

}

}

}

})

.controller(“defaultCtrl”, function ($scope) {

$scope.products = [

{ name: “Apples”, category: “Fruit”, price: 1.20, expiry: 10 },

{ name: “Bananas”, category: “Fruit”, price: 2.42, expiry: 7 },

{ name: “Pears”, category: “Fruit”, price: 2.02, expiry: 6 }

];

$scope.incrementPrices = function () {

for (var i = 0; i < $scope.products.length; i++) {

$scope.products[i].price++;

}

}

})

</script>

</head>

<body ng-controller=”defaultCtrl”>

<div class=”panel panel-default”>

<div class=”panel-heading”>

<h3>Products</h3>

</div>

<div class=”panel-body”>

<button class=”btn btn-primary” ng-click=”incrementPrices()”>

Change Prices

</button>

</div>

<div class=”panel-body”>

<div unordered-list=”products” list-property=”price | currency”></div>

</div>

</div>

</body>

</html>

I have added a button and applied the ng-click directive so that the incrementPrices controller behavior is invoked. This behavior is pretty simple; it uses a for loop to enumerate the objects in the products array and increments the value of the price property of each of them. A value of, say, 1.20 will become 2.20 the first time the button is clicked, 3.20 for the second click, and so on.

4.1. Adding the Watcher

Directives use the $watch method that I described in Chapter 13 to monitor the scope for changes. The process is more complicated for my custom directive because I am obtaining the expression that is to be evaluated from an attribute value, and as you’ll see, that requires an extra preparatory step. In Listing 15-10, you can see the changes I made to the directive to monitor the scope and update the HTML elements when the property values change.

Listing 15-10. Handling Data Changes in the directives.html File

angular.module(“exampleApp”, [])

.directive(“unorderedList”, function () {

return function (scope, element, attrs) {

var data = scope[attrs[“unorderedList”]];

var propertyExpression = attrs[“listProperty”];

if (angular.isArray(data)) {

var listElem = angular.element(“<ul>”);

element.append(listElem);

for (var i = 0; i < data.length; i++) {

var itemElement = angular.element(‘<li>’);

listElem.append(itemElement);

var watcherFn = function (watchScope) {

return watchScope.$eval(propertyExpression, data[i]);

}

scope.$watch(watcherFn, function (newValue, oldValue) { itemElement.text(newValue);

});

}

}

}

})

In Chapter 13, I show you how to use the $watch method with a string expression and a handler function. AngularJS evaluated the expression each time that the scope changed and called the handler function when the evaluation produced a different result.

In this case, I am using two functions. The first function—the watcher junction—calculates a value based on data in the scope and is called by AngularJS each time the scope changes. If the value returned by the function changes, then the handler is called, just as for a string expression.

Being able to provide a function lets me deal with the fact that my expression contains a data value that may be filtered. Here is the watcher function that I defined:

var watcherFn = function (watchScope) {

return watchScope.$eval(propertyExpression, data[i]);

}

The watcher function is passed the scope as an argument each time it is evaluated, and I use the $eval function to evaluate the expression I am working with, passing in one of the data objects as a source of property values. I can then pass this function to the $watch method and specify the callback function, which uses the jqLite text function to update the text content of the li elements to reflect a value change:

scope.$watch(watcherFn, function (newValue, oldValue) {

itemElement.text(newValue);

});

The effect is that the directive monitors the property values that are displayed by the li elements and updates the content of the elements when they change.

Tip Notice that I don’t have to set the content of the li elements outside of the $watch handler function. AngularJS calls the handler when the directive is first applied; the newValue argument gives the initial evaluation of the expression, and the oldValue argument is undefined.

4.2. Fixing the Lexical Scope Problem

If you load the directives.html file into the browser, the directive won’t keep the li elements up-to-date. If you look at the HTML elements in the DOM, you will see that the li elements don’t contain any content. This is a problem that is so common that I want to demonstrate how to fix it even though it results from a JavaScript, rather than AngularJS, feature. The problem is this statement:

var watcherFn = function (watchScope) {

return watchScope.$eval(propertyExpression, data[i]);

}

JavaScript supports a feature called closures, which allows a function to refer to variables outside of its scope. This is a great feature, and it makes writing JavaScript a more pleasant experience. Without closures, you would have to make sure to define arguments for every object and value that your function needed to access.

The point of confusion is that the variable that a function accesses is evaluated when the function is invoked rather than when it is defined. In the case of my watcher function, that means the variable i isn’t evaluated until AngularJS calls the function. This means the sequence of events goes like this:

  1. AngularJS calls the link function to set up the directive.
  2. The for loop starts to enumerate the objects in the products array.
  3. The value of i is 0, which corresponds to the first object in the array.
  4. The for loop increments i to 1, which corresponds to the second object in the array.
  5. The for loop increments i to 2, which corresponds to the third object in the array.
  6. The for loop increments i to 3, which is greater than the length of the array.
  7. The for loop terminates.
  8. AngularJS evaluates the three watcher functions, which refer to data[i].

By the time step 8 happens, the value of i is 3, and this means that all three watcher functions try to access an object in the data array that doesn’t exist, and that’s why the directive doesn’t work.

To address this problem, I need to control the closure feature so that I refer to the data objects using a fixed or bound variable, which just means that the value assigned to the variable is set during steps 3-5 and not when AngularJS evaluates the watcher function. You can see how I have done this in Listing 15-11.

Listing 15-11. Fixing the Value of a Variable in the directives.html File

angular.module(“exampleApp”, [])

.directive(“unorderedList”, function () {

return function (scope, element, attrs) {

var data = scope[attrs[“unorderedList”]];

var propertyExpression = attrs[“listProperty”];

if (angular.isArray(data)) {

var listElem = angular.element(“<ul>”);

element.append(listElem);

for (var i = 0; i < data.length; i++) {

(function () {

var itemElement = angular.element(‘<li>’);

listElem.append(itemElement);

var index = i;

var watcherFn = function (watchScope) {

return watchScope.$eval(propertyExpression, data[index]);

}

scope.$watch(watcherFn, function (newValue, oldValue) {

itemElement.text(newValue);

});

}());

}

}

}

})

I have defined an immediately invoked function expression (IIFE) inside the for loop, which is a function that is evaluated immediately (and, as a consequence, is often called a self-executing function). Here is the basic structure of an IIFE:

(function() {

// …statements that will be executed go here…

}());

The IIFE allows me to define a variable called index to which I assign the current value of i. Since the IIFE is executed as soon as it is defined, the value of index won’t be updated by the next iteration of the for loop, and this means I can access the right object in the data array from within the watcher function, like this:

return watchScope.$eval(propertyExpression, data[index]);

The result of adding the IIFE is that the watcher function uses a valid index to get hold of the data object it is working with, and the directive works the way it is supposed to, as shown in Figure 15-4.

Source: Freeman Adam (2014), Pro AngularJS (Expert’s Voice in Web Development), Apress; 1st ed. edition.

Leave a Reply

Your email address will not be published. Required fields are marked *