Drag and Drop and Delegate

HTML5 Drag and Drop has been popular topic recently on web tech blogs, a good example is Alexis Goldstein's post on Sitepoint which I converted to work in IE. Fortunately for me this all coincided with spending a lot of time working on Drag and Drop for chapter 6 of my book and a project coming up at work where it was a natural fit. So I've been doing a lot of dragging and dropping in recent weeks, to the point where I started to feel like I knew as much about the practical implementation of the Drag and Drop API as any person living.

Of course, whenever I start getting a bit full of myself something soon happens to make me feel like an idiot again. In this case it's my continuing blind spot when it comes to DOM Event Delegation. If you look again at Alexis' example you'll note that all the event handlers are bound directly to the elements where the events are expected to happen. This is a common pattern in example code - it's a straightforward relationship for beginners to understand - but it's not really the best way to do it, and this post is going to explain why. Let's review the code for the drop event:

$('#todo, #inprogress, #done').bind('drop', function(event) {
    var notecard = event.originalEvent.dataTransfer.getData("text/plain");
    event.target.appendChild(document.getElementById(notecard));
    event.preventDefault();
});

What this code does is bind a drop handler to three separate elements specified by the IDs in the selector - #todo, #inprogress, #done. Now have a look at this screenshot of the original planning board in action:

A task embedded within another task

The task 'Learn HTML5' as been dropped directly inside the task 'Learn CSS3' and the 'Learn CSS3' task accepted the drop and ran the drop handler code despite not being mentioned above - why? The answer is because of event delegation. While in this particular case it may look like a bug, this is actually a very powerful and useful process. Here's what's happening when the 'Learn HTML5' task is dropped:

  1. The drop event is fired on the 'Learn CSS3' element
  2. Because there is no drop handler, the event is passed up the document tree< to the parent element - in this case #inprogress
  3. There is a drop handler on the #inprogress, so that handler is run

This process is called event bubbling - the events rise up through the DOM tree like bubbles through water, and the consequence of the bubbling here is that the event is fired on the 'Learn CSS3' element, which remains the target of the event, but it's processed by the event handler on the #inprogress element. Because the handler just says 'append the dragged element as a child of the event target' the 'Learn HTML5' element is added as the last child of 'Learn CSS3'.

Initially you might see this as a bug, but really it's an opportunity. As long as we deal with this unwanted side effect we're freed from having to attach event handlers to every single element. If this doesn't seem like a huge gain, consider these two scenarios:

  1. You want to scale up to handle have a large number of draggable items and drop targets, into the thousands
  2. You want to add and remove draggable items and drop targets dynamically

The first scenario was the one I found myself in with my work project - the drop targets were cells in a table, with over fifty columns and possibly hundreds of rows, and 30-50% of those cells had draggable items in. When I started with the naive approach of binding a handler to each item it took a second or two to run the initialisation code in the good browsers (in IE8 it took nearly 30 seconds...). Event delegation helps in this scenario because the number of events you have to bind is equal to the number of events you want to capture, not the number of elements you want to capture them on.

It's unlikely an agile planning board is going to end up with that many elements though - if it does I suspect there's a good chance you're not really being agile, but the second scenario is more likely to come up. The tasks and the available resources will likely change from sprint to sprint, you may also want to add in some extra statuses. With the current code, every time you want to add an element you will have to attach all the relevant event handlers to it (even if jQuery makes that easy for you, that could still be a lot of overhead). If you forget to do that in a particular code path then strange bugs will likely ensue, which is a shame when all you really want is for the new elements to execute the exact same handlers as all the existing ones. Let's look at how easy this all becomes if you don't try to bind event handlers to every single element.

First, some adjustments to the markup. Because there'll now (potentially) be multiple rows on the board we can't use id attributes to distinguish them so they'll have to be a class. We'll also add a 'row title' for the developer name, and

<div id="board">
    <div id="rob">
        <h1 class="title">Rob</h1>
        <div class="todo droptarget">
            <h2 class="title">To Do</h1>
            <a id="item1" draggable="true" href="#">
                <div class="cardTitle">
                    Learn HTML5
                </div>
            </a>
            <a id="item2" draggable="true" href="#">
                <div class="cardTitle">
                    Learn CSS3
                </div>
            </a>
        </div>
        <div class="inprogress droptarget">
            <h2 class="title">In Progress</h1>
        </div>
        <div class="done droptarget">
            <h2 class="title">Done</h1>
        </div>
    </div>
</div>

I've also added a class droptarget to make life easier in the event handlers. Those event handlers will, of course, have to change. Here's a before and after of the dragstart handler:

Before

$('#item1, #item2').bind('dragstart',
  function(event) {
    event.originalEvent.dataTransfer.setData(
        "Text", 
        event.target.getAttribute('id')
    );
});

After

$('#board').bind('dragstart',
  function(event) {
    event.originalEvent.dataTransfer.setData(
        "Text", 
        $(event.target).closest('a[id]').attr('id')
    );
});

The first, and most important, change is that the event is now attached to the #board element instead of to each individual draggable item. The second change is instead of assuming the event target is the planning item the code now looks for the closest parent element which matches what the draggable element is expected to look like. The next event to consider is dragover, here things are slightly more complicated:

Before

$('#todo, #inprogress, #done').bind('dragover', 
    function(event) {
        event.preventDefault();
});

After

$('#board').bind('dragover', function(event) {
    if ($(event.target)
          .closest('.droptarget')
          .hasClass('droptarget')) {
	      event.preventDefault();
    }
});

Now that the handler is being attached to #board every element will trigger the dragoverdroptarget class added earlier. Finally let's consider the drop event:

Before

$('#todo, #inprogress, #done')
  .bind('drop', 
    function(event) {
        var notecard = event
                .originalEvent
                .dataTransfer.getData("Text");
        event.target.appendChild(
            document.getElementById(notecard)
        );
        event.preventDefault();
  });

After

$('#board').bind('drop', function(event) {
    var notecard = event
            .originalEvent
            .dataTransfer.getData("Text");
    var drop = $(event.target).closest('.droptarget');
    drop.append($('#' + notecard));
    event.preventDefault();
});

Although every element which is a descendant of #board could potentially trigger this event, in practice it's only ever going to be the ones that triggered the event.preventDefault() on the dragover event - anything with class .droptarget or it's descendants. Instead of just assuming the target is where the dropped element has to be attached the closest ancestor with the right class is used, so we never end up with dropped tasks getting appended inside other tasks.

I think you'll agree it isn't really much more difficult to write the event handlers in such a way that they take advantage of event bubbling and so only need to be attached to a single element. Now that these changes have been made it's possible to add further developers, statuses or tasks just by generating and appending the appropriate HTML - no need to loop through the new elements and attach all the correct event handlers.

This example page adds several buttons for dynamically updating the planning board, just to show how easy it is. The code is also available on github.