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 div
s 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.