Monday, January 21, 2013

Adding Labels/Tick Marks to the jQuery UI Slider Widget

jquery-ui-labeled-sliderThe base jQuery UI Slider really seems to need some labels to provide the user some visual reference to where certain values exist along the slider bar. I needed this reference on a recent project so set out to find a way to add them. My original intent was just to wrap the slider with some static HTML since I already knew the range of values I needed. I found this gist and used it as a guide and augmented to meet my needs. The actual setup is not all that complicated. If you look at the required markup:


<div class="ui-slider-wrapper ui-widget horizontal">

<div class="ui-slider-labels">
<div class="ui-slider-label-ticks" style="left: 0%;"><span></span><br>0</div>
<div class="ui-slider-label-ticks" style="left: 17%;"><span></span><br>1</div>
<div class="ui-slider-label-ticks" style="left: 33%;"><span></span><br>2</div>
<div class="ui-slider-label-ticks" style="left: 50%;"><span></span><br>3</div>
<div class="ui-slider-label-ticks" style="left: 67%;"><span></span><br>4</div>
<div class="ui-slider-label-ticks" style="left: 83%;"><span></span><br>5</div>
<div class="ui-slider-label-ticks" style="left: 100%;"><span></span><br>6</div>
</div>

<div id="slider1" class="ui-slider ui-slider-horizontal ui-widget-content ui-corner-all">
<a href="#" class="ui-slider-handle ui-state-default ui-corner-all" style="left: 0%;"></a>
</div>

</div>


The actual slider widget is id="slider1" and its wrapped with a DIV containing labels inserted before it. The labels are styled so that the positioning matches how the slider widget positions the handle (note that the handle is at left: 0% - as it is moved it will progress through the same set of percentages that the labels are set at).

With that basic idea in mind, its time to extend the Slider widget to add the required content. Augmenting Slider with these labels is made easier since we're not trying to affect its functionality - just add some visual cues to help the user know where to position the slider handle. As such, we only need to add some code to the _create, _setOption, and _destroy methods.

To start, we need to call $.widget() to create our new enhanced widget from the existing Slider:



$.widget( "ui.labeledslider", $.ui.slider, {

options: {
tickInterval: 0,
tickLabels: null
},

uiSlider: null,
tickInterval: 0,



In this case, I'm calling it LabeledSlider. I added two options that can be set to control the rendering of the labels. tickInterval can be set to a number greater than the current step option. This means the handle will stop at points in between labeled points on the slider. It can be useful if you have a large range with a small step and don't want a label at every point along the way. The tickLabels provides a way to label the ticks with something other than the actual value of the slider at a given point. It can be an array or hash indexed by number. If a label exists, it will use it, otherwise, it will just use the numeric value at that point.

tickInterval is repeated as an object variable outside of the options because the tickInterval must be greater or equal to the slider's step option. The following code is run to ensure this relationship is maintained:


_alignWithStep: function () {
if ( this.options.tickInterval < this.options.step )
this.tickInterval = this.options.step;
else
this.tickInterval = this.options.tickInterval;
},


uiSlider will be used to cache a reference to the wrapper element which will serve as the actual widget for this version of slider. this.element will continue to point to the actual Slider element but since the labels are going to be siblings of the slider, its easier to have the parent of both handy throughout the code. Also, when cleaning up and responding to the widget(), it will useful:


_destroy: function() {
this._super();
this.uiSlider.replaceWith( this.element );
},

widget: function() {
return this.uiSlider;
}


Ok, now that some of the housekeeping is out of the way, we can actually wrap the slider, add the label container, and create all the individual labels. This needs to be split up sightly since during the life-cycle of our widget, the options can be changed that might affect the rendered labels. However, the wrapper and label container can be created once so they can be placed in the _create() function:


_create: function( ) {

this._super();

this.uiSlider =
this.element
.removeClass( 'ui-widget' )
.wrap( '<div class="ui-slider-wrapper ui-widget"></div>' )
.before( '<div class="ui-slider-labels">' )
.parent()
.addClass( this.orientation )
.css( 'font-size', this.element.css('font-size') );

this._alignWithStep();

if ( this.orientation == 'horizontal' ) {
this.uiSlider
.width( this.element.width() );
} else {
this.uiSlider
.height( this.element.height() );
}

this._drawLabels();
},



Since we're overriding the slider's implementation of _create(), we have to call _super() to execute the original _create() function defined in the Slider widget. Once that is complete, we can add our features. In this case, the element created by Slider is wrapped and the label container is added before the slider element.

At this point, before we can render the actual labels, we need to address several issues related to the wrapping the original slider element. There were two problems I encountered when attempting to match the style of the label container to the slider element. First, since the slider element might be the target of certain styles that affect the size of the slider, those styles may not affect the wrapper element. The two styles that I found that were particularly problematic are height/width and font size. The slider is sized relative to the current font size (ie the height of the slider bar is 0.8em). If you were to directly set the font size on the slider element (ie 8pt) and the normal font size for the page is 12pt, the slider and label container would not size correctly relative to each other. Maintaining the same font size becomes important if everything is going to line up. I didn't want to just say that you can't directly set the font on the slider. So to work around it, I just copied the style from the slider element to the wrapper so everything is matched up properly:



this.uiSlider.css( 'font-size', this.element.css('font-size') )



Next, the height/width needs to be the same between the slider and the wrapper. I set the style on the label container to be 100% of the parent so as long as the wrapper matches, the labels will too. Again, to work around any direct styling, I just copy the size to the wrapper:



if ( this.orientation == 'horizontal' ) {
this.uiSlider
.width( this.element.width() );
} else {
this.uiSlider
.height( this.element.height() );
}


Of course, if you start dynamically changing these styles after the widget is created, strange things might happen depending on where the targets for the styles are set. This scenario is probably not too common so I'm not going to worry about it at this time.

With that problem addressed, the labels and tick marks can finally be rendered:


_drawLabels: function () {

var labels = this.options.tickLabels || {},
$lbl = this.uiSlider.children( '.ui-slider-labels' ),
dir = this.orientation == 'horizontal' ? 'left' : 'bottom',
min = this.options.min,
max = this.options.max,
inr = this.tickInterval,
cnt = ( max - min ) / inr,
i = 0;

$lbl.html('');

for (;i<=cnt;i++) {
$('<div>').addClass( 'ui-slider-label-ticks' )
.css( dir, (Math.round( i / cnt * 10000 ) / 100) + '%' )
.html( ( dir == 'left' ? '<span></span><br/>' : '<span></span> ' ) + ( labels[i*inr+min] ? labels[i*inr+min] : i*inr+min ) )
.appendTo( $lbl );
}

},


Nothing exceptionally fancy here - just create the DIV, position it, and add the content. The most complicated part is calculating the correct scale for the positions and which labels to display. Also note that the orientation needs to be honored here too and different content added and positioning CSS (left vs bottom).

One final step that needs to be taken care of is responding to changes to the options. This functionality made it necessary to split the drawLabels() from the main creation function. When any option changes that could affect the labels, they need to be redrawn:


_setOption: function( key, value ) {

this._super( key, value );

switch ( key ) {

case 'tickInterval':
case 'tickLabels':
case 'min':
case 'max':
case 'step':

this._alignWithStep();
this._drawLabels();
break;

case 'orientation':

this.element
.removeClass( 'horizontal vertical' )
.addClass( this.orientation );

this._drawLabels();
break;
}
},



Here, all the different slider options plus the ones defined in the new widget cause a redraw. The only options we don't care about are range and value since they don't affect the labels and tick content/positioning.

A demo is on my sandbox. If you want to use the widget, you'll need the both the JS and CSS source files. The jQuery UI library may not have every feature you ever wanted, however, it does make it pretty easy to extend and tweak to meet your needs. Since I will probably use this quite often, I took the time to package it up and add to my extensions library on GitHub.