The Bungee Blog

News, updates and rants around The Bungee Book (the landmark book on Prototype and script.aculo.us)

Neuron Workout Solutions #3

Welcome to this third installment of Neuron Workout Solutions™, a series that answers the questions and challenges at the end of many chapters in the book. Today we’ll address the challenges closing chapter 6, which focuses on Prototype’s amazing event system.

Why do we need element() when our handler is bound to the element we call observe() on?

Because of bubbling. Within our observer, this will indeed refer to the element we called observe() on (unless the observer had been bound before). But element() returns another thing entirely: it tells us which element the event happened on.

For instance, say you’re calling something like:

document.observe('click', function(e) {
  alert(Object.inspect(this) + "\n" + Object.inspect(e.element());
});

This observes clicks document-wide. Whenever you click anywhere on your page, this observer will trigger (unless prevented by event kills along the bubbling chain), and in there, this will always refer to the document object (which will probably be displayed something like “[HTMLDocument]“). On the other hand, e.element() will return the specific element on which you clicked, at the very start of the bubbling chain.

What’s the better alternative to doing individual observe() calls with the same handler on a series of similar elements?

Event delegation, which we just described, is a great fit: it’s very useful to implement scoped behaviors that work on many elements within the scope. Perhaps you’re looking for clicks on checkboxes throughout the document:

document.observe('click', function(e) {
  var activator = e.element();
  if (activator.tagName != 'INPUT' || activator.type.toLowerCase() != 'checkbox')
    return;
  // Your behavior here.
});

Consider how much more efficient and handy this is over individual observers:

  • Memory efficiency: you’re only registering one observer instead of numerous (possibly way too numerous) ones.
  • Ajax smoothness: you’re still active on checkboxes loaded through Ajax, as you did not observe the specific, originally-loaded, possibly-now-removed checkboxes, but the whole document.

Takeaway point: bubbling is your friend.

So, we’ve got element(). Why do we need findElement() then? If it weren’t there, how could we easily emulate it?

Actually, I never use element(): I always use findElement(). Why is that? Well, element() does not resist well to markup evolution. Consider the following, first-step markup:

<a href="…" class="imageToggler">Show me images!</a>

Now to script such links, we’d probably go something like the following:

document.observe('click', function(e) {
  var activator = e.element();
  if (activator.tagName != 'A' || !activator.hasClassName('imageToggler'))
    return;
  // Behavior here…
});

This is all well and dandy, until you find yourself evolving your markup, perhaps for styling reasons. Say it now looks like this, for instance:

<a href="…" class="imageToggler"><span>Show me images!</span></a>

Oh noes! Now when calling element(), you’re quite likely to get a <span> element, thereby missing the behavior entirely, as you’ll fail your tagName == 'A' test. What’s a developer to do?!

Enter findElement(). It basically walks the source element’s DOM tree until it finds one matching the CSS3 selection you’re giving it. It relies on Selector to do its job. So with a simple tweak (or coding reflex the first time around), your script is more evolution-resistant, as it’s essentially decoupled from the exact internal structure of your markup. As an extra bonus, it also simplifies your script greatly by letting Selector deal with your element filtering instead of manually filtering it:

document.observe('click', function(e) {
  var activator = e.findElement('a.imageToggler');
  if (!activator)
    return;
  // Behavior here…
});

Finally, if findElement() did not exist, we could easily emulate it by relying on the up() traversal method. We would need to be careful to check first whether the original element matches, otherwise we’d have a problem. You can also simplify that solution by working with Selector.findElements around the ancestors() list prefixed by the current element (which is actually what Prototype does).

If we could use event capture in addition to bubbling, what scenarios would it be useful in?

Anything that needs to provide document-wide censorship of events, really. Like a “glass pane” effect to cancel any clicks in the page while a dialog-box-like UI is at the forefront, or monitoring every event across the page regardless of cancellation performed along the bubbling chain.

Some of this can be worked around with document-sized elements, generally using some level of opacity (Lightbox style), but still, it’s not quite the same.

I’d love to hear about suggested uses you would have in the comments!

Can you find a use case where bindAsEventListener() is absolutely necessary (as in, cannot be emulated in a reasonably concise way)?

There’s only one situation, really: you’re binding a method which uses the this reference, needs the event object as its first argument, and you need to prefill some of its arguments after that. Something like this, perhaps:

var CoolObj = {
  setup: function() { /* … */ },
  niceMethod: function(e, submitAfter) {
    this.setup();
    // … code that still uses the event object (e)
    if (submitAfter)
      // …
  }
};
 
$('btnJustBeNice').observe('click', CoolObj.niceMethod.bind(CoolObj));
$('btnBeNiceAndGoAhead').observe('click', CoolObj.niceMethod.bindAsEventListener(CoolObj, true));

It’s a nice way to bind a method as observer for several UI elements that just need to pass an argument to let the processing differ from one element to another.

Any other use case can get by with a simple bind(), or even just closures:

  • If you don’t need to use the initial event object (or don’t care whether it appears first), a regular bind() will provide the same service.
  • If you don’t use this in the method, you don’t need binding at all! You can prefill arguments with curry() instead

What’s the best way to guarantee a <form> won’t be submitted to the server if our script decides it shouldn’t?

The single event that is guaranteed to trigger on form submission, no matter how the form was submitted, is the submit event on the <form> element. You see, there are many ways to submit a form, including:

  • Hit the Return key in a single-line text field, a radio button or checkbox
  • Click an input control of type submit or image
  • Hit an activator key (often Space or Return) on a submit-typed input control
  • Invoke the submit() method on the form’s DOM element

The unique processing point common to all these is the submit event on the form.

Now once you’re processing this event, the single guaranteed way of preventing actual submission is to prevent the default action for the event object. That’s right, returning false isn’t always enough; it’s actually not even normative; it just happens to work in many situations, but is not an absolute rule. So you should grab the event object and call its preventDefault() method (which Prototype guarantees even on MSIE), or even its shorter stop() method if you don’t mind stopping the bubbling of the event (yes, submit events bubble).

As a final note, remember that when an exception is raised in an event handler, even if you already called the event object’s preventDefault() or stop() method, it’s likely not to be cancelled. Your form will get submitted, your link will navigate, etc. So make sure your submit-processing code does not b0rk (or failing that, wrap it all in a try/catch during debugging).

function handleGenericValidation(e) {
  var form = e.element(), errors = [];
  // …
  if (errors.length > 0) {
    e.stop();
    // Display errors somehow
  }
}
 
document.observe('submit', handleGenericValidation);

And that’s a wrap!

I’ll look forward to any questions, comments or wild guesses you may have. And stay tuned for the fourth installment!

1 Comment so far

  1. kangax March 12th, 2008 6:38 pm

    Probably worth mentioning that #findElement now accepts comma separated selector expression (since http://dev.rubyonrails.org/changeset/8580)

Leave a reply