Twitter Bootstrap control-group directive for AngularJS

Twitter Bootstrap makes it easy to create great looking forms. However, you may find yourself repeating the same control-group boilerplate markup.

<div class="control-group">
    <label class="control-label" for="example">Example</label>
    <div class="controls">
        <input type="text" id="example" />
    </div>
</div>

With AngularJS, we can refactor this UI pattern into a directive, and use it like this:

<div control-group label="Example">
    <input type="text" id="example" />
</div>

The directive generates the boilerplate divs and label elements. It then transcludes the input element you provided inside the generated elements.

Here's the basic control-group directive:

var app = angular.module("app", []);

app.directive("controlGroup", function() {
    return {
        template: 
        '<div class="control-group">\
            <label class="control-label" for="{{for}}">{{label}}</label>\
            <div class="controls" ng-transclude></div>\
        </div>',

        replace: true,
        transclude: true,

        scope: {
            label: "@" // Gets the string contents of the `label` attribute.
        },

        link: function (scope, element) {
            // The <label> should have a `for` attribute that links it to the input.
            // Get the `id` attribute from the input element
            // and add it to the scope so our template can access it.
            var id = element.find(":input").attr("id");
            scope.for = id;
        }
    };
});

The directive specifies transclude: true. This means it can use the ng-transclude directive within its template to insert the child elements of the source markup into the <div class="controls"> element.

Validation styling

Bootstrap provides classes to indicate invalid inputs. Adding the error class to the control-group element will make the label, input and other help text red to alert the user to the problem.

Since we are using AngularJS, let's leverage the existing ng-model and form validation behaviors. Here's a simple form with an input data-bound to a scope property. The input also has the required attribute to trigger some basic AngularJS validation.

<form name="form" class="form-horizontal" ng-controller="ExampleController">
    <div control-group label="Example">
        <input type="text" ng-model="data.example" id="example" name="example" required />
    </div>
</form>

Assume the ExampleController is simply initialising the scope.

app.controller("ExampleController", ["$scope", function($scope) {
    $scope.data = {
        example: ""
    };
}]);

To toggle the error class whenever the input is invalid, we can use ng-class directive within the control group template.

…
template: 
'<div class="control-group" ng-class="{ error: isError }">\
    <label class="control-label" for="{{for}}">{{label}}</label>\
    <div class="controls" ng-transclude></div>\
</div>',
…

The directive's link function will need to managed this new isError property. To do this it's need to access the input's validation state, which is kept by the form. Adding the following require property to the directive defintion will give us access to the NgFormController in the link function.

…
replace: true,
transclude: true,
require: "^form", // Tells Angular the control-group must be within a form
…
link: function(scope, element, attrs, formController) {
…

The validation status of an input is stored in the scope like this: scope.{form-name}.{input-name}.$invalid. Note that the form and input elements must have name attributes for this to work.

Our directive definition has scope: { label: "@" }, which means it will create an isolated scope. So the form validation information is actually in the parent scope i.e. scope.$parent…

Here's the updated link function:

…    
link: function(scope, element, attrs, formController) {
    // The <label> should have a `for` attribute that links it to the input.
    // Get the `id` attribute from the input element
    // and add it to the scope so our template can access it.
    var id = element.find(":input").attr("id");
    scope.for = id;

    // Get the `name` attribute of the input
    var inputName = element.find(":input").attr("name");
    // Build the scope expression that contains the validation status.
    // e.g. "form.example.$invalid"
    var errorExpression = [formController.$name, inputName, "$invalid"].join(".");
    // Watch the parent scope, because current scope is isolated.
    scope.$parent.$watch(errorExpression, function(isError) {
        scope.isError = isError;
    });
}
…

Other improvements

If you don't use the input's id attribute anywhere else in your application, then it only exists to link it to the label. You could remove the attribute from your mark-up and make the directive generate a unique ID instead and set it on the input element.

Auto-generated unique IDs are very useful when your UI widgets may be inserted into pages where you can't guarantee uniqueness of manually defined IDs.

Another useful directive I use is to show validation error messages. Here's an example of it in use:

<div control-group label="Example">
    <input type="text" ng-model="data.example" name="example" required/>
    <span validation-error-for="required">This input is required</span>
</div>

It expands into the following mark-up:

<div control-group label="Example">
    <input type="text" ng-model="data.example" name="example" required/>
    <span class="help-inline" ng-show="form.example.$error.required">This input is required</span>
</div>

I'll leave the implementation as an exercise to the reader, or perhaps a future post! ;)

Demo

Here's a JSFiddle with a demo of the code.

Would you like access to the sample code repository?

Subscribe to my web development mailing list.

No spam, just occasional web development tips, posts and sample code. Unsubscribe at any time.

Comments
blog comments powered by Disqus