Delegating Nested Directive Behavior To Parent Directive In AngularJS
In an AngularJS JavaScript web application, the part of your code that pipes user behaviors into controller methods is known as the, "directive." Out of the box, AngularJS ships with many powerful directives like ngClick, ngRepeat, ngSubmit, etc.; but, AngularJS also allows you to define as many custom directives as you want. This is awesome; but, the downside to directives is that they have to uniquely named. This makes sense for highly-reusable directives, like ngClick; but, when it comes to a complex and unique user interface, nested directives can lead to overly verbose naming conventions. As such, I wanted to see if I could delegate the linking of related directives up to a common parent directive in order to keep "one-off" directive behaviors in a single location with contextual names.
Imagine that I have a list of items in which there is behavior at both the list level and the individual list item level. To capture and manage this behavior, you might normally create two directives, one for the list and one for the list item:
- bn-list-helper
- bn-list-item-helper
Now imagine that you need to create a second list in the same application with similar, but slightly different behavior - different enough to prevent reuse of the pervious directives. To accomodate this, you might rename the original directives and then create two new ones:
- bn-a-list-helper
- bn-a-list-item-helper
- bn-b-list-helper
- bn-b-list-item-helper
Because directives have to be uniquely named within a single AngularJS application (even if separated into different modules), you have to come up with a naming convention that allows similar directives to be differentiated by the AngularJS compiler. Hence, the renaming of the directives in the previous list.
But, what if we could simply have a uniquely named "root" directive for a given UI; and then, provide non-uniquely named nested directives within than UI? We can't do this using HTML attributes, since the compiler is not contextual. However, the $scope of an element is contextual. What if we could create a directive that delegates nested directive linking up to a common parent directive on the same $scope chain?
To play around with this idea, I've created a tiny directive, bnDelegateDirective, which does nothing more than provide a link function that invokes a method - delegateDirectiveLinking() - on the $scope. It is then left up to an ancestral directive to provide that $scope method. Essentially, this allows the ancestral directive to implement the link function for those nested directives.
The only thing the nested directives have to do, then, is provide a contextually-unique identifier for the directive. So, using the list from above, this could be defined as:
bn-delegate-directive="listItem"
Clearly, "listItem" is not globally unique to the application; but, it could easily be unique to the current $scope and directive context.
This is a bit funky, so looking at the code will help:
<!doctype html>
<html ng-app="Demo" ng-controller="DemoController">
<head>
<meta charset="utf-8" />
<title>
Delegating Nested Directive Behavior To Parent Directive In AngularJS
</title>
<style type="text/css">
ul {
font-size: 20px ;
}
li {
margin-bottom: 5px ;
width: 300px ;
}
span.remove,
span.remove a {
color: #999999 ;
}
span.remove a:hover {
color: #CC0000 ;
}
</style>
</head>
<body>
<h1>
Delegating Nested Directive Behavior To Parent Directive In AngularJS
</h1>
<form ng-submit="addFriend()">
<p>
New Friend:
<input type="text" ng-model="newFriendName" size="25" />
<input type="submit" value="Add New Friend" />
</p>
</form>
<h2>
You Have {{ friends.length }} Friends
</h2>
<!--
Notice that my UL has a "helper" directive. This will take
care of adding behavior to the list that cannot be provided
[easily] using the core AngularJS hooks.
-->
<ul bn-list-helper>
<!--
As we repeat the list, each list item is going to have
it's own behavior; but rather than creating a directive
just for the list items, we'll simply delegate to the
"list helper" directive that we already have in play.
-->
<li
ng-repeat="friend in friends"
bn-delegate-directive="listItem">
{{ friend.name }}
<span class="remove">
( <a ng-click="removeFriend( friend )">remove</a> )
</span>
</li>
<!--
Demonstrate that this can be used outside of ngRepeat
due to the fact that we're defining a directive Controller.
-->
<li bn-delegate-directive="listItem" bn-is-last="true">
Static list item, not in ngRepeat.
</li>
</ul>
<!-- Load jQuery and AngularJS from the CDN. -->
<script
type="text/javascript"
src="//code.jquery.com/jquery-2.0.0.min.js">
</script>
<script
type="text/javascript"
src="//ajax.googleapis.com/ajax/libs/angularjs/1.0.4/angular.min.js">
</script>
<script type="text/javascript">
// Create an application module for our demo.
var Demo = angular.module( "Demo", [] );
// -------------------------------------------------- //
// -------------------------------------------------- //
// I am the controller for Demo.
Demo.controller(
"DemoController",
function( $scope ) {
// I add a new friend to the collection.
$scope.addFriend = function() {
if ( ! $scope.newFriendName ) {
return;
}
$scope.friends.push({
id: ( new Date() ).getTime(),
name: $scope.newFriendName
});
$scope.newFriendName = "";
};
// I remove the given friend from the collection.
$scope.removeFriend = function( friend ) {
var index = $scope.friends.indexOf( friend );
$scope.friends.splice( index, 1 );
};
// I am the model for the new friend name.
$scope.newFriendName = "";
// I am the initial list of friends to render.
$scope.friends = [
{
id: 1,
name: "Sarah"
},
{
id: 2,
name: "Tricia"
},
{
id: 3,
name: "Joana"
}
];
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I am the helper directive for the demo.
Demo.directive(
"bnListHelper",
function() {
// I bind the DOM events to the scope.
function link( $scope, element, attributes ) {
// Add behavior to the list as a whole (just for
// demonstrating purposes).
element.on(
"mouseenter",
"li",
function( event ) {
console.log(
$( this ).text().replace( /\s+/g, " " )
);
}
);
} // END: Helper Link Function.
// I am the linking function for the list item directive
// which is being delegated to the list helper.
function linkListItem( $scope, element, attributes ) {
// Log that the link has been delegated!
console.log( "linking delegated:", element );
// Don't override the removeFriend for the last
// list item. We only have to do this here because
// the last list item is not in the ngRepeat, and
// therefore does NOT have its own scope.
if ( attributes.bnIsLast ) {
return;
}
// When a user removes a friend, we don't want to
// remove it immediately - we want to hide it
// gradually and then pass the "remove" request up
// to the parent scope.
$scope.removeFriend = function( friend ) {
element.slideUp({
duration: 500,
queue: false
})
element.fadeOut({
duration: 500,
queue: false,
always: function() {
$scope.$parent.removeFriend( friend );
$scope.$apply();
}
});
};
// Show the new element gradually.
element
.hide()
.fadeIn( 1000 )
;
} // END: Delegate Link Function.
// Return the directive configuration. In this case,
// we have to define the Controller for the directive
// since this is the only way we can access the $scope,
// pre-linking (without a compile function). If we try
// to provide this hook in the parent "link" function,
// we'll miss the directives that were linked in the
// depth-first, bottom-up linking lifecycle.
return({
controller: function( $scope ) {
// Provide a hook for the nested directive such
// that it can delegate its linking method to
// the overall helper.
$scope.delegateDirectiveLinking = function( type, $scope, element, attributes ) {
if ( type === "listItem" ) {
linkListItem( $scope, element, attributes );
}
};
},
link: link,
restrict: "A"
});
}
);
// -------------------------------------------------- //
// -------------------------------------------------- //
// I attempt to delegate the linking responsibilities of this
// directive up to a parent directive that has exposed the
// appropriate delegate method.
Demo.directive(
"bnDelegateDirective",
function() {
// I bind the DOM events to the scope.
function link( $scope, element, attributes ) {
// Check to see if a parent directive has exposed
// a delegating link method.
if ( ! $scope.delegateDirectiveLinking ) {
return;
}
$scope.delegateDirectiveLinking(
attributes.bnDelegateDirective,
$scope,
element,
attributes
);
}
// Return the directive configuration.
return({
link: link,
restrict: "A"
});
}
);
</script>
</body>
</html>
As you can see, the list items in the ngRepeat use the bnDelegateDirective directive to pass the linking responsibility of the LI DOM nodes up to the ancestral directive, bnListHelper. The bnListHelper directive then checks the unique type and invokes the appropriate linking function. In this case, there is only one type; but you can easily imagine multiple types of nested, related directives.
Obviously, the more generic and more reusable that we can make our directives, the better off our code reuse will be. However, with a complex user interface, we will certainly find ourselves in situations where we have to create a number of one-off directives, tailored to a single user interface. In such situations, delegating linking behavior could ease the burden of unique naming and make those directives more contextual.
Want to use code from this post? Check out the license.
Reader Comments
The idea is nice, but I think that's too much boilerplate for a one-off directive. You need to generalize it with a constructor.
@Leonardo,
Keep in mind that the bnDelegateDirective would be re-used in any of these kinds of situations; so, don't think of that as part of the boilerplate. The real pain-point that I see is the routing of the delegated linking function. I can't currently think of a better way to do this.
I wonder if I am trying to solve the wrong problem? My gut feeling is that I don't like having to create these unfortunately named directives. However, when it comes to Controllers, I don't mind it at all, since i can namespace them, example:
app.controller( "project.screens.ScreensControllers, fn )
If I could find a way to "namespace" directives, then I think I would care much less about the names. Something like:
app.directive( "modal.newProject.FormHelper" )
... the issue would then be - how to define that in the HTML?
Hey Ben,
If you have directives with small differences, why can't you just inject what's different into a single directive using an attribute? This would not require switch logic either, as you could simply implement the differing pieces of code as methods on the parent controller.
Am I missing something?
Cheers,
Jonah
Jonah,
Might this be a more attractive solution in a case where you had a fairly large amount of re-use intended and did not want to impact the code base of every other consumer each time a new variant was introduced to the mix?
Or perhaps for a case where you were implementing a reusable directive library that was intended to be deployed without modification and you wanted to create a directive that expands to a fairly complicated DOM tree.
Angular's attributes and nested directives give you a lot of flexibility to modularize large subtrees into discrete building blocks and to use attributes the manipulate the contents of those blocks.
But what if you also had a desire to provide users with the ability to interface with the view model where they placed this behemoth directive?
Transclusion only gets you a single ticket to ride. That's kind of like a Christmas tree with just a star at the top, but no ornaments on the branches.
I'm pleased to stumble across this post today, as I've been contemplating a similar issue where a co-worker has crafted a directive for managing Bootstrap-style Tab/Content panel sets. He's attempting to use it to create a multi-panel form.
Complicating matters, he has a desire to bind substrings within his parent scope's target model. The resulting chain of isolated scopes to bind the authoritative model from within the tab/panel directive's various scopes, has pretty much degenerated into a simulation of deep sea SONAR.
Each change in a nested form widget sets off an upstream emit. An additional <div> breaks the domain-oriented nature of the remainder of the HTML, but serves a viral purpose by acting as a local root that catches the emit and handles by generating a broadcast so that every custom element in the tree has a chance to see the state change.
I hadn't thought about delegating the link function, but was toying with the idea of working at the level of transclude. My intent was ta set of nested and semantically dependendt directives to prune the subtree from the source DOM, $compile it, and then use it as a template to replicate and bind (which I guess does effecively involve delegating the link function). I'm nowhere near as far along as your example, but a strawman could look like:
<root-panel-container>
<user-transclude id="ButtonOne">
<button ng-show="buttonOneOn" ng-click="buttonTwoOne = true"; buttonOneOne = false; />
</user-transclude>
<user-transclude id="ButtonTwo">
<button ng-show="buttonTwoOn" ng-click="buttonOneOne = true"; buttonTwoOn = false; />
</user-transclude>
<panel index="1" title="{{titles[0]}}">
<multi-transclude tc-id="ButtonOne" />
</panel>
<panel index="1" title="{{titles[0]}}">
<multi-transclude tc-id="ButtonOne" />
</panel>
</root-panel-container>
I should add a follow-up note that the <user-transclude> and <multi-transclude> directive would have to be a compile time directive
Gah, I just noticed that the names I assigned don't really capture the symmetry, but the reason for injecting these as compile directives would be the way that they manipulate the DOM tree.
The user-wormhole end of directive pair would compile its nested content with the $compile service, sever itself from the DOM tree and terminate, preventing the Angular compiler from descending into the subtree, and storing a pair of $compile output + parent $scope somewhere they can be retrieved later.
I'm not yet clear whether that somewhere exists, but I suspect that if it does it's either in the controller of a parent directive for <user-wormhole> and <mutl-transclude> or the cross-directive Attributes object.
The multi-transclude end would link to a compiled element /scope pair, do something somewhere between ng-repeat and ng-transclude by linking a clone of its source material (OR perhaps no) depending on whehter repetition is or isn't a viable use case (I suspect not since there is no iteration to differentiate repeats.
@John,
It seems like you've thought about this problem a lot, so I'm sorry to say I can't quite follow your example. A plnkr or jsbin would probably go a long way in clarifying the problem for me.
Anyway, it's possible there is a real need for these gymnastics to solve your issue, but my gut is that this kind of complexity is the kind of thing angular is meant to let us avoid, so maybe there is a simpler solution you haven't found yet?
@John,
I'm also having a little bit of trouble following your example; but the idea of having a complex form is definitely something that I had in mind when I was playing with this idea. Some things that come to mind for forms:
* Auto-focusing a given form field after the form has been submitted (for multiple entries, one after the other).
* Auto-focusing the first "error" field after the form has been submitted.
* High-lighting a field in yellow after it has been saved / generated.
* Selecting the text of a field after it has been saved / generated.
@Jonah,
For many, if not all of these things, you could create shared directives and then using HTML attributes to try and tease out the differences. For example, I do have a "bn-autofocus" directive that will call .focus() on a form field when it becomes visible for the first time (ie. when it has rendered dimensions - height / width).
But, I find that that when you need a number of directives to play together in a coordinated way, sometimes it's easier to just create one-off directives rather than try to make smaller directives more general.