Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
Ben Nadel at Hal Helms' Real World OO (Feb. 2009) with: Steve Good and Joakim Marner and Charlie Griefer and Ezra Parker
Ben Nadel at Hal Helms' Real World OO (Feb. 2009) with: Steve Good@stevegood ) , Joakim Marner , Charlie Griefer@charliegriefer ) , and Ezra Parker

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

Post A Comment

You — Get Out Of My Dreams, Get Into My Comments
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.