By Vittorio Zaccaria
I’ll admit that AngularJS directives have always been a bit tough for me. Maybe because I am getting old – what do I know? The fact is that I’ve always struggled to understand and memorize its cryptic documentation. Besides, whenever I deal with Angular objects, I inevitably end up writing code that is bloated with $scope
accesses. You get the idea.
However, creating a self-contained, custom HTML element that can be be reused inside all of my projects has always been in the back of my mind. So, I decided to give Angular’s directives another try. This time, however, I wanted to see whether rethinking the directives in terms of a language extension could result in a more synthetic and palatable approach for me (and for anybody else).
Enter SweetJS
I am playing with domain specific languages and macros for some time. The (almost) new kid on the block in the Javascript world is Mozilla’s SweetJS. SweetJS allows you to create language extensions to ‘sweeten up’ your Javascript, so I thought: wouldn’t be fantastic create a clean, trivial syntax for defining my Angularjs directives for use in my projects?
Aaron Powell had already tried to create macros for Angular factories and modules. This left me wondering if a similar approach could be applied to Angular directives.
Sweet Angle
Sweet Angle is the result of this challenge. It is based on macro expansion and covers the majority of the cases in which you may need a directive.
For example, let’s assume we want to write a directive for a progress bar:
<div id='pb' progress-bar value='statusToBeShown' color='blue' max='100'></div>
The directive
has three attributes:
value
represents the current percentage to be shown on the progress bar.color
represents the name of the class to be used for coloring the bar.max
represents the maximum value thatvalue
can assume.
color
and max
are passed by value. Passing an attribute by value means that whatever change is done on that attribute by the directive will not be seen outside the directive itself. It’s a one-way bind in Angular’s world. In the case above, we pass two constants to color
andmax
. However, we could have passed a more complex Angular expression (where variables of parent scopes are surrounded by double braces).
value
instead, is passed by reference. This means that, if, for whatever reason, the directive changes the value of that attribute, it will be seen outside of the directive. It’s a two-way bind in Angular’s world. This is why you can only specify the name of a property on a parent’s scope (statusToBeShown
in this case).
Let’s write the beginning of the directive in Sweet Angle:
directive progressBar {
import /* none */;
byref value; /* two-way angularjs bind */
byval color, max; /* one-way angularjs bind */
callback /* none */;
...
In the actual implementation, a Sweet Angle directive header should specify all of its features, even if it does not use them (I fought a battle with SweetJS on this, and it won!). Also, you should follow the exact order you see above.
The import
keyword should be followed by optional Angular module names to be imported. An imported module
will be automatically available as $scope.module
is in the isolated scope created by the directive. If you wanted to import Q and Restangular, you would write:
...
import $q, $restangular;
...
The byref
clause is used to create a local scope variable that mirrors another one in the parent scopes (it uses Angular’s scope qualifier ‘=
‘ internally).
The byval
clause is used to create a one way bind, from the parent scope(s) to the local one (it uses Angular’s scope qualifier ‘@
‘ internally).
callback
can be used to pass one or more callbacks that the directive could call during is lifetime (Sweet Angular uses Angular’s scope qualifier ‘&
‘ internally for this).
Next we specify the HTML template by using Jade’s syntax. Sweet Angle tells Angular to completely replace the original element with the template specified:
template `
div
.ui.progress.transition.active(ng-class='[visibility, color]')
.bar(style='{{bsize}}')
`;
Note two additional scope variables used here: visibility
and bsize
. These will be initialized and modified by the methods of the directive.
As a last step, we define how the directive is created with the create
method. This is converted by Sweet Angle into the directive’s link
function with some de-sugaring:
create
is invoked havingthis
mapped to$scope
.@
can be used as an alias forthis
.
First, let’s save the parameter elem
into the local scope, for convenience:
create(elem, attr) {
@elem = elem; /* This is converted to $scope.elem = elem */
...
Then let’s start by making the progress bar invisible:
...
@visibility = 'hidden';
...
Next, define a directive method that enables us to toggle the visibility of the bar:
...
@toggle = function() {
if(@visibility === 'hidden') {
@visibility = '';
} else {
@visibility = 'hidden';
}
}.bind(@) // <<- Bound to `this`.
...
Now, we need to take care of the changes to $scope.value
(or @value
) to visually update the bar. First we define an event listener that updates $scope.bsize
with the new bar size:
@onValueChange = function() {
var perc;
perc = @value/parseFloat(@max)*window.jQuery(@elem).width();
@bsize = "width: "+perc+"px;"
}.bind(@) // <<- Bound to `this`.
Second, we register the event listener on any change to value
:
...
@$watch('value', @onValueChange);
} /* ends create */
}; /* ends the directive */
...
Result
The complete directive is here:
directive progressBar for application {
import /* none */;
byref value;
byval color, max;
callback /* none */;
template `
div
.ui.progress.transition.active(ng-class='[visibility, color]')
.bar(style='{{bsize}}')
`;
create(elem, attr) {
@visibility = 'hidden';
@elem = elem;
@toggle = function() {
if(@visibility === 'hidden') {
@visibility = '';
} else {
@visibility = 'hidden';
}
@onValueChange();
}
@onValueChange = function() {
var perc;
perc = @value/parseFloat(@max)*window.jQuery(@elem).width();
@bsize = "width: "+perc+"px;"
}
@$watch('value', @onValueChange);
} /* ends create */
};
In order to convert this into vanilla JavaScript use the command:
> sweet-angle directive.sa -a application > directive.js
The result is the following:
angular.module('application').directive('progressBar', function () {
console.log(arguments);
var ret = {};
ret.restrict = 'A';
ret.template = '<div><div ng-class="[visibility, color]" class="ui progress transition active"><div style="{{bsize}}" class="bar"></div></div></div>';
ret.replace = true;
ret.scope = {};
ret.scope.value = '=';
ret.scope.color = '@';
ret.scope.max = '@';
ret.link = function (s) {
(function (elem, attr) {
this.visibility = 'hidden';
this.elem = elem;
this.toggle = function () {
if (this.visibility === 'hidden') {
this.visibility = '';
} else {
this.visibility = 'hidden';
}
this.onValueChange();
};
this.onValueChange = function () {
var perc;
perc = this.value / parseFloat(this.max) * window.jQuery(this.elem).width();
this.bsize = 'width: ' + perc + 'px;';
};
this.$watch('value', this.onValueChange);
}.apply(s, Array.prototype.slice.call(arguments, 1)));
};
return ret;
});
Accessing the directive scope
To get to the isolated scope of the directive, e.g., to invoke its methods, you can use the following helper:
window.$ = function(sel) {
turn angular.element(document.querySelector(sel)).isolateScope();
}
So, for example, you can toggle the progress bar visibility by using:
$('#pb').toggle()
Limits of this approach
Sweet Angle is a preliminary experiment, so it has its own limits. In particular, I didn’t find a simple way to provide different shapes of the directive without copying and pasting the code of the template. For example, if I wanted to provide a templateURL
instead of template
, I’d
have to write two macros. This problem increases exponentially if you consider the ordering of the directive features (import
, byref
, etc.). So I think that SweetJS’ capabilities for modularization should be somewhat improved.
Nevertheless, this has significantly improved my ability to create Angular directives. Now I am much more focused on the functionality of the directive itself instead of being trapped into Angular’s cryptic documentation and the somewhat redundant resulting code.
Thanks for the article, but for me the “macro approach” seems more cryptic. (But it can be caused by fact that I already program in Angularjs for about a year, so it does not surprise me).
Code snippets could be helpful to you.
It helps you to focus on functionality of the directive rather that digging the documentation and remembering it all by heart. Nice example of macros is here https://www.johnpapa.net/angularjs-code-snippets-for-visual-studio/. I use them as a template (or I copy paste existing directive 😉 )
Yeah, just writing out the directive object (in regular object notation, not the way it was compiled) would be just as quick. Much better also to learn the options a directive can take because the terminology (scope, link, transclude) is important on a wider scale.
Interesting idea, the isolate scope binding mechanisms are indeed one of the most obscure parts of Angular. I think it was unfortunate that ‘@’, ‘=’, and ‘&’ started being called ‘one-way’, ‘two-way’, and ‘function callback’, because that terminology is misleading in my opinion. The original documentation under $compile was accurate and did not talk that way, except to say that ‘=’ sets up bi-directional binding. ‘@’ actually binds a DOM attribute *string*, it is inappropriate for any other type — being one-way is not the distinguishing characteristic. ‘=’ binds a *scope property*, I think of it as piping the property between the two scopes (= looks like a little pipe). And ‘&’ binds an *expression* which can be evaluated *on demand* (not automatically on every digest loop, like the other two). When first learning directives my team was hopelessly confused about the differences, e.g., not knowing when to interpolate a value with {{ }} into @, versus using = or &.