/ Tutorials

No clicks for you! Keeping your in-product tour on the rails.

I've built and re-built product tours for several different components of our web application at CamioCam and one of the more common requirements has been the ability to prevent a user from putzing with anything that might derail the tour. There are several ways to accomplish this.

Perhaps the easiest is to simply avoid doing an in-product tour altogether, instead opting for a slideshow of some kind, a la Dropbox. Of course they aren't the first to do this, but it's a good example of how to onboard new users efficiently without investing too heavily on the dev-side of things. If your app is intuitive enough on its own, or conversely, sufficiently complicated that the user is going to need to read the manual, then why bother with the complications of walking the user through the app, in the app?

Another common way to prevent unwanted user input during in-product tours is a partially opaque overlay. I've seen this done best by You Need a Budget, but it's a common feature of many javascript tour frameworks at this point. This approach is especially good as it focuses users' attention on a small part of the screen. You might think your simple app isn't that overwhelming, but you're the technically inclined individual who built the damn thing so it's best give the benefit of the doubt and have the user consume it in manageable 'chunks'.

However, if you can't use an overlay, or it doesn't work well with your tour, you'll need to prevent users from triggering any event handlers that might de-rail the tour. This is a stupidly tedious task if done manually; instead, we need is a way to turn off all event handlers of a certain type (probably click) in a DOM tree.

What's the spec?

Our chief goal is simple: turn off all event handlers of single a type in a DOM tree. However, there are a couple of things that'd be nice to have:

  1. A clean interface. A jQuery plugin makes the most sense here: you're probably already using it, boilerplate for jQuery plugins is practically nil, and jQuery provides one of the more browser-compatible means to accessing event listeners for DOM nodes. I'd recommend taking a look at how jQuery deals with Events as it's a pretty consumable piece of the codebase on its own.

  2. Ability to toggle event handlers. Rather than force a user to reload the page, we'd like to be able to save all the handlers so we can turn them back on later.

  3. Ability to blacklist elements. It'd be great if we could blacklist a DOM element, e.g. allowing us to easily turn off all click events in a nav bar except for the home button.

Building the base case

We need to traverse the DOM tree using a recursive function (not strictly true, but it's the cleanest option), and a base case that terminates on elements without children (leaves) is a good place to start. Similarly, if our selector is empty, for instance $('#id-that-doesnt-exist') we need to make sure we terminate.

jQuery.fn.changeHandlers = function(typeStr, options) {
  var $this, defaults, $eles, turnOn, types;
  $this = $(this);
  if ($this.length === 0)
    return $this;
 }

You'll notice that we have two arguments. The first will satisfy our requirement that we are able to modify multiple event types at the same type. The second is an options object that will allow us to cleanly pass in a bunch of optional arguments so that our method signatures don't end up looking(like, a, CSV, file, null, true). Don't worry about the extra declared variables, we'll be using them later.

Applying our default arguments

defaults = { recurse: true,  
             turn: false }
options = $.extend(defaults, options);  
turnOn = (options.turn === 'on' ? true : false);

$eles = $this
// Remove blacklisted elements from selector
if (options.blacklist) {
  $eles = $eles.filter(':not(' + options.blacklist + ')');
}

Next lets populate our defaults. The first line allows us to omit the options object if we want to just use all defaults and not pass in an empty object. Given the recursive nature of this plugin, it might be better to not copy the options arguments object every single call, but it's a small dictionary so the overhead is probably okay.

We're going to let recursion be optional, but default to true, and defaultly turn event handlers off – this is the most basic use case and it's reflected as such. Finally we filter out elements in our blacklist using the :not CSS psuedo-class – essentially selecting everything that isn't our blacklisted selector.

Toggling the event handlers

// Don't bother if all elements at this call were blacklisted
if ($eles.length > 0) {
  // Split list of event types into array
  types = typeStr.split(',');

  // Go through each element and disable listeners for each type of listener
  $eles.each(function (eleIndex) {
    var $ele, events;
    $ele = $(this);
    events = $._data($ele.get(0), 'events');
    if (events) {
      // Go through handlers of each type, toggle on/off as appropriate
      $.each(types, function (typeIndex, type) {
        var eventsOfOldType, hiddenType;
        hiddenType = '__' + type;
        oldType = turnOn ? hiddenType : type;
        newType = turnOn ? type: hiddenType;

        if (events.hasOwnProperty(oldType)) {
          eventsOfOldType = events[oldType];
          $.each(eventsOfOldType, function(handlerIndex, handlerObj) {
            $ele.on(newType, handlerObj.handler);
          });
          $ele.off(oldType);
        }
      });
    }
  });
}

This is where most of the toggling logic lives so it's rather busy, but it's simpler than it looks. For each event type we want to turn on/off, iterate through our remaining post-blacklisted elements and duplicate the event handlers under a fake event type if we're turning handlers off and vice-versa if turning them on.

To get the events bound to each element we use $._data($ele.get(0), 'events'). This approach isn't terribly clean as the _data method in jQuery is meant for internal use (the underscore before a method is generally used to indicate the method is meant to be private or only used internally), but it's the most consistent across jQuery versions.

This code is general – we could have copy-pasted it without the general bits and modified each for turning events handlers on or off – but our approach is smaller and easier to maintain. Finally we remove the original handlers.

Dive, dive, dive

if (options.recurse)
  $this.children().changeHandlers(typeStr, options);
return $this;

If the recurse option is true (true by default), we call the same function on $this's children. Notice we don't recurse on $eles.children() – just because an element is blacklisted (i.e. absent from $eles) doesn't mean its children are blacklisted as well.

Finally we return the original element so a user could run further operations on the selected nodes. For example to encourage a user to click a link on our nav bar (i.e. during a tour) we could say:

$('.navbar')
  .turnOffHandlers('click', {blacklist: '#css'})
  .find('#css')
  .css({color: 'red'});

Putting it all together

Finally we add the shortcuts for toggling on and off, and that's all there is too it. We could make this somewhat more efficient, but I value the readability in this form. If you see any bugs or have any suggestions for features, shoot me an email – I'd love to chat.

(function ($) {
  $.fn.changeHandlers = function(typeStr, options) {  
    var $eles, turnOn, types;
    if (this.length === 0) {
      return this;
    }
    $eles = this;
    options = $.extend({}, defaults, options);
    turnOn = options.turn === 'on'

    // Remove blacklisted elements from selector
    if (options.blacklist) {
      $eles = $eles.filter(':not(' + options.blacklist + ')');
    }

    if ($eles.length > 0) {
      types = typeStr.split(',');
      // Go through each ele and disable listeners for each type of listener
      $eles.each(function (eleIndex) {
        var $ele, events;
        $ele = $(this);
        events = $._data($ele.get(0), 'events');
        if (events) {
          // Go through handlers of each type, toggle on/off as appropriate
          $.each(types, function (typeIndex, type) {
            var hiddenType, oldType, newType, eventsOfOldType;
            hiddenType = '__' + type;
            oldType = turnOn ? hiddenType : type;
            newType = turnOn ? type: hiddenType;
            if (events.hasOwnProperty(oldType)) {
              eventsOfOldType = events[oldType];
              $.each(eventsOfOldType, function(listenerIndex, listener) {
                $ele.on(newType, listener.handler);
              });
              $ele.off(oldType);
            }
          });
        }
      });
    }
    if (options.recurse) {
      this.children().changeHandlers(typeStr, options);
    }
    return this;
  }

  $.fn.changeHandlers.defaults = {
    recurse: true,
    turn: 'on'
  };

  $.fn.turnOffHandlers = function(typeStr, options) {  
    return this.changeHandlers(typeStr, options);
  }

  $.fn.turnOnHandlers = function(typeStr, options) {  
    return this.changeHandlers(typeStr, $.extend(options, { turn: 'on' }));
  }
}(jQuery));