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 anext
method for convenience but noprevious
method since this is a less used action and still available throughoptions
. 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"]});