Exposing An Optional Directive Template Using ng-Template And The $templateCachce() In AngularJS
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:
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.
Want to use code from this post? Check out the license.
Reader Comments
Man, your code style is very questionable
@Max,
It works pretty well for me. Your mileage may vary :D