@kflorence/jquery-wizard
Version:
Turns a standard HTML form into a form wizard by breaking it into a series of well-defined steps. The purpose of these steps is to better group related inputs, preventing the user from becoming overwhelmed at the size or complexity of a form and helping t
724 lines (584 loc) • 18.6 kB
JavaScript
/*
jQuery.wizard v1.1.3
https://github.com/kflorence/jquery-wizard/
An asynchronous form wizard that supports branching.
Requires:
- jQuery 1.6.0+
- jQuery UI widget 1.9.0+
Copyright (c) 2017 Kyle Florence
Dual licensed under the MIT and GPLv2 licenses.
*/
(function( $, undefined ) {
var count = 0,
selector = {},
className = {},
// Reference to commonly used methods
aps = Array.prototype.slice,
// Used to normalize function arguments that can be either
// an array of values or a single value
arr = function( obj ) {
return $.isArray( obj ) ? obj : [ obj ];
},
// Commonly used strings
id = "id",
form = "form",
click = "click",
submit = "submit",
disabled = "disabled",
namespace = "kf-wizard",
wizard = "wizard",
def = "default",
num = "number",
obj = "object",
str = "string",
bool = "boolean",
// Events
afterBackward = "afterBackward",
afterDestroy = "afterDestroy",
afterForward = "afterForward",
afterSelect = "afterSelect",
beforeBackward = "beforeBackward",
beforeDestroy = "beforeDestroy",
beforeForward = "beforeForward",
beforeSelect = "beforeSelect",
beforeSubmit = "beforeSubmit";
// Generate selectors and class names for common wizard elements
$.each( "branch form header step wrapper".split( " " ), function() {
selector[ this ] = "." + ( className[ this ] = wizard + "-" + this );
});
$.widget( "kf." + wizard, {
version: "1.1.3",
options: {
animations: {
show: {
options: {
duration: 0
},
properties: {
opacity: "show"
}
},
hide: {
options: {
duration: 0
},
properties: {
opacity: "hide"
}
}
},
backward: ".backward",
branches: ".branch",
disabled: false,
enableSubmit: false,
forward: ".forward",
header: ":header:first",
initialStep: 0,
stateAttribute: "data-state",
stepClasses: {
current: "current",
exclude: "exclude",
stop: "stop",
submit: "submit",
unidirectional: "unidirectional"
},
steps: ".step",
submit: ":submit",
transitions: {},
unidirectional: false,
/* callbacks */
afterBackward: null,
afterDestroy: null,
afterForward: null,
afterSelect: null,
beforeBackward: null,
beforeDestroy: null,
beforeForward: null,
beforeSelect: null,
create: null
},
_create: function() {
var $form, $header,
self = this,
o = self.options,
$element = self.element,
$steps = $element.find( o.steps ),
$stepsWrapper = $steps.eq( 0 ).parent();
if ( $element[ 0 ].elements ) {
$form = $element;
// If element isn't form, look inside and outside element
} else if ( !( $form = $element.find( form ) ).length ) {
$form = $element.closest( form );
}
// If header isn't found in element, look in form scope
if ( !( $header = $element.find( o.header ) ).length ) {
$header = $form.find( o.header );
}
self.elements = {
form: $form.addClass( className.form ),
submit: $form.find( o.submit ),
forward: $form.find( o.forward ),
backward: $form.find( o.backward ),
header: $header.addClass( className.header ),
steps: $element.find( o.steps ).hide().addClass( className.step ),
branches: $element.find( o.branches ).add( $stepsWrapper ).addClass( className.branch ),
stepsWrapper: $stepsWrapper.addClass( className.wrapper ),
wizard: $element.addClass( wizard )
};
if ( !$stepsWrapper.attr( id ) ) {
// stepsWrapper must have an ID as it also functions as the default branch
$stepsWrapper.attr( id, wizard + "-" + ( ++count ) );
}
self.elements.forward.on( "click." + namespace, function( event ) {
event.preventDefault();
self.forward( event );
});
self.elements.backward.on( "click." + namespace, function( event ) {
event.preventDefault();
self.backward( event );
});
self._currentState = {
branchesActivated: [],
stepsActivated: []
};
self._stepCount = self.elements.steps.length;
self._lastStepIndex = self._stepCount - 1;
// Cache branch labels for quick access later
self._branchLabels = [];
self.elements.steps.each(function( i ) {
self._branchLabels[ i ] = $( this ).parent().attr( id );
});
// Called in the context of jQuery's .filter() method in _state()
self._excludesFilter = function() {
return !$( this ).hasClass( o.stepClasses.exclude );
};
// Add default transition function if one wasn't defined
if ( !o.transitions[ def ] ) {
o.transitions[ def ] = function( state ) {
return self.stepIndex( state.step.nextAll( selector.step ) );
};
}
// Select initial step
self.select.apply( self, arr( o.initialStep ) );
},
_fastForward: function( toIndex, relative, callback ) {
var i = 0,
self = this,
stepIndex = self._currentState.stepIndex,
stepsTaken = [ stepIndex ];
if ( $.isFunction( relative ) ) {
callback = relative;
relative = undefined;
}
(function next() {
self._transition( self._state( stepIndex, stepsTaken ), function( step, branch ) {
if ( ( stepIndex = self.stepIndex( step, branch ) ) === -1 ) {
throw new Error( '[_fastForward]: Invalid step "' + step + '"' );
} else if ( $.inArray( stepIndex, stepsTaken ) >= 0 ) {
throw new Error( '[_fastForward]: Recursion detected on step "' + step + '"' );
} else {
stepsTaken.push( stepIndex );
if ( stepIndex === self._lastStepIndex ||
( relative ? ++i : stepIndex ) === toIndex ) {
callback.call( self, stepIndex, stepsTaken );
} else {
next();
}
}
});
})();
},
_find: function( needles, haystack, wrap ) {
var element, i, l, needle, type,
found = [],
$haystack = haystack instanceof jQuery ? haystack : $( haystack );
function matchElement( i, current ) {
if ( current === needle ) {
element = current;
// Break from .each loop
return false;
}
}
if ( needles !== null && $haystack.length ) {
needles = arr( needles );
for ( i = 0, l = needles.length; i < l; i++ ) {
element = null;
needle = needles[ i ];
type = typeof needle;
if ( type === num ) {
element = $haystack.get( needle );
} else if ( type === str ) {
element = document.getElementById( needle.replace( '#', '' ) );
} else if ( type === obj ) {
if ( needle instanceof jQuery && needle.length ) {
needle = needle[ 0 ];
}
if ( needle.nodeType ) {
$haystack.each( matchElement );
}
}
if ( element ) {
found.push( element );
}
}
}
// Returns a jQuery object by default. If the wrap argument is
// false, it will return an array of elements instead.
return wrap === false ? found : $( found );
},
_move: function( step, branch, relative, history, callback ) {
var self = this,
current = self._currentState;
if ( typeof branch === bool ) {
callback = history;
history = relative;
relative = branch;
branch = undefined;
}
function move( stepIndex, stepsTaken ) {
callback.call( self, stepIndex, $.isArray( history ) ?
history : history !== false ? stepsTaken : undefined );
}
if ( relative === true ) {
if ( step > 0 ) {
self._fastForward( step, relative, move );
} else {
callback.call( self, current.stepsActivated[
// Normalize to zero if negative
Math.max( 0, step + ( current.stepsActivated.length - 1 ) ) ] );
}
// Don't attempt to move to invalid steps
} else if ( ( step = self.stepIndex( step, branch ) ) !== -1 ) {
if ( step > current.stepIndex ) {
self._fastForward( step, move );
} else {
move.call( self, step );
}
}
},
_state: function( stepIndex, stepsTaken ) {
if ( !this.isValidStepIndex( stepIndex ) ) {
return null;
}
var o = this.options,
state = $.extend( true, {}, this._currentState );
// stepsTaken must be an array of at least one step
stepsTaken = arr( stepsTaken || stepIndex );
state.step = this.elements.steps.eq( stepIndex );
state.branch = state.step.parent();
state.branchStepCount = state.branch.children( selector.step ).length;
state.isMovingForward = stepIndex > state.stepIndex;
state.stepIndexInBranch = state.branch.children( selector.step ).index( state.step );
var branchLabel, indexOfBranch, indexOfStep,
i = 0,
l = stepsTaken.length;
for ( ; i < l; i++ ) {
stepIndex = stepsTaken[ i ];
branchLabel = this._branchLabels[ stepIndex ];
// Going forward
if ( !state.stepIndex || state.stepIndex < stepIndex ) {
// No duplicate steps
if ( $.inArray( stepIndex, state.stepsActivated ) < 0 ) {
state.stepsActivated.push( stepIndex );
// No duplicate branch labels
if ( $.inArray( branchLabel, state.branchesActivated ) < 0 ) {
state.branchesActivated.push( branchLabel );
}
}
// Going backward
} else if ( state.stepIndex > stepIndex ) {
indexOfBranch = $.inArray( branchLabel, state.branchesActivated ) + 1;
indexOfStep = $.inArray( stepIndex, state.stepsActivated ) + 1;
// Don't remove initial branch
if ( indexOfBranch > 0 ) {
state.branchesActivated.splice( indexOfBranch,
// IE requires this argument
state.branchesActivated.length - 1 );
}
// Don't remove the initial step
if ( indexOfStep > 0 ) {
state.stepsActivated.splice( indexOfStep,
// IE requires this argument
state.stepsActivated.length - 1 );
}
}
state.stepIndex = stepIndex;
state.branchLabel = branchLabel;
}
// Steps completed: the number of steps we have visited
state.stepsComplete = Math.max( 0, this._find(
state.stepsActivated, this.elements.steps
).filter( this._excludesFilter ).length - 1 );
// Steps possible: the number of steps in all of the branches we have visited
state.stepsPossible = Math.max( 0, this._find(
state.branchesActivated, this.elements.branches
).children( selector.step ).filter( this._excludesFilter ).length - 1 );
$.extend( state, {
branchLabel: this._branchLabels[ stepIndex ],
isFirstStep: stepIndex === 0,
isFirstStepInBranch: state.stepIndexInBranch === 0,
isLastStep: stepIndex === this._lastStepIndex,
isLastStepInBranch: state.stepIndexInBranch === state.branchStepCount - 1,
percentComplete: ( 100 * state.stepsComplete / state.stepsPossible ),
stepsRemaining: ( state.stepsPossible - state.stepsComplete )
});
return state;
},
_transition: function( state, action ) {
var response,
self = this,
o = self.options,
stateName = state.step.attr( o.stateAttribute ),
transitionFunc = stateName ? o.transitions[ stateName ] : o.transitions[ def ];
if ( $.isFunction( transitionFunc ) ) {
response = transitionFunc.call( self, state, function() {
return action.apply( self, aps.call( arguments ) );
});
} else {
response = stateName;
}
// A response of 'undefined' or 'false' will halt immediate action
// waiting instead for the transition function to handle the call
if ( response !== undefined && response !== false ) {
// Response could be array like [ step, branch ]
action.apply( self, arr( response ) );
}
},
_update: function( event, state, force ) {
var self = this,
current = self._currentState,
data = [ state, function( response ) {
self._update( event, state, response !== false );
} ],
o = self.options;
if ( current.step ) {
if (
!state ||
o.disabled ||
state.stepIndex === current.stepIndex ||
force !== true && (
!this._trigger( beforeSelect, event, data ) ||
( state.isMovingForward && !this._trigger( beforeForward, event, data ) ) ||
( !state.isMovingForward && !this._trigger( beforeBackward, event, data ) )
)
) {
return;
}
current.step.removeClass( o.stepClasses.current )
.animate( o.animations.hide.properties,
// Fixes #3583 - http://bugs.jquery.com/ticket/3583
$.extend( {}, o.animations.hide.options ) );
}
// Note that this does not affect the value of 'current'
this._currentState = state;
state.step.addClass( o.stepClasses.current )
.animate( o.animations.show.properties,
// Fixes #3583 - http://bugs.jquery.com/ticket/3583
$.extend( {}, o.animations.show.options ) );
if ( state.isFirstStep || o.unidirectional ||
state.step.hasClass( o.stepClasses.unidirectional ) ) {
this.elements.backward.attr( disabled, true );
} else {
this.elements.backward.removeAttr( disabled );
}
if ( ( state.isLastStepInBranch && !state.step.attr( o.stateAttribute ) ) ||
state.step.hasClass( o.stepClasses.stop ) ) {
this.elements.forward.attr( disabled, true );
} else {
this.elements.forward.removeAttr( disabled );
}
if ( o.enableSubmit || state.step.hasClass( o.stepClasses.submit ) ) {
this.elements.submit.removeAttr( disabled );
} else {
this.elements.submit.attr( disabled, true );
}
if ( current.step ) {
this._trigger( afterSelect, event, state );
this._trigger( state.isMovingForward ? afterForward : afterBackward, event, state );
}
},
backward: function( event, howMany ) {
if ( typeof event === num ) {
howMany = event;
event = undefined;
}
if ( howMany === undefined ) {
howMany = 1;
}
if ( this._currentState.isFirstStep || typeof howMany !== num ) {
return;
}
this._move( -howMany, true, false, function( stepIndex, stepsTaken ) {
this._update( event, this._state( stepIndex, stepsTaken ) );
});
},
branch: function( branch ) {
return arguments.length ?
this._find( branch, this.elements.branches ) : this._currentState.branch;
},
branches: function( branch ) {
return arguments.length ?
this.branch( branch ).children( selector.branch ) : this.elements.branches;
},
branchesActivated: function() {
return this._find( this._currentState.branchesActivated, this.elements.branches );
},
destroy: function( event, force ) {
var self = this,
$elements = self.elements,
data = [ self.state(), function( response ) {
return self.destroy( event, response !== false );
} ];
// args: force
if ( typeof event === bool ) {
force = event;
event = undefined;
}
if ( force !== true && !self._trigger( beforeDestroy, event, data ) ) {
return;
}
self.elements.backward.off( "." + namespace );
self.elements.forward.off( "." + namespace );
self.element.removeClass( wizard );
$elements.form.removeClass( className.form );
$elements.header.removeClass( className.header );
$elements.steps.show().removeClass( className.step );
$elements.stepsWrapper.removeClass( className.wrapper );
$elements.branches.removeClass( className.branch );
$.Widget.prototype.destroy.call( self );
self._trigger( afterDestroy );
},
form: function() {
return this.elements.form;
},
forward: function( event, howMany, history ) {
if ( typeof event === num ) {
history = howMany;
howMany = event;
event = undefined;
}
if ( howMany === undefined ) {
howMany = 1;
}
if ( this._currentState.isLastStep || typeof howMany !== num ) {
return;
}
this._move( howMany, true, history, function( stepIndex, stepsTaken ) {
this._update( event, this._state( stepIndex, stepsTaken ) );
});
},
isValidStep: function( step, branch ) {
return this.isValidStepIndex( this.stepIndex( step, branch ) );
},
isValidStepIndex: function( stepIndex ) {
return typeof stepIndex === num && stepIndex >= 0 && stepIndex <= this._lastStepIndex;
},
stepCount: function() {
return this._stepCount;
},
select: function( event, step, branch, relative, history ) {
// args: step, branch, relative, history
if ( !( event instanceof $.Event ) ) {
history = relative;
relative = branch;
branch = step;
step = event;
event = undefined;
}
if ( step === undefined ) {
return;
}
// args: [ step, branch ], relative, history
if ( $.isArray( step ) ) {
history = relative;
relative = branch;
branch = step[ 1 ];
step = step[ 0 ];
// args: step, relative, history
} else if ( typeof branch === bool ) {
history = relative;
relative = branch;
branch = undefined;
// args: step, history
} else if ( $.isArray( branch ) ) {
history = branch;
branch = undefined;
}
this._move( step, branch, relative, history, function( stepIndex, stepsTaken ) {
this._update( event, this._state( stepIndex, stepsTaken ) );
});
},
state: function( step, branch, stepsTaken ) {
if ( !arguments.length ) {
return this._currentState;
}
// args: [ step, branch ], stepsTaken
if ( $.isArray( step ) ) {
stepsTaken = branch;
branch = step[ 1 ];
step = step[ 0 ];
// args: step, stepsTaken
} else if ( $.isArray( branch ) ) {
stepsTaken = branch;
branch = undefined;
}
return this._state( this.stepIndex( step, branch ), stepsTaken );
},
step: function( step, branch ) {
if ( !arguments.length ) {
return this._currentState.step;
}
// args: [ step, branch ]
if ( $.isArray( step ) ) {
branch = step[ 1 ];
step = step[ 0 ];
}
var $step,
type = typeof step;
// Searching for a step by index
if ( type === num ) {
$step = this._find( step,
// Search within branch, if defined, otherwise search all steps
branch !== undefined ? this.steps( branch ) : this.elements.steps );
// Searching for a step or branch by string ID, DOM element or jQuery object
} else {
$step = this._find( step, this.elements.steps.add( this.elements.branches ) );
if ( $step && $step.hasClass( className.branch ) ) {
// If a branch is found, the arguments are essentially flip-flopped
$step = this._find( branch || 0, this.steps( $step ) );
}
}
return $step;
},
stepIndex: function( step, branch, relative ) {
if ( !arguments.length ) {
return this._currentState.stepIndex;
}
var $step;
// args: [ step, branch ], relative
if ( $.isArray( step ) ) {
relative = branch;
branch = step[ 1 ];
step = step[ 0 ];
// args: step, relative
} else if ( typeof branch === bool ) {
relative = branch;
branch = undefined;
}
return ( $step = this.step( step, branch ) ) ?
// The returned index can be relative to a branch, or to all steps
( relative ? $step.siblings( selector.step ).andSelf() : this.elements.steps ).index( $step )
: -1;
},
steps: function( branch ) {
return arguments.length ?
this.branch( branch ).children( selector.step ) : this.elements.steps;
},
stepsActivated: function() {
return this._find( this._currentState.stepsActivated, this.elements.steps );
},
submit: function() {
this.elements.form.submit();
}
});
})( jQuery );