Skip to main content
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"]});