Checkbox Range Selection (a la GMail)

If any of you use GMail, you'll know that you can shift click the checkboxes on the conversation list to select a range of conversations (i.e. click the second conversation's checkbox and then shift-click the tenth conversation's checkbox). You can also deselect the same way (click the seventh, and then shift-click the fourth). Finally, you can shift-click a second time (third click total) to extend the range. I wanted that functionality in one of my apps, and here it is.

Update: I've repackaged the code as a jQuery plugin based on Dan Switzer's suggestion. I've left the original code in place, just struck it out.

Update: I've added namespacing of the event handler, and support for the meta key (Command on Mac) as well as shift based on Henrik's comments below.

function configureCheckboxesForRangeSelection(spec) {
  var lastCheckbox = null;
  jQuery(function($) { // for Prototype protection
    var $spec = $(spec);
    $spec.bind("click", function(e) {
      if (lastCheckbox != null && e.shiftKey) {
        $spec.slice(
          Math.min($spec.index(lastCheckbox), $spec.index(e.target)),
          Math.max($spec.index(lastCheckbox), $spec.index(e.target)) + 1
        ).attr({checked: e.target.checked ? "checked" : ""});
      }
      lastCheckbox = e.target;
    });
  }); // for Prototype protection
};

(function($) {
  $.fn.enableCheckboxRangeSelection = function() {
    var lastCheckbox = null;
    var $spec = this;
    $spec.unbind("click.checkboxrange");
    $spec.bind("click.checkboxrange", function(e) {
      if (lastCheckbox != null && (e.shiftKey || e.metaKey)) {
        $spec.slice(
          Math.min($spec.index(lastCheckbox), $spec.index(e.target)),
          Math.max($spec.index(lastCheckbox), $spec.index(e.target)) + 1
        ).attr({checked: e.target.checked ? "checked" : ""});
      }
      lastCheckbox = e.target;
    });
  };
})(jQuery);

It requires jQuery to be available (I used 1.2.1 and 1.2.3), and is safe to use with Prototype also in-scope (regardless of which owns the $ function). Call that function passing in a jQuery expression (NOT a jQuery object) that describes the checkboxes you want to be range-selectable:

configureCheckboxesForRangeSelection("input.image-checkbox");
$("input.image-checkbox").enableCheckboxRangeSelection();

Thanks to Matt Wood (a coworker) for the slice/index suggestion. My initial implementation had used an each with a conditional and a status variable – definitely less elegant.  Check the project page for any additional updates.

26 responses to “Checkbox Range Selection (a la GMail)”

  1. Dan G. Switzer, II

    My jQuery Field Plug-in (http://plugins.jquery.com/project/field) contains a method which does the same thing. It also has some other helpful form functions (like auto tab advancing) and limiting the number of selection you can make.

    Also, with a little refactoring you could make your code an actual true jQuery plug-in–which makes it a little more intuitive to use.

    (function($){
      $.fn.checkboxesForRangeSelection = function(v){
        var lastCheckbox = null;
    
        return this.bind("click", function(e) {
          if (lastCheckbox != null && e.shiftKey) {
            $spec.slice(
              Math.min($spec.index(lastCheckbox), $spec.index(e.target)),
              Math.max($spec.index(lastCheckbox), $spec.index(e.target)) + 1
            ).attr({checked: e.target.checked ? "checked" : ""});
          }
          lastCheckbox = e.target;
        });
      };
    })(jQuery);

    Now you should be able to just do:

    $("input.image-checkbox").checkboxesForRangeSelection();
  2. Dan G. Switzer, II

    FYI – I should clarify that I didn't actually test the code above (so there may be a bug.)

  3. Weixi Yen

    Please see example here:
    http://resopollution.com/granicus/training/library.html

    This works, until I sort the columns. Then the checkboxes indexing is messed up. Is there anyway to re-define the index values?

  4. Weixi Yen

    Thanks for the feedback!

    Please excuse me as I am new to jquery, but what do you mean when you are talking about listener?

    Thanks,

    Weixi

  5. Weixi Yen

    Hi Barney,

    Thanks, I found it

    $("myobject").unbind("click")
    $("myobject").enableCheckboxRangeSelection()

    This did the trick!

    Unfortunately, the checkboxes now go unchecked when I sort. I wish there was a way to maintain the checkboxes that are checked but with the new indexes… i'll keep working on it.

    Thanks for all the help so far!

  6. Weixi Yen

    Hi Barney,

    I am using this for sort plugins:

    jQuery.fn.sort = function() {
      return this.pushStack([].sort.apply(this,arguments), []);
    }

    I realized something odd. Firefox and IE7 does cache the checkboxes, just IE6 does not. Anyways, that is good enough. It pretty much works well enough.

    Another question if I may. In Gmail, I noticed that the background container gets highlighted when someone clicks a checkbox. Could you possibly point me in the right direction how to link the action of shift-clicking a checkbox and binding another object action?

    Thank you,

    Weixi

  7. Weixi Yen

    Hi Barney,

    Thanks for explaining. However, I still don't quite understand how the unbind works. Is there an unbind() function? If so, which object should I apply it to?

    Thanks,

    Weixi

  8. Dan G. Switzer, II

    @Weixi:

    IE6 loses the state of most form fields when the element is cloned. I would bet you might have to do something more complex to actually move the nodes other than the sort code you have above, but before implementing any more code I'd make sure you test the latest version of jQuery.

  9. Weixi Yen

    Thanks for the insights guys. Instead of keeping the selection, I actually decided to remove caching for consistency's sake for now. I did get the highlighting to work too. I just appended some code to the plugin. Not sure if there was a better way to do it:
    http://resopollution.com/granicus/training/js/ui.checkboxrange.js

    I am really happy with the result I have so far though. Just discovered I could use tags to sync with the checkboxes and enable the entire row to be shift-clickable.
    http://resopollution.com/granicus/training/library.html

    I could even get rid of the checkboxes visually by hiding them behind other divs or using position:absolute and left:-10000px

    This is a great plugin and thanks for the great support!

  10. Matt Simp

    you rock

  11. Henrik N

    Thanks for this.

    I made a slight modification, namespacing the click event (so it can be unbound unambiguously) and unbinding it before reapplying.

    http://pastie.textmate.org/316003

    I have checkboxes in drag-and-drop sortables. This way, I can re-apply enableCheckboxRangeSelection after sorting to get the order right.

  12. Henrik N

    I also changed e.shiftKey to (e.shiftKey || e.metaKey) so Command (in OS X) can be used in addition to Shift. Feels better somehow.

  13. Henrik N

    Barney, I made Gist with my modifications to this method as well as some other useful checkbox methods. I linked back here – let me know if your code is under some specific license or you want me to change how I credit you.

  14. scorphus

    Works charmingly well! Thank you very much!

  15. Pete

    By putting the LastCheckBox assignment inside an ELSE clause, you can toggle the selected checkboxes by shift-clicking again.

    if (lastCheckbox != null && (e.shiftKey || e.metaKey)) {
      ......
    } else {
      lastCheckbox = e.target;   <-----
    }
  16. Anne

    I know this is an old thread, but i was inspired by the function of the original post. Still it didnt work exactly like the solution gmail uses.

    I experimented a lot these 2 days to get the same behavior gamail uses and figured out how to get a correct implementation. Be aware: The code is much longer :(

    Solution is cross browser compatible

    (function ($) {
        $.fn.enableCheckboxRangeSelection = function () {
            var lastCheckbox = null;
            var $spec = this;
            $spec.unbind("click.checkboxrange");
            $spec.bind("click.checkboxrange", function (e) {
                var alreadyChecked = true;
                var currentTarget = e.target;
                if (typeof currentTarget.htmlFor !== 'undefined')
                {
                    currentTarget = document.getElementById(currentTarget.htmlFor);
                    alreadyChecked = false;
                }
                if (lastCheckbox != null && (e.shiftKey || e.metaKey)) {
                    var elements = $spec.slice(
                    Math.min($spec.index(lastCheckbox), $spec.index(currentTarget)),
                    Math.max($spec.index(lastCheckbox), $spec.index(currentTarget)) + 1
                    );
                    if (currentTarget.checked == alreadyChecked){
                        elements.attr({ checked:"checked"});
                    }
                    else{
                        elements.removeAttr("checked");
                    }
                }
                lastCheckbox = currentTarget;
    
                //Hack: Reset the value of the input when a label is clicked and then trigger the click of the input self. Now this solution is compatible with events on that input (like a custom change event)
    
                if (!alreadyChecked && (e.shiftKey || e.metaKey)){
                    e.preventDefault();
                    if (currentTarget.checked == true){
                        $(currentTarget).removeAttr("checked");
                    }
                    else{
                        $(currentTarget).attr({ checked:"checked"});
                    }
                    currentTarget.click();
                }
    
                return true;
            });
        };
    })(jQuery);
    
  17. Laszlo

    Thanks a lot! I needed to customize because i have ajax function when clicking a checkbox. I added a section to launch the ajax commands:

    (function ($) {
        $.fn.enableCheckboxRangeSelection_custom = function () {
            var lastCheckbox = null;
            var $spec = this;
            $spec.unbind("click.checkboxrange");
            $spec.bind("click.checkboxrange", function (e) {
                var alreadyChecked = true;
                var currentTarget = e.target;
                if (typeof currentTarget.htmlFor !== 'undefined')
                {
                    currentTarget = document.getElementById(currentTarget.htmlFor);
                    alreadyChecked = false;
                }
                if (lastCheckbox != null && (e.shiftKey || e.metaKey)) {
                    var elements = $spec.slice(
                    Math.min($spec.index(lastCheckbox), $spec.index(currentTarget)),
                    Math.max($spec.index(lastCheckbox), $spec.index(currentTarget)) + 1
                    );
    
                    //begin added by Laszlo
                    elements.each(function(index, elem){
                          var _name = $(elem).attr("name");
                          //you can do anithing with jQuery here, call other functions, etc...
                      }
                    //end added by Laszlo
    
                    });
                    if (currentTarget.checked == alreadyChecked){
                        elements.attr({ checked:"checked"});
                    }
                    else{
                        elements.removeAttr("checked");
                    }
    
                }
                lastCheckbox = currentTarget;
    
                //Hack: Reset the value of the input when a label is clicked and then trigger the click of the input self. Now this solution is compatible with events on that input (like a custom change event)
    
                if (!alreadyChecked && (e.shiftKey || e.metaKey)){
                    e.preventDefault();
                    if (currentTarget.checked == true){
                        $(currentTarget).removeAttr("checked");
                    }
                    else{
                        $(currentTarget).attr({ checked:"checked"});
                    }
                    currentTarget.click();
                }
    
                return true;
            });
        };
    })(jQuery);
    

    don't forget to init the function when document.ready:

    $("input:checkbox").enableCheckboxRangeSelection_custom();
  18. Psyke

    I think the code should be changed from:

    .attr({checked: e.target.checked ? "checked" : ""});

    to

    .attr({checked: e.target.checked });

    You don't need the "checked" or "", simply true or false with attributed checked is good. People also need to understand that the first "checked" is a string not a variable (for those who didn`t know). Using "checked" and "" instead of true or false, was making my code bug thus i couldn't uncheck my boxes.