1
\$\begingroup\$

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"]});
\$\endgroup\$
1
\$\begingroup\$

Readability

The writing style is seriously hurting readability at some places, but especially here:

_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;}
}

With the if, else if, else nicely laid out, this becomes:

_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;
    }
}

When laid out like this, it's easier to see new opportunities for simplification. For example:

_constrain: function(step) {
    step = parseInt(step) || 0;
    if (step > this.options.steps.length) {
        return this.options.steps.length + 1;
    }
    return step < 0 ? 0 : step;
}

Parsing integers

The documentation of parseInt says to always specify a radix parameter:

parseInt(step, 10);

Inconsistent writing style

Sometimes you write if statements like this:

if(step===this.options.step){

And sometimes like this:

if( rebind ){
    this._bind();
}
if( update ){
    this.update();
}

It looks as if the code had been written by 2 different people. It would be better to pick one style and stick with it. Which one you pick is a matter of taste, my preference is as you can see in earlier examples.

Ternary on boolean

This is just silly:

var jumpable = ($(e.target).parents('.jumpable').length)?true:false;

Instead of the ternary operator (or if-else) on boolean-y expressions, you can be more direct:

var jumpable = $(e.target).parents('.jumpable').length > 0;
\$\endgroup\$
  • \$\begingroup\$ The term "jump" is used to indicate 1 or more steps. So, when a user clicks on a step, it fires a jump callback since they could be clicking on any step. Whereas the function next only moves one step and isn't considered a jump. Does this make sense? Or should I find a better name? \$\endgroup\$ – slicedtoad Sep 18 '14 at 12:54
  • \$\begingroup\$ You're right, I totally overlooked. Dropped that point from my post, good you pointed out. \$\endgroup\$ – janos Sep 18 '14 at 12:57

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.