Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.
I am the chief technical officer at InVision App, Inc - a prototyping and collaboration platform for designers, built by designers. I also rock out in JavaScript and ColdFusion 24x7.
Meanwhile on Twitter
Loading latest tweet...
Ben Nadel at CFUNITED 2009 (Lansdowne, VA) with: Nathan Stanford

Using Isolate Scope In Directives In AngularJS

By Ben Nadel on

The other day, in my AngularJS "code smell" blog post about directives, I stated that the content may not apply to directives that use the "isolate" scope. The truth is, I don't know too much about the isolate scope feature since I never really understood the use-case. But, I'm tired of not knowing; so, I'm going to start digging into it. This blog post is mostly for me (I think better when I write) - my first AngularJS directive that uses the isolate scope.


 
 
 

 
 
 
 
 

Run this demo in my JavaScript Demos project on GitHub.

The point of the isolate scope, from what I have read, is to maintain a bubble around the directive that prevents it from accidentally reading from or writing to the parent $scope chain. Communication through this bubble wall can only be done through explicitly defined openings in the isolate scope configuration. This configuration maps directive-local scope properties to parent-scope expression evaluation or bi-directional parent-scope property bindings; both, of which, are defined using element attributes on the DOM (Document Object Modal) tree.

The isolate scope configuration can also map Element attribute values onto directive scope bindings. But, as of this writing, I am not sure how (if at all) this differs from the Attributes collection, which is still available inside of an isolate scope directive. It seems the only difference would be that one is monitored using scope.$watch() and the other is monitored using attributes.$observe(). But, that's a blog post for another day.

To experiment with this idea, I've created an isolate scope directive which tracks mouse-down events on the document. The goal here is to evaluate an expression on the parent scope (using isolate scope bindings) when the user mouses-down outside of the directive element:

bn-mousedown-outside="doSomething()"

But, in order to make this a more meaty exploration, I also want to make use of the property and attribute bindings as well. As such, you can also enabled and disable the directive using a scope property and tell it to ignore mouse-down events inside elements based on CSS selectors:

ignore-mousedown-if="[ flag for enabled/disabled ]"
ignore-mousedown-inside="[ css selectors to ignore ]"

Ok, let's look at some code:

  • <!doctype html>
  • <html ng-app="Demo">
  • <head>
  • <meta charset="utf-8" />
  •  
  • <title>
  • Using Isolate Scope In Directives In AngularJS
  • </title>
  •  
  • <link rel="stylesheet" type="text/css" href="./demo.css"></link>
  • </head>
  • <body ng-controller="AppController">
  •  
  • <h1>
  • Using Isolate Scope In Directives In AngularJS
  • </h1>
  •  
  • <p class="actions">
  • <a ng-click="showMessage()">Show Message</a>
  • &nbsp;|&nbsp;
  • <a ng-click="hideMessage()">Hide Message</a>
  • </p>
  •  
  • <!--
  • When this message shows, we are going to use an "outside" mousedown event
  • binding to know when to close it. This is a common use-case in popups, modals,
  • and dropdowns.
  •  
  • However, we don't want to indiscriminantly handle the mousedown event; we want
  • to ingore certain DOM elements (like the container) as well as certain conditions
  • (ie, when the handler is disabled by the parent controller).
  •  
  • ignoreMousedownIf : Scope property that determins if directive is enabled.
  • ignoreMousedownInside : CSS selectors for "safe" elements.
  • -->
  • <p
  • ng-if="isShowingMessage"
  • bn-mousedown-outside="hideMessage()"
  • ignore-mousedown-if="shouldIgnoreMousedown"
  • ignore-mousedown-inside="p.actions , h1"
  • class="message">
  •  
  • I'm sorry, I can't hear you over the awesomeness of this message!
  •  
  • [
  • <span ng-hide="shouldIgnoreMousedown">
  • Enabled &mdash;
  • <a ng-click="disableClickDetection()">Disable click detection</a>
  • </span>
  •  
  • <span ng-show="shouldIgnoreMousedown">
  • Disabled &mdash;
  • <a ng-click="enableClickDetection()">Enable click detection</a>
  • </span>
  • ]
  •  
  • </p>
  •  
  •  
  • <!-- 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.2.16.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.
  • app.controller(
  • "AppController",
  • function( $scope ) {
  •  
  • // I determine whether or not we're showing the demo message.
  • $scope.isShowingMessage = false;
  •  
  •  
  • // I determine whether or not we want to actually show the
  • $scope.shouldIgnoreMousedown = false;
  •  
  •  
  • // ---
  • // PUBLIC METHODS.
  • // ---
  •  
  •  
  • // I disable click detection. The message can only be closed through
  • // explicit calls to teh hideMessage() method.
  • $scope.disableClickDetection = function() {
  •  
  • $scope.shouldIgnoreMousedown = true;
  •  
  • };
  •  
  •  
  • // I enable click detection. The message can be hidden through mousedown
  • // events located outside of the message.
  • $scope.enableClickDetection = function() {
  •  
  • $scope.shouldIgnoreMousedown = false;
  •  
  • };
  •  
  •  
  • // I hide the demo message.
  • $scope.hideMessage = function() {
  •  
  • $scope.isShowingMessage = false;
  •  
  • };
  •  
  •  
  • // I show the demo message.
  • $scope.showMessage = function() {
  •  
  • $scope.isShowingMessage = true;
  •  
  • };
  •  
  • }
  • );
  •  
  •  
  • // -------------------------------------------------- //
  • // -------------------------------------------------- //
  •  
  •  
  • // I provide hooks to mouse-down events on the document that take place outside
  • // of the current element.
  • app.directive(
  • "bnMousedownOutside",
  • function( $document ) {
  •  
  • // I bind JavaScript events to the directive scope.
  • function link( $scope, element, attributes ) {
  •  
  • // In the isolate-scope configuration, the external scope property,
  • // [ignoreMousedownIf] was mapped to local scope property
  • // [isDisabled]. However, if the attribute doesn't exist, the local
  • // scope value will be undefined. As such, we are defining a default
  • // value in the $watch expression.
  • $scope.$watch(
  • "! ( isDisabled || false )",
  • function( newValue, oldValue ) {
  •  
  • // If enabled, listen for mouse events.
  • if ( newValue ) {
  •  
  • $document.on( "mousedown", handleMouseDown );
  •  
  • // If disabled, but previously enabled, remove mouse events.
  • } else if ( oldValue ) {
  •  
  • $document.off( "mousedown", handleMouseDown );
  •  
  • }
  •  
  • }
  • );
  •  
  • // When the local scope is destroyed, be sure to clean up the event
  • // bindings on the document.
  • $scope.$on(
  • "$destroy",
  • function() {
  •  
  • $document.off( "mousedown", handleMouseDown );
  •  
  • }
  • );
  •  
  •  
  • // I handle the mouse-down events on the document.
  • function handleMouseDown( event ) {
  •  
  • // Check to see if this event target provides a click context
  • // that should be ignored.
  • if ( shouldIgnoreEventTarget( $( event.target ) ) ) {
  •  
  • return(
  • console.warn( "Ignoring mouse-down event.", ( new Date() ).getTime() )
  • );
  •  
  • }
  •  
  • // Even though this directive is isolated, we still need to call
  • // $apply() to tell AngularJS that a change has happened. The
  • // $digest mechanism can still be triggered from an isolated
  • // scope.
  • $scope.$apply(
  • function() {
  •  
  • $scope.callback();
  •  
  • }
  • );
  •  
  • }
  •  
  •  
  • // I detemine if the given mousedown context should be ignored.
  • function shouldIgnoreEventTarget( target ) {
  •  
  • // If the click is inside the parent, ignore.
  • if ( target.closest( element ).length ) {
  •  
  • return( true );
  •  
  • }
  •  
  • // If the click is inside the "exception" CSS selectors
  • // (if provided), then ignore.
  • // --
  • // NOTE: Demo assumes that attribute value does not use
  • // interpolation and therefore will not have to be watched.
  • if (
  • $scope.exceptionSelectors &&
  • target.closest( $scope.exceptionSelectors ).length
  • ) {
  •  
  • return( true );
  •  
  • }
  •  
  • // If there is no need to ignore the target at this point, let
  • // the event be processed.
  • return( false );
  •  
  • }
  •  
  • }
  •  
  •  
  • // Return the directive configuration for scope isolation.
  • return({
  • link: link,
  • scope: {
  • callback: "&bnMousedownOutside",
  • exceptionSelectors: "@ignoreMousedownInside",
  • isDisabled: "=ignoreMousedownIf"
  • }
  • });
  •  
  • }
  • );
  •  
  • </script>
  •  
  • </body>
  • </html>

In this case, because I wrote both the demo and the directive, I know that all of the various scope properties and DOM tree attributes will exist. As a directive author, however, you don't. As such, your isolate scope mappings won't always correspond to actual values. In such cases, the directive scope properties will show up as "undefined."

NOTE: The mapped property keys still exist in the scope; but, the value of the property is "undefined."

You may notice that when I invoke the callback, inside of the isolate scope, I am using the normal $apply() method to tell AngularJS about potential model changes. Even though this scope is outside of the normal scope chain, the $apply() method still works in the greater context of the application. This is because the $apply() method triggers a $digest on the $rootScope, regardless of where the $apply() call was initiated.

As this is my first isolate scope directive, I find it all very interesting. But, I am not sure that I fully realize the benefits at this time. I do like that the scope bindings force you to explicitly define all of your communication channels. But the same could be done with "best practice" conventions. There's still a lot more to explore, however, so I will try to defer any judgement until I have a more well-rounded understanding.




Reader Comments

On a previous project I used to create one-off directives with isolated scopes instead of using ng-include. I only passed to them what was needed and it completely broke scope inheritance... on purpose. To me scope inheritance always felt dangerous and risky. If you use something inherited from scope, you are guaranteeing that you've broken encapsulation.

I'm not sure if I even knew the benefits when I did it, but it made refactoring amazingly easy. I also used a similar technique instead of ng-switch/ng-include combo which had the side effect of making the 1.08 to 1.2+ switch way easier because of the transclusion changes.

Reply to this Comment

@Jonathan,

I definitely like that it the isolate approach really pushes the develop to think about encapsulation. I'm not opposed to scope inheritance, in general, and I think it definitely makes sense in the Controllers. But, I am kind of digging on the way scope isolation forces you to define HTML attribute hooks instead of relying on scope methods. This way, you can have different scope method names pipe into the same directive in different contexts. That's definitely a win.

Reply to this Comment

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.