1.Table Of Contents

  1. TOC
  2. Introduction
  3. Building Our Basic AngularJS Directive
  4. Adding Dynamic Content Bindings
  5. Dynamically Adding Data To The Model
  6. Demo
  7. Conclusion

2.Introduction

This is the second tutorial of the AngularJS directives series using Twitter’s Bootstrap CSS Framework and its JavaScript components. In this part we are going to create a custom collapse(accordion) directive. We are going to build upon the previous tutorial of the series – AngularJs Bootstrap Components – Part 1 – Building Popover Directive.

3.Building Our Basic AngularJS Directive

I’ll begin with creating a simple directive that will display static collapse Bootstrap component. Then I’ll show you how to populate the collapse panel with data from AngularJS model and further expand the functionality by dynamically adding items to this model.

Basic HTML

The initial HTML code for this tutorial will look like this:

<div ng-app="customDirectives">
    <div custom-collapse collapse-panel-id="collapse-panel" collapse-panel-body-id="collapse-panel-body"></div>
</div>

We have three custom attributes in the initial state of our HTML element that will be used to match the directive:
custom-collapse -will be used by AngularJS to match the element with the directive in the parsing process.
collapse-panel-id – will be used to set custom ID for the panel group(the parent element for the collapse panel).
collapse-panel-body-id – will be used to set custom IDs for the separate panel bodies. These IDs will be used to toggle the panels.

Basic JavaScript Code

We are going to use the same AngularJS module from the previous tutorial and only change the name of the directive, the template, and add the two attributes to the local scope.

customDirectives = angular.module('customDirectives', []);
customDirectives.directive('customCollapse', function () {
    return {
        restrict: 'A',
        template: '<div class="panel-group" id="{{panelId}}">\
                       <div class="panel panel-default">\
                           <div class="panel-heading">\
                               <h4 class="panel-title">\
<a data-toggle="collapse" data-parent="#{{panelId}}" href="#{{panelBaseId}}-1">Panel Title 1</a>\
                               </h4>\
                           </div>\
<div id="{{panelBaseId}}-1" class="panel-collapse collapse">\
                               <div class="panel-body">Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.</div>\
                           </div>\
                       </div>\
                       <div class="panel panel-default">\
                           <div class="panel-heading">\
                               <h4 class="panel-title">\
<a data-toggle="collapse" data-parent="#{{panelId}}" href="#{{panelBaseId}}-2">Panel Title 1</a>\
                               </h4>\
                           </div>\
<div id="{{panelBaseId}}-2" class="panel-collapse collapse">\
                               <div class="panel-body">Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.</div>\
                           </div>\
                       </div>\
                   </div>',
        link: function (scope, el, attrs) {
            scope.panelBaseId = attrs.collapsePanelBodyId;
            scope.panelId = attrs.collapsePanelId;
        }
    };
});

angular.module('CustomComponents', ['customDirectives']);

As you can see in this state the directive is pretty useless as we have to manually add panels to the template, so our next step will be to add dynamic bindings for the contents of the panel group.

The Result:

4.Adding Dynamic Content Bindings

First we’ll need a base controller(for its scope) to keep the model that will hold the contents of the panel. We can do this by wrapping the directive matching element in the HTML code with another element and adding the ngController directive to it.

Adding Base Controller

In our directive matching element we need to add the the model that will bind the data for the accordion. We are going to use ng-model, but you can use any custom property to add the data. Our extended HTML will look like this:

<div ng-app="customDirectives">
    <div ng-controller="CustomDirectivesController">       
        <div custom-collapse ng-model="collapseData" collapse-panel-id="collapse-panel" collapse-panel-body-id="collapse-panel-body"></div>
    </div>
</div>

Next we need to update our JavaScript and add the controller code. I use functions to define controllers, so the name of the function should match the string we assigned to the ng-controller directive in the HTML element matching the controller directive. The next thing we need to do is to add a variable to the global controller’s scope and populate it with the panel contents – you can use ajax to load the contents, but for this example we are going to hardcode the initial data.

function CustomDirectivesController($scope)
{    
    $scope.collapseData = [
        {
            title: "Collapse Group Item Title 1",
            content: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.",
            collapsed: true
        },
        {
            title: "Collapse Group Item Title 2",
            content: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.",
            collapsed: false
        },
        {
            title: "Collapse Group Item Title 2",
            content: "Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.",
            collapsed: false
        }
    ];
}
Keep in mind that for bigger projects it is best to wrap scope variables in additional objects – instead of adding collapseData directly to the scope you should add it to some container like $scope.uiWidgets.collapseData and then in the HTML call it with uiWidgets.collapseData. This will keep your bindings clean and readable, and eventually keep you sane!

Extending The Directive

There are some significant changes we need to apply to our directive’s code:

customDirectives.directive('customCollapse', function () {
    return {
        require: '?ngModel',
        scope:{
            ngModel: '='
        },
        restrict: 'A',
        template: '<div class="panel-group" id="{{panelId}}">\
                       <div class="panel panel-default" ng-repeat-start="item in ngModel">\
                           <div class="panel-heading">\
                               <h4 class="panel-title">\
<a ng-click="toggleCollapsedStates($index)" href="#{{panelBaseId}}-{{$index}}">{{item.title}}</a>\
                               </h4>\
                           </div>\
<div id="{{panelBaseId}}-{{$index}}" data-parent="#{{panelId}}" class="panel-collapse collapse">\
                               <div class="panel-body">{{item.content}}</div>\
                           </div>\
                       </div>\
                       <div ng-repeat-end></div>\
                   </div>',
        link: function(scope, el, attrs) {
            scope.panelBaseId = attrs.collapsePanelBodyId;
            scope.panelId = attrs.collapsePanelId;
            
            $(document).ready(function(){
                angular.forEach(scope.ngModel, function(value, key){
                    if (value.collapsed)
                    {
                        $("#" + scope.panelBaseId + "-" + key).collapse('show');
                    }
                });
            });
            
            scope.toggleCollapsedStates = function(ind){
                angular.forEach(scope.ngModel, function(value, key){
                    if (key == ind)
                    {
                        scope.ngModel[key].collapsed = !scope.ngModel[key].collapsed;
                        $("#" + scope.panelBaseId + "-" + ind).collapse('toggle');
                    }
                    else
                        scope.ngModel[key].collapsed = false;
                });
            }
        }
    };
});
Require

The first change is the addition of the require option. It adds dependency and if we fail to provide the required model through the ng-model directive or through custom attribute the browser will throw error. The require option accepts two prefixes:

^ – this prefix option means that the directive will look for controller on both the local and parent elements’s scope.
? – this prefix will suppress the error give when no controller is provided. We are using it in this example because we don’t need a controller.

If the require is used without prefix the directive will look for controller only in its own element scope.
Scope

The next change is the scope option. It allows the directive to have its own isolated scope. The scope option accepts three different values:

false – the default value – won’t create scope for the directive.
true – will create scope for the directive, but it wont be isolated from the controller’s scope.
{} – by passing an object an isolated scope will be created for the directive.

It’s important to give your directives their own isolated scope because otherwise you’ll have to use only one for every controller or it will cause unexpected behavior as directives without scope will use the one of the controller they are invoked in.

Now creating an isolated scope with an empty object isn’t particularly useful. You won’t have any data bindings with the local scope in which the directive was invoked and you’ll be able to access only the variables you manually add to it. To be able to access external variables in your directive’s local scope you need to assign them one of the following three aliases and pass them to the scope object:

scope:{
    ngModel: '=',  // Bi-directional binding
    customAttribute1: '@',  // One way binding of a local property to the value of external variable
    functionReference: '&'  // Binding allowing to execute function in the context of the parent scope
}

= – allows a bi-directional binding between a property of the directive’s isolated local scope and external property. If one is changed the other will reflect the change.
@ – allows a binding between a property of the directive’s isolated local scope and the value of the external property.
& – allows an external method to be called from the directive’s scope in the context of the controller’s scope where the method was defined.

New Template HTML

I’ve changed the template HTML code by adding the ng-repeat-start and ng-repeat-end directives which are special start and end points that repeat series of elements instead of one parent element like ng-repeat directive. The ng-repeat-start works the same as ng-repeat, but it requires the next element to have ng-repeat-end directive.

Custom Method For Toggling Collapse Panels

I’ve also added ng-click directive to the title of the panels and passed the toggleCollapsedStates method to it. The method is defined in the link function of the directive.

scope.toggleCollapsedStates = function(ind){
    angular.forEach(scope.ngModel, function(value, key){
        if (key == ind)
        {
            scope.ngModel[key].collapsed = !scope.ngModel[key].collapsed;
            $("#" + scope.panelBaseId + "-" + ind).collapse('toggle');
        }
        else
            scope.ngModel[key].collapsed = false;
    });
}

What this function does is to change the state of the collapsed property in the model for the respective collapse item and toggle it. We use the angular.forEach method to loop our model.

Initiating Panels With Collapsed Property Set To True

When rendering the collapsed directive we want to show panels that have the collapsed property set to true, but for this to work we need to wrap it in jQuery’s .ready() event callback. This is required because in the current AngularJS version(1.2.1) the ngRepeat directive is rendered after the link function of the parent directive is called.

$(document).ready(function(){
    angular.forEach(scope.ngModel, function(value, key){
        if (value.collapsed)
        {
            $("#" + scope.panelBaseId + "-" + key).collapse('show');
        }
    });
});

We simply loop through the model and call the collapse method of the panel with the ‘show‘ option.

The Result

Here is what we have so far:

5.Dynamically Adding Data To The Model

The last addition to this example will be a little form that will call a method in the controller that will dynamically add content to the model.

Here is the HTML we are going to use – nothing special there:

<div id="collapse-add-form">
     <input ng-model="title" type="text" placeholder="Collapse Panel Title">
     <div>
        <textarea ng-model="content" placeholder="Collapse Panel Content"></textarea>
    </div>
    <button ng-click="addItem()">Add Item</button>
</div>

And here is the definition of the addItem() method we need to add to the controller:

$scope.addItem = function() {
        $scope.collapseData.push({
            title: $scope.title,
            content: $scope.content,
            collapsed: false
        });
        
        $scope.title = '';
        $scope.content = '';
    };

6.Demo

Aaand we are done. Here is the final result:

7.Conclusion

This was the second tutorial of the AngularJS Bootstrap Components series I’ve showed you how to build collapse directive using Twitter’s Bootstrap Framework.

In the next tutorial of the series I’ll show you how to build Tabs Directive based on Bootstrap’s Tab JS widget

Ivan Kovachev
Follow me

Ivan Kovachev

Ivan Kovachev is Technical Team Lead and Senior Web Developer with over six years of professional experience in the field. Ivan also has an unhealthy interest in everything web related.
Ivan Kovachev
Follow me