Thursday, October 18, 2012

Exploring the New jQuery UI Spinner - Beyond the Basics

jQuery UI 1.9 is out and with it a bunch of new goodies to play with. I thought I'd start by digging into the new Spinner widget and see what kind of features it offers. Very simply, Spinner adds a up/down arrow to the left of a input box thus allowing a user to increment/decrement a value in the input box. It adds keyboard support so you can use up/down arrows and page up/down to move through values. It also has a step feature to skip values. In addition to the basic numeric features, it also enables globalized formatting options (ie currency, thousand separator, decimals, etc.) thus providing a convenient internationalized masked entry box.  The demo on the jQuery UI site even extends Spinner to enable time aware "spinning".

As I started using the widget, I noticed it scaled nicely with different sized input fields.  The CSS enables you to use any font size in the input field and still maintain a visually appealing layout.  The optional min/max values provide a way to prevent the spinner from going outside a valid range.  However, the min/max controls the spinning functionality - you can still type a invalid value into the box and it won't revert the entry.  It seems that this would be important feedback to the user that they manually entered a value that the spin control would not allow.  Additionally, the spin controls can only be placed on the left of the input box.  In some situations, it may be desirable to have the buttons above and below the input box.

In light of this, I thought I'd try extending the Spinner widget to add some of these enhancements.   I created a demo that implements the min/max constraint on entries manually entered in the input box by the user and also provides a means to move the spin buttons to be positioned above and below the input box.  The screenshot on the left shows the default field and how it looks compared to two different examples of a top/bottom alternative.  The last example extends the base Spinner widget to allow it to step through the alphabet.

Top/Bottom Spin Buttons



I started by inspecting the base Spinner widget's CSS properties.  I was curious how difficult it would be to shift the UI components around to achieve this look without having to change any functionality.   The input field is wrapped in a span and then the buttons are added to this wrapper.  CSS controls the positioning of the buttons to align them to the right.  To move them, I just needed to override the default CSS with rules that would position the buttons above and below the input.


.c-topbottom .ui-spinner-input {
margin: 0;
margin-top: 10px;
margin-bottom: 10px;
text-align: center;
}

.c-topbottom .ui-spinner-button {
height: 10px;
left: 0px;
width: 100%;
}

.c-topbottom a.ui-spinner-button {
border: none;
}

.c-topbottom .ui-spinner .ui-icon {
margin-left: -7px;
top: 5px;
left: 50%;
}


These rules follow along with the original definitions but change the necessary properties to position the buttons above and below the input field.

Those changes move the buttons to the correct place, however, if you zoom into the page, the top/bottom corners of the buttons are not rounded.  This is because they are squared off in the base widget to fit nicely against the right side of the input box. However, now they need to be rounded to look correct in their new home.  Looking at the generated HTML, the corners are rounded using a CSS class assigned to the anchor tag that represents the button (ui-corner-tl and ui-corner-bl).   These two classes only round the left corners not the right corners.  I could have just added a corner radius rule to my new CSS but then there would be a chance that a future change to the other jQuery UI ui-corner-* rules would make my custom rule break and not look right.  Instead, I'd like to just add the ui-corner-tr and ui-corner-br to the button elements. This required some Javascript to call jQuery.addClass() on the elements after creating the widget:


$('#topbottom input').spinner()
.parent()
.find('.ui-spinner-up')
.addClass('ui-corner-tl')
.end()
.find('.ui-spinner-down')
.addClass('ui-corner-bl');


So after making the CSS tweaks and adding the new classes, the spin buttons are now in the correct place and format.

Extending Spinner



I wanted to try extending the Spinner widget to enable it to understand how to spin through the alphabet. Since each letter is just an sequential ASCII code, it should be fairly simple to add logic to convert to/from the string/numeric representation so the spinner can iterate through the letters. Working from the example time extension source found on the jQuery UI site, I came up with the following implementation:


$.widget( "ui.alphaspinner", $.ui.spinner, {
options: {
max: 'Z',
min: 'A'
},

_create: function( ) {

this._super();

// Make this a top/bottom spinner. Add rounded corners.
this.uiSpinner
.addClass('ui-spinner-alpha')
.find('.ui-spinner-up').addClass('ui-corner-tl').end()
.find('.ui-spinner-down').addClass('ui-corner-bl');

},

_parse: function( value ) {
if ( typeof value === "string" ) {
// Only one letter is valid
if (value.length > 1) {
return "";
}
return value.toUpperCase().charCodeAt(0);
}
return value;
},

_format: function( value ) {
return String.fromCharCode(value);
}

});


Here I added an override for _create to add classes to the base Spinner so my widget will automatically have the spin controls above and below the input box. Additionally, _parse() is used to convert from the value in the input box so it can be manipulated by the spin control and _format() is used to convert back to the value displayed in the input field. Overriding those two functions is all that is needed to enable the remaining spinner widget to understand how to "spin" through the alphabet.

Validating Manual Input



So far, the new widget does not address validating manual entry in the field to ensure it is A) a single valid letter, and B) inside the range specified by min/max. The _parse() function does parse strings that are only 1 character long and will convert everything to uppercase. However, this only affects the internal representation and not the value in the input box. The user is not provided any feedback that they are entering bad values.

Looking at the example, I output the value of the three controls to the right of the widgets. I first attempted to enter a letter into the box. Since this widget only wants numeric values, the internal value will be null. You can see that the output on the right does not show the "j" because of this parsing.



Next, the field only is suppose to accept 0-9 as a valid input range. However, I can type a 12 into the box and it will accept the value and it will appear in the output on the right.



In my extension, I added an override to the _stop() function to check the value to ensure it is one that parses correctly. _stop() is called on each keyup event so it seemed like a good place to add this code. There are probably half a dozen other ways to add this logic. In the end, this is where I decided to place it:



_stop: function( event ) {
var value = this.value() || 0;

if (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));
}

this._super(event);
}



The _adjustedValue() function is used in a similar fashion when the spin functionality occurs. If the adjusted value is not the same, something is wrong, so set the value back to the previous value. I also trigger a custom "invalid" event that can be caught and an error/help box could be shown to guide the user to enter a correct value. Additionally, this code writes the formatted value back to the input box so if you type a lower case "g", the input will update with a upper case "G" for consistency.

The full source and demo is in my sandbox. Some of these features seem like them might be a good addition to the base Spinner widget. Enabling multiple layouts and checking manual entries seem like good options that can make building solutions with the widget a little bit easier.