Read the updated version now!
Most anyone who’s looked at the feature list knows that one of Dojo’s core features is a drag and drop framework. What’s not immediately obvious is that Dojo actually has two drag and drop APIs. The first, dojo.dnd, is designed to manage the process of dragging items between two or more containers, including multiple selection, item acceptance filtering on drop targets, and other behavioral tweaks. The second API, dojo.dnd.move, is a bit lower-level in scope; it’s designed to manage the process of moving single objects around, without the concept of attaching items to containers. Both of these are very useful features to have at your disposal. In this article, we’ll cover dojo.dnd.
Simple Single Source DnD
Meet Dylan. Collecting junk is his passion. A while back, he decided that he needed to get rid of some of it to make room for more interesting junk, so he got a booth at the local farmers’ market and started a small junk outlet business. Like most people passionate about sharing their junk with the world, he decided to open an online storefront. His brother, currently working towards a degree in Marketing, convinced him that he needed to brand himself as a form of differentiation; thus was born Dylan’s Original. He decided that his best bet would be to create a user experience so ridiculously awesome that people wouldn’t be able to help buying his junk. That’s where we come in. To demonstrate drag and drop techniques, we’ll help build a mockup of Dylan’s Original Junk Outlet.
Let’s start with the basics. Just about the easiest way to get drag and drop working is to demonstrate a single list that the user can reorder dynamically. First, we’ll create our page skeleton, using Dojo from AOL CDN, spiced up with a bit of CSS. .
As you can see, we’re starting with a simple wish list:
<div id="store">
<div class="wishlistContainer">
<h2>Wishlist</h2>
<ol id="wishlistNode" class="container">
<li>Wrist watch</li>
<li>Life jacket</li>
<li>Toy bulldozer</li>
<li>Vintage microphone</li>
<li>TIE fighter</li>
</ol>
</div>
</div>
The DnD Workhorse, dojo.dnd.Source
To enable drag and drop, dojo.dnd gives us a class called Source, which is basically just what it sounds like: a source for dragged items (as well as a target for dropped items). To instantly turn a DOM node into such a source, create a dojo.dnd.Source out of it:
dojo.require("dojo.dnd.Source");
var wishlist = new dojo.dnd.Source("wishlistNode");
wishlist.insertNodes(false, [
"Wrist watch",
"Life jacket",
"Toy bulldozer",
"Vintage microphone",
"TIE fighter"
]);
That’s all there is to it! If you’re the kind of person who likes to do markup-style quick prototyping, instantiate the node in markup with dojoType="dojo.dnd.Source"
, and use class="dojoDndItem"
on draggable child nodes, like so:
<ol dojoType="dojo.dnd.Source" id="wishlistNode" class="container">
<li class="dojoDndItem">Wrist watch</li>
<li class="dojoDndItem">Life jacket</li>
<li class="dojoDndItem">Toy bulldozer</li>
<li class="dojoDndItem">Vintage microphone</li>
<li class="dojoDndItem">TIE fighter</li>
</ol>
Of course, you have to make sure to dojo.require("dojo.dnd.Source")
and to turn on the parser, but there you go.
What can you turn into a DnD source? Well sheesh, what can’t you turn into a DnD source? Take a look at the technical documentation. The dojo.dnd.Source will take into account the node type of your container when creating the child nodes:
- If the container is <div> or <p>, it will create <div> nodes.
- If the container is <ul> or <ol>, it will create <li> nodes.
- If the container is a <table>, it will create a set of <tr><td> and add it to the table’s <tbody>.
- All other times, it will create <span> nodes.
So basically, turn whatever you want into a dojo.dnd.Source, and Dojo will intelligently set up your DOM. Pretty nifty. Out of the box, dojo.dnd.Source has quite a lot of functionality baked in:
- Multiple selection. Each container has the notion of a selection; click on an item and it’s “selected.” Ctrl-/Command-click or Shift-click to do multiple selection, just like in a regular desktop application.
- Child node introspection. In addition to the
insertNodes()
method demonstrated above, the Source provides a few methods to work with the list of child nodes:getAllNodes()
– returns a standard Dojo NodeList of the contained items.forInItems(f, o)
– calls f in the context of o for each contained node. Similar todojo.forEach()
.selectNone()
,selectAll()
,getSelectedNodes()
,deleteSelectedNodes()
– just what they sound like, methods for manipulating the selection state.- plus a few other things you can hook into for customizing the way the internal list gets handled. See the technical docs for details.
- Copy vs. move semantics. By default nodes are moved when you drag them around. However, Ctrl-/Command-dragging does a copy operation instead, similar to the average desktop file manager. This is useful when you don’t want your DnD source to change in response to your drag operations.
- Drag cancellation. This isn’t technically a property of the Source, but it’s worth noting here that pressing the Esc key cancels the current drag operation. You can do this programmatically, too, if you need to.
- Automatic avatar creation. The dojo.dnd framework uses “avatars” to represent the nodes you drag around; it creates these for you automatically, based on the data itself. You can customize this, of course. More on that later.
Using Multiple Sources
Of course, if you’re only using a single dojo.dnd.Source in your application, the move/copy distinction is only useful for duplicating nodes in the list. Let’s help Dylan expand.
What have we changed? Well, for starters, we now have three Sources: the Catalog, the Cart, and the Wishlist. Now you can drag items back and forth between them to see multiple-container dojo.dnd in action. Some items are marked as “out of stock” (more on this in a bit), and—hey, some of these items aren’t junk at all: they’re food! Yes, while we weren’t looking, Dylan merged his junk outlet with Dylan’s Nutritious Dietarium, the company he uses to unload what he doesn’t eat from his garden.
DnD Item Types
The biggest change here is the introduction of item types. Notice how we’re declaring our containers:
var catalog = new dojo.dnd.Source("catalogNode", {
accept: ["inStock,outOfStock"]
});
catalog.insertNodes(false, [
{ data: "Wrist watch", type: ["inStock"] },
{ data: "Life jacket", type: ["inStock"] },
{ data: "Toy bulldozer", type: ["inStock"] },
{ data: "Vintage microphone", type: ["outOfStock"] },
{ data: "TIE fighter", type: ["outOfStock"] },
{ data: "Apples", type: ["inStock"] },
{ data: "Bananas", type: ["inStock"] },
{ data: "Tomatoes", type: ["outOfStock"] },
{ data: "Bread", type: ["inStock"] }
]);
catalog.forInItems(function(item, id, map){
// set up CSS classes for inStock and outOfStock
dojo.addClass(id, item.type[0]);
});
var cart = new dojo.dnd.Source("cartNode", {
accept: ["inStock"]
});
var wishlist = new dojo.dnd.Source("wishlistNode", {
accept: ["inStock","outOfStock"]
});
In the markup version it looks like so:
<div class="catalogContainer">
<h2>Catalog</h2>
<ul dojoType="dojo.dnd.Source" accept="inStock,outOfStock"
id="catalogNode" class="container">
<li class="dojoDndItem inStock" dndType="inStock">Wrist watch</li>
<li class="dojoDndItem inStock" dndType="inStock">Life jacket</li>
<li class="dojoDndItem inStock" dndType="inStock">Toy bulldozer</li>
<li class="dojoDndItem outOfStock" dndType="outOfStock">
Vintage microphone</li>
<li class="dojoDndItem outOfStock" dndType="outOfStock">TIE fighter</li>
<li class="dojoDndItem inStock" dndType="inStock">Apples</li>
<li class="dojoDndItem inStock" dndType="inStock">Bananas</li>
<li class="dojoDndItem outOfStock" dndType="outOfStock">Tomatoes</li>
<li class="dojoDndItem inStock" dndType="inStock">Bread</li>
</ul>
</div>
<div class="cartContainer">
<h2>Cart</h2>
<ol dojoType="dojo.dnd.Source" accept="inStock"
id="cartNode" class="container">
</ol>
</div>
<div class="wishlistContainer">
<h2>Wishlist</h2>
<ol dojoType="dojo.dnd.Source" accept="inStock,outOfStock"
id="wishlistNode" class="container">
</ol>
</div>
Each DnD item can be given a type, specified in JavaScript as the type
member of the object(s) you provide to, e.g, insertNodes()
, or in markup as dndType
. Correspondingly, each DnD container can be given a list of item types to accept. The default type, if you don’t specify it, is “text” for all nodes and containers. Here, we’re using the type to denote whether an item is in stock or not, and we’re using that to determine what can be dropped where: the Cart only accepts items that are in stock, while the Wishlist accepts anything. If you drag around multiple items at once, you’ll notice that you can only drop a set of items on a container that accepts every type of item in the set—no partial drops allowed!
You may have noticed a few issues with this demo:
- Unless you explicitly invoke copy semantics by pressing the appropriate key, dragging items removes them from the catalog, which doesn’t make much sense for this application.
- You can do a copy/drag, but then it becomes easy to duplicate items.
- Using simple lists like this doesn’t really give a great user experience for an establishment as dignified as Dylan’s Original Junk Outlet / Dylan’s Nutritious Dietarium (DOJO/DND, get it? I kill me (groan)).
Let’s start with the appearance.
Customizing Item Creation
As I discussed above, the default drag and drop implementation is intelligent enough to create nodes according to the context in the DOM. However, if you want to display more than a string of text, the default can be lacking, since all it does is put the data into the new child node’s .innerHTML
. Fortunately, dojo.dnd gives us a way to customize this: the creator
function.
Since Dylan wants the product catalog to be both prettier and more informative, let’s give each item an image, short description, and quantity available. For example, for the wrist watch:
{
name: "Wrist watch",
image: "watch.jpg",
description: "Tell time with Swiss precision",
quantity: 3
}
We’ll use this structure in the data field in the items we create. To create DOM nodes from this kind of object, we’ll need a function we can pass to the dojo.dnd.Source’s constructor:
// create the DOM representation for the given item
function catalogNodeCreator(item, hint) {
// create a table/tr/td-based node structure; each item here needs an
// image, a name, a brief description, and a quantity available
var tr = document.createElement("tr");
var imgTd = document.createElement("td");
var nameTd = document.createElement("td");
var qtyTd = document.createElement("td");
var img = document.createElement("img");
img.src = "images/" + (item.image || "_blank.gif");
dojo.addClass(imgTd, "itemImg");
imgTd.appendChild(img);
nameTd.appendChild(document.createTextNode(item.name || "Product"));
if (item.description && hint != "avatar"){
// avatars don't get the description
var descSpan = document.createElement("span");
descSpan.innerHTML = item.description;
nameTd.appendChild(document.createElement("br"));
nameTd.appendChild(descSpan);
}
dojo.addClass(nameTd, "itemText");
tr.appendChild(imgTd);
tr.appendChild(nameTd);
if (hint != "avatar") {
// avatars don't display the quantity
qtyTd.innerHTML = item.quantity;
dojo.addClass(qtyTd, "itemQty");
tr.appendChild(qtyTd);
}else{
// put the avatar into a self-contained table
var table = document.createElement("table");
var tbody = document.createElement("tbody");
tbody.appendChild(tr);
table.appendChild(tbody);
node = table;
}
// use the quantity when determining the DnD item type
var type = item.quantity ? ["inStock"] : ["outOfStock"];
return {node: tr, data: item, type: type};
}
Things to note here:
- Firstly, we’re going to be using tables for our DnD sources now. That’ll help improve the presentation (and no, not in the heretical tables-for-layout way).
- In the bit at the end, we dynamically choose the DnD item type based on the quantity provided. Notice that the type specifier is actually an array; we can compile the types as combinations of property strings if we want, by adding items to the array.
- The creator function takes a
hint
in the second parameter. Whenhint=="avatar"
we’re being asked to create a DOM representation of the avatar, so our function takes that into account. Here we skip displaying the description and quantity when we make an avatar, and we put the entire avatar into its own table, since the default node that contains the avatar(s) is itself a table, and we don’t want to ruin our DOM.
At this point, we can introduce version 3 of the demo. There’s a substantial overhaul in the appearance now, thanks to our table-based DnD sources. We’ve now put the wishlist and shopping cart into a couple of dijit.TitlePanes so we can easily toggle their visibility. This demonstrates a couple of concepts:
Creating Pure Targets
var cart = new dojo.dnd.Target("cartPaneNode", {accept: ["inStock"]});
We have a new class here: dojo.dnd.Target. This is just a thin wrapper around dojo.dnd.Source, but it sets an internal variable isSource = false
, rendering it a pure target. You can drop items on it, but can’t drag them back out again! Incidentally, you can freely manipulate this field at runtime on a regular dojo.dnd.Source; we’ll do that later on.
Changing the “Drop Parent”
cart.parent = dojo.query("#cartNode tbody")[0];
Here we use the dojo.dnd.Source’s internal parent
field to change the drop behavior. You see, this object separates the concept of its 1) own node and the 2) node that’ll contain its children (mostly so tables work, since the children actually live underneath the <tbody>
). We can take advantage of that by actually dropping items into a node further underneath the one that we create the TitlePane on. See, given the markup we’re using:
<div id="wishListAndCart">
<div id="cartPaneNode">
<table id="cartNode"><tbody></tbody></table>
</div>
<div id="wishlistPaneNode">
<table id="wishlistNode"><tbody></tbody></table>
</div>
</div>
We don’t want to drop items on a closed TitlePane (the <div>
s) and create items as their immediate children; we want them to get put inside a table that lives inside the TitlePane, so the DOM stays legal (we’re moving <tr>
s around, and we don’t want them to get attached directly to a <div>
)
Things are starting to look better, but there are still a couple of changes we can make to demonstrate a few more concepts.
Handling Events
The drag and drop framework uses Dojo’s topic system to handle event communication. We can polish up a few things if we take advantage of this. For example, it would be nice if we could show the number of items in the wishlist and cart when they’re closed so we needn’t open them to check. Plus, it would clean things up visually if we cleared the selection states of our containers when we drop an item. We can do that.
Building a Drop Handler
Let’s hook into the drop notification. We can either listen for the DnD topics directly, or we can simply connect to the existing method handlers. Either one is fine; there is a subtle semantic difference, which we’ll discuss in a minute.
// calculate simple totals in the wishlist and cart titles
var setupCartTitle = function(){
var title = "Shopping Cart";
var cartLength = cart.getAllNodes().length;
if(cartLength){
var items = cartLength > 1 ? " items" : " item";
title += " (" + cartLength + items + ")";
}
cartPane.setTitle(title);
};
var setupWishlistTitle = function(){
var title = "Wishlist";
var wishlistLength = wishlist.getAllNodes().length;
if(wishlistLength){
var items = wishlistLength > 1 ? " items" : " item";
title += " (" + wishlistLength + items + ")";
}
wishlistPane.setTitle(title);
};
dojo.connect(cart, "onDndDrop", setupCartTitle);
dojo.connect(wishlist, "onDndDrop", setupWishlistTitle);
So here we connect to the cart’s and wishlist’s onDndDrop()
respective methods and do some simple logic. However, be careful: these are fired by the topic system, so they both execute every time an item is dropped, no matter which container it’s dropped on. In other words, DnD notifications are broadcasted everywhere rather than sent to specific objects. In this particular case here, that’s no problem, since we’re just setting up the TitlePane titles; however, if you want to make sure your handler only executes for some particular object, you need something like this at the beginning of your function:
if(dojo.dnd.manager().target !== this){
return;
}
That’ll do it. Later on when we decide to add code specific to each object into these handler functions, we’ll be glad we’re using this method, but as-is, we’re not doing anything that can’t be handled by a single function that doesn’t care about the items being tracked (the drag source, target, etc.). If you’re thinking we wouldn’t need this target
snippet if we had just dojo.subscribe()
‘d to the topic itself in the first place, you’d be absolutely correct. That’s the subtle semantic difference I mentioned above; for a global operation, listening for the drag topics makes sense; if you want to connect to a single object’s drag notifications, use the dojo.connect() style above, with the .target
check snippet.
Speaking of that snippet, this is the first time we’ve seen dojo.dnd.manager(); this call gives us access to the singleton Manager object the DnD system uses to coordinate everything. This object handles all of the business logic of dragging and dropping, publishing the topics, initiates avatar creation, and so forth. It’s a handy source of information on the overall DnD system state. As above, see the technical docs for the details.
Listening Directly to the Topics
Since I mentioned that you can subscribe to the topics yourself when it makes sense, let’s cook up an example. When you start dragging items around, it’s not completely intuitive where you’re allowed to drop them; you have to keep dragging until the avatar turns green. On top of that, there’s no immediate feedback that your drop was successful. We can create a better experience than that.
function highlightTargets(){
var props = {
margin: { start: '0', end: '-5', unit: 'px' },
borderWidth: { start: '0', end: '5', unit: 'px' }
};
var m = dojo.dnd.manager();
var hasZero = false;
dojo.forEach(m.nodes, function(node){
// check the selected item(s) to look for a zero quantity
// so we know whether we can highlight the cart
if(m.source.getItem(node.id).data.quantity == 0){
hasZero = true;
}
});
dojo.style("wishlistPaneNode", "borderColor", "#97e68d");
dojo.style("cartPaneNode", "borderColor", "#97e68d");
dojo.anim("wishlistPaneNode", props, 250);
if(!hasZero){
dojo.anim("cartPaneNode", props, 250);
dojo.byId("cartPaneNode").isHighlighted = true;
}
}
function unhighlightTargets(dropTarget){
var props = {
margin: { start: '-5', end: '0', unit: 'px' },
borderWidth: { start: '5', end: '0', unit: 'px' }
};
cpn = dojo.byId("cartPaneNode");
var cartIsHighlighted = cpn.isHighlighted;
cpn.isHighlighted = false;
if(dropTarget && dropTarget.node && dropTarget.node.id){
// dropTarget lets us know which node to highlight yellow
switch(dropTarget.node.id){
case "wishlistPaneNode":
if(cartIsHighlighted){
dojo.anim("cartPaneNode", props, 250);
}
dojo.style("wishlistPaneNode", "borderColor", "#ffff33");
dojo.anim("wishlistPaneNode", props, 500, null, null, 750);
break;
case "cartPaneNode":
dojo.anim("wishlistPaneNode", props, 250);
dojo.style("cartPaneNode", "borderColor", "#ffff33");
dojo.anim("cartPaneNode", props, 500, null, null, 750);
break;
default:
dojo.anim("wishlistPaneNode", props, 250);
if(cartIsHighlighted){
dojo.anim("cartPaneNode", props, 250);
}
}
}else{
dojo.anim("wishlistPaneNode", props, 250);
if(cartIsHighlighted){
dojo.anim("cartPaneNode", props, 250);
}
}
}
Then, in our initialization function:
var resetSelections = function(){
cart.selectNone();
wishlist.selectNone();
junkCatalog.selectNone();
foodCatalog.selectNone();
};
// highlight valid drop targets when a drag operation starts;
dojo.subscribe("/dnd/start", null, highlightTargets);
dojo.subscribe("/dnd/cancel", null, unhighlightTargets);
dojo.subscribe("/dnd/drop", function(){
resetSelections();
unhighlightTargets(dojo.dnd.manager().target);
});
Since we’re listening to the topic broadcast itself, we know these will only run once per event. Manipulating a bit of CSS with dojo.anim()
helps make the drag and drop system a bit friendlier here, and that’s always a good thing.
Armed with knowledge of how to run code at various parts of the drag and drop timeline, we can see that it wouldn’t be difficult to extend this further. For example, our item quantities basically only determine whether you can drop something on the shopping cart, but it would be great if those updated when you did so (but not when you drop on the wishlist!). Since we have two separate DnD sources here, they each track their own selection state. Maybe we could set it up so that when you click on an item in one of the sources, it clears the selection on the other one. And of course, this little storefront doesn’t have any prices! In a real store you’d want to add that, and probably upgrade setupCartTitle()
to calculate a subtotal. The sky’s the limit.
Avoiding Duplicate Items
One thing I hadn’t pointed out before was that in version 3 of our demo, in addition to specifying the node creator function when instantiating our dojo.dnd.Source objects, we also passed a parameter copyOnly: true
. This overrides the default move semantics to do a copy operation by default, without requiring a special key press. This is nice because now we can avoid removing items from the catalog(s) when we drag them around, but the downside is that if you drop an item on the container where it already lives, it duplicates the item.
Huh. That’s interesting, because we’re not specifying the accept
type for the catalogs, so they should default to ["text"]
, which should keep us from dropping the products on them (we’re explicitly giving them different types, remember). However, it obviously doesn’t work the way we want. If you dig into the Dojo source, you’ll see that the reason is because the function that checks for matches between item types and container accept values automatically accepts “self drops,” short circuiting the item type check. Often, that’s the correct behavior, but for our copyOnly
-style DnD here, this is backwards. Fortunately again, overriding this is easy: just replace the object’s checkAcceptance()
function:
// based on dojo.dnd.Source.checkAcceptance()
function checkAcceptanceWithoutSelfDrop(source, nodes) {
if(this == source){ return false; }
for(var i = 0; i < nodes.length; ++i){
var type = source.getItem(nodes[i].id).type;
// type instanceof Array
var flag = false;
for(var j = 0; j < type.length; ++j){
if(type[j] in this.accept){
flag = true;
break;
}
}
if(!flag){
return false;
}
}
return true;
}
Then in our initialization code,
junkCatalog.checkAcceptance = checkAcceptanceWithoutSelfDrop;
foodCatalog.checkAcceptance = checkAcceptanceWithoutSelfDrop;
But wait! That’s not all. There’s still a small bug here: self drops are properly blocked at first, but after you drop an item successfully on the wishlist or shopping cart, self drops are accepted again. The reason is basically due to the timing and placement of the internal calls that cache acceptance criteria inside dojo.dnd.Manager. I won’t go into the details, but suffice it to say that we need to add this to our drop topic handler from before:
// reset the manager's drop flag to false, since in our
// case DnD operations always start on a container that
// will not allow "self drops"
dojo.dnd.manager().canDrop(false);
This way whenever a drag operation is initiated, we always start in a “cannot drop” state, and now all is right with the world. Some of the code has been reorganized and/or moved to an external file to reduce clutter, but the new stuff is all there if you view the page source.
Tweaking DnD Behavior
The final thing left to talk about for this demo is the set of buttons we introduce in version 4. We have buttons to clear the wishlist and shopping cart now, but notice the buttons at the bottom of the page. These demonstrate different ways to change the way DnD behaves. You can read the code to see how they work, but here’s what they do:
isSource
(“enable DnD”): If you’ve ever wondered how to turn DnD on and off completely, here’s one way. This toggles theisSource
member of each of our sources; I mentioned this earlier, but recall that when this is false, the manager won’t initiate any drag operations. Objects will still accept drops, but if there’s nothing acting as a source, we’ve effectively disabled the DnD system.withHandles
(“drag via handles only”): If you give a DOM node thedojoDndHandle
class, dojo.dnd will consider it a handle. Each Source has a member variablewithHandles
that determines whether you can drag any part of an item, or just the handle. This demo sets up the product images as the handles, so if you toggle the button, you’ll see the drag behavior change accordingly.skipForm
(“no button in the demo”): If you have form elements in your drag items, you can use theskipForm
field to toggle whether or not clicks inside them will initiate drags. Setting it totrue
will allow you to, say, select text in a<textarea>
without dragging everything around.
Finishing Up
For reference, here are the steps we’ve taken so far:
- Step 0: skeleton page
- Step 1: a single list
- Step 2: multiple lists
- Step 3: customizing item creation
- Step 4: listening to events
By putting our Javascript code into an external .js file and adding a bit of markup (that we remove via JS in the initialization sequence), we can even create a new version that validates as XHTML 1.0 Strict!
There’s quite a lot to discuss in dojo.dnd. For example, we haven’t really touched the CSS it uses to let your app know what’s happening in DnD land. There’s also a whole discussion we could have on how to cleanly set up your own custom DnD sources by extending dojo.dnd.Source with dojo.declare()
. A lot of dojo.dnd’s internals are specifically set up to be easy to override with your own code so you can customize just about any part necessary. And finally, there’s the dojo.dnd.move API, but as I said at the beginning, that’s for next time.
Until then, happy dragging and dropping!