Introduction
Directives are an integral part of Angular but are often hyped as being "difficult" and "complicated". As a result, they don't get the love they deserve. While there's certainly a lot to know, the barrier of entry is surprisingly low. By thoroughly embracing directives now, you can create more modular code that'll be even easier to port to Angular 2!
As always, if you have any questions or comments about this tutorial, feel free to reach out to me on twitter.
What We're Building
This tutorial will take you through building a fully-functional tab directive based on the styles and functionality of the Bootstrap widget.
To accomplish this, we will implement two distinct directives: tabset
and
tab
. By the end of it, you will be familiar with concepts such as the DDO,
transclusion, directive to directive communication, scope, and more!
Our widget will be architected very similarly to the tabs directive found in AngularUI's UI-Bootstrap library so after you finish this tutorial head on over to their github page and start contributing to a project with >8k stars! :)
What is a Directive?
A directive is a way of defining HTML elements that use JavaScript to create custom behaviours. There are two main types of directives: elements and attributes (also referred to as decorators). Elements contain their own custom templates and are used as actual HTML elements. Attributes are used as attributes on existing HTML elements to add new behaviours such as onclick functionality. In this tutorial, we'll be creating two element directives.
The Anatomy of a Directive
Directives are declared using the directive()
function. The first parameter
is the name of the directive specified in
camelCase. The second parameter is
a function that returns a directive definition
object
(or DDO). The properties of the DDO specify various configuration options for
the directive.
There are a LOT of things one can specify in the DDO, and the shear volume of properties is often why directives are thought be be complex. Fortunately, you only need to worry about a small subset of them to get a lot of value out of directives.
Setting Things Up
Before we begin, let's take a moment to setup the project:
<html>
<head>
<title>Tabs Directive</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.4/css/bootstrap.min.css">
<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.0-rc.0/angular.min.js"></script>
<script src="app.js"></script>
</head>
<body ng-app="app">
<h1>Tabs, tabs, tabs!</h1>
</body>
</html>
;(function(window) {
angular.module('app', [])
// Define directives here
})(window);
As always, you'll need some way of serving these files locally. I recommend
browser-sync: npm install -g browser-sync
. You
can start the web server using the command browser-sync start --server --port
3001 --files="./*"
(Learn more about why you should do this)
Now that we have an environment ready to rock, let's dive in!
Creating Our First Directive
The first directive we need to define is tab
. This directive will represent
a single pane in the tabs widget.
angular.module('app', [])
.directive('tab', function() {
return {
restrict: 'E',
transclude: true,
template: '<h2>Hello world!</h2> <div role="tabpanel" ng-transclude></div>',
scope: { },
link: function(scope, elem, attr) {}
}
})
<tab>
Lorem ipsum dolor sit amet, ut eam nullam utroque liberavisse, ea
graecis tractatos contentiones quo. Ipsum phaedrum scripserit sit id,
eu insolens indoctum vel, eos eu offendit delectus tincidunt. Eum
nostrum reprehendunt in, ullum nostrud legimus ei quo. Sed et elitr
corrumpit. Nibh maiestatis voluptatum has no, at est inermis epicuri,
omnis temporibus cu mei. Legere scriptorem voluptatibus et est, ea
noluisse deterruisset sea.
</tab>
When you load up the web page in your browser you should see the "Lorem ipsum" text displayed below "Hello world!"
What Just Happened!?!?!
An excellent question. We specified a lot of options here so let's go through them and explain what they do:
Firstly, we specify restrict: 'E'
which which means the directive will be an
element. If we specified 'A', the directive would become a attribute (a ==
"attribute").
We use template
to specify an HTML template string that will be inserted into
the DOM wherever the element directive is used.
The scope
property tells the directive what scope should be used. If it is
an objects (as it is in this case), the directive will create an isolated
scope. Any
properties specified in the scope object will be bound to the directive's
isolated scope and available for use inside of the directive. If this seems
a little nebulous right now, don't worry! It should become clearer with some
examples.
The link
property is used to specify the linking
function.
Practically speaking, this function is similar to an angular controller
function and is home to the majority of the directive's logic. The function
accepts 3 parameters:
scope
- similar to the$scope
object seen in controllers. Values attached to it will be available for use in the directive's templates. AngularJS docs on scopeelem
- the actual HTML element wrapped as a jqLite using theangular.element()
function. This object can be used to manipulate the actual HTML DOM.attr
- a normalized object of all the HTML properties attached to the directive HTML element. For example, if you specified anhref="thinkster.io"
property, you could access the string "thinkster.io" withattr.href
.
Finally we get to 'transclusion', which is an esoteric computer science term
that basically means 'to include one document inside another'. By specifying
transclude: true
, we are instructing the directive to insert any content
included between the <tab></tab>
HTML into the ng-transclude
div we
specified in the template string. Because this can be such a confusing topic,
it's highly recommended that you check out our short article on transclude,
which goes more in depth.
Scaffolding the tabset Directive
Now that we've created tab
, it's time to define the tabset
directive. This
directive will wrap multiple tab
s and provide the logic needed to select
which tab is shown.
.directive('tabset', function() {
return {
restrict: 'E',
transclude: true,
scope: { },
templateUrl: 'tabset.html',
bindToController: true,
controllerAs: 'tabset',
controller: function() {
var self = this
self.tabs = []
}
}
})
Many of these options should look familiar so let's quickly cover the new ones:
controller
allows us to specify an Angular controller. We'll cover why
tabset
uses a controller as opposed to a link function later down the line.
controllerAs: 'tabset'
allows us to bind properties directly to the
controller object using this
and have them accessible via tabset
in the
template. This removes the controller's dependency on the $scope
service and
is generally the recommended way to use controllers in Angular.
Finally, bindToController: true
specifies
that any values passed into the directive's scope via the scope
property
are automatically accessible in the controller using this
-- we'll
see how this works later on.
Notice that rather than defining a template string, we're using templateUrl
to specify an external template.
<div role="tabpanel">
<ul class="nav nav-tabs" role="tablist">
<li role="presentation"
ng-repeat="tab in tabset.tabs">
<a href=""
role="tab"
ng-click="tabset.select(tab)">{{tab.heading}}</a>
</li>
</ul>
<ng-transclude>
</ng-transclude>
</div>
<tabset>
<tab>
Lorem ipsum dolor sit amet, ut eam nullam utroque liberavisse, ea
graecis tractatos contentiones quo. Ipsum phaedrum scripserit sit id,
eu insolens indoctum vel, eos eu offendit delectus tincidunt. Eum
nostrum reprehendunt in, ullum nostrud legimus ei quo. Sed et elitr
corrumpit. Nibh maiestatis voluptatum has no, at est inermis epicuri,
omnis temporibus cu mei. Legere scriptorem voluptatibus et est, ea
noluisse deterruisset sea.
</tab>
<tab>
Just another tab!
</tab>
</tabset>
Communicating Between Directives
I mentioned above that there was a reason for using a controller on the
tabset
directive as opposed to the link function. By using a controller, we
can require that the tab
directive be nested inside the tabset
. Doing this
will inject the tabset
controller instance into each of the tab
link
functions allowing us to operate on the controller object from within the link
functions of the tab
s.
.directive('tab', function() {
return {
restrict: 'E',
transclude: true,
template: '<h2>Hello world!</h2> <div role="tabpanel" ng-transclude></div>',
require: '^tabset',
scope: { },
link: function(scope, elem, attr, tabsetCtrl) {}
}
})
The '^' character instructs the directive to move up the scope hierarchy one
level and look for the controller on tabset
. If the controller can't be
found, angular will throw an error.
Also note that we've added a forth parameter to the link
function. This
parameters is the tabset
controller, which we can now manipulate.
Registering Tabs
Up until this point, we haven't really described how this widget will function
as a whole. When the page loads, the two directives will be initialized. Any
tab
directives will then register themselves with the tabset
directive by
calling a method on the tabset
controller. The tabset
directive will
be responsible for orchestrating which tab is active as well as providing an
interface for selecting tabs.
self.addTab = function addTab(tab) {
self.tabs.push(tab)
}
link: function(scope, elem, attr, tabsetCtrl) {
tabsetCtrl.addTab(scope)
}
Now any property bound to scope in the tab
directive will also be accessible
by the tabset
controller!
Using scope
to Add a Heading
We've been doing a lot of behind-the-scenes work so let's make an addition that'll actually be noticeable.
A tab should have two pieces of content: a heading and a body. The body is transcluded in but we have no way to specifying the tab heading.
<tabset>
<tab heading="Tab 1">
Lorem ipsum dolor sit amet, ut eam nullam utroque liberavisse, ea
graecis tractatos contentiones quo. Ipsum phaedrum scripserit sit id,
eu insolens indoctum vel, eos eu offendit delectus tincidunt. Eum
nostrum reprehendunt in, ullum nostrud legimus ei quo. Sed et elitr
corrumpit. Nibh maiestatis voluptatum has no, at est inermis epicuri,
omnis temporibus cu mei. Legere scriptorem voluptatibus et est, ea
noluisse deterruisset sea.
</tab>
<tab heading="Tab 2">
Just another tab!
</tab>
</tabset>
Now, we need some way to access this heading
string from within our Angular
directive.
scope: {
heading: '@'
},
By specifying a property on the scope object of the DDO, the scope
object
passed to our link
function will now have a heading
property automatically
attached to it whose value is equal to the string defined in index.html
.
The '@' symbol is a special symbol in Angular that means this scope property should be a string. There are several other special symbols that you can read about in the official docs (look for the 'scope' section).
We mentioned that the heading
property is automatically bound to scope
and
we are registering scope
with the tabset
controller. This means that we can
now display the heading
property in the tabset
template. This has already
been taken care of via the ng-repeat
directive in tabset.html
so go ahead
and refresh your browser-- you should see the two heading strings we defined
above the heading body!
Activating Tabs
It's great that we can now see the tab heading, but all of our tabs are still displayed at once. To fix this, we need to add the notion of active tabs into our widget.
link: function(scope, elem, attr, tabsetCtrl) {
scope.active = false
tabsetCtrl.addTab(scope)
}
The active
property will determine whether or not an individual tab is shown
so all tabs should begin life as inactive. It'll be up to the tabset
controller
to determine which tab is active first.
return {
restrict: 'E',
transclude: true,
template: '<div role="tabpanel" ng-show="active" ng-transclude></div>',
require: '^tabset',
scope: {
heading: '@'
},
link: function(scope, elem, attr, tabsetCtrl) {
scope.active = false
tabsetCtrl.addTab(scope)
}
}
Because all of the tabs are inactive at first, when you refresh the page you should only see the header. To fix this, let's set the first tab that's registered to be active:
self.addTab = function addTab(tab) {
self.tabs.push(tab)
if(self.tabs.length === 1) {
tab.active = true
}
}
Now the "Lorem ipsum" text is displayed!
Showing Tab Activation
<ul class="nav nav-tabs" role="tablist">
<li role="presentation"
ng-repeat="tab in tabset.tabs"
ng-class="{'active': tab.active}">
<a href=""
role="tab"
ng-click="tabset.select(tab)">{{tab.heading}}</a>
</li>
</ul>
Now the activated tab header gets a nice outline representing it's activated state.
Selecting Tabs
We need to enable a user to actually select a tab. You'll notice that we've
already created an ng-click
directive that invokes a select()
method on the
tabset
controller-- all we need to do is define that method:
self.select = function(selectedTab) {
angular.forEach(self.tabs, function(tab) {
if(tab.active && tab !== selectedTab) {
tab.active = false;
}
})
selectedTab.active = true;
}
This method will deactivate all the tabs that weren't selected before finally activating the selected tab.
Now you can select "Tab 2" (or any other tabs you may have registered)!
We're Done!
Wait what? That wasn't so bad....
We've just implemented a tabs widget using Bootstrap. You can add extra tabs by
nesting more tab
directives inside <tabset/>
.
Of course, there are more features we can add to our directive like the ability to disable tabs or select which Bootstrap styles to apply. If you're interested in learning more, I highly recommend you check out the source code for the UI-Bootstrap tab directive on Github.
Bonus Features! (Screencasts only)
Below is a serious of screencasts and code samples that will walk you through adding some additional functionality to the tabs directive.
Disabling a Tab
tab
directive:
link: function(scope, elem, attr, tabsetCtrl) {
scope.active = false
scope.disabled = false
if(attr.disable) {
attr.$observe('disable', function(value) {
scope.disabled = (value !== 'false')
})
}
tabsetCtrl.addTab(scope)
}
tabset
controller:
self.select = function(selectedTab) {
if(selectedTab.disabled) { return }
angular.forEach(self.tabs, function(tab) {
if(tab.active && tab !== selectedTab) {
tab.active = false;
}
})
selectedTab.active = true;
}
<ul class="nav nav-tabs" role="tablist">
<li role="presentation"
ng-repeat="tab in tabset.tabs"
ng-class="{'active': tab.active, 'disabled': tab.disabled}">
<a href=""
role="tab"
ng-click="tabset.select(tab)">{{tab.heading}}</a>
</li>
</ul>
Selecting Tab vs Pill Styling
tabset
controller-- add type: '@',
to scope object.
self.classes = {}
if(self.type === 'pills') { self.classes['nav-pills'] = true}
else { self.classes['nav-tabs'] = true }
<ul class="nav" ng-class="tabset.classes" role="tablist">
Enabling Vertical and Justified Navigation
tabset
controller:
scope: {
type: '@',
vertical: '@',
justified: '@',
},
if(self.justified) { self.classes['nav-justified'] = true }
if(self.vertical) { self.classes['nav-stacked'] = true }