Skip to content

Sorting with the help of HTML5 Drag'n'Drop API

Lebedev Konstantin edited this page Jan 15, 2015 · 3 revisions

Sortable.js is a minimalistic library for modern browsers and touch devices that doesn't require jQuery. 

As you may have guessed from its name, the library was developed for sorting elements by means of drag’n’drop. Standard solution in this case is jQuery UI/Sortable, and that’s 64kB + 10kB, neither more and no less. The result is 75kB of gzipped in a project, where jQuery is not used at all.

Apart from problems with weight, all libraries that I’ve found were unable to work with a dynamically varying list. At the moment of plug-in initialization, they were defining positions of all elements; to refresh them, we had to reinitialize the plug-in, or to call $('...').sortable('refresh') method, which is quite inconvenient. 

Since my task didn’t require supporting old browsers, I’ve tried to create the functionality I needed in pure JS with the use of HTML5 Drag’n’Drop.



After reading the related articles, it turned out that today it is very simple to create such functionality, it can even be made with 25 lines only (without commentaries and spacing):



http://jsfiddle.net/RubaXa/zLq5J/

function sortable(rootEl, onUpdate) {
   var dragEl;
   
   // Making all siblings movable
   [].slice.call(rootEl.children).forEach(function (itemEl) {
       itemEl.draggable = true;
   });
   
   // Function responsible for sorting
   function _onDragOver(evt) {
       evt.preventDefault();
       evt.dataTransfer.dropEffect = 'move';
      
       var target = evt.target;
       if (target && target !== dragEl && target.nodeName == 'LI') {
           // Sorting
           rootEl.insertBefore(dragEl, target.nextSibling || target);
       }
   }
   
   // End of sorting
   function _onDragEnd(evt){
       evt.preventDefault();
      
       dragEl.classList.remove('ghost');
       rootEl.removeEventListener('dragover', _onDragOver, false);
       rootEl.removeEventListener('dragend', _onDragEnd, false);


       // Notification about the end of sorting
       onUpdate(dragEl);
   }
   
   // Sorting starts
   rootEl.addEventListener('dragstart', function (evt){
       dragEl = evt.target; // Remembering an element that will be moved
       
       // Limiting the movement type
       evt.dataTransfer.effectAllowed = 'move';
       evt.dataTransfer.setData('Text', dragEl.textContent);


       // Subscribing to the events at dnd
       rootEl.addEventListener('dragover', _onDragOver, false);
       rootEl.addEventListener('dragend', _onDragEnd, false);


       setTimeout(function () {
           // If this action is performed without setTimeout, then
           // the moved object will be of this class.
           dragEl.classList.add('ghost');
       }, 0)
   }, false);
}
                       
// Using                    
sortable(document.getElementById('list'), function (item) {
   console.log(item);
});

As it may be noticed from the code, the whole sorting process consists of simple movement of the dragged element by means of rootEl.insertBefore(dragEl, target.nextSibling || target), where target is an element that was targeted. If you have already tested the example, you must have noticed that it is impossible to move an element to the first position. One more peculiarity of this method is that onUpdate is called each time, even if the element was not moved.



In order to fix the first problem, all we have to do is to add testing during sorting. It is necessary to insert an element after target.nextSibling only in case it is not the first element of the list:



http://jsfiddle.net/RubaXa/zLq5J/3/

if (target && target !== dragEl && target.nodeName == 'LI') {
     // Sorting
     rootEl.insertBefore(dragEl, rootEl.children[0] !== target && target.nextSibling || target);
}

Besides that, simply saving a link to the next element (nextEl = dragEl.nextSibling) in the moment dragstart allows us to get rid of the second problem (http://jsfiddle.net/RubaXa/zLq5J/4/, lines 29 and 38).



On the face of it, everything looks fine, we have a compact and intelligible code that is supported by the majority of browsers, and if we add the support of attachEvent and remove dragEl.classList.add/remove, then the code will work even in IE5.5 :]



But if we change the example a little bit by simply increasing height of list elements, we will have a third problem. Sorting works fine from the top downward, but it works poorly from the bottom upwards. That’s why we need to rewrite the logic of element inserting before or after so that it would consider, in which half the mouse cursor is located (upper or lower). For this purpose, we acquire element coordinates against the screen at onDragOver and check, in which half the cursor is located:



http://jsfiddle.net/RubaXa/zLq5J/6/

var rect = target.getBoundingClientRect();
var next = (evt.clientY - rect.top)/(rect.bottom - rect.top) > .5;
rootEl.insertBefore(dragEl, next && target.nextSibling || target);

Touch support

Unfortunately, drag’n’drop doesn’t work on touch devices. That’s why we needed to create some sort of emulation based on touch-events. I have been scratching my had over this for a long time, read documentation, but never found an answer. Finally, after digging a little bit more, I remembered one more excellent method document.elementFromPoint, which allows to obtain a link to an element by coordinates.



As the result, I clone the element that will play the role of a “ghost” under the finger at touchstart, and move it by means of translate3d at touchmove:

var
    touch = evt.touches[0],
    dx = touch.clientX - tapEvt.clientX,
    dy = touch.clientY - tapEvt.clientY
;

Besides that, I initiate setInterval, where I check the current element under the finger every 100ms:

_emulateDragOver: function () {
   if (touchEvt) {
        // Hiding a “ghost” under the finger
        _css(ghostEl, 'display', 'none');

        // Obtaining an element under the finger
        var target = document.elementFromPoint(touchEvt.clientX, touchEvt.clientY);

        // Checking the obtained element, and if it belongs to rootEl, 
        // we call `onDragOver` method:
        this._onDragOver({
            target:   target,
            clientX:  touchEvt.clientX,
            clientY:  touchEvt.clientY,
        });

        // Showing the “ghost” again
        _css(ghostEl, 'display', '');
     }
}

That’s it, as you can see, there’s nothing supernatural. Now, we need to draw up a code, write some documentation, and the micro library is ready. 



Sortable

The library turned out to weight 2kB gzipped and has the following capabilities: 


  • Sorting of vertical and horizontal lists;
  • Ability to set the elements to be sorted (css-selector);
  • Combining into groups;
  • Ability to set handle (an element that can be dragged);
  • A class that is added to the moved element;
  • onAdd, onUpdate, onRemove events;
  • Working with dynamically varied lists.

Code example:
// Simple list, e.g. ul > li
var list = document.getElementById("my-ui-list");
new Sortable(list); // That's all.


// Grouping
var foo = document.getElementById("foo");
new Sortable(foo, { group: "omega" });

var bar = document.getElementById("bar");
new Sortable(bar, { group: "omega" });


// handle + event
var container = document.getElementById("multi");
new Sortable(container, {
 handle: ".tile__title", // css-selector, which can be used to drag
 draggable: ".tile", // css-selector of elements, which can be sorted
 onUpdate: function (/**Event*/evt){
    var item = evt.item; // a link to an element that was moved
 }
});

Today, only basic functionality is available. I would be glad to receive any feedback or pull request, thanks for your attention.