Whether you're developing websites or web applications, you've probably encountered the need for some form of interactive controls that don't come out of the box from the browser. The date picker is a classic example of such functionality. In many cases, the interactivity can be generalized and packaged into reusable components which I'll call widgets.
One of the most common ways to write widgets is through jQuery plugins. An example of a popular widget framework is jQueryUI. jQueryUI is used in Trello to add cross browser drag and drop functionality to its cards interface.
AngularJS introduces new concepts to rethink the creation and usage of widgets. Some of these ideas may even become part of the open web standards for Web Components.
In this article, I'll talk about how to quickly convert a jQuery plugin to an Angular directive to begin using AngularJS's features. In the next part of this series, I'll introduce Angular Directive Controllers, which allows you to create widgets that are extensible without compromising maintainability.
Let's begin by building a simple shape widget that displays dynamic text from a text input in an SVG shape. Here's what it looks like (hint: type into the input box to see the shapes) :
The jQuery Way
We'll start off with one way to go about it, written in a jQuery plugin [git].
The plugin file, shapetext.js looks like:
$.fn.shapeText = function () { return this.each(function () { var $element = $(this), d3element = d3.select($element[0]), modelSelector = $element.data('model'), $model = $(modelSelector); // Set up text field var d3text = setupTextField($element); // Get position of element var x = +$element.attr('x'), y = +$element.attr('y'); var changeFn = function () { // Update text d3text.text($model.val()); // Get bounding box var bbox = d3text.node().getBBox(); // Update sizes d3text .attr('x', x) .attr('y', y + bbox.height); d3element .attr('width', bbox.width) .attr('height', bbox.height + 5); }; $model.on('input change', changeFn); }); function setupTextField($element) { // ... Add text field and return it ... } };
Usage:
This component tracks changes to the text in the model
input element. On each change event (changeFn
call), it updates the text element and the size of the rectangle to cover the text.
Okay, now what if you wanted to add support for circle shapes?
Example 1: The jQuery plugin
A first guess would be to add an if
statement to check the type of element and customize the update logic from there.
It'll look like this [git, diff]:
$.fn.shapeText = function () { return this.each(function () { var $element = $(this), d3element = d3.select($element[0]), modelSelector = $element.data('model'), $model = $(modelSelector); // Set up text field var d3text = setupTextField($element); // Set up update functions depending on type of element var updateSizeFn; if ($element.is('rect')) { updateSizeFn = rectUpdateSizeFn(d3element, d3text); } else if ($element.is('circle')) { updateSizeFn = circleUpdateSizeFn(d3element, d3text); } else { throw new Error('shapeText called on unsupported element'); } var changeFn = function () { // Update text d3text.text($model.val()); // Get bounding box var bbox = d3text.node().getBBox(); // Update sizes updateSizeFn(bbox); }; $model.on('input change', changeFn); }); function setupTextField($element) { // ... Same as above ... } function rectUpdateSizeFn(d3element, d3text) { // Get position of element var x = +d3element.attr('x'), y = +d3element.attr('y'); function updateSizeFn(bbox) { d3text .attr('x', x) .attr('y', y + bbox.height); d3element .attr('width', bbox.width) .attr('height', bbox.height + 5); } return updateSizeFn; } function circleUpdateSizeFn(d3element, d3text) { // Get position of element var x = +d3element.attr('cx'), y = +d3element.attr('cy'); function updateSizeFn(bbox) { d3text .attr('x', x - bbox.width / 2) .attr('y', y + 5); d3element .attr('r', bbox.width / 2 + 5); } return updateSizeFn; } };
Usage:
The rectUpdateSizeFn
and circleUpdateSizeFn
methods handle updating the text for the two different shapes. An exception is thrown if shapeText
is called on an element that it doesn't recognize (not circle
or rect
).
What if we had to add support for a triangle? Or a Reuleaux polygon, or a salinon etc.? In no time, this widget will be bloated with logic for each shape it needs to support.
Worse still, the core logic of the widget has to be modified to support each additional shape.
Why is this so bad? This means a developer would then need to maintain a fork of this widget in order to add support for more shapes. That would be a nightmare when you need to add new features and ask developers to update to a newer version of your widget.
Going Angular
Example 2: First attempt at an Angular directive
Let's re-arrange the above methods into a linking function, which I'll talk about in a bit [git, diff]:
angular.module('myMod', []) .directive('shapeText', function () { return { link: linkFn }; function linkFn(scope, $element, iAttrs) { var d3element = d3.select($element[0]), modelSelector = iAttrs.shapeText, $model = angular.element(modelSelector); // Set up text field var d3text = setupTextField($element); // Set up update functions depending on type of element var updateSizeFn; if ($element.is('rect')) { updateSizeFn = rectUpdateSizeFn(d3element, d3text); } else if ($element.is('circle')) { updateSizeFn = circleUpdateSizeFn(d3element, d3text); } else { throw new Error('shapeText called on unsupported element'); } var changeFn = function () { // Update text d3text.text($model.val()); // Get bounding box var bbox = d3text.node().getBBox(); // Update sizes updateSizeFn(bbox); }; $model.on('input change', changeFn); } // ... Other functions are unchanged ... });
The linking function looks almost exactly like what we had before, in Example 2 (see diff here). So, what has changed? Look at the usage:
Thanks to AngularJS, there's no longer any JavaScript initialization code needed to use the widget.
JavaScript initialization code isn't needed because AngularJS automatically looks for elements containing matching directives and applies the matched directive's linking functions.
Angular directive's most basic functionality is to apply the linking function to the elements found with matching names of directives in the HTML.
These directives can either be attributes or tag names. The directive names are converted from camelCase
to snake-case
before being matched in HTML. In our example, AngularJS converts the shapeText
directive to shape-text
then looks for elements that contain the shape-text
attribute and applies the linking function of the shapeText
directive on that element.
This replaces the need to manually do the jQuery selection to apply the widget functionality as I was doing with .text-from-model
in example 1.
Note: The only additional initialization code needed is the ng-app
attribute that indicates which module (myMod
) to look for directive definitions from.
Another note: The compilation phase is beyond the scope of this post. Read more about the compilation process in the Angular documentation.
Linking function's arguments
The linking function (linkFn
in the example) contains the current element (that contains the directive), its attributes, and the scope.
The linking function's second argument ($element
) is the current element wrapped in jQuery. By default, the element is wrapped in Angular's limited lite version of jQuery. However, since the full version of jQuery is included in the HTML, the full jQuery is used to wrap the element. In the Angular documentation, this argument is usually named iElement
where i
refers to instance to read like element of current instance. I named this argument $element
here to indicate that the methods used in this example require the full version of jQuery.
The third argument (iAttrs
) is an object containing the attributes of the current element, camelCased. Here, I make use of the the shape-text
attribute to specify the selector for the input field.
Notice that I skipped talking about the first argument, the scope
. The scope is Angular's single source of truth for what's being displayed in the DOM.
Hm.. That doesn't sound like what we're doing here. This directive works, but it isn't the Angular way of doing things.
Example 3: Using the scope
Let's make some adjustments to make use of the scope and allow the use of our directive to be more declarative, more Angular.
Here's the updated linkFn
in the shapeText
directive [git, diff]:
function linkFn(scope, iElement, iAttrs) { var d3element = d3.select(iElement[0]); // Set up text field var d3text = setupTextField(iElement); // Set up update functions depending on type of element var updateSizeFn; if (iElement[0].tagName == 'rect') { updateSizeFn = rectUpdateSizeFn(d3element, d3text); } else if (iElement[0].tagName == 'circle') { updateSizeFn = circleUpdateSizeFn(d3element, d3text); } else { throw new Error('shapeText called on unsupported element'); } var changeFn = function (val) { // Update text d3text.text(val); // Get bounding box var bbox = d3text.node().getBBox(); // Update sizes updateSizeFn(bbox); }; iAttrs.$observe('shapeText', changeFn); }
Usage:
The usage is starting to look more declarative. There's no need for the full jQuery since I'm no longer using jQuery selectors to specify the model.
The ng-model
directive makes a two-way binding of the text input field to the model.myText
variable. When this variable is first assigned, through any change or input in the input field, AngularJS automatically creates the model
object. The scope would then contain the following:
{ model: { myText: 'Text typed in the field' }, // ... Other scope stuff like $parent }
It's easy to see which text shapes are linked to this scope variable by reading the interpolated Angular strings (wrapped in {{
and }}
).
Declarative nature
Instead of going through jQuery selectors, Angular allows developers to use variables in the scope directly. In the shapeText
widget, instead of stating the selector of the input field (#myText
in the first example), what we really want is to assign the input field's value to a variable and display that variable in the shapes. For example, there won't be any issues if we had more than one input field for the same variable.
Extensible + Maintainable
Switching over to Angular directives didn't answer any of the questions above about maintainability issues when adding support for more shapes. However, we're now in position to use AngularJS features to make our widget both extensible and maintainable.
In the next part of this blog series, I'll talk about using AngularJS features to add extensibility to the widget without compromising maintainability.
Additional notes on examples
Usage of d3
d3 was used to avoid cluttering the code with logic for handling namespaces, since jQuery (and AngularJS' jQuery lite) doesn't handle namespaced DOM (e.g. SVG DOM).
No controllers!
If you've seen AngularJS examples before, you might've noticed that most examples start off with declaring a controller function. However, this wasn't necessary in the examples here.
As we're merely displaying data that is entered by the user and not manipulating it, there's no need to have a controller.
Since a controller wasn't declared, the root scope is the only scope in this Angular application. Hence, the model declared in the ng-model
is assigned onto the root scope.
Most Angular applications have at least one controller to perform operations on the scope's data. For example, the most common example of such an operation is to send the scope data to the server through AJAX.