April 4, 2016

Transclusion Part 2 - Multi Transclusion

Basic transclusion is great for solving simple use cases, but sometimes you need more than a simple container. Advanced transclusion techniques can be very powerful, allowing you to essentially create your own templating language for use in certain components. In this article I will cover advanced transclusion techniques and how to use them to build a card component with a consumer specified header, body, and footer.

Before we dig into the technical details of advanced transclusion, I want to talk about why this technique is so powerful. I mentioned a card component as a use case for this. Typically, when creating a card, you’ll use CSS to set the look and feel. Then in code, you might implement it so clicking the header of the card will toggle the visibility of the body and the footer, allowing you to open and close the card. Nothing too crazy here. A simple flag and toggle function will do. In our markup, building the card is as simple as adding a few classes, an ng-click, and an ng-show/ng-if.

<div class=”card”>
	<div class=”header” ng-click=”controller.toggle()”>My header</div>
	<div ng-show=”controller.open”>
		<div class=”body”>My body</div>
		<div class=”footer”>My footer</div>
	</div>
</div>
// controller definition
this.open = false;
this.toggle = function() {
	this.open = !this.open;
}

(Note: You may want to set var self = this at the top of your controller instead of relying on the fact that this will still be the controller instance when toggle is called)

At this point, you may not be convinced why building a reusable component for this is so important. You already have reusable styling, and the open-close behavior is as simple as four lines of javascript and two angular directives. What if we want to tie multiple cards together into a list? Now the behavior becomes more complicated. Each card needs to signal the list when it wants to close. The list should then tell all cards or the active card to close. If the active card closes successfully we can signal the original card that it can now open. This is not terribly difficult to implement, but it becomes cumbersome if you want to re-implement it for each instance of a list of cards. What we want here is to create a reusable card component and a simple container component to go with it.

It shouldn’t take long for us to see here that basic transclusion is not going to be sufficient here. First of all, if we had the consumer specify the header, body, and footer content directly, we wouldn’t be able to tie in the open close behavior.

<my-card>
	<div class=”header”>My header</div>
	<div>
		<div class=”body”>My body</div>
		<div class=”footer”>My footer</div>
	</div>
</my-card>

In theory we could use JQuery to select the header and the main content div tag and attach the behavior directives, but this would require additional work to make sure the angular directives actually render once they’re injected into the template. Angular gives us one better with multi-transclusion. Multi-transclusion allows us to let the consumer specify sections of the injected content in their markup, and we can inject those sections into the proper part of the card component. As of angular 1.4, this is only achievable using transclusion and JQuery in tandem. However, angular 1.5 is slated to provide a new feature for specifying multiple transclusion in the directive definition. This is also supported in the new projection system in angular 2. I will cover both of these later in this article.

So we’ve already determined that we want the consumer to be able to define sections of their content. The nicest way to do this is by specifying custom tags that can be used to define the content sections. This allows the consumer to simple specify their markup as follows:

<my-card>
	<card-header>My header</card-header>
	<card-body>My body</card-body>
	<card-footer>My footer</card-footer>
</my-card>

This is cleaner than the earlier example, and also allows us to abstract away the underlying CSS classes.

How can we achieve this in angular 1.4? Clearly, the basic transclusion method we used before will not be sufficient. For more advanced use cases, angular provides a transclude function, which can be used to programmatically inject the transcluded content. Once you enable transclusion in your directive (transclude: true) this function is passed as the fifth parameter to your link function. Alternately, you can inject it into your controller as $transclude. Generally it is best practice to keep any DOM manipulation encapsulated in your link function, while the simpler javascript logic should live in the controller. This makes things much easier to test as the controller doesn’t have to depend on the mock implementation of $transclude.

The angular transclude function has three distinct signatures:

transclude(scope, function(clone, transcludeScope) {});
transclude(function(clone, transcludeScope) {});
var content = transclude();

Each of these has slightly different behavior. We’ll briefly go over what makes each one unique, and then discuss how we can use them to solve our use case. The first signature is the most versatile of the three. With the first parameter you specify the scope context that the transcluded content should render in. The second is a function for specifying how to inject the transcluded content. Angular provides a clone of the transcluded content as the first parameter and the scope context as the second. (In this case this is the same scope we specified as the first parameter of the transclude function)

This combination is powerful, but most cases the second signature should suffice. This one operates identical to the first, except that angular defaults the scope context of the transcluded content to a new scope that inherits from the parent context, where the transcluded content was specified. In most cases, this is the best fit, as it means the consumer can bind to properties in his own controller from the transcluded content. Note that this identical to using the first signature with scope.$parent.$new() as our scope context.

I’m not going to go into a lot of detail about the last signature. The main thing to be aware of is that the content returned by the function is the original transcluded content and not a clone. In some cases, this could work just as well as the other signatures. We just have to be careful, since this could have other effects, particularly if we want to repeat the transcluded content.

For our use case, the second signature should be adequate. By combining JQuery with the transclude function, we can select the consumer’s content sections and inject them into the designated targets in the template.

link: function(scope, element, attrs, controller, transclude) {
	transclude(function(clone) {
		var header = clone.filter('card-header'); 
		var body = clone.filter('card-body');
        var footer = clone.filter('card-footer');

        var headerArea = element.find('.header');
		headerArea.append(header); 

		var bodyArea = element.find('.body');
		bodyArea.append(body);

		var footerArea = element.find('.footer');
        footerArea.append(footer);
    }
},

Note that this implementation uses the card classes to find where we want to put the transcluded content. We can also specify custom classes or ids to specify a different hit target. This is useful if you want to have other markup around the transcluded content, such as a clearfix div. Unfortunately this solution also requires JQuery filter, which is not available in Jqlite and would require the full version to be installed. There are other workarounds for this, such as programmatically wrapping the transcluded content in a div before running JQuery find on it. For a detailed description of the difference between find and filter: http://www.mkyong.com/jquery/difference-between-filter-and-find-in-jquery/.

While this technique is very powerful, it’s also quite complicated and can be hard to debug if issues come up. A better solution would be to use tools within angular itself to accomplish this. While angular does not have built in support for multi-transclusion in 1.4, they plan to add a feature to accommodate this in the 1.5 release. This feature extends the transclude option by allowing you to specify an object instead of a Boolean. The keys correspond to elements you want to select from the content of the consumer, or card-header, card-body, and card-footer, in our case. The values should be strings you can use to inject them into your template. For the injection, we can fall back to using ng-transclude, only now we can also specify the value of the transclusion group. Also note that you can prefix the value with ‘?’ in your transclude definition to indicate that this should be an optional transclusion.
Now let’s put it all together.

function myCard() {
return {
    restrict: 'E',
    transclude: {
        cardHeader: ‘headerTarget',
        cardBody: 'bodyTarget',
        cardFooter: ‘footerTarget’,
    },
    template: 
        ‘<div class=”header” ng-transclude=”headerTarget”>My header</div>’ +
        ‘<div>’ +
        	‘<div class=”body” ng-transclude=”bodyTarget”>My body</div>’ +
        	‘<div class=”footer” ng-transclude=”footerTarget”>My footer</div>’ +
        ‘</div>’,
    };
}

This makes handling complex transclusion significantly easier, and we can now get rid of the complex JQuery in our link function.

In the last part we touched briefly on the angular 2 counterpart for transclusion, now called projection. Angular 2 greatly simplifies the process while retaining much of the power of transclusion in angular 1.x. As we discussed previously, projection is applied by plugging an ng-content tag into our component’s template. There is no other configuration that needs to be specified.

Projection also natively supports multiple content slots. The syntax for this is even simpler and easy-to-use than multi-transclusion in angular 1.5. ng-content allows for setting a ‘select’ attribute. As the name indicates, this attribute is used to ‘select’ a subset of the child content. This is both simpler than multi-transclusion, and at the same time more versatile. The select syntax functions similar to the selector for angular 2 components, so we can use square brackets ‘[]’ to select using an attribute, or a dot ‘.’ to select using a class. For our purposes, a simple tag selection should suffice. This further simplifies our component definition to a simple template component.

import {Component, View} from 'angular2/angular2';

@Component({
    selector: ‘my-card’
})
@View({
    template: `
        <div class=”header”><ng-content select=”card-header”></ng-content></div>
        <div>
    	    <div class=”body”><ng-content select=”card-body”></ng-content></div>
    	    <div class=”footer”><ng-content select=”card-header”></ng-content></div>
        </div>
    `,
  directives: []
})
export class MyCard {}

This gives us a basic understanding of the complex concept of multi-transclusion, and how to accomplish it in angular 1.4, 1.5, and 2.0.