Skip to main content
Code Review

Return to Question

replaced http://codereview.stackexchange.com/ with https://codereview.stackexchange.com/
Source Link

This is a follow up on JS Progress Bar Widget

This is a follow up on JS Progress Bar Widget

Source Link

jQuery Widget - Progress Tracker

This is a follow up on JS Progress Bar Widget

I've rewritten it as a jQuery Widget Factory widget, attempting to follow that standard as much possible and fixing the various problems pointed out in my first question.

Here is a demo fiddle: http://jsfiddle.net/slicedtoad/eo5hy4LL/

Ooh, I didn't know this was live. Duplicate of the fiddle:

////////////////////////////
// Progress Tracker Widget
(function($){
 $.widget("dan.progresstracker", {
 options: {
 step: 1, // current step
 steps:['','',''], // default is 3 no-name steps
 jumpDirection: "back", // values: none,back,forward,both
 jumpables: ".step-number, .step-label",
 // callbacks
 jumpforward: null, 
 jumpback: null,
 complete: null
 },
 // Constructor
 _create: function() {
 this.options.step = this._constrain(this.options.step);
 this.element.addClass("hasProgressTracker")
 .append(this._build());
 this._bind();
 this.update();
 },
 // Unbinds and then binds a click event for each jumpable
 _bind: function(){
 this._off(this.element,"click");
 var onMap = {};
 onMap["click "+this.options.jumpables] =
 function(e){
 this._stepClick(e);
 };
 this._on(this.element, onMap);
 },
 // Limit step to an integer >= 0 and <= the number of steps+1
 _constrain: function(step){
 step = parseInt(step) || 0;
 if(step>this.options.steps.length) {return this.options.steps.length+1;} else
 if(step<0) {return 0;} else
 {return step;}
 },
 // Builds and returns a jQuery element that is the progress tracker in a neutral state.
 _build: function() {
 var $node = $("<ol class='progresstrack container'></ol>");
 var html = "";
 for(var step in this.options.steps){
 html +=
 "<li class='step'>" +
 "<div class='step-number'>"+(parseInt(step)+1)+"</div>" +
 "<div class='step-line'></div>" +
 "<div class='step-label-wrap'>" +
 "<label class='step-label'>"+this.options.steps[step]+"</label>" +
 "</div>" +
 "</li>";
 }
 $node.html(html);
 return $node;
 },
 // Options setter override.
 // Handles options that need updates or rebuilds after changing
 _setOptions: function( options ) {
 var that = this,
 update = false,
 rebuild = false,
 rebind = false;
 $.each( options, function( key, value ) {
 if(key === "step"){
 that.step(value); // use the setter
 }else{
 that._setOption( key, value );
 if(key === "jumpDirection"){
 update = true;
 }else if(key === "steps"){
 rebuild = true;
 }else if(key === "jumpables"){
 update = true;
 rebind = true;
 }
 }
 });
 if( rebuild ){
 this.element.find(".progresstrack").replaceWith(this._build());
 this.step(this.options.step);
 this.update(); 
 }
 if( rebind ){
 this._bind();
 }
 if( update ){
 this.update();
 }
 },
 // Handler for user clicking on a jumpable element
 // Triggers relevant callbacks
 _stepClick: function(e){
 var step = this.element.find(".step")
 .index($(e.target).closest('.step'))+1;
 var jumpable = ($(e.target).parents('.jumpable').length)?true:false;
 
 if(step===this.options.step){
 return; // Nothing changed, return
 }else if(step>this.options.step && jumpable){
 if(!this._trigger("jumpforward",e,{step:step})){
 return; // Canceled
 }
 }else if(step<this.options.step && jumpable){
 if(!this._trigger("jumpback",e,{step:step})){
 return; // Canceled
 };
 }else{
 return; // Wrong direction
 }
 this.step(step); // Apply change
 },
 // step() gets the current step
 // step(int) sets the current step. Does not trigger callbacks except "complete".
 step: function(step){
 if(typeof step === 'undefined') {
 return this.options.step;
 }else{
 this.options.step = this._constrain(step);
 this.update();
 if(this.options.step === this.options.steps.length+1){ // if complete
 this._trigger("complete");
 }
 return this;
 }
 },
 // Increments step by one.
 // Convenience function since this will usually be the most common action.
 // Only triggers "complete" callback and only if it actually changed to complete (and wasn't there already)
 next: function(){
 var nextstep = this.options.step+1;
 if(this._constrain(nextstep)===this.options.step){
 return this;
 }else{
 this.step(nextstep);
 return this;
 }
 },
 // Update the <ol> and <li> classes to reflect the current step
 update: function(){
 // Reset progress bar status
 $e = this.element;
 $e.find(".step-current").removeClass("step-current");
 $e.find(".step-finished").removeClass("step-finished");
 $e.find(".jumpable").removeClass("jumpable");
 
 // If complete
 if(this.options.step>this.options.steps.length){
 $e.find('.step').addClass("step-finished");
 $e.addClass("complete");
 if((this.options.jumpDirection==="back" ||
 this.options.jumpDirection==="both")){ // if jumpback
 // add jumpable to all steps
 $e.find(".step").addClass("jumpable"); 
 }
 return this;
 }
 
 // If current == 0 (pre-first step)
 if(this.options.step===0){
 if((this.options.jumpDirection==="forward" ||
 this.options.jumpDirection==="both")){ // if jumpforward
 // add jumpable to all steps
 $e.find(".step").addClass("jumpable"); 
 }
 return this;
 }
 
 // Set current step
 var $current = $e.find(".step:nth-child("+this.options.step+")")
 .addClass("step-current");
 var $prevAll = $current.prevAll('.step').addClass("step-finished");
 if((this.options.jumpDirection==="back" ||
 this.options.jumpDirection==="both")){
 $prevAll.addClass("jumpable");
 }
 if((this.options.jumpDirection==="forward" ||
 this.options.jumpDirection==="both")){
 $current.nextAll('.step').addClass("jumpable");
 }
 return this;
 }
 });
})(jQuery);
////////////////////////////
// Usage
// Tracker 1 test - all options and callbacks
var $pbar = $("#ProgressTracker1");
var steps = [["Date","Items","Preview","Details","Confirm"],["Cart","Shipping","Checkout"]];
var toggle = 0;
$pbar.progresstracker({
 step: 1,
 steps:steps[toggle],
 jumpDirection:"both",
 jumpforward:function(e,data){
 if(data.step === 3){
 $("#Events").append("<br/>Cancelled forward jump to "+data.step);
 return false; 
 }
 $("#Events").append("<br/>Jumped forward to step "+data.step);
 },
 jumpback:function(e,data){
 $("#Events").append("<br/>Jumped back to step "+data.step);
 },
 complete:function(){
 $("#Events").append("<br/>Progress complete.");
 }
});
$("#next").on("click",function(){
 $pbar.progresstracker("next")
});
$("#previous").on("click",function(){
 $pbar.progresstracker("step",$pbar.progresstracker("step")-1)
});
$("#steps").on("click",function(){
 toggle = !toggle;
 $pbar.progresstracker("option",{steps:steps[toggle|0]});
});
$("#jumpdir").on("change",function (e) {
 var valueSelected = this.value;
 $pbar.progresstracker("option",{jumpDirection:this.value});
});
// Tracker 2 test - default
var $pbar2 = $("#ProgressTracker2").progresstracker();
$("#next2").on("click",function(){
 $pbar2.progresstracker("next")
});
$("#previous2").on("click",function(){
 $pbar2.progresstracker("step",$pbar2.progresstracker("step")-1)
});
/*Default progresstrack CSS*/
ol.progresstrack {
 list-style-type: none;
 padding-left:0;
}
.progresstrack.container {
 display: flex;
 align-items: flex-end;
 padding-top: 22px;
}
.progresstrack .step {
 flex-grow:1;
 position:relative;
 text-align: center;
 z-index:1;
}
.progresstrack .step-number {
 position:relative;
 z-index:10;
 width: 20px;
 height: 20px;
 border-radius: 10px;
 background-color:white;
 display:inline-block;
 text-align: center;
 line-height: 20px;
 border:1px solid;
}
.progresstrack .jumpable .step-number:hover{
 cursor: pointer;
}
.progresstrack .step-finished .step-number{
 background-color: green;
}
.progresstrack .step-current .step-number{
 background-color:lightblue;
}
.progresstrack .step-label-wrap {
 position: absolute;
 width:100%;
 top: -22px;
}
.progresstrack .step-label {
 padding: 0px 1px;
}
.progresstrack .step-line {
 width: 100%;
 height: 0px;
 overflow: auto;
 margin: auto;
 position: absolute;
 top: 0; left: 0; bottom: 0; right: -100%;
 background-color: white;
 border:1px solid;
}
.progresstrack .step:last-of-type .step-line{
 display:none;
}
/*User CSS*/
#ProgressTracker1.hasProgressTracker{
 border:1px solid;
 background-color:lightgrey;
}
#ProgressTracker1 .progresstrack .step-number{
 -webkit-transition: .2s;
 -moz-transition: .2s;
}
#ProgressTracker1 .progresstrack .step-line{
 -webkit-transition: .2s;
 -moz-transition: .2s; 
}
#ProgressTracker1 .progresstrack .jumpable .step-number:hover{
 -webkit-transform: scale(1.2);
 -moz-transform: scale(1.2);
}
#ProgressTracker1 .progresstrack .step-finished .step-line {
 background-color: green;
}
#ProgressTracker1 .progresstrack .step-current .step-line {
 background-color: lightblue;
}
#ProgressTracker1 .progresstrack .step-line {
 height:1px;
 background-color: white;
}
#ProgressTracker1 .step-label {
 -webkit-transition: .2s;
 -moz-transition: .2s;
 border:1px solid;
 padding:0px 2px;
 background:white;
}
#ProgressTracker1 .step-finished .step-label{
 background-color: green;
}
#ProgressTracker1 .step-current .step-label {
 background-color: lightblue;
}
#ProgressTracker1 .progresstrack .jumpable .step-label:hover{
 cursor: pointer;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.8.3/jquery.min.js"></script>
<script src="//ajax.googleapis.com/ajax/libs/jqueryui/1.9.2/jquery-ui.min.js"></script>
<div id='ProgressTracker2'>Default Progress Tracker</div>
<button type='button' id='previous2'>Previous</button>
<button type='button' id='next2'>Next</button>
<hr/>
<div id='ProgressTracker1'>Custom Progress Tracker</div>
<button type='button' id='previous'>Previous</button>
<button type='button' id='next'>Next</button>
<button type='button' id='steps'>Change Steps</button>
<br/>
<label for='jumpdir'>Jump Direction</label>
<select id='jumpdir'>
 <option>both</option>
 <option>forward</option>
 <option>back</option>
 <option>none</option>
</select>
<p id="Events">Tracker Callbacks: </p>

Focus

  • low coupling
  • simple interface allowing for lots of flexibility without too many options
  • customization: as much as possible with just css, other things with the options method.
  • standards. I tried to use the patterns/conventions that are outlined in the widget factory docs but there are something I'm unsure about:
    • naming conventions (of everything, but specifically the css classes: do the hyphenated class names make sense?)
    • public methods vs _setOptions override. For example, I have a next method for convenience but no previous method since this is a less used action and still available through options. Does this make sense?
  • support for modern browsers only.

One thing I decided not to do that was suggested in my previous answer was to define the ol in the html and have the widget add the functionality. I attempted but it got too messy and I've dropped it.

Interface

Methods

progresstracker(options): Creates a progress tracker with the specified options object.

step(step): Changes the tracker's current step.

next(): Changes the tracker to the next step.

update(): Sets the appropriate css classes according to the current step. This should be called automatically unless the progresstracker is modified by another class.

Options

step
int
default 1

steps
string array
default ["","",""]

jumpDirection
string (none, back, forward, or both)
default back

jumpables
comma delim string (list of classes that trigger jump events when clicked)
default ".step-number, .step-label"

jumpforward
callback(event,data) return false cancels the jump

jumpback
callback(event,data) return false cancels the jump

complete
callback()

Code

Progress Tracker Widget

(function($){
 $.widget("dan.progresstracker", {
 options: {
 step: 1, // current step
 steps:['','',''], // default is 3 no-name steps
 jumpDirection: "back", // values: none,back,forward,both
 jumpables: ".step-number, .step-label",
 // callbacks
 jumpforward: null, 
 jumpback: null,
 complete: null
 },
 // Constructor
 _create: function() {
 this.options.step = this._constrain(this.options.step);
 this.element.addClass("hasProgressTracker")
 .append(this._build());
 this._bind();
 this.update();
 },
 // Unbinds and then binds a click event for each jumpable
 _bind: function(){
 this._off(this.element,"click");
 var onMap = {};
 onMap["click "+this.options.jumpables] =
 function(e){
 this._stepClick(e);
 };
 this._on(this.element, onMap);
 },
 // Limit step to an integer >= 0 and <= the number of steps+1
 _constrain: function(step){
 step = parseInt(step) || 0;
 if(step>this.options.steps.length) {return this.options.steps.length+1;} else
 if(step<0) {return 0;} else
 {return step;}
 },
 // Builds and returns a jQuery element that is the progress tracker in a neutral state.
 _build: function() {
 var $node = $("<ol class='progresstrack container'></ol>");
 var html = "";
 for(var step in this.options.steps){
 html +=
 "<li class='step'>" +
 "<div class='step-number'>"+(parseInt(step)+1)+"</div>" +
 "<div class='step-line'></div>" +
 "<div class='step-label-wrap'>" +
 "<label class='step-label'>"+this.options.steps[step]+"</label>" +
 "</div>" +
 "</li>";
 }
 $node.html(html);
 return $node;
 },
 // Options setter override.
 // Handles options that need updates or rebuilds after changing
 _setOptions: function( options ) {
 var that = this,
 update = false,
 rebuild = false,
 rebind = false;
 $.each( options, function( key, value ) {
 if(key === "step"){
 that.step(value); // use the setter
 }else{
 that._setOption( key, value );
 if(key === "jumpDirection"){
 update = true;
 }else if(key === "steps"){
 rebuild = true;
 }else if(key === "jumpables"){
 update = true;
 rebind = true;
 }
 }
 });
 if( rebuild ){
 this.element.find(".progresstrack").replaceWith(this._build());
 this.step(this.options.step);
 this.update(); 
 }
 if( rebind ){
 this._bind();
 }
 if( update ){
 this.update();
 }
 },
 // Handler for user clicking on a jumpable element
 // Triggers relevant callbacks
 _stepClick: function(e){
 var step = this.element.find(".step")
 .index($(e.target).closest('.step'))+1;
 var jumpable = ($(e.target).parents('.jumpable').length)?true:false;
 
 if(step===this.options.step){
 return; // Nothing changed, return
 }else if(step>this.options.step && jumpable){
 if(!this._trigger("jumpforward",e,{step:step})){
 return; // Canceled
 }
 }else if(step<this.options.step && jumpable){
 if(!this._trigger("jumpback",e,{step:step})){
 return; // Canceled
 };
 }else{
 return; // Wrong direction
 }
 this.step(step); // Apply change
 },
 // step() gets the current step
 // step(int) sets the current step. Does not trigger callbacks except "complete".
 step: function(step){
 if(typeof step === 'undefined') {
 return this.options.step;
 }else{
 this.options.step = this._constrain(step);
 this.update();
 if(this.options.step === this.options.steps.length+1){ // if complete
 this._trigger("complete");
 }
 return this;
 }
 },
 // Increments step by one.
 // Convenience function since this will usually be the most common action.
 // Only triggers "complete" callback and only if it actually changed to complete (and wasn't there already)
 next: function(){
 var nextstep = this.options.step+1;
 if(this._constrain(nextstep)===this.options.step){
 return this;
 }else{
 this.step(nextstep);
 return this;
 }
 },
 // Update the <ol> and <li> classes to reflect the current step
 update: function(){
 // Reset progress bar status
 $e = this.element;
 $e.find(".step-current").removeClass("step-current");
 $e.find(".step-finished").removeClass("step-finished");
 $e.find(".jumpable").removeClass("jumpable");
 
 // If complete
 if(this.options.step>this.options.steps.length){
 $e.find('.step').addClass("step-finished");
 $e.addClass("complete");
 if((this.options.jumpDirection==="back" ||
 this.options.jumpDirection==="both")){ // if jumpback
 // add jumpable to all steps
 $e.find(".step").addClass("jumpable"); 
 }
 return this;
 }
 
 // If current == 0 (pre-first step)
 if(this.options.step===0){
 if((this.options.jumpDirection==="forward" ||
 this.options.jumpDirection==="both")){ // if jumpforward
 // add jumpable to all steps
 $e.find(".step").addClass("jumpable"); 
 }
 return this;
 }
 
 // Set current step
 var $current = $e.find(".step:nth-child("+this.options.step+")")
 .addClass("step-current");
 var $prevAll = $current.prevAll('.step').addClass("step-finished");
 if((this.options.jumpDirection==="back" ||
 this.options.jumpDirection==="both")){
 $prevAll.addClass("jumpable");
 }
 if((this.options.jumpDirection==="forward" ||
 this.options.jumpDirection==="both")){
 $current.nextAll('.step').addClass("jumpable");
 }
 return this;
 }
 });
})(jQuery);

Default CSS

ol.progresstrack {
 list-style-type: none;
 padding-left:0;
}
.progresstrack.container {
 display: flex;
 align-items: flex-end;
 padding-top: 22px;
}
.progresstrack .step {
 flex-grow:1;
 position:relative;
 text-align: center;
 z-index:1;
}
.progresstrack .step-number {
 position:relative;
 z-index:10;
 width: 20px;
 height: 20px;
 border-radius: 10px;
 background-color:white;
 display:inline-block;
 text-align: center;
 line-height: 20px;
 border:1px solid;
}
.progresstrack .jumpable .step-number:hover{
 cursor: pointer;
}
.progresstrack .step-finished .step-number{
 background-color: green;
}
.progresstrack .step-current .step-number{
 background-color:lightblue;
}
.progresstrack .step-label-wrap {
 position: absolute;
 width:100%;
 top: -22px;
}
.progresstrack .step-label {
 padding: 0px 1px;
}
.progresstrack .step-line {
 width: 100%;
 height: 0px;
 overflow: auto;
 margin: auto;
 position: absolute;
 top: 0; left: 0; bottom: 0; right: -100%;
 background-color: white;
 border:1px solid;
}
.progresstrack .step:last-of-type .step-line{
 display:none;
}

Example Use

// Initialize
$("#ProgressTracker").progresstracker({
 step: 1,
 steps:["Date","Items","Preview","Details","Confirm"],
 jumpDirection:"both",
 jumpforward:function(e,data){
 if(data.step === 3){
 return false; //cancel forward jumps to step 3
 }
 //do something
 },
 jumpback:function(e,data){
 //do something;
 },
 complete:function(){
 //do something
 }
});
//Make changes to options
$("#ProgressTracker").progresstracker("option",{steps:steps["a","b","c"]});
lang-css

AltStyle によって変換されたページ (->オリジナル) /