Saturday, January 25, 2014

Pick a date

This post is content previously published on www.cathmhaol.com

For the last month or so, I've been keeping my head down and trying to get some work done. Some of the effort has paid off, some hasn't, but that's as it always is. Anyway, I decided to put aside all the other topics kicking around in my head to put something down on (virtual) paper about one of the things I've been working on. So, here goes....

Recently I've been fiddling with the datepicker written by eternicode1. It's a decent product though not so terribly different than the vanilla JS libraries I wrote for the same purpose years ago2. Here's my problem - writing code to pick a date range using this sucks. The documentation is, at best, incomplete, and in some cases is downright incomprehensible. To help address that sad state of affairs, I'm going to show all the code necessary to implement the datepicker to solve the problem of wanting a start date and an end date without having odd things like a start date that's after the end date.

First, let's start off with the HTML - because that's where you should always start. People who try to get you to abandon Progressive Enhancement are evil geniuses who spend their time gently stroking a cat. Luckily for us, generating the HTML is fairly simple, even when we add in the challenge of generating something that's both semantic and accessible. One little note - I'm adding a div outside of the fieldset that collects the date range information - it will only be used in the 'inline' implementation, not the 'native' implementation of the datepicker. Enough babbling...here's the HTML.

HTML

<fieldset>
  <legend>Date Range</legend>
  <div class="date">
    <label for="dtfrom">From</label>
    <input id="dtfrom" type="text" />
  </div>
  <div class="date">
    <label for="dtto">To</label>
    <input id="dtto" type="text" />
  </div>
</fieldset>
<!-- don't add the 'inline-calendar" or "backToTop" elements if you're not displaying the calendar inline -->
<div id="inline-calendar"></div>
<a aria-hidden="true" name="backToTop">back to top</a>

Ok. Now that you have your HTML written, make sure that it works just as it is, because on this site we're always going to use Progressive Enhancement, and no matter what sort of validation bells and whistles you have on the front-end, you always need to validate data the user passes you.

Once you've gotten your HTML under control you'll need to add the datepicker, and that means you'll need to decide if you want the 'native' version - the version that displays when the input has focus, like a tooltip - or the 'inline' version - the version that displays the calendar all of the time. This is really a design decision but in my experience, the 'native' version is too small for a mobile device...so even if you opt for the 'native' version you might want to do the inline version for mobile devices. As another consideration, there are times when the 'native' implementation looks a little odd in a small viewport - the carets used to associate the calendar with the control don't always line up the way you might want. All that, of course, leads me to the point that you always want to be sure to test your code on several different devices - it's not enough to just test the most popular browsers on a desktop.

Now that you've made your design decisions and you have working HTML, you'll want to instantiate your datepicker(s). Here I'm going to use variables for a few things without showing you their values - things like a language code3 and starting and ending dates for the initial date range - and since everyone loves jQuery, I'll give the example using jQuery syntax - if you're used to vanilla JS you won't have any trouble figuring out what's going on anyway. I'm going to separate this section by implementation style, which will make it easier for you to jump to the section that matches your design choice.

Native Implementation

A snapshot of the native implementation
Native Implementation
The 'native' implementation is relatively straightforward, and you can find plenty of examples of it online. Restricting the date ranges for the two dates, beginning and ending, is a little tricky. If you're not careful and attempt to restrict the dates by removing the datepicker and re-creating it, you'll end up in an infinite loop. That means that the gotcha you need to watch out for is that you're handing the changeDate event appropriately.

In the example presented here, I'm passing in the corresponding date and if that value is null, setting the appropriate boundary date to either three (3) years ago (by passing the string -3y4) or today. You may wish to put all of this into an object and pass the entire object into the changeDate event, or you may wish to handle defaulting the date another way.

/**
 * Get the beginning date input and create a datepicker,
 * and be sure to use the changeDate event to set the
 * earliest date the ending date can start
 */
$('#dtfrom').datepicker({
  language: strLanguageCode
  startDate: datStart,
  endDate: datEnd,
  autoclose: true,
  forceParse: true
}).on('changeDate', datEnd, setValidRangeEnd);

/**
 * Get the ending date input and create a datepicker,
 * and be sure to use the changeDate event to set the
 * latest date the beginning date can end
 */
$('#dtto').datepicker({
  language: strLanguageCode
  startDate: datStart,
  endDate: datEnd,
  autoclose: true,
  forceParse: true
}).on('changeDate', datStart, setValidRangeBegin);

/**
 * Sets the ending date for the 'beginning' date
 * @return {void}
 * @param {event} event
 */
function setValidRangeBegin(event) {
  var $input = $('#dtfrom')
    , dt = (event && event.date) ? new Date(event.date) : (event.data || '-3y')
  ;
  $input.datepicker('setEndDate', dt);
}

/**
 * Sets the starting date for the 'ending' date
 * @return {void}
 * @param {event} event
 */
function setValidRangeEnd(event) {
  var $input = $('#dtto')
    , dt = (event && event.date) ? new Date(event.date) : (event.data || new Date())
  ;
  $input.datepicker('setStartDate', dt);
}

Of course you may also want to set the dates based on the values in the 'beginning' and 'ending' date fields. Doing so will require a little finesse as you'll need to set the dates when the field gets focus but before the datepicker is shown. This approach is much more difficult because there isn't a 'before the element is shown' event raised by the datepicker.

Inline Implementation

A snapshot of the inline implementation
Inline Implementation
The 'inline' implementation is a little less straightforward. Unlike the 'native' implementation, the inline implementation requires that we keep track of which date element is active so that we can set the correct element. Also, since the changeDate event applies to a single calendar, we have to handle it a little differently too.

As you'll notice from the snapshot, the inline implementation is not tied to a single text input, but is instead tied to an HTML block. When the datepicker is shown, it is contained by the HTML block with the 'inline-calendar' id in the sample HTML. If you select the inline implementation, you will need to provide this stub in your HTML.

Also, you'll likely be using that "back to top" anchor. To do this, add a line to your CSS - a[aria-hidden="true"] { position: absolute; clip: rect(0px, 0px, 0px, 0px); } - to hide the 'back to top' link, and add a focus event handler to move focus from the link to the first element in the form. You'll especially need to do this if your form should be modal, otherwise it won't be modal, focus will move to the next element. Since that listener is just about the easiest thing you can do, I won't bother adding it here. Be sure to use the clip method to hide the link, however, because if you use display none or visibility hidden, the focus event will never fire.

/**
 * Get the element used to contain the calendar,
 * and create the datepicker, assigning an event handler
 * for the changeDate event so that the value of the
 * active date element is set
 */
$('#inline-calendar').datepicker({
  language: strLanguageCode
  startDate: datStart,
  endDate: datEnd,
  forceParse: true
}).on('changeDate', setValue);

/**
 * Set a focus handler on the beginning date,
 * so that we can track which element is active
 * when focus moves to the datepicker
 */
$('#dtfrom').on('focus', setActive);

/**
 * Set a focus handler on the ending date,
 * so that we can track which element is active
 * when focus moves to the datepicker
 */
$('#dtto').on('focus', setActive);

/**
 * Return true if a value is a date
 * @return {boolean}
 * @param {variant} val
 */
function isDate(val) {
  return (val && val.getTime && !isNaN(val.getTime()));
}

/**
 * Set a class on the active element
 * @return {void}
 * @param {event} event
 */
function setActive(event) {
  var id = event.currentTarget.id
    , $active = $(event.currentTarget).closest('.date')
    , $inactive = $active.siblings().find(id == 'dtfrom' ? 'dtto' : 'dtfrom').closest('.date')
    , $toDate = $('#dtto')
    , $fromDate = $('#dtfrom')
  ;
  if ($inactive && $active) {
    $inactive.removeClass('active');
    $active.addClass('active');
  }
  if (id == 'dtfrom') {
    setRange(new Date(event.data.EARLIEST), $toDate.val(), $fromDate.val());
  } else if (id == 'dtto') {
    setRange($fromDate.val(), new Date(), $toDate.val());
  }
}

/**
 * Set the date range based on the user's selection
 * @return {void}
 * @param {date} beg - beginning date
 * @param {date} end - ending date
 * @param {date} sel - selected date
 */
function setRange(beg, end, sel) {
  // Make sure the params are dates
  var $beg = isDate(beg) ? beg : new Date(beg)
    , $end = isDate(end) ? end : new Date(end)
    , $pick = isDate(sel) ? sel : new Date(sel)
  ;
  // Set the dates
  $('#inline-calendar').datepicker('setStartDate', isDate($beg) ? $beg : beg);
  $('#inline-calendar').datepicker('setEndDate', isDate($end) ? $end : end);
  // Set the date and call the fill method so we highlight the date selected
  $('#inline-calendar').datepicker('setDate', isDate($pick) ? $pick : sel).datepicker('fill');
}
/**
 * Set the value of the active input using the selected date
 * @return {void}
 * @param {event} event
 */
function setValue(event) {
  // Use the pre-selected format by passing null into the format method
  $('div.date.active input[type="text"]').val(event.format());
}

Again, this is my preferred approach for mobile devices, because a little tweak of the zoom on the datepicker element and the calendar is as large as you need it without worrying about carets associating the calendar with a control. By creating style rules for the 'active' class you also visually indicate focus for the date field, removing the need for a caret.

Conclusion

Overall, the eternicode datepicker library is a decent library if you're already using bootstrap. I've not had any experience with the original bootstrap datepicker, so perhaps that's better documented, but I am doubtful of that considering the amount of searching I did before starting work with the library.

Just one more note - after years of implementing third-party code I've learned to start any implementation project by finding all the documentation I can about the code, whether I think it will apply to my work or not, and in fact have started more than one project by creating documentation for libraries where none existed before...though why an engineer would not document their code when tools like JavaDoc and JSDoc are available is beyond my understanding.
Give me six hours to chop down a tree, and I will spend four hours sharpening the axe.
Not Abraham Lincoln5







Notes and references

Links in the notes and references list open in a new window
  1. You can find the eternicode repo on github.
  2. There are two date-related libraries at http://js.cathmhaol.com/: Calendar and DateInput, which is similar to eternicode's datepicker fork of the bootstrap repo.
  3. The datepicker library is just one of several that uses common ISO codes for countries and languages, so it's best to become at least passingly familiar with those codes. Fortunately, you can find lists of both, country and language codes, on Wikipedia. Language codes are at http://en.wikipedia.org/wiki/List_of_ISO_639-1_codes and the country codes are at http://en.wikipedia.org/wiki/ISO_3166-1.
  4. In the eternicode library, the startDate and endDate parameters can handle a Date object, a String formatted according to the given format, or a string representative of a delta relative to today in the format {+|-}integer{d|w|m|y}", e.g., "-1d", "+6m", "-26w", or "+1y", where "d" represents days, "w" represents weeks, "m" represents months, and "y" represents years.
  5. Although it's good advice and it's possible Mr. Lincoln said this (not putting it in writing), that's not a foregone conclusion. You can find out more about what Mr. Lincoln didn't say at http://abrahamlincolnblog.blogspot.com/2010/05/lincoln-never-said-these-life-lessons.html

No comments:

Post a Comment