CodePen for the article: Accessible Drag and Drop with Multiple Items http://www.sitepoint.com/accessible-drag-drop
Forked from SitePoint's Pen Keyboard Dropping.
<ol data-draggable="target"> | |
<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> | |
</ol> | |
<ol data-draggable="target"> | |
<li data-draggable="item">Item 4</li> | |
<li data-draggable="item">Item 5</li> | |
</ol> | |
<ol data-draggable="target"> | |
<li data-draggable="item">Item 6</li> | |
<li data-draggable="item">Item 7</li> | |
</ol> | |
<ol data-draggable="target"> | |
<li data-draggable="item">Item 8</li> | |
</ol> |
CodePen for the article: Accessible Drag and Drop with Multiple Items http://www.sitepoint.com/accessible-drag-drop
Forked from SitePoint's Pen Keyboard Dropping.
(function() | |
{ | |
//exclude older browsers by the features we need them to support | |
//and legacy opera explicitly so we don't waste time on a dead browser | |
if | |
( | |
!document.querySelectorAll | |
|| | |
!('draggable' in document.createElement('span')) | |
|| | |
window.opera | |
) | |
{ return; } | |
//get the collection of draggable targets and add their draggable attribute | |
for(var | |
targets = document.querySelectorAll('[data-draggable="target"]'), | |
len = targets.length, | |
i = 0; i < len; i ++) | |
{ | |
targets[i].setAttribute('aria-dropeffect', 'none'); | |
} | |
//get the collection of draggable items and add their draggable attributes | |
for(var | |
items = document.querySelectorAll('[data-draggable="item"]'), | |
len = items.length, | |
i = 0; i < len; i ++) | |
{ | |
items[i].setAttribute('draggable', 'true'); | |
items[i].setAttribute('aria-grabbed', 'false'); | |
items[i].setAttribute('tabindex', '0'); | |
} | |
//dictionary for storing the selections data | |
//comprising an array of the currently selected items | |
//a reference to the selected items' owning container | |
//and a refernce to the current drop target container | |
var selections = | |
{ | |
items : [], | |
owner : null, | |
droptarget : null | |
}; | |
//function for selecting an item | |
function addSelection(item) | |
{ | |
//if the owner reference is still null, set it to this item's parent | |
//so that further selection is only allowed within the same container | |
if(!selections.owner) | |
{ | |
selections.owner = item.parentNode; | |
} | |
//or if that's already happened then compare it with this item's parent | |
//and if they're not the same container, return to prevent selection | |
else if(selections.owner != item.parentNode) | |
{ | |
return; | |
} | |
//set this item's grabbed state | |
item.setAttribute('aria-grabbed', 'true'); | |
//add it to the items array | |
selections.items.push(item); | |
} | |
//function for unselecting an item | |
function removeSelection(item) | |
{ | |
//reset this item's grabbed state | |
item.setAttribute('aria-grabbed', 'false'); | |
//then find and remove this item from the existing items array | |
for(var len = selections.items.length, i = 0; i < len; i ++) | |
{ | |
if(selections.items[i] == item) | |
{ | |
selections.items.splice(i, 1); | |
break; | |
} | |
} | |
} | |
//function for resetting all selections | |
function clearSelections() | |
{ | |
//if we have any selected items | |
if(selections.items.length) | |
{ | |
//reset the owner reference | |
selections.owner = null; | |
//reset the grabbed state on every selected item | |
for(var len = selections.items.length, i = 0; i < len; i ++) | |
{ | |
selections.items[i].setAttribute('aria-grabbed', 'false'); | |
} | |
//then reset the items array | |
selections.items = []; | |
} | |
} | |
//shorctut function for testing whether a selection modifier is pressed | |
function hasModifier(e) | |
{ | |
return (e.ctrlKey || e.metaKey || e.shiftKey); | |
} | |
//function for applying dropeffect to the target containers | |
function addDropeffects() | |
{ | |
//apply aria-dropeffect and tabindex to all targets apart from the owner | |
for(var len = targets.length, i = 0; i < len; i ++) | |
{ | |
if | |
( | |
targets[i] != selections.owner | |
&& | |
targets[i].getAttribute('aria-dropeffect') == 'none' | |
) | |
{ | |
targets[i].setAttribute('aria-dropeffect', 'move'); | |
targets[i].setAttribute('tabindex', '0'); | |
} | |
} | |
//remove aria-grabbed and tabindex from all items inside those containers | |
for(var len = items.length, i = 0; i < len; i ++) | |
{ | |
if | |
( | |
items[i].parentNode != selections.owner | |
&& | |
items[i].getAttribute('aria-grabbed') | |
) | |
{ | |
items[i].removeAttribute('aria-grabbed'); | |
items[i].removeAttribute('tabindex'); | |
} | |
} | |
} | |
//function for removing dropeffect from the target containers | |
function clearDropeffects() | |
{ | |
//if we have any selected items | |
if(selections.items.length) | |
{ | |
//reset aria-dropeffect and remove tabindex from all targets | |
for(var len = targets.length, i = 0; i < len; i ++) | |
{ | |
if(targets[i].getAttribute('aria-dropeffect') != 'none') | |
{ | |
targets[i].setAttribute('aria-dropeffect', 'none'); | |
targets[i].removeAttribute('tabindex'); | |
} | |
} | |
//restore aria-grabbed and tabindex to all selectable items | |
//without changing the grabbed value of any existing selected items | |
for(var len = items.length, i = 0; i < len; i ++) | |
{ | |
if(!items[i].getAttribute('aria-grabbed')) | |
{ | |
items[i].setAttribute('aria-grabbed', 'false'); | |
items[i].setAttribute('tabindex', '0'); | |
} | |
else if(items[i].getAttribute('aria-grabbed') == 'true') | |
{ | |
items[i].setAttribute('tabindex', '0'); | |
} | |
} | |
} | |
} | |
//shortcut function for identifying an event element's target container | |
function getContainer(element) | |
{ | |
do | |
{ | |
if(element.nodeType == 1 && element.getAttribute('aria-dropeffect')) | |
{ | |
return element; | |
} | |
} | |
while(element = element.parentNode); | |
return null; | |
} | |
//mousedown event to implement single selection | |
document.addEventListener('mousedown', function(e) | |
{ | |
//if the element is a draggable item | |
if(e.target.getAttribute('draggable')) | |
{ | |
//clear dropeffect from the target containers | |
clearDropeffects(); | |
//if the multiple selection modifier is not pressed | |
//and the item's grabbed state is currently false | |
if | |
( | |
!hasModifier(e) | |
&& | |
e.target.getAttribute('aria-grabbed') == 'false' | |
) | |
{ | |
//clear all existing selections | |
clearSelections(); | |
//then add this new selection | |
addSelection(e.target); | |
} | |
} | |
//else [if the element is anything else] | |
//and the selection modifier is not pressed | |
else if(!hasModifier(e)) | |
{ | |
//clear dropeffect from the target containers | |
clearDropeffects(); | |
//clear all existing selections | |
clearSelections(); | |
} | |
//else [if the element is anything else and the modifier is pressed] | |
else | |
{ | |
//clear dropeffect from the target containers | |
clearDropeffects(); | |
} | |
}, false); | |
//mouseup event to implement multiple selection | |
document.addEventListener('mouseup', function(e) | |
{ | |
//if the element is a draggable item | |
//and the multipler selection modifier is pressed | |
if(e.target.getAttribute('draggable') && hasModifier(e)) | |
{ | |
//if the item's grabbed state is currently true | |
if(e.target.getAttribute('aria-grabbed') == 'true') | |
{ | |
//unselect this item | |
removeSelection(e.target); | |
//if that was the only selected item | |
//then reset the owner container reference | |
if(!selections.items.length) | |
{ | |
selections.owner = null; | |
} | |
} | |
//else [if the item's grabbed state is false] | |
else | |
{ | |
//add this additional selection | |
addSelection(e.target); | |
} | |
} | |
}, false); | |
//dragstart event to initiate mouse dragging | |
document.addEventListener('dragstart', function(e) | |
{ | |
//if the element's parent is not the owner, then block this event | |
if(selections.owner != e.target.parentNode) | |
{ | |
e.preventDefault(); | |
return; | |
} | |
//[else] if the multiple selection modifier is pressed | |
//and the item's grabbed state is currently false | |
if | |
( | |
hasModifier(e) | |
&& | |
e.target.getAttribute('aria-grabbed') == 'false' | |
) | |
{ | |
//add this additional selection | |
addSelection(e.target); | |
} | |
//we don't need the transfer data, but we have to define something | |
//otherwise the drop action won't work at all in firefox | |
//most browsers support the proper mime-type syntax, eg. "text/plain" | |
//but we have to use this incorrect syntax for the benefit of IE10+ | |
e.dataTransfer.setData('text', ''); | |
//apply dropeffect to the target containers | |
addDropeffects(); | |
}, false); | |
//keydown event to implement selection and abort | |
document.addEventListener('keydown', function(e) | |
{ | |
//if the element is a grabbable item | |
if(e.target.getAttribute('aria-grabbed')) | |
{ | |
//Space is the selection or unselection keystroke | |
if(e.keyCode == 32) | |
{ | |
//if the multiple selection modifier is pressed | |
if(hasModifier(e)) | |
{ | |
//if the item's grabbed state is currently true | |
if(e.target.getAttribute('aria-grabbed') == 'true') | |
{ | |
//if this is the only selected item, clear dropeffect | |
//from the target containers, which we must do first | |
//in case subsequent unselection sets owner to null | |
if(selections.items.length == 1) | |
{ | |
clearDropeffects(); | |
} | |
//unselect this item | |
removeSelection(e.target); | |
//if we have any selections | |
//apply dropeffect to the target containers, | |
//in case earlier selections were made by mouse | |
if(selections.items.length) | |
{ | |
addDropeffects(); | |
} | |
//if that was the only selected item | |
//then reset the owner container reference | |
if(!selections.items.length) | |
{ | |
selections.owner = null; | |
} | |
} | |
//else [if its grabbed state is currently false] | |
else | |
{ | |
//add this additional selection | |
addSelection(e.target); | |
//apply dropeffect to the target containers | |
addDropeffects(); | |
} | |
} | |
//else [if the multiple selection modifier is not pressed] | |
//and the item's grabbed state is currently false | |
else if(e.target.getAttribute('aria-grabbed') == 'false') | |
{ | |
//clear dropeffect from the target containers | |
clearDropeffects(); | |
//clear all existing selections | |
clearSelections(); | |
//add this new selection | |
addSelection(e.target); | |
//apply dropeffect to the target containers | |
addDropeffects(); | |
} | |
//else [if modifier is not pressed and grabbed is already true] | |
else | |
{ | |
//apply dropeffect to the target containers | |
addDropeffects(); | |
} | |
//then prevent default to avoid any conflict with native actions | |
e.preventDefault(); | |
} | |
//Modifier + M is the end-of-selection keystroke | |
if(e.keyCode == 77 && hasModifier(e)) | |
{ | |
//if we have any selected items | |
if(selections.items.length) | |
{ | |
//apply dropeffect to the target containers | |
//in case earlier selections were made by mouse | |
addDropeffects(); | |
//if the owner container is the last one, focus the first one | |
if(selections.owner == targets[targets.length - 1]) | |
{ | |
targets[0].focus(); | |
} | |
//else [if it's not the last one], find and focus the next one | |
else | |
{ | |
for(var len = targets.length, i = 0; i < len; i ++) | |
{ | |
if(selections.owner == targets[i]) | |
{ | |
targets[i + 1].focus(); | |
break; | |
} | |
} | |
} | |
} | |
//then prevent default to avoid any conflict with native actions | |
e.preventDefault(); | |
} | |
} | |
//Escape is the abort keystroke (for any target element) | |
if(e.keyCode == 27) | |
{ | |
//if we have any selected items | |
if(selections.items.length) | |
{ | |
//clear dropeffect from the target containers | |
clearDropeffects(); | |
//then set focus back on the last item that was selected, which is | |
//necessary because we've removed tabindex from the current focus | |
selections.items[selections.items.length - 1].focus(); | |
//clear all existing selections | |
clearSelections(); | |
//but don't prevent default so that native actions can still occur | |
} | |
} | |
}, false); | |
//related variable is needed to maintain a reference to the | |
//dragleave's relatedTarget, since it doesn't have e.relatedTarget | |
var related = null; | |
//dragenter event to set that variable | |
document.addEventListener('dragenter', function(e) | |
{ | |
related = e.target; | |
}, false); | |
//dragleave event to maintain target highlighting using that variable | |
document.addEventListener('dragleave', function(e) | |
{ | |
//get a drop target reference from the relatedTarget | |
var droptarget = getContainer(related); | |
//if the target is the owner then it's not a valid drop target | |
if(droptarget == selections.owner) | |
{ | |
droptarget = null; | |
} | |
//if the drop target is different from the last stored reference | |
//(or we have one of those references but not the other one) | |
if(droptarget != selections.droptarget) | |
{ | |
//if we have a saved reference, clear its existing dragover class | |
if(selections.droptarget) | |
{ | |
selections.droptarget.className = | |
selections.droptarget.className.replace(/ dragover/g, ''); | |
} | |
//apply the dragover class to the new drop target reference | |
if(droptarget) | |
{ | |
droptarget.className += ' dragover'; | |
} | |
//then save that reference for next time | |
selections.droptarget = droptarget; | |
} | |
}, false); | |
//dragover event to allow the drag by preventing its default | |
document.addEventListener('dragover', function(e) | |
{ | |
//if we have any selected items, allow them to be dragged | |
if(selections.items.length) | |
{ | |
e.preventDefault(); | |
} | |
}, false); | |
//dragend event to implement items being validly dropped into targets, | |
//or invalidly dropped elsewhere, and to clean-up the interface either way | |
document.addEventListener('dragend', function(e) | |
{ | |
//if we have a valid drop target reference | |
//(which implies that we have some selected items) | |
if(selections.droptarget) | |
{ | |
//append the selected items to the end of the target container | |
for(var len = selections.items.length, i = 0; i < len; i ++) | |
{ | |
selections.droptarget.appendChild(selections.items[i]); | |
} | |
//prevent default to allow the action | |
e.preventDefault(); | |
} | |
//if we have any selected items | |
if(selections.items.length) | |
{ | |
//clear dropeffect from the target containers | |
clearDropeffects(); | |
//if we have a valid drop target reference | |
if(selections.droptarget) | |
{ | |
//reset the selections array | |
clearSelections(); | |
//reset the target's dragover class | |
selections.droptarget.className = | |
selections.droptarget.className.replace(/ dragover/g, ''); | |
//reset the target reference | |
selections.droptarget = null; | |
} | |
} | |
}, false); | |
//keydown event to implement items being dropped into targets | |
document.addEventListener('keydown', function(e) | |
{ | |
//if the element is a drop target container | |
if(e.target.getAttribute('aria-dropeffect')) | |
{ | |
//Enter or Modifier + M is the drop keystroke | |
if(e.keyCode == 13 || (e.keyCode == 77 && hasModifier(e))) | |
{ | |
//append the selected items to the end of the target container | |
for(var len = selections.items.length, i = 0; i < len; i ++) | |
{ | |
e.target.appendChild(selections.items[i]); | |
} | |
//clear dropeffect from the target containers | |
clearDropeffects(); | |
//then set focus back on the last item that was selected, which is | |
//necessary because we've removed tabindex from the current focus | |
selections.items[selections.items.length - 1].focus(); | |
//reset the selections array | |
clearSelections(); | |
//prevent default to to avoid any conflict with native actions | |
e.preventDefault(); | |
} | |
} | |
}, false); | |
})(); |
/* canvas styles */ | |
html, body | |
{ | |
font:normal normal normal 100%/1.4 tahoma, sans-serif; | |
background:#f9f9f9; | |
color:#000; | |
} | |
body | |
{ | |
font-size:0.8em; | |
} | |
/* draggable targets */ | |
[data-draggable="target"] | |
{ | |
float:left; | |
list-style-type:none; | |
width:42%; | |
height:7.5em; | |
overflow-y:auto; | |
margin:0 0.5em 0.5em 0; | |
padding:0.5em; | |
border:2px solid #888; | |
border-radius:0.2em; | |
background:#ddd; | |
color:#555; | |
} | |
/* drop target state */ | |
[data-draggable="target"][aria-dropeffect="move"] | |
{ | |
border-color:#68b; | |
background:#fff; | |
} | |
/* drop target focus and dragover state */ | |
[data-draggable="target"][aria-dropeffect="move"]:focus, | |
[data-draggable="target"][aria-dropeffect="move"].dragover | |
{ | |
outline:none; | |
box-shadow:0 0 0 1px #fff, 0 0 0 3px #68b; | |
} | |
/* draggable items */ | |
[data-draggable="item"] | |
{ | |
display:block; | |
list-style-type:none; | |
margin:0 0 2px 0; | |
padding:0.2em 0.4em; | |
border-radius:0.2em; | |
line-height:1.3; | |
} | |
/* items focus state */ | |
[data-draggable="item"]:focus | |
{ | |
outline:none; | |
box-shadow:0 0 0 2px #68b, inset 0 0 0 1px #ddd; | |
} | |
/* items grabbed state */ | |
[data-draggable="item"][aria-grabbed="true"] | |
{ | |
background:#8ad; | |
color:#fff; | |
} |