Thursday, December 02, 2010

SHIFT Click javascript selection

I found myself needing a standard shift click selection functionality JavaScript for a website.  Preferably it should encapsulate the actual SHIFT click logic so it would be easy to use and reuse. It should provide the same or similar functionality of windows explorer or Konqueror. I was not able to find anything that I felt suited my need so I rolled my own. It is based on JQuery. You can see a demo of the Shift Click Javascript selection here.

I have tested it in Firefox 3.6, Chrome5/6 and IE8.

First the accompanying index.html page

<html>
<head>
<link href="style.css" rel="Stylesheet" type="text/css">
<title></title>
</head>
<body>
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js" type="text/javascript" charset="utf-8"></script>
<script src="ShiftClick.js" type="text/javascript" charset="utf-8"></script>
<script src="main.js" type="text/javascript" charset="utf-8"></script>
<ul id="my-list">
    <li data-id="1">
        <div class="checkbox"></div>
        <h4 title="My name" class="name">My item1</h4>
    </li>
    <li data-id="2">
        <div class="checkbox"></div>
        <h4 title="My name" class="name">My item2</h4>
    </li>
    <li data-id="3">
        <div class="checkbox"></div>
        <h4 title="My name" class="name">My item3</h4>
    </li>
    <li data-id="4">
        <div class="checkbox"></div>
        <h4 title="My name" class="name">My item4</h4>
    </li>
    <li data-id="5">
        <div class="checkbox"></div>
        <h4 title="My name" class="name">My item5</h4>
    </li>
</ul>
</body>
</html>

and now the style.css file

#my-list li { height: 16px; position: relative; cursor: pointer; margin: 10px 0;}

#my-list li div, 
#my-list li h4 { position: absolute; overflow: hidden; left: 5px; margin: 0; top: 0;}

#my-list li div.checkbox { border-style:solid; border-width:1px; border-color:#fff; cursor: pointer; left: -25px; height: 17px; width: 17px; background: url(check-mark.png) no-repeat 0 0;}
#my-list li.checked div.checkbox { background-position: -16px 0; }

the main.js file

/**
* Solution specific file that is using the ShiftClick closure for both UI modification as well as the actualy
* SHIFT clicking logic
*/
var state = { previouslySelected: undefined, previouslyShiftSelected: undefined };



// "Your own click handler"
function onItemClick(elmTarget) {
 // Call the ShiftClick closure and let that handle the heavy lifting (passing in the callback as the last argument)
 ShiftClick.itemClick(elmTarget, state, '#my-list li', 'checked', handleItemClick);
 
 // INPUT YOUR CODE HERE
}

// Your own handleItemClick
function handleItemClick(elmTarget, selectedClass, itemChecked) {
 // Call the ShiftClick closure to do the UI updating
 ShiftClick.updateItemUI(elmTarget, selectedClass, itemChecked);
 
 // INPUT YOUR CODE HERE ... calling a backend service or logging ...
}



// JQuery extend to avoid html dom text to be selected
// Shamelessly copied from http://stackoverflow.com/questions/1319126/prevent-highlight-of-text
jQuery.fn.extend({ 
 disableSelection : function() { 
   this.each(function() { 
     this.onselectstart = function() { return false; }; 
     this.unselectable = "on"; 
     jQuery(this).css('-moz-user-select', 'none'); 
   }); 
 } 
});

$(document).ready(function(){
 // disable selection on #li text
 $('#my-list li').disableSelection();

 // Setup the "Your own click handler" for the selector
 $('#my-list li .checkbox').click( onItemClick );
});

and the ShiftClick closure/lib

/**
* Utility closure that implements range selection in a list of html elements
* SHIFT clicking
*/
(function (window, $, undefined) {
 var CHECKED = 'checked';
 var UNCHECKED = 'unchecked';
 
 /**
 * Implements the familiar SHIFT click functionality which can be found in most UI programs that allow
 * item selection. SHIFT clicking selects a range from the first item to the second item clicked
 */
 function itemClick(elmTarget, state, itemsSelector, selectedClass, handleItemClickCallback) {
  var startLoopIndex, endLoopIndex, previousSelectedIndex, previousShiftSelectedIndex, currentlySelectedIndex, items;

  if (elmTarget.shiftKey) {
   var selectedItems = $(itemsSelector + '.' + selectedClass);
   
   // The user is selecting the second item in the range
   if (selectedItems.length) {
    items = $(itemsSelector);
    previousSelectedIndex = items.index(state.previouslySelected);
    currentlySelectedIndex = items.index( $(elmTarget.target).closest('li'));

    // Clear previous range if any
    if (state.previouslyShiftSelected !== undefined) {
     previousShiftSelectedIndex = items.index(state.previouslyShiftSelected);
     startLoopIndex = Math.min(previousShiftSelectedIndex, currentlySelectedIndex);
     endLoopIndex = Math.max(previousShiftSelectedIndex, currentlySelectedIndex);

     for (var i = startLoopIndex; i <= endLoopIndex; i++) {
      handleItemClickCallback && handleItemClickCallback(items.eq(i), selectedClass, UNCHECKED);
     }
    }
    
    // Select current range
    startLoopIndex = Math.min(previousSelectedIndex, currentlySelectedIndex);
    endLoopIndex = Math.max(previousSelectedIndex, currentlySelectedIndex);

    for (var i = startLoopIndex; i <= endLoopIndex; i++) {
     handleItemClickCallback && handleItemClickCallback(items.eq(i), selectedClass, CHECKED);
    }
   }
   // The user have not normal clicked an item before and starts with SHIFT clicking
   else {
    handleItemClickCallback && handleItemClickCallback(elmTarget.target, selectedClass);
   }
   state.previouslyShiftSelected = $(elmTarget.target).closest('li');
  }
  
  // Normal clicking
  else {
   state.previouslySelected =  $(elmTarget.target).closest('li');

   // Reset previously SHIFT selected items
   state.previouslyShiftSelected = undefined;
   handleItemClickCallback && handleItemClickCallback(elmTarget.target, selectedClass);
  }
 }

 /**
 * Handles the actual UI modification to make the UI reflect the items selected
 * Handles one item at a time
 */
 function updateItemUI(elmTarget, selectedClass, itemChecked, selectCallback) {
  
  var elmParentLi = $(elmTarget).closest('li');
  if (itemChecked == CHECKED) {
   elmParentLi.addClass(selectedClass);
  }
  else if (itemChecked == UNCHECKED) {
   elmParentLi.removeClass(selectedClass);
  }
  else {
   elmParentLi.toggleClass(selectedClass);
  }

  var checked = elmParentLi.hasClass(selectedClass);
  selectCallback && selectCallback(elmParentLi.attr('data-id'), checked);
 }

 /**
 * Public methods
 */
 ShiftClick = {
  itemClick: itemClick,
  updateItemUI: updateItemUI
 };

})(window, jQuery);

No comments: