Tuesday, November 20, 2012

Selecting Ranges with the jQuery UI Datepicker

The current supported method of selecting a date range with the jQuery UI Datepicker is to use two pickers which represent the start and end of the range. In certain scenarios, where there is a large range, using two fields is a preferable choice. However, for shorter ranges, having the ability to allow the user to pick it from one box seems like a feasible alternative.

The Google Analytics report date filter takes a kind of hybrid approach. The UI has one field representing the range with a drop down menu that shows several different range selection options. There are two fields to enter a range but you can also select the range from the picker and achieve the same result. Its a fairly specific use case but an interesting approach worth considering:



I did find a jQuery plugin that provided this feature at eyecon.ro/datepicker/. I tested the plugin to see how it worked and ran into several issues. Typically, you don't have to specify any options to use a widget, however, minimally, you need to specify the date option otherwise it will throw an error. Additionally, the code referenced the depreciated jQuery.curCSS(). That needed to change to get it to work with a more recent version of jQuery. There are some other quirks as well - its last build was in 2009 so its probably not as well maintained as one would like. Regardless of the issues, the demos provided some ideas on how to modify the jQuery Datepicker to provide a similar feature.

With a little work, I was able to get the jQuery UI Datepicker to select more than one date. It was a little frustrating trying to deal with the two modes of operation - inline in a DIV or attached to an input field. In the former, the picker is always visible while, in the latter, it only appears when focusing on the input and hides once the user selects a date. I unfortunately needed a hybrid behavior:


  • Show the picker when the input receives focus. However, don't hide the picker once a date is selected, instead stay visible to allow the user to pick a second date to complete the range.

  • Show the selected range in the picker by highlighting the date included in the range. Additionally, update the input field with the selected range as the user picks different dates.

  • Only hide the picker when the user explicitly says they are done selecting the range.



The first feature can be accomplished using an inline picker, manually positioning it under the input field, and handling the binding to the focus event outside of the pickers built-in functionality. Here's the setup:


<div id="jrange" class="dates">
<input />
<div></div>
</div>




// global variables to track the date range
var cur = -1, prv = -1;

// Create the picker and align it to the bottom of the input field
// Hide it for later
$('#jrange div')
.datepicker();
.position({
my: 'left top',
at: 'left bottom',
of: $('#jrange input')
})
.hide();

// Listen for focus on the input field and show the picker
$('#jrange input').on('focus', function (e) {
$('#jrange div').show();
});



That part is pretty straight forward - it mimics the functionality that would happen automatically if the picker was attached to the input field. However, since I don't want the picker to hide once a date is selected or have that date set in the input field, I have to attach it inline to the DIV.

The next step is to use several of the callback function available in the Datepicker widget to manage the range selection. Two variables are needed to capture the current day selected and remember the last day selected. Each time a day is selected, the onSelect callback is called. The bulk of the logic needs to go in this function:


$('#jrange div')
.datepicker({
onSelect: function ( dateText, inst ) {
var d1, d2;

prv = +cur;
cur = inst.selectedDay;
if ( prv == -1 || prv == cur ) {
prv = cur;
$('#jrange input').val( dateText );
} else {
d1 = $.datepicker.formatDate(
'mm/dd/yy',
new Date( inst.selectedYear, inst.selectedMonth, Math.min(prv,cur) ),
{}
);

d2 = $.datepicker.formatDate(
'mm/dd/yy',
new Date( inst.selectedYear, inst.selectedMonth, Math.max(prv,cur) ),
{}
);
$('#jrange input').val( d1+' - '+d2 );
}
}
});


This code will update the global range variable and update the input box with the current selected range (or just the one date if only one day is selected). This captures the range but what about showing it on the actual picker? The beforeShowDay callback provides the necessary hook to achieve this feature. Its called for each day in the picker and enables you to set a class on the TD that represents the date passed in as a parameter:


$('#jrange div')
.datepicker({
beforeShowDay: function ( date ) {
return [true,
( (date.getDate() >= Math.min(prv, cur) && date.getDate() <= Math.max(prv, cur)) ?
'date-range-selected' : '')];
}
});


Once the class is set, a small bit of carefully crafted CSS styles are needed to set the background color of the links in the table cells:


.date-range-selected > .ui-state-active,
.date-range-selected > .ui-state-default {
background: none;
background-color: lightsteelblue;
}


So far, I have been able to utilize all the built-in options of the Datepicker to make this feature work. However, hiding the picker proved to be a challenge. Since, internally to the picker, it is considered inline, any logic that would allow the picker to be hidden is disabled. This includes clicking outside the picker, the close button that can optionally be enabled, or any other method normally available when the picker is attached to the input field. I decided to enable the button line and manually add the close button back onto that line myself. Unfortunately, it can't be done only once when the widget is created because the HTML for the picker is constantly being regenerated which would immediately remove the button. Instead, I added an option for an onAfterUpdate callback to the Datepicker and added a proxy to the internal _updateDatepicker function to call that callback. This will enable me to add the button back each time the calendar HTML is rebuilt:


$.datepicker._defaults.onAfterUpdate = null;

var datepicker__updateDatepicker = $.datepicker._updateDatepicker;
$.datepicker._updateDatepicker = function( inst ) {
datepicker__updateDatepicker.call( this, inst );

var onAfterUpdate = this._get(inst, 'onAfterUpdate');
if (onAfterUpdate)
onAfterUpdate.apply((inst.input ? inst.input[0] : null),
[(inst.input ? inst.input.val() : ''), inst]);
}


The implementation of the callback simply adds the same button HTML that the widget would create if it were in non-inline mode:


$('#jrange div')
.datepicker({
onAfterUpdate: function ( inst ) {
$('<button type="button" class="ui-datepicker-close ui-state-default ui-priority-primary ui-corner-all" data-handler="hide" data-event="click">Done</button>')
.appendTo($('#jrange div .ui-datepicker-buttonpane'))
.on('click', function () { $('#jrange div').hide(); });
}
});


The full source of this implementation and a demo are on my sandbox. There are probably some improvements that can be made to make it easier to reuse the customizations and allow it to work properly with other non-range pickers. However, its a good proof of concept that illustrates how to provide range selection in one Datepicker widget.