⇧ home

Handling JavaScript events on multiple elements

By Mislav on 09 Feb 2008 in js

Implementing proper event handling on your site or application is a design issue, meaning there are many ways of solving a problem and choosing the right way is a matter of skill and experience. Today I want to talk about handling events on multiple elements because a great deal of JavaScript developers are constantly struggling to get some overcomplicated code working—usually looping over a set of elements and attaching a handler to each one. When they need to identify which of the targets actually triggered the event, or when they inject new elements as a result of an Ajax request and find out they need to re-apply all the handlers again, they start pulling their hairs out. Let’s look at an approach where we don’t need loops; we’ll simply play with bubbles. Sometimes this is called event delegation.

A common need

Here is a simple table with nonsense data. Try to select some orders (rows) for processing. Tip: click on whole rows, not just the checkboxes.

DateNameSurnamePriceIP Address
21/01/2006NeilCrosby$1.96192.168.1.1
01/02/2006BeccaCourtley$23.95192.167.2.1
17/11/2004DavidFreidman-Jones$14.00192.168.2.1
17/10/2004AnnabelTyler$104.00192.168.2.17
17/11/2005CarlConway$17.00192.168.02.13

So you’ve played with it and saw it’s pretty much basic. But how did we implement it? Many people will say oh, if each row has to be clickable I’ll just go right ahead and attach a click handler to each of the rows. That is a complex solution and generally should be avoided. Others will try to be smarter than that and use something like Behaviour, but that’s just doing the same thing in a nicer way.

The key is simply intercepting all the click events on the table or TBODY elements themselves. Most of the events in JavaScript bubble, which means they propagate up the document tree from the node they originate from. You can handle such events on any element that contains the target of the event; you can also stop its default action, like following a link, or stop it from bubbling. These event methods are called preventDefault() and stopPropagation(), respectively. (With the Prototype library you also have the stop() method that is the combination of both.)

Here is the complete code for the above example:

document.observe('dom:loaded', function() {
  when('#mytable tbody', function(table) {
    // we only set one event handler, and that is on the table body
    table.observe('click', function(e) {
      // when an event is handled, descend to from where it's triggered to table row
      var checkbox, row = e.findElement('tr')
      if (row) {
        // find the first input element in the row; that's our checkbox
        var checkbox = row.down('input')
        // toggle the checkbox unless the click event originated on it
        if (e.target != checkbox) checkbox.checked = !checkbox.checked
        // toggle the classname of the row
        row.toggleClassName('selected')
      }
    }).select('input').each(function(input) {
      // add the "selected" class if some inputs are already slected
      if (input.getValue()) input.up(1).addClassName('selected')
    })

    // catch the submit on the form
    table.up('form').observe('submit', function(e) {
      var data = this.serialize(true), // serialize to object
          selected = data['order[]']

      if (selected) {
        var list = Object.isArray(selected) ? selected.join(', ') : selected
        alert('Orders to process: ' + list)
      } else {
        alert('No orders to process. Please select some')
      }

      // prevent the real submit action taking place in the browser
      e.stop()
    })
  })
})

Pay special attention to e.findElement('tr'). We don’t really care where exactly the event originated—it is most probably on some table cell or even the element inside a cell—we just want to know what row was it on. Prototype findElement() method is very helpful here because it traverses elements upwards from event origin and returns the first one that matches the CSS selector (tr, in this case).

When we get a reference to the row, rest is straightforward. We toggle the checkbox programmatically while adding/removing a CSS class on the row for visual feedback.

Analytics example

If you are using Google Analytics on your site, at one point you probably wondered how to track PDF or archive file downloads, or even outgoing (off-site) clicks. There is a solution: Analytics help suggests that you use the urchinTracker() function with an absolute path as argument. (Note: the name of the method is _trackPageview if you’re using the new tracking code from December 2007.)

They suggest putting the code in an onclick attribute:

<!-- file downloads: -->
<a href="report.pdf" onclick="urchinTracker('/downloads/report.pdf')">awesome report, has pie charts</a>
<!-- outgoing clicks: -->
<a href="http://another-site.com" onclick="urchinTracker('/outgoing/another-site.com')">visit my sponsor!</a>

Hooray, it’s possible—but also pretty gross :( First of all, when you switch to the new Analytics tracking code you’ll have to manually replace each call to the old function. Seconds, if you decide to stop using Analytics and remove the Urchin script, all of these links will generate a JavaScript error on click. But, the worst drawback definitely is: you have to manually add this to each link you want tracked.

Don’t listen to Analytics help. We are smarter than that. The following script is an unobtrusive, one-time, drop-in solution when you’re using Prototype (but can also be ported to any other library, easily). Features include:

  1. fail silently if tracker code isn’t available (like when Urchin script hasn’t yet loaded);
  2. tracking all outgoing URLs;
  3. tracking all local files with extensions other than ‘html’;
  4. tracking middle-mouse clicks (that open links in a new tab in some browsers);
  5. other, custom, rules can easily be added by hand.

Again, pay attention to the usage of findElement():

var root = 'http://' + window.location.host + '/'

if (window.Prototype) document.observe('mouseup', function(e) {
  if (!urchinTracker) return
  var link = e.findElement('a[href]')
  if (link) {
    var url = null, leftOrMiddle = (e.isLeftClick() || e.isMiddleClick())
    // track outgoing clicks:
    if (!link.href.startsWith(root) && leftOrMiddle)
      url = '/outgoing/' + link.href.replace(/^http:\/\//, '')
    // track clicks to files with extensions other than ".html"
    else if (/.(\w{2,5})$/.test(link.href) && RegExp.$1.toLowerCase() != 'html' && leftOrMiddle)
      url = '/' + link.href.replace(root, '')

    if (url) urchinTracker(url)
  }
})

We observe mouse clicks on document level and then test if they originated from link elements; then we apply some simple rules to determine whether we are going to track the click or not. Lastly, we call the tracker function. After executing all the code, default action for the click takes place: the browser follows the link.

This is how the report is going to look in Google Analytics:

Outgoing links report

Related reading