Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Exposing An Optional Directive Template Using ng-Template And The $templateCachce() In AngularJS

By Ben Nadel on

Most directives can be easily defined by a single template. But, some directives are not so clear-cut. This is especially true for 3rd-party directives. Case in point, the "tooltip." The tooltip is a rendered element; but, it doesn't replace its contextual content. It's kind of an odd mixture of both a component directive and a behavioral directive. And, this line only gets fuzzier if the tooltip is provided by an external library. This got me thinking - can a directive expose a hook for an optional template (or multiple optional templates) using the Script directive, ng-template, and the $templateCache()?

Run this demo in my JavaScript Demos project on GitHub.

Before we dive in, let me stress that this is an experiment. I am not sure if this is a good idea - it's just something that I wanted to try.

As we've talked about before, AngularJS allows you to pre-heat the template cache by using Script tags with the type "text/ng-template". When AngularJS is compiling the DOM (Document Object Model), it will find these script tags, extract their content, and stick the content in the $templateCache() service. This means that, after the DOM has been compiled, any directive can use the $templateCache() to see if the developer provided additional content on the DOM, outside the bounds of the directive.

If additional templates are available, a directive could then $compile() that content and use the generated link function to clone and append different elements on the page. To see this in action, I've put together a small "tooltip" example in which the tooltip directive will try to transclude the physical tooltip element using one of three templates:

  • An internal, pre-compiled template.
  • A template with the URL / ID "m-tooltip.htm".
  • A template with the URL / ID provided by the calling context (ie, element attribute).

In the following code, I'm looping over a static array of 5 items and linking an instance of the tooltip to each ngRepeat clone. Notice that I'm using the "tooltip-template" attribute which sometimes corresponds to a matching Script[ng-template] block:

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

	<title>
		Exposing An Optional Directive Template Using ng-Template In AngularJS
	</title>

	<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>

	<h1>
		Exposing An Optional Directive Template Using ng-Template In AngularJS
	</h1>

	<div class="m-boxes">

		<!--
			As we output the elements here, notice that I am providing two values:
			--
			* bn-tooltip : This is the tooltip content.
			* tooltip-template : This is the optional tooltip template
		-->
		<div
			ng-repeat="i in [ 1, 2, 3, 4, 5 ]"
			bn-tooltip="This is box {{ i }}"
			tooltip-template="tooltip-override-{{ i }}.htm"
			class="box">

			Box {{ i }}

		</div>

	</div>


	<!--
		This Script/ng-template based content can be optionally used to render the
		tooltip. If this is present in the $templateCache(), the bnTooltip directive
		will try to use it. Otherwise, it will defer to its own internal tooltip.
	-->
	<script type="text/ng-template" id="m-tooltip.htm">

		<div class="m-tooltip">
			<strong>Tooltip:</strong> {{ content }}
		</div>

	</script>

	<script type="text/ng-template" id="tooltip-override-2.htm">

		<div class="m-tooltip">
			<strong>Pro-tip:</strong> {{ content }}
		</div>

	</script>

	<script type="text/ng-template" id="tooltip-override-5.htm">

		<div class="m-tooltip">
			<strong>Up in yo grill:</strong> {{ content }} ( index: {{ $index }} )
		</div>

	</script>


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

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


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


		// I create a simple tooltip directive.
		app.directive(
			"bnTooltip",
			function( $templateCache, $compile, $document ) {

				// I manage the instance of the tooltip that is rendered on the screen.
				var manager = (function Manager() {

					// I hold a reference to the current instance of the tooltip. Whenever
					// the user mouses-into a tooltip element, a new instance of the
					// tooltip element is created (and the previous one destroyed).
					var instance = {
						scope: null,
						element: null
					};

					// I hold the transclusion functions for the tooltip element. In
					// addition to the internal one, we also allow optional tempaltes to
					// be exposed using ngTemplate (and the tooltip-template attribute).
					// The $compile()'d functions will be cached here.
					var transcluders = {
						internal: $compile( "<div class='m-tooltip'>{{ content }}</div>" )
					}

					// Return the public API (used by the link function).
					return({
						hide: hide,
						position: position,
						show: show
					});


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


					// I hide the current instance of the tooltip.
					function hide() {

						instance.scope.$destroy();
						instance.element.remove();

						instance.scope = instance.element = null;

					}


					// I reposition the current instance of the tooltip according to the
					// given page-oriented coordinates.
					function position( x, y ) {

						instance.element.css({
							left: ( x + 25 + "px" ),
							top: ( y - 10 + "px" )
						});

					}


					// I show a new instance of the tooltip with the given content. The
					// initial show doesn't position the element - a subsequent call to
					// .position() should be made afterward.
					function show( triggerScope, content, templateUrl ) {

						// Get the most appropriate linking method - this might be based
						// on the built-in template; or, it might be based on an optional
						// template provided by the user.
						var linker = getLinkFunction( templateUrl );

						// Create a new scope for our template. This scope will inherit
						// from the scope of the trigger context.
						instance.scope = triggerScope.$new();

						// Store the view-model - this is the value that actually gets
						// rendered inside of the tooltip.
						instance.scope.content = content;

						// Clone the tooltip element and inject it into the page. Since it
						// globally positioned, it can just be added to the Body container.
						instance.element = linker(
							instance.scope,
							function appendClone( clone ) {

								$document.prop( "body" )
									.appendChild( clone[ 0 ] )
								;

							}
						);

						// Once the tooltip element has been transcluded, we have to
						// trigger a $digest since this will have happened outside of an
						// AngularJS digest. The use of $digest(), as opposed to $apply(),
						// allows the update to be localized to the tooltip element.
						instance.scope.$digest();

					}


					// ---
					// PRIVATE METHODS.
					// ---


					// I get the linking function for the tooltip element. If there is
					// an optional template exposed on the cache, that will be used;
					// otherwise the template will be hard-coded.
					function getLinkFunction( templateUrl ) {

						templateUrl = ( templateUrl || "m-tooltip.htm" );

						// If we've already compiled this template, just return the
						// existing link function.
						if ( transcluders[ templateUrl ] ) {

							return( transcluders[ templateUrl ] );

						}

						// If the user has provided an optional template in the cache,
						// compile it and use it as the linking function.
						if ( $templateCache.get( templateUrl ) ) {

							transcluders[ templateUrl ] = $compile( $templateCache.get( templateUrl ) );

							return( transcluders[ templateUrl ] );

						}

						// If the user provided a template, but it didn't exist in the
						// template cache, try to get the primary optional template and
						// use that one instead.
						if ( $templateCache.get( "m-tooltip.htm" ) ) {

							transcluders[ templateUrl ] = $compile( $templateCache.get( "m-tooltip.htm" ) );

							return( transcluders[ templateUrl ] );

						}

						// If the user did not provide a template for the given URL, then
						// just re-cache the internal linker - this will make the lookup
						// faster next time.
						transcluders[ templateUrl ] = transcluders.internal;

						return( transcluders[ templateUrl ] );

					}

				})();


				// Return the directive configuration.
				return({
					link: link
				});


				// I bind the JavaScript events to the local scope.
				function link( scope, element, attributes ) {

					element.on( "mouseenter", handleMouseEnter );


					// I handle the mouse-enter event on the tooltip trigger. When the
					// user mouses into a tooltip trigger we need to show the tooltip
					// and then start listening for reasons to hide the tooltip.
					function handleMouseEnter( event ) {

						// Show with the tooltip content associated with the element.
						manager.show( scope, attributes.bnTooltip, attributes.tooltipTemplate );

						element
							.off( "mouseenter", handleMouseEnter )
							.on( "mouseleave", handleMouseLeave )
						;

						$document.on( "mousemove", handleMouseMove );

					}


					// I handle the mouse-leave event on the tooltip trigger. When the
					// user mouses out of the tooltip trigger we need to hide the current
					// tooltip element.
					function handleMouseLeave( event ) {

						manager.hide();

						element
							.off( "mouseleave", handleMouseLeave )
							.on( "mouseenter", handleMouseEnter )
						;

						$document.off( "mousemove", handleMouseMove );

					}


					// I handle the mouse-move event on the tooltip trigger. When the
					// user moves around within the bounds of the trigger, we need to
					// update the position of the tooltip relative to the mouse.
					function handleMouseMove( event ) {

						manager.position( event.pageX, event.pageY );

					}

				}

			}
		);

	</script>

</body>
</html>

It's probably hard to get a sense of what is going on just from looking at the code. But, notice that the ng-repeat template defines a "tooltip-template" attribute. The value of that template attribute can correspond to a Script tag in the document. And, if it does, the tooltip directive will use that template to transclude the tooltip element:

Exposing an optional directive template using ng-template and the $templateCache() in AngularJS.

When you're writing your own directives, it will likely be difficult to see this as anything but totally crazy. And, for your own applications, I agree that this probably won't make sense. But, if you're writing a directive to be consumed by other apps, there may be some value here. I'm not sure - like I said, this was more of an experiment than anything else.



Reader Comments