Build a Tabs Directive in AngularJS

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 scope
  • elem - the actual HTML element wrapped as a jqLite using the angular.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 an href="thinkster.io" property, you could access the string "thinkster.io" with attr.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 tabs 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 tabs.

.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 }

How'd We Do? A Look at UI-Bootstrap's Widget