Saturday, March 22, 2014

Build a Time Picker using jQuery UI Spinner Widgets

I've tried several methods of allowing a user to enter time. I prefer solutions that ensure the user can not enter an invalid time. A free-form text box with a mask will work to guide the entry but the validation is more complicated - especially if you're trying to restrict the entry to specific time intervals (ie every 5 minutes). In situations where there is an allowed time interval, I provided a single drop-down with all the valid selections. However, for small time intervals, the select box becomes large and difficult to navigate. In those cases, I've used several select boxes, each representing a different component of the time. The jQuery UI Spinner demo uses a single field to allow spinning through components of the entered time. However, there is no guided free-form entry so you'll have to validate the entry and ensure it is actually correct. I also found a time picker that extends the jQuery UI Datepicker widget. It has a lot of options for controlling the input a user interacts with while entering time. When creating a widget to enter different time components, I prefer to keep the input visually similar to how time is actually displayed. I think its more intuitive and natural since the whole value can be seen in its normal form. Given that, I decided to assemble several spinners to create a time selection widget. While the widget I present here will work, it will not have many features. However, there are three aspects of the functionality I'd like to focus on providing:

  • A value method to get/set the time in the widget. Since the value is split across three spinners, it needs to be parsed and formatted appropriately
  • Minimally, the minutes should be zero padded for anything less than 10 and you should only be able to switch between AM and PM
  • When you reach the limit of the hours or minutes, roll over to the beginning/end and change the other spinners. For example, if you go from 11 to 12, switch the AM/PM choice to allow you to move through the entire 24 hour day without having to change both fields individually.


This example uses my enhanced jQuery UI Spinner widget. This variation enables positioning the spinner buttons above and below the input boxes which provides a nice compact area to enter time. Starting with some markup to which the spinner widgets can be attached:

 <input id="hour"/>
 <span style="font-size: 1.4em">:</span>
 <input id="minute"/> 
 <input id="ampm"/>


And style the inputs to be a reasonable width:

.ui-timepicker-hour, .ui-timepicker-minute, .ui-timepicker-ampm {
   width: 1.6em;
}


We can now write the code required to implement the time entry. The base spinner doesn't have any kind of formatting or validation. It expects integers ranging from some minimum value to a maximum value. Customizing the formatting needs to be done by overriding some internal methods in the spinner widget. The second feature outlined above requires two specialized extensions to the spinner - one to handle the padding and the second to handle restricting the AM/PM values. A while ago, I wrote about customizing the spinner widget to enable alternative display formats. In each widget, the _parse and _format functions need to be implemented to move back and forth between the internal numeric representation being "spun" and the displayed value in the input box. The _stop function is patched to perform validation and ensure that if a value is typed, it is actually valid:

 var _base_stop = $.ui.spinner.prototype._stop;
 $.ui.spinner.prototype._stop = function( event ) {

      var value = this.value() || 0;

      if (event && event.type == 'keyup' && value != this._adjustValue(value) ) {
         this.value(this.previous);
         this._trigger( "invalid", event );
      } else {
         this.previous=value;
         this.element.val(this._format(value));
      }

      _base_stop.call( this, event );
   }


That's common between each variation regardless of the format. With that patch in place, we can define the PaddedSpinner to ensure the value in the spinner input box has a consistent number of characters:


 $.widget( "ui.paddedspinner", $.ui.spinner, {
     widgetEventPrefix: "spin",
     options: {
        padCount: 2,
        padString: '0'
     },

     _parse: function( value ) {
         return +value;
     },

     _format: function( value ) {
         var str = value+'';
         while ( str.length < this.options.padCount )
            str = this.options.padString + str;
         return str;
     },

 });


And then the AMPMSpinner to only allow two possible values - AM or PM:


 $.widget( "ui.ampmspinner", $.ui.spinner, {
     widgetEventPrefix: "spin",
     options: {
        max: 1,
        min: 0,
        alignment: 'vertical'
     },

     _parse: function( value ) {

         if ( typeof value === "string" ) {
             return value == 'AM' ? 0 : 1;
         }
         return value;
     },

     _format: function( value ) {
         return value === 0 ? 'AM' : 'PM';
     },

 });


Its important to remember that the value returned by spinner( 'value' ) is not the formatted value but, instead, the internal numeric value. This will become important in the next step and later when we want to assemble the full time string.

Now, we want to use those two variations and combine them to form a time picker. The very simple implementation can be done using min/max and the alignment options:


$( '#hour' ).paddedspinner({
   alignment: 'vertical',
   min: 1,
   max: 12
});

$('#minute').paddedspinner({
   alignment: 'vertical',
   min: 0,
   max: 59
});

$('#ampm').ampmspinner();



However, one of the other features I wanted was to "rollover" the hours and minutes to affect the value of one of the other boxes. When you reach the limit on the hours, the AM/PM toggle. When you reach the limit on the minutes, the hours increase or decrease. To implement this, you need to use a min/max value one less/more than the actual range. This allows you to catch the value in the spin callback, roll it over, change the dependent field, and cancel the change. Below are the spin callbacks for the hours and minutes fields:


$( '#hour' ).paddedspinner({
   alignment: 'vertical',
   min: 0,
   max: 13,
   spin: function( e, ui ) {

      var ampm,
          val = +ui.value;

      if ( val == 0 || val == 13 ) {

         if ( val == 0 ) $( '#hour' ).paddedspinner( 'value', 12 );
         if ( val == 13 ) $( '#hour' ).paddedspinner( 'value', 1 );

         ampm = $( '#ampm' ).ampmspinner( 'value' );
         $( '#ampm' ).ampmspinner( 'value', ampm == 0 ? 1 : 0 );

         return false;
      }
   }
});

$('#minute').paddedspinner({
   alignment: 'vertical',
   min: -1,
   max: 60,
   spin: function( e, ui ) {

      var hour, val = +ui.value;

      if ( val == -1 || val == 60 ) {

         if ( val == -1 ) $( '#minute' ).paddedspinner( 'value', 59 );
         if ( val == 60 ) $( '#minute' ).paddedspinner( 'value', 0 );

         hour = $( '#hour' ).paddedspinner( 'value' );
         hour = val == -1 ? hour -1 : hour + 1;
         $( '#hour' )
            .paddedspinner( 'value', hour )
            .data( 'ui-paddedspinner' )._trigger( 'spin', e, { value: hour } );

         return false;
      }
   }
});

$('#ampm').ampmspinner();



The result is a set of spinners that enable picking the different time components:

:

This is a good start, but the current state doesn't make it very easy to get or set a time value. It would be nice to either pass a whole date or a time string and have it split up and properly set the value of the spinners. The reverse is also helpful so only one value function needs to be called to query the time from the spinners. The next step is to wrap all the above code up into a TimePicker widget and implement a value getter/setter function:


$.widget('osb.timepicker', {

   widgetEventPrefix: 'timepicker',

   options: {
      // format: 'hh:mm'
      // not implemented, but a good idea
   },

   _init: function () {

      // ... slightly modified code above ...
      // removed, see full source available in
      // link below
   },

   _destroy: function () {
      this.element.empty();
   },

   _value: function() {
      var hour = this.$hour.val(),
          min = this.$minute.val(),
          ampm = this.$ampm.val();

      return hour + ':' + min + ' ' + ampm;
   },

   _parse: function( val ) {
      var parts = val.split( /[: ]/ ),
          hour, min, ampm;

      if ( parts.length < 2 ) return;

      hour = parts[0];
      min = parts[1];
      ampm = parts[2];

      this.$hour.paddedspinner( 'value', +hour );
      this.$minute.paddedspinner( 'value', +min );
      this.$ampm.ampmspinner( 'value', ampm == 'AM' ? 0 : 1 );

   },

   value: function( val ) {

      if ( typeof( val ) == 'undefined' )
         return this._value();
      else
         this._parse( val );

   }

});


This is a basic implementation which only handles time strings with very little validation or formatting options. Now, to test it, you just need a container for the widget and some controls to get/set the value on the widget:


<div id="picker"></div>

<input id="inout"/>

<button id="get">Get</button>
<button id="set">Set</button>



Add some code to initialize the widget and attach handlers to the click events on the buttons:


$(function() {

   $( '#picker' ).timepicker();

   $( '#get' ).click(function() {

      $( '#inout' ).val( $( '#picker' ).timepicker( 'value' ) );

   });

   $( '#set' ).click(function() {

      $( '#picker' ).timepicker( 'value', $( '#inout' ).val() );

   });

   if ( $( '#inout' ).val().length === 0 ) {
      var dt = new Date(),
          hh = dt.getHours(),
          mm = dt.getMinutes(),
          tt = hh >= 12 ? 'PM' : 'AM';

      hh = hh >= 12 ? hh - 12 : hh;

      $( '#inout' ).val( hh+':'+mm+' '+tt );

   }

});



And you have a basic TimePicker that combines several specialized spinners to enable the functionality:





The widget is not complete by any means. It needs better validation on the keyboard entry on the spinners. You can currently enter one of the values that should cause the "rollover" logic, however, that code is only in the spin callback so it does not execute. Additionally, better format options, including 24-hour time, and a little more sophisticated validation of incoming values are obvious improvements. There's enough here to get started and is simple enough to make it easy to tweak to your own needs. If you would like to use it, I've added this widget to my jQuery UI extensions project on GitHub. The download will get you the enhanced spinner widget, the latest version of the TimePicker, and the necessary CSS.