Source: pureWizard.js

/* globals define, module, exports */

/**
 * @module constructor
 */
(function( root, factory ) {

    'use strict';

    if( typeof define === 'function' && define.amd ) {
        // AMD. Register as an anonymous module.
        define( [], factory );
    } else if( typeof exports === 'object' ) {
        // Node. Does not work with strict CommonJS, but
        // only CommonJS-like environments that support module.exports,
        // like Node.
        module.exports = factory();
    } else {
        // Browser globals (root is window)
        root.PureWizard = factory();
    }
}( this, /** @lends module:constructor */ function() {

    'use strict';

    /**
     * Check if class is present
     * @param elem
     * @param className
     * @private
     * @returns {boolean}
     */
    function hasClass( elem, className ) {
        return elem.classList ? elem.classList.contains( className ) : !!elem.className.match( new RegExp( '(\\s|^)' + className + '(\\s|$)' ) );
    }

    /**
     *  Add class to element
     * @param elem
     * @param className
     * @private
     */
    function addClass( elem, className ) {
        if( className ) {
            elem.classList ? elem.classList.add( className ) : hasClass( className ) || (elem.className = elem.className.trim() + ' ' + className);
        }
    }

    /**
     * Remove class from element
     * @param elem
     * @param className
     * @private
     */
    function removeClass( elem, className ) {
        if( className ) {
            elem.classList ? elem.classList.remove( className ) : hasClass( className ) && (elem.className = elem.className.replace( new RegExp( "(^|\\s)" + className.split( " " ).join( "|" ) + "(\\s|$)", "gi" ), " " ));
        }
    }

    var defaultConfig = {
        wizardNodeId: '',
        errorClass: 'has-error',
        statusContainerCfg: {},
        stepsSplitCssQuery: 'fieldset',
        hideNextPrevButtons: true,
        startPage: 0
    };


    /**
     * Creates a wizard instance
     * 
     * @constructor
     *
     * @param    {Object}  config                                   - The defaults values for wizard config
     * @property {String}  config.wizardNodeId                      - Id of main section that contains wizard
     * @property {String}  [config.errorClass='has-error']          - Custom style for validation errors
     * @property {String}  [config.stepsSplitCssQuery='fieldset']    - Form will be splited into the steps with this query.
     * @property {Boolean} [config.hideNextPrevButtons='true']      - Show as disabled or hide the wizard buttons when they are not available
     * @property {Number}  [config.startPage='0']                   - Open form at the particular page number, starts from 0,
     * @property {Object}  [config.statusContainerCfg='{}']         - Allow to configure a status panel
     *
     *
    */
    function PureWizard( config ) {

        if( !config || !config.wizardNodeId ) {
            throw new Error( 'PureWizard init error - wizardNodeId should be defined' );
        }

        // External config
        this.config = {};

        Object.keys( defaultConfig ).forEach( function( configKey ) {
            this.config[configKey] = config.hasOwnProperty( configKey ) ? config[configKey] : defaultConfig[configKey];
        }, this );

        // Internal config
        this.pages = [];
        this.current = null;
        this.currentIndex = 0;
        this.form = document.getElementById( this.config.wizardNodeId );
        this.statusSectionId = this.config.statusContainerCfg.containerId;

        this.buttons = {
            next: this.form.querySelector( '.pwNext' ),
            prev: this.form.querySelector( '.pwPrev' ),
            finish: this.form.querySelector( '.pwFinish' )
        };

        this.buttonsInitialDisplay = {
            next: this.buttons.next.style.display,
            prev: this.buttons.prev.style.display,
            finish: this.buttons.finish.style.display
        };

        if( !this.buttons.next ) {
            throw new Error( 'PureWizard init error - next button not found, create one with class \'pwNext\'' );
        }

        if( !this.buttons.prev ) {
            throw new Error( 'PureWizard init error - previouse button not found, create one with class \'pwPrev\'' );
        }

        var
            self = this,
            fieldsets = this.form.querySelectorAll( '#' + this.config.wizardNodeId + '>' + (this.config.stepsSplitCssQuery) ),
            i,
            node;

        if( fieldsets.length === 0 ) {
            throw new Error( 'Can\'t find the sections to divide wizard, please check the stepsSplitCssQuery option.' );
        }

        divideIntoPages();

        if( this.statusSectionId && document.getElementById( this.statusSectionId ) ) {
            initStatusSection();
        }

        // Hide all pages at start
        this.pages.forEach( function( p ) {
            p.hide();
        } );

        self.goToPage( this.config.startPage, true );

        this.buttons.next.addEventListener( 'click', function() {
            PureWizard.prototype.next.call( self );
        } );

        this.buttons.prev.addEventListener( 'click', function() {
            PureWizard.prototype.prev.call( self );
        } );

        if( this.buttons.finish ) {
            this.buttons.finish.addEventListener( 'click', function( e ) {
                if( !self.current.toggleErrors() ) {
                    if( self.onSubmitCallback ) {
                        self.onSubmitCallback( e, {} );
                    } else {
                        self.form.submit();
                    }
                } else {
                    e.preventDefault();
                    return false;
                }
            } );
        }

        this.form.addEventListener( 'reset', function() {
            self.goToPage( 0 );
        } );

        function divideIntoPages() {
            for( i = 0; i < fieldsets.length; i+=1 ) {
                node = fieldsets[i];
                if( self.pages.length === 0 ) {
                    self.pages.push( new PureWizardPage( self.config, node ) );
                    self.current = self.pages[0];
                } else {
                    var prev = self.pages[self.pages.length - 1];
                    var newPage = new PureWizardPage( self.config, node, prev );
                    self.pages.push( newPage );
                    prev.next = newPage;
                }
            }
        }

        function initStatusSection() {
            var statusSectionContainer = document.getElementById( self.statusSectionId );
            self.statusSection = new WizardSteps( self.pages, statusSectionContainer, self.config.statusContainerCfg );

            statusSectionContainer.addEventListener( 'click', function( e ) {
                var link = e.target;
                if( link.tagName.toLocaleLowerCase() === 'a' ) {
                    var pageNumber = Number( link.attributes['data-step'].value ),
                        index = self.currentIndex;
                    if( !self.goToPage( pageNumber ) || index > pageNumber ) {
                        self.current.toggleErrors();
                    }
                }
            } );
        }
    }

    /**
     * Move to next page if present
     * @function
     */
    PureWizard.prototype.next = function PureWizard_next() {
        if( this.current.getNext() ) {
            if( !this.goToPage( this.currentIndex + 1 ) ) {
                this.current.toggleErrors();
            }
        }
    };

    /**
     *  Move to previous page if present
     *  @function
     */
    PureWizard.prototype.prev = function pureWizard_prev() {
        if( this.current.getPrev() ) {
            this.goToPage( this.currentIndex - 1 );
            this.current.toggleErrors();
        }
    };

    /**
     * Get current page
     * @function
     *
     * @returns {PureWizadPage}
     */
    PureWizard.prototype.getCurrentPage = function() {
        return this.current;
    };

    /**
     * Get current page index, starts from 0
     * @function
     *
     * @returns {Number}
     */
    PureWizard.prototype.getCurrentPageNumber = function() {
        return this.currentIndex;
    };

    /**
     * Subscription to an event when the page is changed
     * @function
     * @param {Function} callback Executes after page is changed
     *
     */
    PureWizard.prototype.onPageChanged = function pureWizardOnPageChanged( callback ) {
        this.form.addEventListener( 'onPWPageChanged', function( e ) {
            callback( e );
        } );
    };

    /**
     *  When the form is submit
     *  @function
     *
     * @param {Function} callback
     */
    PureWizard.prototype.onSubmit = function PureWizard_onSubmit( callback ) {
        this.onSubmitCallback = callback;
    };

    /**
     * Go to page
     * @function
     * @param {Number} pageNumber        - page number to navigate, starts from 0
     * @param {Boolean} [skipValidation] - skip validation when switching the page
     *
     * @returns {Boolean}
     *
     * @fires onPWPageChanged
     */
    PureWizard.prototype.goToPage = function PureWizard_goToPage( pageNumber, skipValidation ) {

        if( pageNumber >= this.pages.length ) {
            pageNumber = this.pages.length - 1;
        }
        if( pageNumber < 0 ) {
            pageNumber = 0;
        }

        var page = this.pages[pageNumber];

        // Validate page if we go forward
        if( this.current.isValid() || this.currentIndex > pageNumber || skipValidation ) {
            if( !skipValidation ) {
                // If goes for example from page 1 to 3 and page 2 is invalid
                // validate through page 2, but page 3 can be invalid
                if( this.currentIndex < pageNumber && this.currentIndex + 1 !== pageNumber ) {
                    if( this.pages.filter( function( p, i ) {
                            return !p.isValid() && (i !== pageNumber);
                        } ).length > 0 ) {
                        return false;
                    }
                }
            }

            this.current.hide();
            this.current = page;
            this.current.show();
            this.currentIndex = pageNumber;

            // Update the status section
            if( this.statusSection ) {
                this.statusSection.setStep( pageNumber );
            }

            this.toggleButtons();

            /**
             * PureWizard page changed
             * 
             * @event onPWPageChanged
             *
             * @type {Object}
             * @param    {Object} detail
             * @property {Number} previousPage
             * @property {Number} currentPage
             */
            var event = new CustomEvent( 'onPWPageChanged', {
                detail: {
                    'previousPage': this.currentIndex,
                    'currentPage': (this.currentIndex + 1)
                }
            } );
            this.form.dispatchEvent( event );

            return true;
        }

        return false;
    };

    /**
     * Toggle next, prev, submit buttons state depending the current wizard states
     * @function
     */
    PureWizard.prototype.toggleButtons = function PureWizard_toggleButtons() {

        var self = this;

        function setNextPrevVisibility( button, initialDisplay, value ) {
            if( self.config.hideNextPrevButtons ) {
                button.style.display = value ? initialDisplay : 'none';
            } else {
                button.disabled = !value;
            }
        }

        self.buttons.finish.style.display = 'none';
        if( !this.current.getPrev() ) {
            setNextPrevVisibility( self.buttons.prev, self.buttonsInitialDisplay.prev, false );
        } else {
            setNextPrevVisibility( self.buttons.prev, self.buttonsInitialDisplay.prev, true );
        }
        if( !this.current.getNext() ) {
            setNextPrevVisibility( self.buttons.next, self.buttonsInitialDisplay.next, false );
            self.buttons.finish.style.display = self.buttonsInitialDisplay.finish;
        } else {
            setNextPrevVisibility( self.buttons.next, self.buttonsInitialDisplay.next, true );
        }
    };

    /**
     * Represents a single page
     * @class
     * @constructor
     *
     * @param {Object} config
     * @property {String} [config.hideClass='']   - Css class used to hide wizard pages
     * @property {String} [config.errorClass='']  - Css class to Highlight field with errors, if no
     * @param {Node} container
     * @param {PureWizardPage} [prev]
     * @param {PureWizardPage} [next]
     */
    function PureWizardPage( config, container, prev, next ) {
        this.el = container;
        this.prev = prev || null;
        this.next = next || null;
        this.config = config;
    }

    /**
     * Return invalid elements from the page
     * @function
     *
     * @returns {NodeList}
     */
    PureWizardPage.prototype.getInvalidElements = function PureWizardPage_getInvalidElements() {
        return this.el.querySelectorAll( ':invalid' );
    };

    /**
     * Check if the page is valid
     * @function
     *
     * @returns {Boolean}
     */
    PureWizardPage.prototype.isValid = function PureWizardPage_isValid() {
        return this.getInvalidElements().length === 0;
    };

    /**
     * Toggle the validation messages and apply error class
     * @function
     *
     * @returns {Boolean}
     */
    PureWizardPage.prototype.toggleErrors = function PureWizardPage_toggleErrors() {
        var
            invalidInputs = this.getInvalidElements(),
            containsErrors = false,
            i, invalidInputLabel, errorMsg, errorMsgContainer;

        for( i = 0; i < invalidInputs.length; i++ ) {
            invalidInputLabel = this.el.querySelector( 'label[for="' + invalidInputs[i].id + '"]' );

            if( !hasClass( invalidInputs[i], this.config.errorClass ) ) {

                addClass( invalidInputs[i], this.config.errorClass );
                if( invalidInputLabel && !hasClass( invalidInputLabel, this.config.errorClass ) ) {
                    addClass( invalidInputLabel, this.config.errorClass );
                }
                // If error message and error container exists display error
                errorMsg = invalidInputs[i].getAttribute( 'data-error-msg' );
                errorMsgContainer = document.getElementById( invalidInputs[i].id + 'Error' );
                if( errorMsg && errorMsgContainer ) {
                    errorMsgContainer.innerHTML = errorMsg;
                }
            }
            containsErrors = true;
        }

        // Clean the errors from valid elements
        var cssClass = this.config.errorClass ? '.' + this.config.errorClass : '';
        var validElements = this.el.querySelectorAll( cssClass + ':valid' );
        for( i = 0; i < validElements.length; i++ ) {
            removeClass( validElements[i], this.config.errorClass );
            invalidInputLabel = this.el.querySelector( 'label[for="' + validElements[i].id + '"]' );
            errorMsgContainer = document.getElementById( validElements[i].id + 'Error' );

            if( invalidInputLabel ) {
                removeClass( invalidInputLabel, this.config.errorClass );
            }
            if( errorMsgContainer ) {
                errorMsgContainer.innerHTML = '';
            }
        }
        return containsErrors;
    };

    /**
     * Get page title
     * @function
     */
    PureWizardPage.prototype.getTitle = function PureWizardPage_getTitle() {
        var legend = this.el.querySelector( '.pwStepTitle' );
        if( !legend ) {
            throw new Error( 'PureWizard error - no title for page, please add with \'.pwStepTitle\' class.' );
        }
        return legend.innerHTML;
    };

    /**
     * Return the next page
     * @function
     *
     * @returns {PureWizardPage}
     */
    PureWizardPage.prototype.getNext = function() {
        return this.next;
    };

    /**
     * Return the previouse page
     * @function
     *
     * @returns {PureWizardPage}
     */
    PureWizardPage.prototype.getPrev = function() {
        return this.prev;
    };

    /**
     * Show the page
     * @function
     */
    PureWizardPage.prototype.show = function() {
        if( this.config.hideClass ) {
            this.el.className = this.el.className.replace( this.config.hideClass, '' );
        } else {
            this.el.style.display = '';
        }
    };

    /**
     * Hide the page
     * @function
     */
    PureWizardPage.prototype.hide = function() {
        if( this.config.hideClass ) {
            addClass( this.el, this.config.hideClass );
        } else {
            this.el.style.display = 'none';
        }
    };

    /**
     * Section with steps, constructs from list, list item and link.
     * @class
     * @constructor
     *
     * @param {PureWizardPage} pages         - The list with all pages
     * @param {HTMLElement} container        - Container where the steps are located
     * @param {Object} config                 - Additional config object
     * @property {String} config.ulClass      - Add custom ul class
     * @property {String} config.liClass      - Add custom li class
     * @property {String} config.aClass       - Add custom a class
     * @property {String} config.aActiveClass -
     */
    function WizardSteps( pages, container, config ) {

        this.config = config;

        var ulClass = config.ulClass || '',
            liClass = config.liClass || '',
            aClass = config.aClass || '',
            self = this,
            li, a;

        this.el = document.createElement( 'ul' );
        addClass( this.el, ulClass );

        // Populate status sections items
        pages.forEach( function( e, i ) {
            li = document.createElement( 'li' );
            a = document.createElement( 'a' );
            addClass( li, liClass );
            a.setAttribute( 'href', '#' );
            a.setAttribute( 'data-step', i.toString() );
            a.appendChild( document.createTextNode( e.getTitle() ) );
            addClass( a, aClass );
            li.appendChild( a );
            self.el.appendChild( li );
        } );
        container.appendChild( self.el );
    }

    /**
     * Highlight current step with class
     * @function
     * @param {Number} stepNumber Make the step active, starts from 0
     */
    WizardSteps.prototype.setStep = function( stepNumber ) {
        var currentActive = this.el.querySelector( '.' + this.config.aActiveClass );
        if( currentActive ) {
            removeClass( currentActive, this.config.aActiveClass );
        }
        addClass( this.el.children[stepNumber], this.config.aActiveClass );
    };

    return PureWizard;
} ));