Skip to content

Instantly share code, notes, and snippets.

@dfkaye
Last active November 26, 2016 20:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dfkaye/f1efc4be64699df2d65c55e27b41f725 to your computer and use it in GitHub Desktop.
Save dfkaye/f1efc4be64699df2d65c55e27b41f725 to your computer and use it in GitHub Desktop.
Using only month and year controls in jQueryUI datepicker

Filter by Month and Year Only, Using jQueryUI Datepickers

User interface components in general are built with an expected user behavior. We used the jQuery-UI datepicker, to save time, but in an unusual way, which cost us a lot of time discovering what we'd missed. We re-learned a key insight, namely, month and year are navigation, not selection controls.

The rest of this gist describes the implications we worked out. Short version of the code is at the bottom of this gist. There are other files attached below, containing more parts, for history's sake.

Background

As this is a gist, there is no screenshot to prove it, but recently (November 2016) we ran into the design requirement that a dashboard report should cover a time period, with start and end dates and that the report should update when users change a month or a year in either the start or end datepicker.

In the interest of saving time, rather than roll our own control for that, we chose to use the JQueryUI datepicker for each of these, but with an unusual approach that we would not show the calendar control (the table with all the clickable date links).

Of course, that unexpected approach led to unexpected and undesired behavior.

Setup

In our implementation, we used changeMonth: true and changeYear: true in the datepicker(options) argument, in order to display the month and year <select> elements, and set let the datepicker display "inline" (i.e., always, without a dialog - see http://jqueryui.com/datepicker/#inline).

We also set the defaultDate to the current year and month, and use 1 as the day, so that we're always comparing the first of any month.

We also needed to keep the two datepickers synchronized, such that a user could not set the start date to something greater than the end date. We do that in the onChangeMonthYear handler, re-setting either start's maxDate or end's minDate after a change to the end or the start date, respectively.

Problem

The prev and next arrows worked just fine, as they are decrement/increment handlers only. But we noticed that sometimes the onChangeMonthYear handler was called multiple times when we changed a date by using the month and year <select> elements. For example, we'd change the start year, then change the end year. On that second change, we'd see the update to the start date's maxDate - which seemed to trigger a onChangeMonthYear call to the start datepicker again, even though the start <select> element had not been touched. The result was that date ranges became unpredictable, even un-navigable.

Fix

It turns out the onChangeMonthYear event results only in re-drawing the calendar - it does not calculate a new date selection.

*Translation: month and year are navigation controls; the calendar's date links are the selection controls.

The expected behavior in the datepicker is that users select a date link inside the calendar. Because we are not using the calendar, we have to re-set the date, in code, on the datepicker for which the onChangeMonthYear handler is called - every time.

You can see most of the code fragments necessary to make the whole thing go by looking at the other files posted in this gist. Here's the condensed version of the JavaScript we used:

onChangeMonthYear: function (year, month, instance) {
  ...
  
  // Build a new Date from the display date, decrementing the display
  // month by 1 for use in JavaScript's built-in Date().
  var newDate = new Date(year, Number(month) - 1, 1);

  // This one line prevents the buggy behavior noted above.
  $(instance.input.context).datepicker('setDate', newDate);
  
  ...
}

This took quite a bit of time to reason out, but that's all there was to it.

/*------------------------------------------------------------------------------
* filters
* + override jQuery-UI datepicker styles
-----------------------------------------------------------------------------*/
/* debug input for date change testing */
[type="x-hidden"] {
background: inherit;
border: 0;
color: white;
padding: 0 0 0 3em;
}
.filter-date .ui-datepicker-calendar {
display: none;
}
.filter-date .ui-datepicker-title {
margin: 0;
}
.filter-date .ui-datepicker-header .ui-icon {
/* must point to a css-local copy of this file */
background-image: url("jQueryUI/images/ui-icons_ffffff_256x240.png");
background-repeat: no-repeat;
}
.filter-date .ui-datepicker-header .ui-datepicker-month,
.filter-date .ui-datepicker-header .ui-datepicker-year {
background: #b2d166;
border: 1px solid #fff;
border-radius: 3px;
color: inherit;
margin: 0 1em;
padding: 0.5em 0.75em;
width: auto;
}
.filter-date .ui-datepicker-header {
background-color: transparent;
border: 0;
color: #fff;
padding: 0;
}
.filter-date .ui-datepicker {
background: transparent;
font-size: 16px;
padding: 0;
}
<div class="filter" data-report-date-range>
<h2>Report Date Range</h2>
<div class="filter-field">
<span class="filter-field-label" id="report-date-range-start-label">From</span>
<div class="filter-date" id="report-date-range-start"
aria-labelledby="report-date-range-start-month-label">
<input type="x-hidden" id="report-date-range-start-date"
class="filter-field-date">
</div>
</div>
<div class="filter-field">
<span class="filter-field-label" id="report-date-range-end-label">To</span>
<div class="filter-date" id="report-date-range-end"
aria-labelledby="report-date-range-end-label">
<input type="x-hidden" id="report-date-range-end-date"
class="filter-field-date">
</div>
</div>
</div>
!(function () {
var buildDate = function(year, month) {
var date = new Date();
year != null || (year = date.getFullYear());
month != null || (month = date.getMonth());
return new Date(year, month, 1, 0, 0, 0, 0);
};
var updateMinOrMaxDate = function (element, date) {
// id values: report-date-range-start, report-date-range-end
var type = element.id.substring(1 + element.id.lastIndexOf('-'));
if (type == 'start') {
$(element).datepicker('option', 'maxDate', date);
}
if (type == 'end') {
$(element).datepicker('option', 'minDate', date);
}
};
var defaultDate = buildDate();
// Save a reference to our datepickers for re-use in the onChangeMonthYear
// handler.
var datepickers = $('.filter-date').datepicker({
// static configuration
changeMonth: true,
changeYear: true,
dateFormat: "yy-mm-dd",
defaultDate: defaultDate,
/**
* The onChangeMonthYear handler synchronizes start's maxDate with end's
* date, and/or end's minDate with start's date when either a year or
* month select value changes. The key is that we always update the date
* for the current instance directly, because the jquery-ui-datepicker's
* month and year select elements do not do that - only the day links
* (which we don't use) do that; otherwise, changes to minDate or
* maxDate cause date recalculations and further 'change' events - which
* may result in mutually recursive calls to this function.
*/
onChangeMonthYear: function onChangeMonthYear(year, month, instance) {
// Left-pad the one-digit months for yyyy-mm format.
month = month < 10 ? '0' + month : month;
// Verify we can extract the formatted value...
var value = ''.concat(year, '-', month);
$(instance.input).find('.filter-field-date').val(value);
// Build a new Date from the display date, decrementing the display
// month by 1 for use in JavaScript's built-in Date().
var newDate = buildDate(year, Number(month) - 1);
// This one line prevents the buggy behavior noted above.
$(instance.input.context).datepicker('setDate', newDate);
datepickers.map(function(i, element) {
if (instance.input.context !== element) {
updateMinOrMaxDate(element, newDate);
}
});
};
});
datepickers.map(function (ignoreThisParam, element) {
// dynamic configuration
// + initialize the minDate and maxDate options
updateMinOrMaxDate(element, defaultDate);
});
}());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment