Skip to main content
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Monika Rebsdat
Ben Nadel at CFCamp 2023 (Freising, Germany) with: Monika Rebsdat

Creating An Isolate-Scope Directive With Multiple Transclusion Points In AngularJS

By
Published in Comments (7)

When creating a component directive, in AngularJS, transcluding a single chunk of content is fairly easy; you define the directive as using transclusion and the AngularJS $compile() function takes care of the rest, creating an isolate scope and providing a transclude() function to your linker. But what if you wanted to create more of a "layout component" that coordinates several chunks of content? This is not such an obvious task with the current directive options. But, as a fun experiment, I wanted to see if I could create an AngularJS directive that included multiple points of transclusion.

Run this demo in my JavaScript Demos project on GitHub.

As a side note, when I was noodling on this problem, I happen to come across this recent commit in the AngularJS code base. It looks like Pete Bacon Darwin is actually building this very concept into the AngularJS core. So, in the near future, this should be a much more straightforward task using an object-based trasnclude definition.

When it comes to AngularJS directives, the moment you use "template" or "templateUrl" in your configuration object, you lose access to the content of the element as it appears in the HTML. This is because, at that point, all references to the template-element or the linked element pertain to the "template" and not to the contents (ie, children) of the directive.

In a previous post, however, I demonstrated that we could compile transcluded content by defining a single directive at two different priorities. Using this same approach, we can extract sub-content areas with one priority and then transclude them within a lower priority of the same directive.

This is a bit complicated, but after a few mornings of trial-and-error, I finally got something "somewhat satisfactory" working. I have a Layout component that lets multiple content areas be transcluded through the use of "panels." In order to get this working, I had to break the "layout" component directive into two priorities:

Priority -1: Defines a terminal directive that provides the controller and defines the isolate scope bindings, but not the template. By omitting the template, I retain access to the content of the natural element, which I can query and extract. The use of terminal prevents the children (and lower priority directives) from being compiled and linked. This makes sense since we don't want the contents compiled as-is since they will be transcluded.

Priority -2: Defines the directive template and transcludes the sub-content areas using the linking functions provided by the higher priority.

The higher priority directive defines the controller and then, during the linking phase, injects the sub-content linking functions into the controller. The lower priority directive then requires that controller and uses the provided linking functions to transclude the sub-content areas. Each linking function is keyed by the "role" attribute found in the original content:

<!doctype html>
<html ng-app="Demo">
<head>
	<meta charset="utf-8" />

	<title>
		Creating An Isolate-Scope Directive With Multiple Transclusion Points In AngularJS
	</title>

	<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController as vm">

	<h1>
		Creating An Isolate-Scope Directive With Multiple Transclusion Points In AngularJS
	</h1>

	<!--
		This Layout component creates an isolate scope, but transcludes the
		contents of each one of the embedded "panels" so that the content of
		each panel can access the outer scope of the context while the structure
		of the component can remain encapsulated within the isolate template.
	-->
	<layout size="vm.size">
		<layout-panel role="header">

			Layout Header

		</layout-panel>
		<layout-panel role="body">

			<!--
				These values will all be bound to the outer scope, NOT to the
				ISOLATE scope.
			-->

			<p>
				Layout Body - Size: {{ vm.size }}
			</p>

			<p>
				<a ng-click="vm.toggleSize()">Toggle Size</a>
			</p>

			<p>
				Sanity Check: {{ vm.message }}
			</p>

		</layout-panel>
		<layout-panel role="footer">

			Layout Footer

		</layout-panel>
	</layout>


	<!-- Load scripts. -->
	<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
	<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.5.min.js"></script>
	<script type="text/javascript">

		// Create an application module for our demo.
		var app = angular.module( "Demo", [] );


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I control the root of the application.
		angular.module( "Demo" ).controller(
			"AppController",
			function AppController( $scope ) {

				var vm = this;

				// I hold the desired size of the layout. This is passed into the
				// layout component as a isolate-bound attribute.
				vm.size = "small";

				// I hold a sanity-check value to make sure the isolate layout scope
				// and the main scope are not colliding.
				vm.message = "Hello world!";

				// Expose public methods.
				vm.toggleSize = toggleSize;


				// ---
				// PUBLIC METHODS.
				// ---


				// I toggle the currently-desired size of the layout component.
				function toggleSize() {

					vm.size = ( vm.size === "small" )
						? "large"
						: "small"
					;

				}

			}
		);


		// --------------------------------------------------------------------------- //
		// --------------------------------------------------------------------------- //


		// I provide a layout with thee panels - header, footer, and body.
		// --
		// NOTE: This component directive is broken up into two different priorities.
		// We need to do this because we need to access the element content before the
		// "template" is applied, which is not currently possible in a single directive
		// (that I know of). As such, we have two passes:
		// --
		// The first pass defines the controller and inspects the element and gathers
		// up the linking functions for the sub-content areas.
		// --
		// The second pass pulls in the template, replaces the element content
		// (automatically) and performs the sub-content transclusion using the linking
		// functions compiled in the first pass.
		angular.module( "Demo" ).directive(
			"layout",
			function layoutDirective( $compile ) {

				// Return the directive configuration.
				// --
				// NOTE: We are creating an isolate-scope along with a Controller.
				return({
					controller: LayoutController,
					controllerAs: "vm",
					compile: compile,
					priority: -1,
					restrict: "E",
					scope: {
						layoutSize: "=size"
					},
					terminal: true
				});


				// I compile the contents of the individual panels and make the
				// resultant linking functions available on the controller.
				function compile( tElement ) {

					// I hole the linking functions, indexed by role.
					var linkFunctions = {};

					// Find each <layout-panel> and compile the content.
					tElement
						.children( "layout-panel" )
						.each(
							function iterator( i, node ) {

								var panel = angular.element( node );
								var role = panel.attr( "role" );

								if ( role ) {

									linkFunctions[ role ] = $compile( panel.contents() );

								}

							}
						)
					;

					return( link );


					// I continue the linking process of the terminal workflow.
					function link( scope, element, attribute, controller ) {

						// When the first pass on the Layout links, we have a chance to
						// expose the sub-content linking functions on the controller.
						controller.linkFunctions = linkFunctions;

						// Since this pass was "terminal", we now have to explicitly
						// compile and link the second-pass. Note that we are linking it
						// to the ISOLATE SCOPE so that the template in the second-pass
						// will be linked to the proper scope, not the parent scope.
						$compile( element, null, -1 )( scope /* Isolate scope. */ );

					}

				}


				// I control the Layout component directive.
				function LayoutController( $scope ) {

					var vm = this;

					// Create a ReactJS-inspired props object for bound attributes.
					var props = $scope.props = $scope;

					// As a sanity check, let's define a value that we know will also
					// be defined on the parent scope. This way, we can see if any
					// scope collision takes place.
					vm.message = "From isolate scope.";

				}

			}
		);

		// I manage the second pass on the layout component directive, providing the
		// component template and transcluding the sub-content areas.
		angular.module( "Demo" ).directive(
			"layout",
			function layoutDirective() {

				// Return the directive configuration.
				return({
					link: link,
					require: "layout",
					restrict: "E",
					priority: -2, // Ensure lower priority than first-pass.
					template:
					`
						<div class="layout-inner" ng-class="{ large: ( props.layoutSize == 'large' ) }">
							<div class="layout-header">
								<!-- Transcuded. -->
							</div>
							<div class="layout-body">
								<!-- Transcuded. -->
							</div>
							<div class="layout-footer">
								<!-- Transcuded. -->
							</div>
						</div>
					`
				});


				// I transclude the sub-content areas using the controller-
				// provided linking functions and bind JavaScript events to the
				// local view-model.
				function link( scope, element, attributes, controller ) {

					// Since the first pass on the layout component explicitly linked
					// this lower-priority directive on the ISOLATE scope, we need to
					// go up to the parent scope to access the content scope.
					var transcludeScope = scope.$parent;

					// Locate the transclusion containers.
					var inner = element.children( "div.layout-inner" );
					var headerPanel = inner.children( "div.layout-header" );
					var bodyPanel = inner.children( "div.layout-body" );
					var footerPanel = inner.children( "div.layout-footer" );

					// Link and inject the header.
					controller.linkFunctions.header(
						transcludeScope,
						function attachClone( clone ) {

							headerPanel.append( clone );

						}
					);

					// Link and inject the body.
					controller.linkFunctions.body(
						transcludeScope,
						function attachClone( clone ) {

							bodyPanel.append( clone );

						}
					);

					// Link and inject the footer.
					controller.linkFunctions.footer(
						transcludeScope,
						function attachClone( clone ) {

							footerPanel.append( clone );

						}
					);

				}

			}
		);

	</script>

</body>
</html>

Since this was just a fun little exploration, I won't try to offer up too much more explanation. Nothing about this code is terribly obvious - hence why it took me like 3 mornings to figure out. But, when we run this code, you can see that each "panel" is successfully transcluded into the layout component:

Using multiple points of transclusion in an isolate-scope directive in AngularJS.

Based on the commit that I found in the AngularJS code base, it looks like this kind of approach is going to become much easier in the near future. So, I'm looking forward to that. But, in the meantime, this was a fun experiment that stretched my understanding of isolate scopes and transclusion.

Want to use code from this post? Check out the license.

Reader Comments

2 Comments

Thank once again for a great post!
Interesting and educational approach..

Are You coming to angular-connect This week? Would like to buy You a beer or somerthing!

15,880 Comments

@Martin,

Ah, very interesting. I had somewhat considered doing something like that, but I had concerns about pulling out "portions" of a cloned DOM tree. Somehow, it felt cleaner to be appending the entire cloned element. But, it's definitely possible that I just had trouble getting it working. Anyway, great link - that is definitely much more straightforward than what I had.

As far as the core Angular stuff, I actually link directly to Pete's commit in the blog post.

15,880 Comments

@Martin,

Ok, something wasn't sitting right in my head. So, I went back and ran a sanity check. I could have sworn that I tried the approach outlined in the AirPair article. And, I think I did, and I think there's a reason that I ultimately didn't go that way. That approach seems to work if you rip out a transcluded *ELEMENT*; but, it doesn't work if you rip out transcluded *CONTENTS*.

In that approach, the author is basically doing (pseudo code):

clone.filter( ".to-header" ).appendTo( element.find( ".header" ) )

... taking an entire ELEMENT (.to-header) and appending it to the template element (.header). And that works. But, in my scenario, that would pull in the <layout-panel> element into my rendering, which would lead to something like this:

<div class="layout-body>
. . . . <layout-panel role="body"> .... </layout-panel>
</div>

Which is not what I want. I wanted to the *content* of the layout-panel, not the layout-panel itself.

So, if you go back and take the author's approach, but rip out the .contents() of the transcluded element:

element.find( ".header" ).append( clone.filter( ".to-header" ).CONTENTS() )

... you end up getting a couple of different AngularJS errors:

> TypeError: Cannot set property 'nodeValue' of undefined

or

> TypeError: Cannot read property 'childNodes' of undefined

... depending on the contents() that is being appended. I think this happens because AngularJS doesn't know how to deal with the embedded interpolation properly once the separate the contents from its parent element (just my guess, I haven't confirmed in the source code).

So, while the AirPair article definitely has a more straightforward approach, it doesn't quite do what I was trying to do. That said, if you don't mind pulling in the top-level elements, it is definitely an easier approach.

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel