Friday, July 5, 2013

I'm Busy: Enhancing the jQueryUI Button with a Waiting Spinner

The Dojo Toolkit has a convenient BusyButton widget. This seemed like a nice feature to have available in my jQuery UI widget library. The motivation for having the button is two-fold:


  1. Prevent the user from clicking multiple times on a button that performs an action you only want to happen once
  2. Provide visual feedback to the user so they know they did click the button and something is actually happening
The circumstances for using a button like this makes the most sense on AJAX based action like form submissions or searching. The jQuery button fortunately provides a really good base to add this functionality. I wrote a quick prototype to see if my idea would work:

function toggleButton ( $el ) {

   if ( $el.button( 'option', 'disabled' ) ) {
      $el.button( 'enable' );
      $el.button( 'option', 'label', 'Save' );
      $el.button( 'option', 'icons', { primary: null } );
   } else {
      $el.button( 'disable' );
      $el.button( 'option', 'label', 'Saving ...' );
      $el.button( 'option', 'icons', { primary: 'ui-icon-waiting' } );
   }
}

function clickButton () {
   var $el = $(this);

   toggleButton( $el );
   
   /* This probably will be an AJAX-based call  */
   setTimeout(function () {
      toggleButton( $el );
      $el.one( 'click', clickButton );
   }, 2000);
}

$(function () {

   $('.ui-wait-button')
      .button({ disabled: false })
      .one( 'click', clickButton );

});
This code will result in the following button:



The code leverages the existing features of the button widget which allows setting an icon and controlling whether the button appears and acts disabled. I wanted to see how easy it would be to swap my own animated GIF into the spot where an icon would be displayed. Additionally, to ensure only one click is handled, I bound the click event using $.one() instead of $.on(). From there, its just a matter of adding the functionality to perform the action we're going to wait to complete and then reset the state back to the initial state so we can click the button again. In this experiment, I just used setTimeout() to emulate an action that may take a few seconds.

There is a little CSS needed to show the spinner as the icon. By default, the button widget is expecting a class that will position the palette of icons attached by the ui-icon class. Our class needs to replace this image with the animated GIF that represents our spinner:
.ui-wait-button .ui-icon-waiting {
   background-image: url("loading.gif");
   background-position: 0 center;
}

Its important to ensure the background of the spinner is transparent and it will fit in the expected 16px square defined by the theme. My spinner is 16X11 pixels so I had to adjust the positioning a bit to align it properly. You can use a site like ajaxload.info to select from a list of existing spinners and generate one with a transparent background.

Finally, we just need some simple HTML that will represent our button:
  

This approach might be enough for a one-time use scenario, but its not very DRY if you need to have several buttons that will provide a waiting state. Plus, there are some other features that might be better suited if it were built as a jQuery UI widget. By encapsulating it in a widget, I can use an event to signal the waiting state from the click and provide a callback function that the event handler can use to signal the waiting is complete. That function can take several arguments to indicate what to do next. In some situations, it might be desirable to change the label to something other than the original starting label and/or leave the button in the disabled state.

I used the above concept and built an extension to the button widget called WaitButton. I have the code available on GitHub (JS|CSS|GIF) along with some examples. I added two options to the button, waitLabel and waitIcon, which do basically what the name implies. Neither are actually required as the widget will use the default ui-icon-waiting class as the primary icon in the button and the existing label if a waiting label option is not specified. You can define your own class and set waitIcon, override the existing ui-icon-waiting class, or replace the animated GIF (the class loads waitbutton-loading.gif from the images folder). In addition to those options, I added a waiting event that should be used to handle the click from the user and call the done() callback function to signal the processing is complete. Without any arguments, the button will return to the starting state. However, several variations are available:

Default completion - return to the start state
   $('#save')
      .waitbutton({ waitLabel: 'Saving ...' })
      .on( 'buttonwaiting', function ( e, ui ) {
            
            setTimeout(function () { ui.done(); }, 2000);
         });

Return to original label, but leave disabled
   $('#save')
      .waitbutton({ waitLabel: 'Saving ...' })
      .on( 'buttonwaiting', function ( e, ui ) {
            
            setTimeout(function () { ui.done(false); }, 2000);
         });

Change to a different label, but enable the button
   $('#save')
      .waitbutton({ waitLabel: 'Saving ...' })
      .on( 'buttonwaiting', function ( e, ui ) {
            
            setTimeout(function () { ui.done( 'Saved' ); }, 2000);
         });

Change to a different label and leave disabled
   $('#save')
      .waitbutton({ waitLabel: 'Saving ...' })
      .on( 'buttonwaiting', function ( e, ui ) {
            
            setTimeout(function () { ui.done( 'Saved', false ); }, 2000);
         });

The last three require calling waitbutton() on the element to force it back to the initial state. In my demos, I used a reset button to enable that step.

Just a few simple tweaks to the base UI framework and we have a handy button that can provide visual feedback about state and prevent extra clicks from messing up our processing.