Table of contents

< Previous chapterNext chapter >

Last updated .

Using and creating directives #2

Controllers in directives

The use of controllers in directives was briefly introduced in the previous chapter. I used the $scope syntax for the controller. If you have been following along over the previous chapters in this series, the recommended controllerAs syntax will be more familiar to you. You may be wondering if that can then be used for a controller in a directive. It can, there are just a few things to be aware of, which will be clear from the next example, which is pretty lame, but short. It just renders a few names:

directiveController.html <!doctype html> <html> <head> <title>directiveController</title> <script src="Scripts/Angular/angular-1.5.0-rc.0/angular.js"></script> </head> <body ng-app="app" ng-Controller="myController as vm"> <h1>Name is {{ vm.name }}</h1> <h2 directive-with-controller name="vm.name"></h2> <script src="directiveController.js"></script> </body> </html>

The above template expects a directiveWithController, which is created in this script:

directiveController.html
( function () { var app = angular.module("app", []); function DirectiveWithController() { return { scope: { parentName: "=name", }, template: "{{ vm.name }} {{ vm.parentName }}!", controller: function ($scope, $element, $attrs, $transclude) { var vm = this; vm.name = "Albert"; }, controllerAs: "vm", bindToController: true, }; } app.directive("directiveWithController", [DirectiveWithController]); function MyController() { var vm = this; vm.name = "Einstein"; } app.controller("myController", [MyController]); } )();
Names

The controller uses the now familiar syntax with the vm variable (short for "viewmodel"). It has a name property, the value of which is rendered with an expression. For this to work, there has to be a controllerAs property on the DDO, specifying the name you are using for the viewmodel variable - here "vm". The directive also imports a name property from the parent scope, which is aliased to parentName. That value is also rendered with an expression. For that to work, there has to be a bindToController: true property on the DDO.

Notice that the controller constructor function takes four arguments. I didn't use them in the example, so I could have left them out, but I just wanted to show they are there. $element is the element that the directive is on. The $attrs object contains the element attributes and $scope is the usual scope. (The $transclude function is an advanced option which I'll ignore until I need it, if ever).

New stuff

DDO.controllerAs: Set this to specify the name of the controller, usually "vm".

DDO.bindToController: Set this to true to have Angular correctly set values imported with { @ }, { = } or { & }.

Alternative syntax

As usual with Angular, there are several ways to the same goal. Instead of declaring the controller function inside the directive, it can be created outside, registered with the module and then just referenced by name in the directive. It will then be injected into the directive. This has the benefit of making the controller testable as a separate entity and makes it easy to inject services into the controller. Using this approach, the directive from the last example can be rewritten like this:

directiveController.js (partial)
.... function DirectiveController(log, element) { var vm = this; vm.name = "Albert"; log.log(element[0]); } app.controller("directiveController", ["$log", "$element", DirectiveController]); function OkCancelDialogDirective() { return { controller: "directiveController", scope: {}, ....

Using transclusion

The word transclusion has an air of something awfully complicated, actually it is not even a proper word, but it certainly is common in the Angular official documentation. In fact, the concept is quite simple. If you've worked with ASP.Net you know about Master Pages. A master page contains one or more named content placeholders. Then, to use that master, you construct a content page with a named content tag. When the page is requested, the content pages are merged with the master page, the content bits being injected into the master page at the place of the placeholders. Transclusion is very similar, although not quite. With transclusion, the consumer of a directive can specify content for the directive to render. That content is specified as a DOM child of the directive. The directive is in charge, in that it is free to spew out its own content in addition to the supplied content. Let's first see a very basic example involving a okcanceldialog directive:

Transclude1.html <!doctype html> <html> <head> <title>Transclude1</title> <script src="Scripts/Angular/angular-1.5.0-rc.0/angular.js"></script> </head> <body ng-app="app" ng-Controller="myController as vm"> <okcanceldialog> <label>Name:</label> <input /> </okcanceldialog> <script src="transclude1.js"></script> </body> </html>

The plan is to have the okcanceldialog eject its own content, somehow merging it with the content that we provided in the template as children of the directive, in this case the label and input elements. We can do that with this script for the directive:

Transclude1.js
( function () { var app = angular.module("app", []); function OkCancelDialogDirective() { return { transclude: true, template: "<div>" + "<h3>You are welcome to dialog</h3>" + "<ng-transclude></ng-transclude>" + "<div><button>OK</button><button>Cancel</button></div>" + "</div>" }; } app.directive("okcanceldialog", [OkCancelDialogDirective]); function MyController() { var vm = this; } app.controller("myController", [MyController]); } )();
The rendered dialog
The rendered dialog

I am not sure you'd want to make such a generic dialog component, but I hope the example still gives you a good idea of the ingredients needed to make transclusion work. The directive specifies the html to render through the use of the DDO.template property that was introduced in the previous chapter. That bit of html includes a ng-transclude tag and this is the placeholder, where the consumer's content is inserted. For this to work, the DDO.transclude property must be set to true. The sample produces the following HTML in the browser, the content within the okcanceldialog element is now a combination of what the directive supplied and what the client supplied:

Runtime HTML
Runtime HTML

Multiple named placeholders

The above example involved only one placeholder, but thankfully, it's possible to have multiple by naming the placeholders. I modified the example to make the displayed header dynamic; now it is up to the client to supply a header. This means I will now need a placeholder for the header and one for the dialog body. The next example also shows how to supply default content in a placeholder that the client has left out:

Transclude2.html <!doctype html> <html> <head> <title>Transclude2</title> <script src="Scripts/Angular/angular-1.5.0-rc.0/angular.js"></script> </head> <body ng-app="app" ng-Controller="myController as vm"> <okcanceldialog> <dialog-header><h4>My OK-Cancel dialog</h4></dialog-header> <dialog-body> <label>Name:</label> <input /> </dialog-body> </okcanceldialog> <script src="transclude2.js"></script> </body> </html>

Now, this markup is starting to look a lot like something you'd see in ASP.Net, I hope you are comfortable with that! And the directive code:

Transclude2.js
( function () { var app = angular.module("app", []); function OkCancelDialogDirective() { return { transclude: { "header": "?dialogHeader", "body": "dialogBody", }, template: "<div>" + "<div ng-transclude='header'><h3>You are welcome to dialog</h3></div>" + "<div ng-transclude='body'>Never rendered</div>" + "<div><button>OK</button><button>Cancel</button></div>" + "</div>" }; } app.directive("okcanceldialog", [OkCancelDialogDirective]); function MyController() { var vm = this; } app.controller("myController", [MyController]); } )();
The dialog with custom header
The dialog with custom header

Now there are two named placeholders, with descriptive names dialog-header and dialog-body. In the code for the directive, these placeholder names are declared in the transclude property, which is now no longer a boolean, but an object whose properties describe the placeholders. These names are then used in the markup produced in the template property. The ng-transclude directive is now used as an attribute instead of as an element.

Notice also that the dialog-header is optional, indicated by the leading question mark in the transclude specification. The directive supplies default content for the placeholder, which will be displayed if the client omits that tag. The body part isn't optional, but it doesn't mean that everything bombs if it's left out. It just means that nothing will be rendered in that place - the directive is not able to give default content in that case.

The replace option

In the previous example, the resulting HTML turned out to involve a okcanceldialog element containing a div child, which in turn contained three div children - the result of the merger was inserted into the okcanceldialog element. But that element may not be terribly useful. Instead of inserting into it, we can replace it altogether by setting the replace property to true (default is false):

Transclude2.js (partial) .... return { replace: true, transclude: { "header": "?dialogHeader", "body": "dialogBody", }, ....

Compare the HTML resulting from result: false versus result: true:

replace: false
replace: false
replace: true
replace: true

Beware! The replace property is going to be removed in Angular v. 2.0!

Transclusion and scope

Directive scope was treated at length in the previous chapter. You might wonder, if you define a directive to use transclusion and have isolated scope, how can it then access data in the parent scope ? Let's see what happens. I modified the previous example a bit to use isolated scope:

Transclude3.html <!doctype html> <html> <head> <title>Transclude3</title> <script src="Scripts/Angular/angular-1.5.0-rc.0/angular.js"></script> </head> <body ng-app="app" ng-Controller="myController as vm"> <okcanceldialog> <dialog-body> <label>State your surname, {{ vm.name }}:</label> <input /> </dialog-body> </okcanceldialog> <script src="transclude3.js"></script> </body> </html>

As you can see, now I am using just one placeholder for transclusion and a bit of data - the name property - from the scope is output.

Transclude3.js
( function () { var app = angular.module("app", []); function OkCancelDialogDirective() { return { scope: {}, transclude: { "body": "dialogBody", }, template: "<div>" + "<h3>Hello {{ vm.name }}!</h3>" + "<div ng-transclude='body'>Never rendered</div>" + "<div><button>OK</button><button>Cancel</button></div>" + "</div>", controller: function () { var vm = this; vm.name = "Albert"; }, controllerAs: "vm", }; } app.directive("okcanceldialog", [OkCancelDialogDirective]); function MyController() { var vm = this; vm.name = "Stephen"; } app.controller("myController", [MyController]); } )();
The dialog with mixed scope
The dialog with mixed scope

I have configured the directive to use isolated scope and I have added a controller just to set a name property to some value. The myController controller also has a name property, set to a different value. So which name is rendered? Well, as you can deduce from the screenshot, the directive has its own scope and its own value of the name, so that is what is rendered for the header. No surprise there. But the point is that the parent scope is what is being used in the rendition of the transcluded content, i.e. the dialog-body section. One might say that the parent scope has been transcluded into the dialog along with the content.

This is a good thing. It means the directive doesn't have to import a lot of data from the parent scope. Only if the directive itself renders, or otherwise uses, data from the parent scope does it need to use the { @ }, { = } or { & } constructs to get the data. (see previous chapter.)

The mixed scope
The mixed scope

New stuff

ngTransclude: Used as an attribute or element, this directive is used as a placeholder for importing content into a directive.

DDO.transclude: false (default), true or an object with properties defining placeholder names.

DDO.replace: false (default) or true. Determines whether content will replace the element with the directive or be inserted into it.

The link function

The controller is one place to put code for a directive. But that's not all. You can also add a link function to the directive defintion object, which Angular will then call from time to time. The Angular docs say that this function is where most of the directive logic will be put. It also says that a best practice is "to use a controller when you want to expose an API to other directives, otherwise use link." However, this is debated. There are other arguments in favor of using the controller as the center for the directive logic and deprecate the use of the link function.

If the directive manipulates the DOM directly (as opposed to going through Angular methods or constructs), then the link function is definitely the place to put the code. It has the signature

function link(scope, element, attrs, controllers, transcludeFn)

The parameters are as for a controller (see above), except for the fourth parameter. This is a reference to one or more controllers, in the latter case it is an array. The exact contents of this parameter depend on the value of the DDO.require property. The controllers parameter and DDO.require property in tandem open up the possibility of directive controllers communicating with each other. That way, it is possible to build a component that's built from a combination of directives, say a tab control with child tab pages.

In the DDO.require property one can specify which controllers to inject in the fourth parameter of the link function. It takes the name or names of the required controllers. The syntax is cryptic, though, because the controller names can be prefixed with characters with special meaning. Suffice it to say that "^someController" will bring in "someController", if it can be found as an ancestor of the directive.

New stuff

DDO.link: Use the link function for DOM manipulation and for calling other directive controllers.

DDO.require: The require property can be used to declare dependencies on other controllers. Instances of those controllers will then be passed in the fourth parameter of the link function.

Next up is a sample showcasing a component composed of two types of directive, using the DDO.require property for inter-directive communication. It also employs transclusion. The sample was adapted from a sample in the Angular documentation. It's a TabControl component, where one directive is for an outer container (the parent), and another directive is for a single tab (child). I've had to employ a minimum of styling to make it usable. I am not showing the css, but you can get the source files in a zip file. The resulting UI looks like this:

The tabcontrol
The tabcontrol

The markup for a test page containing the tabcontrol is straight forward. It contains a tab-container element with a number of tab-pane children. A pane has a caption property which is the displayed caption text. I added a bit of arbitrary content to the tabpanes, just to have something to look at.

Tabs.html <!doctype html> <html> <head> <title>Tabs</title> <meta charset="utf-8" /> <link rel="stylesheet" href="tabstyle.css" /> <script src="Scripts/Angular/angular-1.5.0-rc.0/angular.js"></script> </head> <body ng-app="app"> <div style="width:400px;"> <tab-container> <tab-pane caption="Tab pane 1"> <div>Tab pane 1 content</div> <label>Name:</label><input /> </tab-pane> <tab-pane caption="Tab pane 2"> <h3>Tab pane 2 content</h3> <div>Tab pane 2 Tab pane 2 Tab pane 2 </div> <label>Whatever:</label><input type="checkbox" /> </tab-pane> </tab-container> </div> <script src="tabs.js"></script> </body> </html>

The tab-pane directive needs to call a method in the parent directive, and so declares a dependency on that controller by the use of the require property:

Tabs.js (partial) var app = angular.module("app", []); function TabPaneDirective() { return { require: "^^tabContainer", transclude: true, scope: { caption: "@" }, link: function (scope, element, attrs, tabContainerCtrl) { tabContainerCtrl.addPane(scope); }, template: "<div ng-show='selected' ng-transclude></div>", }; } app.directive("tabPane", [TabPaneDirective]);

The use of the require property causes the fourth argument to the link function to be a reference to the parent tab-container controller. The reference is used to call a method on that controller, informing the parent controller that a tabpane has been added. The caption for the tabpane is imported to the scope by using the { @ } construct. A selected flag in the scope is used to govern the visibility of the tab - only one tab should be displayed at a time. The child content of the tab is brought in by the use of transclusion.

The tabcontainer uses an ul tag to render the list of captions:

Tabs.js (partial) function TabContainerDirective() { return { scope: {}, transclude: true, controller: function () { var vm = this; vm.panes = []; vm.selectPane = function (pane) { angular.forEach(vm.panes, function (pane) { pane.selected = false; }); pane.selected = true; }; vm.addPane = function (pane) { if (vm.panes.length === 0) vm.selectPane(pane); vm.panes.push(pane); }; }, controllerAs: "vm", template: "<ul>" + "<li ng-repeat='pane in vm.panes' ng-class='{ active:pane.selected }'>" + "<a href='#' ng-click='vm.selectPane(pane)'>{{ pane.caption }}</a>" + "</li>" + "</ul>" + "<div ng-transclude></div>" }; } app.directive("tabContainer", [TabContainerDirective]);

The controller for the tab-container directive maintains an array of tab panes; actually it is an array of tab pane scope objects. The controller exposes an addPane method that the child tab-pane directive calls to get the pane added to the array. The selectPane function is called when the tab is clicked. The function causes an update to the UI by selecting which tab is active. By using transclusion, the child content is rendered below the unordered list.

Get the source code for the tabcontrol in a zip file.

< Previous chapterNext : Synchronizing with $watch and $apply >


2016 by Niels Hede Pedersen Linked in