Source: index.js

const WizardStep = require('./WizardStep')

class Zangdar {

    /**
     * @type {HTMLFormElement}
     */
    $form = null

    /**
     *
     * @type {NodeListOf|null}
     * @private
     */
    _$prevButtons = null

    /**
     *
     * @type {Array}
     * @private
     */
    _steps = []

    /**
     *
     * @type {Number|null}
     * @private
     */
    _currentIndex = null


    /**
     * @type {Object}
     * @type {{step_selector: string, prev_step_selector: string, onStepChange(Object, Object): void, active_step_index: number, onSubmit(Event): boolean, classes: {prev_button: string, next_button: string, form: string, step: string, step_active: string}, next_step_selector: string, customValidation: (function(Object, NodeList): boolean), onValidation(Object, NodeList): void}}
     * @private
     */
    _params = {
        step_selector: '[data-step]',
        prev_step_selector: '[data-prev]',
        next_step_selector: '[data-next]',
        submit_selector: '[type="submit"]',
        active_step_index: 0,
        classes: {
            form: 'zandgar__wizard',
            prev_button: 'zandgar__prev',
            next_button: 'zandgar__next',
            step: 'zandgar__step',
            step_active: 'zandgar__step__active',
        },
        onSubmit: null,
        onStepChange: null,
        onValidation: null,
        customValidation: null
    }

    /**
     * @param {HTMLFormElement|String} element
     * @param {Object} options
     */
    constructor(element, options = {}) {
        this.$form = element instanceof HTMLFormElement
            ? element
            : document.querySelector(element)

        if (this.$form.constructor !== HTMLFormElement)
            throw new Error(`[Err] Zangdar.constructor - the container must be a valid HTML form element`)

        this._params = {
            ...this._params,
            ...options
        }

        this._init()
    }

    get currentIndex() {
        return this._currentIndex
    }

    get steps() {
        return this._steps
    }

    /**
     * Get a step
     *
     * @param {String|Number} key step index or label
     * @returns {WizardStep|null} WizardStep instance if exists, null otherwise
     */
    getStep(key) {
        if (key.constructor === String)
            return this._steps.find(step => step.labeled(key))
        if (key.constructor === Number)
            return this._steps[key]
        return null
    }

    /**
     * Get the current step
     *
     * @returns {WizardStep|null} the current WizardStep instance if exists, null otherwise
     */
    getCurrentStep() {
        return this.getStep(this._currentIndex)
    }

    /**
     * Reveal previous step
     */
    prev() {
        this._prevStep()
    }

    /**
     * Reveal next step
     */
    next() {
        this._nextStep()
    }

    /**
     * Go to a step by label (data-step attribute value)
     *
     * @param {String} label
     */
    revealStep(label) {
        const index = this._steps.findIndex(step => step.labeled(label))
        if (index >= 0) {
            this._currentIndex = index
            this._revealStep()
        } else {
            throw new Error(`[Err] Zangdar.revealStep - step "${label}" not found`)
        }
    }

    /**
     * Create a wizard from an existing form with a template which is describes it
     *
     * @param {Object} template the wizard template
     */
    createFromTemplate(template) {
        let i = 0
        for (let label in template) {
            ++i
            if (template.hasOwnProperty(label)) {
                const fields = template[label]
                const $section = this._buildSection(label)
                fields.forEach(field => {
                    const el = this.$form.querySelector(field)
                    if (el !== null) {
                        const newElm = el.cloneNode(true)
                        $section.appendChild(newElm)
                        el.parentNode.removeChild(el)
                    }
                })
                if (i < Object.keys(template).length && $section.querySelector(this._params.next_step_selector) === null) {
                    let $nextButton = document.createElement('button')
                    $nextButton = this._appendSelector(this._params.next_step_selector, null, $nextButton)
                    $nextButton.innerText = 'Next'
                    $section.appendChild($nextButton)
                }
                if (i === Object.keys(template).length) {
                    const $submitButton = this.$form.querySelector(this._params.submit_selector)
                    if ($submitButton !== null) {
                        const newBtn = $submitButton.cloneNode(true)
                        $section.appendChild(newBtn)
                        $submitButton.parentNode.removeChild($submitButton)
                    }
                }
                this.$form.appendChild($section)
            }
        }
        this._init()
    }

    _init() {
        if (this.$form.querySelectorAll(this._params.step_selector).length) {
            this._buildForm()
            this._buildPrevButton()
            this._buildSteps()
        }
    }

    _buildForm() {
        const onSubmit = this._params.onSubmit
        this.$form.classList.add(this._params.classes.form)
        this.$form.addEventListener('submit', e => {
            if (this._validateCurrentStep()) {
                if (onSubmit && onSubmit.constructor === Function)
                    onSubmit(e)
                else e.target.submit()
            }
        })
    }

    /**
     * @private
     */
    _buildPrevButton() {
        this._$prevButtons = this.$form.querySelectorAll(this._params.prev_step_selector)

        if (!this._$prevButtons || !this._$prevButtons.length) {
            const $prevBtn = document.createElement('button')
            $prevBtn.setAttribute('data-prev', '')
            $prevBtn.innerText = 'Prev.'
            this.$form.insertBefore($prevBtn, this.$form.firstChild)
            this._buildPrevButton()
        } else {
            Array.from(this._$prevButtons).forEach(btn => {
                btn.classList.add(this._params.classes.prev_button)
                btn.addEventListener('click', e => {
                    e.preventDefault()
                    this._prevStep()
                })
            })
        }
    }

    /**
     * @private
     */
    _buildSteps() {
        let steps = Array.from(this.$form.querySelectorAll(this._params.step_selector))

        if (!steps.length)
            throw new Error(`[Err] Zangdar._buildSteps - you must have at least one step (a HTML element with "${this._params.step_selector}" attribute)`)

        steps.reduce((acc, item, index) => {
            const label = item.dataset.step
            const isActive = index === this._params.active_step_index
            item.classList.add(this._params.classes.step)
            if (isActive) {
                item.classList.add(this._params.classes.step_active)
                this._currentIndex = index
            }

            if (index < steps.length - 1 && item.querySelector(this._params.next_step_selector)) {
                const $nextButton = item.querySelector(this._params.next_step_selector)

                $nextButton.classList.add(this._params.classes.next_button)
                $nextButton.addEventListener('click', e => {
                    e.preventDefault()
                    if (this._validateCurrentStep())
                        this._nextStep()
                })
            }

            const step = new WizardStep(index, item, label, isActive)

            acc.push(step)

            return acc
        }, this._steps)

        this._currentIndex = this._params.active_step_index
        this._revealStep()
    }

    _buildSection(label) {
        let $section = document.createElement('section')
        return this._appendSelector(this._params.step_selector, label, $section)
    }

    /**
     * @private
     */
    _revealStep() {
        this._steps.forEach((step, i) => {
            step.active = this._currentIndex === i
            if (step.active) {
                step.element.classList.add(this._params.classes.step_active)
            } else {
                step.element.classList.remove(this._params.classes.step_active)
            }
        })
        this._hidePrevBtns()
    }

    /**
     * @private
     */
    _hidePrevBtns() {
        if (!this._$prevButtons || !this._$prevButtons.length)
            this._buildPrevButton()
        else
            Array.from(this._$prevButtons).forEach(btn => btn.style.display = this._currentIndex === 0 ? 'none' : '')
    }

    /**
     * @private
     */
    _prevStep() {
        const oldStep = this.getCurrentStep()
        this._currentIndex = this._currentIndex - 1 < 0 ? 0 : this._currentIndex - 1
        if (this._params.onStepChange && this._params.onStepChange.constructor === Function)
            this._params.onStepChange(this.getCurrentStep(), oldStep, -1, this.$form)
        this._revealStep()
    }

    /**
     * @private
     */
    _nextStep() {
        const oldStep = this.getCurrentStep()
        this._currentIndex = this._currentIndex < this._steps.length - 1
            ? this._currentIndex + 1
            : this._steps.length
        if (this._params.onStepChange && this._params.onStepChange.constructor === Function)
            this._params.onStepChange(this.getCurrentStep(), oldStep, 1, this.$form)
        this._revealStep()
    }

    /**
     * @private
     */
    _validateCurrentStep() {
        const currentStep = this.getCurrentStep()
        const fields = this._formElements(currentStep.element)
        if (this._params.customValidation && this._params.customValidation.constructor === Function) {
            this.$form.setAttribute('novalidate', '')
            return this._params.customValidation(currentStep, fields, this.$form)
        }
        this.$form.removeAttribute('novalidate')
        currentStep.clearErrors()
        let isValid = true
        Array.from(fields)
            .reverse()
            .forEach(el => {
                if (!el.checkValidity()) {
                    isValid = false
                    currentStep.addError(el.name, el.validationMessage)
                    el.reportValidity()
                }
            })

        if (this._params.onValidation && this._params.onValidation.constructor === Function)
            this._params.onValidation(currentStep, fields, this.$form)

        return isValid
    }

    /**
     * Get form inputs
     * @param {HTMLElement} element
     * @returns {NodeListOf<HTMLElement>}
     * @private
     */
    _formElements(element) {
        return element.querySelectorAll(`\
            ${this._params.step_selector} input:not([type="hidden"]):not([disabled]),\
            ${this._params.step_selector} select:not([disabled]),\
            ${this._params.step_selector} textarea:not([disabled])\
        `)
    }

    /**
     * Append a selector to an element
     *
     * @param {String} selector
     * @param {String|null} value
     * @param {HTMLElement} element
     * @returns {HTMLElement}
     * @private
     */
    _appendSelector(selector, value, element) {
        if (selector.startsWith('.')) {
            element.classList.add(selector.slice(1))
        } else if (selector.startsWith('#')) {
            element.id = selector.slice(1)
        } else {
            let matches = selector.match(/^.*\[(?<datakey>[a-zA-Z\-]+)(\=['|"]?(?<dataval>[a-zA-Z0-9\-]+)['|"]?)?\]$/)
            if (matches && matches.length) {
                const key = matches.groups.datakey || matches[1] || null
                const val = value || matches.groups.dataval || matches[3] || ''
                if (key) element.setAttribute(key, val)
            }

        }
        return element
    }
}

if (window !== undefined) {
    !window.hasOwnProperty('Zangdar') && (window.Zangdar = Zangdar)
    if(!HTMLFormElement.prototype.zangdar) {
        HTMLFormElement.prototype.zangdar = function (options) {
            return new Zangdar(this, options)
        }
    }
} else {
    module.exports = Zangdar
}