Monday, April 28, 2014

A Basic Star Rating Bar jQuery Widget

The first web-based rating system I remember using was provided by Netflix when picking movies to watch. I can remember inspecting the HTML to learn how they built it. At the time, it seemed like an amazing trick given the available browser technology. Today, a rating bar UI widget is pretty common place. Whether you build your own or pick from the pool of existing options depends on your needs. Interested in what was out there, I did some quick research to see what existed related to libraries I already work with like Bootstrap and jQuery UI. On the Bootstrap GitHub issue log, an external project was recommended in the discussion for those interesting in an implementation. I also found there's a plan to build this type of widget into jQuery UI and found a project that already provides such an implementation.

Looking through these projects, it became evident that they are a bit larger than I might really need. I was curious how little code I could write to make a very simple rating bar that would work in a variety of situations. If its small enough, it costs very little in the way of bandwidth and would be easy to extend and adapt to other use cases. The final product I built is about 120 lines of code (both JS and CSS) with comments and blank lines. Follow along through this post to see how I got there. Not interested in how I built it? Then scroll to the end for a jsFiddle demo and links to the final code.

Basic Setup


The mark up below is my starting point for structuring the widget. I used the Glyphicons in Bootstrap to create the stars. If you don't use Bootstrap, you can still use Glyphicons separately or, alternatively, you can create your own images or find another web font like Font Awesome.

<div class="star-ctr">
<ul class="star-bg">
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
</ul>
<ul class="star-fg">
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
</ul>
</div>


The following CSS will take care of styling the above markup. You can add as many stars (or any other icon) as you'd like. The background set (.star-bg) represents the unselected items while the foreground (.star-fg) displays the current rating:


.star-ctr {
    display: inline-block;
    position: relative;
}

.star-ctr ul {
    list-style: none outside none;
    white-space: nowrap;
    overflow: hidden;
    padding: 0 !important;
    margin: 0 !important;
}

.star-fg {
    top: 0;
    position: absolute;
}

.star-ctr li {
    display: inline-block;
    vertical-align: middle;
    padding: 0;
    margin: 0;
}

.star-ctr a > span {
    font-size: 3em;
}

.star-bg a > span {
    color: silver
}

.star-fg a > span {
    color: yellow;
}



The last three rules could be changed as needed as they affect the size and color of the stars. The UL element needs to have no margin or padding so the measurements work later on in the code. The LI elements are also set to have no padding or margin to keep the initial example cases easier, but, eventually, I want to allow element-to-element variations. To actually indicate that a rating is selected, I chose to always show the background set and then vary the width of the foreground set to create the illusion that a rating is being selected. Here's a few fixed examples of the different states the bar could be in:

No Stars

Setting the star-fg UL block to a width of zero hides all the yellow stars resulting in a rating of zero:



3.5 Stars

Increasing the width exposes more stars:



All Stars

All the way up to 100% to show all the stars and give a rating of 5:



With the basic markup and CSS mechanics worked out, its time to add some code to simplify the definition and add mouse interaction that calculates the width and find the rating.

First Improvement: Less HTML


To reduce the amount of duplicate markup, I introduced a small bit of jQuery to clone a group of stars so only one set needs the HTML coded. Starting with this reduced markup:

<div class="star-ctr">
<ul>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
<li><a href="#"><span class="glyphicon glyphicon-star"></span></a></li>
</ul>
</div>


Use this code to build the rest of the HTML which creates the foreground and background icon sets:


   var $me = $( '.star-ctr' ), $bg, $fg;

   $bg = $me.children( 'ul' );
   $fg = $bg.clone().addClass( 'star-fg' ).css( 'width', 0 ).appendTo( $me );
   $bg.addClass( 'star-bg' );



The Really Simple Version: Continuous Rating Bar


This solution requires the least amount of code to add interaction to the rating bar. Adding to the setup introduced above, we need to initialize some variables and then track the mouse as it moves over the container:


   var $me = $( '.star-ctr' );

   var $bg, $fg, wd, cc, ini;

   $bg = $me.children( 'ul' );
   $fg = $bg.clone().addClass( 'star-fg' ).css( 'width', 0 ).appendTo( $me );
   $bg.addClass( 'star-bg' );

   function initialize() {

      ini = true;

      // How many rating elements
      cc = $bg.children().length;

      // Total width of the bar
      wd = $bg.width();

   }

   $me.mousemove(function( e ) {
      
      // Do once, deferred since fonts might not
      // be loaded so the calcs will be wrong
      if ( !ini ) initialize();

      var dt, vl;

      // Where is the mouse relative to the left
      // side of the bar?
      dt = e.pageX - $me.offset().left;
      vl = Math.round( dt / wd * cc * 10 ) / 10;

      $me.attr( 'data-value', vl );
      $fg.css( 'width', Math.round( dt )+'px' );

   }).click(function() {

       // Grab value
       alert( $( this ).attr( 'data-value' ) );

       return false;
   });


We only need to capture the total width and number of children once. Since I'm using a web font to create the stars, it might not be fully loaded when the page loads. I can't capture the width of the element until the font loads and the stars all render. I only had an issue in Chrome where attempting to initialize the immediately after page load caused the width measurement to be incorrect. I found a solution to this problem which would allow me to wrap the initialization code into an event that fires when the font loads, but chose to just avoid the extra code and use a flag to indicate that the initialization needed to be done on the first movement over the rating bar. Obviously, its not perfect since a wondering mouse could still trigger the initialization before the font is loaded and the stars fully render but it seemed like a lot of extra overhead for a proportionately smaller amount of code.

Other than that minor issue, this code needs to find the offset of the mouse relative to the left edge of the rating bar container element and then set the width of the foreground star UL element to match that calculated value. Additionally, this relative position can be used to calculate the rating value represented by the position using the ratio of the width and number of stars. This value is recorded on the element (in the data-value attribute) and can be queried whenever its needed. In this example, I show an alert box when you click the rating bar:



Customize this version in jsFiddle.

Stepping One Star at a Time


If you haven't stopped reading yet, then you must want your bar to do something a bit more fancy. You may only want to permit whole value ratings which require you to interpret the mouse position and find which star(s) need to be completely filled to "snap" to the correct rating. The prior example allowed you to naively resize the foreground stars without having any knowledge of the width or position of the intermediate stars. However, to snap the width to specific star requires more information and calculations.

To begin, we need some additional variables in the initialization code:

   // Width of one rating element; assumes all are the
   // same width;  Used if step > cc
   sw = $bg.children().first().outerWidth( true );

   // Width of each star (including spacing)
   fw = wd / cc;


These measurements record the width of one star and the distance from the beginning of one star to the beginning of the next star, respectively. You can see this introduced an assumption that all the stars are the same width and consistently spaced within the rating container element. In the final version, I'll address this issue and make it more flexible, however, to keep thing simple, I'll just work with this assumption.

The next additions are in the mousemove event where we need to find the correct width of the foreground element to properly expose the desired number of stars.
   $me.mousemove(function( e ) {
       
       if ( !ini ) initialize();

       var dt, nm, nw, ns, ow, vl;

      // Where is the mouse relative to the left
      // side of the bar?
      dt = e.pageX - $me.offset().left;
       
      // Find the per element step.  This gets us
      // to the left edge of the closest star left of 
      // the current mouse position
      vl = nm = Math.floor( dt / fw );
      nw = $fg.children().eq( nm ).position().left;
          
      // Now, take the remaining distance from the left
      // edge of the star and see if the mouse moved
      // pass the center of it to highlight the whole
      // star or only up to the star left of the one
      // with the mouse hovering over it
      ns = Math.round( ( dt - nw ) / sw );
      ow = nw + ns * sw;

      // Update everything 
      $me.attr( 'data-value', vl );
      $fg.css( 'width', Math.round( ow )+'px' );

   })


Using those extra calculations gets us a star rating bar that works like below:



Customize this version in jsFiddle.

Stepping Partial Stars


Adding the ability to pick a partial star rating at fixed intervals requires a little more logic added to the above version. This variation is about as complicated as I want to build so the widget doesn't get too big and fall out of the realm of "simple" or "basic". This version can probably cover a lot of cases and anything outside of that can use this version as a basis and extend it as necessary.

To add the partial rating intervals, we need to add another pre-calculated variable at the bottom of the initialization function:


      if ( steps > 0 ) {

         // Width of each sub-step
         cw = sw / steps;
      }


That extra width value enables us to know how to subdivide a star to show partial coverage. However, there's also a new variable "steps" introduced here. This value represents the total number of increments to step through per star. So, for the whole star example, this was implied to be 1. For half a star, this would be 2( 5 stars with 2 steps per star), and so on. I decided to encode that option as an attribute on the container element (data-steps). By default, it will assume zero which reverts the bar to the continuous mode. This code reads the attribute and ensures its a whole number:


  steps = Math.floor( +( $me.attr( 'data-steps' ) || 1 ) );



Now, the mousemove function gets a little bit longer:


   $me.on( 'mousemove', function( e ) {

      if ( !ini ) initialize();

      var dt, nm, nw, ns, ow, vl;

      // Where is the mouse relative to the left
      // side of the bar?
      ow = dt = e.pageX - $me.offset().left;
      vl = Math.round( ow / wd * cc * 10 ) / 10;

      // steps == 0 means continous mode, so no need to
      // waste time finding a snapping point
      if ( steps > 0 ) {

         // Find the per element step;
         // nm varies from 0 to the number of stars
         vl = nm = Math.floor( dt / fw );
         ow = nw = $fg.children().eq( nm ).position().left;

         // Now, instead of trying to figure out if
         // the whole star should be selected, we need
         // to subdivide the star more and decide if that
         // slice should be selected.  ns with vary from 0 to steps
         ns = Math.round( ( dt - nw ) / cw );
         ow = nw + ns * cw;

         // Adjust the value with the fractional part of the rating
         vl += Math.round( ( ns / steps ) * 10 ) / 10;

      }

      $me.attr( 'data-value', vl );
      $fg.css( 'width', Math.round( ow )+'px' );

   });


This example uses a step value of 2 so the increment snaps at half-a-star and full star points:



Customize this version in jsFiddle.

Finishing Touches


The final product is close to the last example with the exception of handling variable width/spaced individual ratings elements and packaging it up in a simple jQuery plugin. As I solved removing the assumption related to the width/spacing, I realized that I should just check for the target LI element the mouse is hovering over to find which star to target in the calculations. As I wrote this, I started from the continuous case and built up from there. After taking a step back, it became apparent that there was a better way. However, I already wrote up most of this post and built all the jsFiddles so I decided not to go back and rework everything. The latest version requires less initialization calculations, and, in my opinion is a bit easier to follow. Most importantly, it can handle variable sized ratings elements that may not be spaced evenly. Below is a few more examples of other variations that can be handled with this widget.

Hearts with variable sizes and spacing



10 Stars with 3 steps per star



3 Really big stars with 10 steps per star



Customize these examples in jsFiddle.

I've assembled all the examples on this page into one condensed page on my sandbox. Links to the source are on that page or you can copy the JS and CSS from these links.

While not loaded with features, this basic rating bar offers a reasonable amount of flexibility without adding a lot of extra code to a project requiring this type of functionality. You can quickly adapt and extend it as necessary to meet specific needs. However, if you need more features than can be obtained here and are not in the mood to write them yourself, then you can check out one of the other many implementations I mentioned at the beginning of this post.