In this article, I’d like to show you how to extend the capabilities of HTML5 drag and drop — so it can handle multiple elements, and support keyboard interaction, for sighted and screen reader users.
I’m going to assume that you already have a passing familiarity with the drag and drop API, but if not, have a look at this introductory article (ignoring the references to Modernizr, which you won’t need).
The basic approach to dragging multiple elements is actually pretty trivial — we simply need to remember the drag data for more than one element. We could do that using the
dataTransfer object, or we can just use a separate array (which is what we’re going to do). So why write a whole article about it?
Why — because although the data is simple, the interface is rather more complex.
We’ll need to do some extra work to implement a pre-selection mechanism, and we’ll need to make it work from the keyboard (since native drag and drop doesn’t support this).
Note: This article won’t cover touch events, nor provide a polyfill for older browsers. Furthermore, the approach we take will only work within a single page, it won’t support dragging between windows. Although the drag and drop API does support this, there’s no straightforward way of making it keyboard accessible.
Basic Drag and Drop
So let’s start with a functional example which defines the basic drag and drop events, allowing a single element to be dragged with the mouse between two containers:
See the Pen Basic Drag and Drop by SitePoint (@SitePoint) on CodePen.
There’s a few things in there that might differ from other demos you’ve seen.
The first is the way it maintains an
item reference for the element being dragged, rather than passing the element’s
ID through the
dataTransfer object (although we do have to pass something or the whole operation will fail in Firefox; it could be anything, so an empty string will do):
var item = null;
item = e.target;
This simplifies the demo by avoiding the need for the elements to have IDs, and would make it much easier to extend into a server-side application (like a CMS), where element IDs may not be easily knowable. This approach will also be the basis of multiple selection, where the
item reference will become an
The next significant thing is the omission of the event properties,
dropEffect. These can take a value such as
"move", and are supposed to control which actions are allowed and which cursor the browser will show. However, browser implementation is inconsistent, so there’s not much point including them.
Finally, note how the underlying HTML does not include any
<li data-draggable="item">Item 0</li>
<li data-draggable="item">Item 1</li>
<li data-draggable="item">Item 2</li>
<li data-draggable="item">Item 3</li>
draggable attribute to browsers which pass feature detection.
This approach also provides the opportunity to exclude any broken implementations, for the cases where feature detection fails. Opera 12 or earlier is excluded using the
window.opera test, because its implementation is quite buggy, and it’s not worth spending time on anymore.
Accessible Drag and Drop
Accessibility is a fundamental design principle, and it’s always easier to it get right when considered in those terms. So before we go any further with this demo, we must have a clear idea of the requirements for keyboard and screen reader accessibility.
The ARIA Authoring Practices has a section on drag and drop, which outlines the attributes we’ll need, and has guidelines on how the interactions should work. To summarize:
- Draggable elements are identified with
aria-grabbed="false", and must be navigable with the keyboard.
- Provide a mechanism for the user to select which elements they want to drag, and the recommended keystroke is Space. When an element is selected for dragging, its
aria-grabbed attribute is set to
- Provide a mechanism for the user to indicate that they’ve finished making selections, and the recommended keystroke is
- Target elements are then identified with
aria-dropeffect, with a value that indicates which actions are allowed, such as
"copy". At this point in the process, target elements must also be navigable with the keyboard.
- When the user arrives at a target element, provide a mechanism for them to perform the drop action, and the recommended keystroke is also
- Users can cancel the entire operation at any time, by pressing the
- When the action is completed or aborted, clean-up the interface by setting all
aria-dropeffect attributes to
"none" (or removing them), and all
aria-grabbed attributes to
Now the two ARIA attributes,
aria-dropeffect, must be used according to specification. However, the events and interactions are simply recommendations, and we don’t have to follow them slavishly. Nonetheless, we should (and will) follow these recommendations as closely as possible, because they’re the closest thing we have to a normative reference.
And for the most part, I think it makes sense. But I do take issue with both uses of the
Control+M keystroke. I’m not convinced that it’s really necessary to have an end-of-selection keystroke at all, and I don’t the keystroke itself is very intuitive for sighted keyboard users.
So I think the best approach is to supplement those recommendations:
- We will implement the end-of-selection keystroke, but it won’t be required nor prevent further selection, it will simply be a shortcut for moving focus to the first drop target.
- We will use
Control+M for both keystrokes, but we’ll also allow the drop action to be triggered with the
The guidelines also talk about implementing multiple selection using modifiers. The recommendation is to use
Shift+Space for contiguous selection (i.e. selecting all items between two end points), and
Control+Space for non-contiguous selection (i.e. selecting arbitrary items). Those are clearly the best modifiers to use … however, the Mac equivalent of
Control+Space would be
So for the sake of simplicity, and to side-step this problem, we’re not going to implement contiguous selection. The simplest thing we can do for now is to support all three modifiers for non-contiguous selection — Command+Space, Control+Space or Shift+Space are all treated the same way on every platform — then whatever a particular user can trigger, and whatever they think makes sense, is available.
Now that we have a solid idea of what’s required for keyboard accessibility, we can start to implement selection events for both the mouse and the keyboard. Selecting an item will inevitably mean adding a
class or attribute to indicate the selection, so let’s use
aria-grabbed in both cases. This will give us a handy and semantically appropriate styling hook, and will make it possible to support cross-modality — i.e. the ability to support any mixture of mouse and keyboard interaction, rather than assuming that only one or the other will be used.
So let’s start by updating the initial code that applied the
draggable attribute, so it also applies default values for
items = document.querySelectorAll('[data-draggable="item"]'),
len = items.length,
i = 0; i < len; i ++)
Then we can implement the selection functionality, beginning with the mouse events.
In this demo, you can make single selections with the mouse, or multiple selections using a modifier (and that’s all you can do). But note how multiple selections can only be made within a single container, you can’t make selections across multiple containers:
See the Pen Mouse Selection by SitePoint (@SitePoint) on CodePen.
Limiting selections to a single ‘owner’ container is deliberate, and implemented in the scripting with the
selections.owner reference. The purpose of that behavior is to simplify the interactions for users, so that it’s always clear where the selections came from, and where they can be moved to (i.e. anywhere else).
clearSelections — which add and remove
aria-grabbed to indicate item selection, and manage the
selections object’s data.
The mouse interactions which call those functions use two mouse events. Single selection or global reset (without a modifier) is triggered by a
mousedown event, but deselection or multiple selection (with a modifier) is deferred to the
Using two events is essential for creating intuitive interactions, which meet user expectations for how modified and unmodified clicks should behave. Most of it’s pretty straightforward, but there’s one potential contradiction:
- Modified click on an already-selected item should deselect it.
- Modified or unmodified drag on any already-selected item should move the whole selection without changing it.
It’s impossible to implement both of those using only
dragstart events, because they would directly contradict each other. But if we use the
mouseup event for modified selection and deselection, then we can satisfy both expectations.
There’s also a special case we have to handle, which is where the user attempts a modified drag on an item which isn’t selected. Since modified selection is deferred to
mouseup, that action would initiate dragging on an item which won’t be included in the drop. But we can fix that by using the
dragstart event to automatically select the target item.
Keyboard selection is implemented using the model we looked at earlier — you can Tab to items, and then use Space to make selections, with a modifier for multiple selections:
See the Pen Keyboard Selection by SitePoint (@SitePoint) on CodePen.
keydown event differentiated by
keydown also handles the Escape key on any target element, to implement the global reset keystroke. For the mouse, you can reset the selections by clicking anywhere outside the containers, and so pressing Escape is the keyboard equivalent of that.
So now we have a complete set of selection events. And crucially, since we’ve used
aria-grabbed for both sets of events, there’s no functional difference in the result of mouse or keyboard interaction — a user can select with the keyboard then drag with the mouse, or vice versa — and the state of the interface will be correct for any combination.
Dragging the Selection
Having made one or more selections, the user needs to know where they can drag the items to (i.e. which containers are valid drop targets). Once again, we can use the same attribute for both mouse and keyboard interaction — in this case
aria-dropeffect, which we first apply to all the target containers by default:
targets = document.querySelectorAll('[data-draggable="target"]'),
len = targets.length,
i = 0; i < len; i ++)
When targets are available, their
aria-dropeffect changes from
"move". This lets screen readers know that the elements are now valid targets, and can also be used as a visual styling hook:
(Note that, although the only visual differences are colors, the change in contrast is enough that it’s not relying on color alone to convey information.)
For keyboard interaction, the
"move" state is applied as soon as any selections are made. Keyboard interaction doesn’t have a ‘dragging’ state as such — targets are chosen by users when they Tab to a target container, and therefore the target containers must be available whenever selections exist:
See the Pen Keyboard Dragging by SitePoint (@SitePoint) on CodePen.
clearDropeffect — which manage
tabindex on the target containers.
But when targets are available, we also remove
tabindex from all the items inside them, to improve usability for keyboard users by reducing the amount of stuff in the tab order. Since items outside the owner container can’t be selected, they needn’t (and arguably shouldn’t) be in the tab order at all. Such items also have their
aria-grabbed attribute temporarily removed, so that screen readers don’t continue to announce them as draggable items.
These functions are called by the same
keydown events as we used for selection — adding dropeffect whenever any items are selected, and clearing it again when they’re all reset. That event also includes the end-of-selection keystroke (Control+M for PC or Command+M for Mac), which is simply a shortcut to set focus on the first available target container.
We also need extra focus management for the Escape keystroke, since the keystroke might be used while focus is on a target container, and has the effect of removing
tabindex from that very container. That would cause the focus position to be reset back to the top of the page, so we have to manage the focus explicitly, to prevent that. The simplest solution is to set the focus back on the last-selected item.
You might notice how the calls to
clearDropeffect are somewhat over-egged — for example, every selection event calls
addDropeffect, even if we already have selections. We need to do this to support cross-modality — i.e. we can’t assume that those previous selections were also made with the keyboard. But similarly, we can’t assume that a mouse selection follows another mouse selection, so we need to update the mouse events as well as the keyboard events, to restore the mode-applicable state whenever either are used.
For mouse dragging, we don’t add
aria-dropeffect until dragging has actually started, at which point all the available target containers become highlighted:
See the Pen Mouse Dragging by SitePoint (@SitePoint) on CodePen.
The complication with mouse interaction is the need to create a ‘hover’ state. For the keyboard, it was simple to add a
:focus rule to the target styles, but we can’t use
:hover for mouse interaction because the hover state never occurs during native dragging.
What we can do, however, is to use native drag events to monitor the mouse position, and then implement a scripted hover state. The events we need are called
dragleave, which are conceptually similar to
mouseout, but with one important difference — the drag events don’t have a
relatedTarget property. So when you drag the mouse into a target element (for example), the
dragenter event can’t tell you which element the mouse is moving away from.
But we can fix that by maintaining a manual reference — since a
dragleave event is always preceded by a
dragenter event, the
target of that
dragenter must logically be the same element as the related-target of the
dragleave. So if we record that reference in the former, we can use it in the latter:
var related = null;
related = e.target;
//the related var is this event's relatedTarget element
To make that useful, we also need this
getContainer function, which returns a target container reference from any inner element:
if(element.nodeType == 1 && element.getAttribute('aria-dropeffect'))
while(element = element.parentNode);
We need this because the
dragleave events will bubble — just like
mouseout, they’ll fire when the mouse moves over any element inside the same container. We need to filter those events so they don’t reset the hover state, so
getContainer is used to normalize all references to their parent container, then we only respond to the ones where that reference has changed:
var droptarget = getContainer(related);
if(droptarget != selections.droptarget)
//... update target highlighting
selections.droptarget = droptarget;
Dropping the Selection
The final interaction is to drop the items into a target container. At this point in the process, we’ll have an
items array with references to all the items being dragged, so moving that selection is simply a case of appending the items to the drop target container:
for(var len = items.length, i = 0; i < len; i ++)
We don’t need to reference the
dataTransfer object at all, because we’re not using it.
For mouse interaction, we’ll already have a
droptarget reference created by the
dragleave event (or it will be
null if there is no current target). So we simply need a
dragend event to handle what happens when the mouse is released:
See the Pen Mouse Droppings by SitePoint (@SitePoint) on CodePen.
What’s significant here, is that there’s no
drop event nominally means that the element has been dropped into a valid target, while the
dragend event simply means that the mouse has been released (i.e. whether or not the drop target was valid). But in practice, within the context of a single document, there’s actually no difference between those two events — both events fire every time, and a ‘valid drop target’ is nothing more than ‘whatever we say it is’!
However we do have to deal with a browser event bug — Mac/Webkit won’t fire the drop event if the Command key is still held down. Consequently, releasing the mouse would leave items still grabbed and targets still active, so to avoid that problem, we simply use the
dragend event for everything.
Within that event, we have one condition to handle drop actions (when the target was valid), and one for handling reset (in either case). This split in conditional logic ensures that we don’t reset the grabbed state if there’s no valid drop target — so the user can try again without having to re-select the items.
For keyboard interaction, dropping the selection means navigating to a target container, and then pressing the drop keystroke (either Enter or Modifier+M). We don’t have an active
droptarget reference, but then we don’t need one — the drop target is the element that receives the keystroke:
See the Pen Keyboard Dropping by SitePoint (@SitePoint) on CodePen.
So the drop action is simply a
keydown event on the target containers, which checks the target element and keystroke, and responds accordingly — moving the selection, then cleaning up the interface. In this case we’re only dealing with valid drop actions — there’s no such thing as invalid drops with keyboard navigation, because only valid targets receive the keystroke in the first place (and we’ve already implemented user abort via the Escape key).
But we do have to manage the focus, in the same way as we did for abort — since the element that currently has the focus is about to lose its
tabindex, we must move the focus somewhere explicit, to prevent it from being reset. Again, the most simply-intuitive solution is to focus the last-selected item.
And that’s it!
There are, of course, many possibilities for additional enhancements, among them:
- Adding support for touch and/or pointer events.
- Creating a polyfill for older browsers.
- Sorting the selection when it’s dropped.
- The choice of “copy” or “move” using Modifier+Drop.
- Contiguous selection using Shift+Select and/or keyboard-range selection using Shift+Arrow.
- Using a custom drag-ghost to indicate how many items are being dragged.
- Visually dimming the selection when items are dragged by mouse, or when the end-of-selection keystroke is used.
I’ll talk about some of these possibilities in a future article.
In the meantime, you can grab a copy of the files from our GitHub repo: