UNPKG

flyonui

Version:

The easiest, free and open-source Tailwind CSS component library with semantic classes.

885 lines (674 loc) 24.9 kB
/* * HSStepper * @version: 3.2.2 * @author: Preline Labs Ltd. * @license: Licensed under MIT and Preline UI Fair Use License (https://preline.co/docs/license.html) * Copyright 2024 Preline Labs Ltd. */ import { dispatch } from '../../utils' import { IStepper, IStepperItem, IStepperOptions } from './interfaces' import HSBasePlugin from '../base-plugin' import { ICollectionItem } from '../../interfaces' class HSStepper extends HSBasePlugin<{}> implements IStepper { private currentIndex: number | null private readonly mode: string | null private isCompleted: boolean | null private totalSteps: number | null private navItems: IStepperItem[] | null private contentItems: IStepperItem[] | null private backBtn: HTMLElement | null private nextBtn: HTMLElement | null private skipBtn: HTMLElement | null private completeStepBtn: HTMLElement | null private completeStepBtnDefaultText: string | null private finishBtn: HTMLElement | null private resetBtn: HTMLElement | null private onNavItemClickListener: | { el: HTMLElement fn: () => void }[] | null private onBackClickListener: () => void private onNextClickListener: () => void private onSkipClickListener: () => void private onCompleteStepBtnClickListener: () => void private onFinishBtnClickListener: () => void private onResetBtnClickListener: () => void constructor(el: HTMLElement, options?: IStepperOptions) { super(el, options) const data = el.getAttribute('data-stepper') const dataOptions: IStepperOptions = data ? JSON.parse(data) : {} const concatOptions = { ...dataOptions, ...options } this.currentIndex = concatOptions?.currentIndex || 1 this.mode = concatOptions?.mode || 'linear' this.isCompleted = typeof concatOptions?.isCompleted !== 'undefined' ? concatOptions?.isCompleted : false this.totalSteps = 1 this.navItems = [] this.contentItems = [] this.onNavItemClickListener = [] this.init() } private navItemClick(item: IStepperItem) { this.handleNavItemClick(item) } private backClick() { this.handleBackButtonClick() if (this.mode === 'linear') { const currentNavItem = this.navItems.find(({ index }) => index === this.currentIndex) const currentContentItem = this.contentItems.find(({ index }) => index === this.currentIndex) if (!currentNavItem || !currentContentItem) return if (currentNavItem.isCompleted) { currentNavItem.isCompleted = false currentNavItem.isSkip = false currentNavItem.el.classList.remove('is-valid', 'skipped') } if (currentContentItem.isCompleted) { currentContentItem.isCompleted = false currentContentItem.isSkip = false currentContentItem.el.classList.remove('is-valid', 'skipped') } if (this.mode === 'linear' && this.currentIndex !== this.totalSteps) { if (this.nextBtn) this.nextBtn.style.display = '' if (this.completeStepBtn) this.completeStepBtn.style.display = '' } this.showSkipButton() this.showFinishButton() this.showCompleteStepButton() } } private nextClick() { this.fireEvent('beforeNext', this.currentIndex) dispatch('beforeNext.stepper', this.el, this.currentIndex) if (this.getNavItem(this.currentIndex)?.isProcessed) { this.disableAll() return false } this.goToNext() } private skipClick() { this.handleSkipButtonClick() if (this.mode === 'linear' && this.currentIndex === this.totalSteps) { if (this.nextBtn) this.nextBtn.style.display = 'none' if (this.completeStepBtn) this.completeStepBtn.style.display = 'none' if (this.finishBtn) this.finishBtn.style.display = '' } } private completeStepBtnClick() { this.handleCompleteStepButtonClick() } private finishBtnClick() { this.fireEvent('beforeFinish', this.currentIndex) dispatch('beforeFinish.stepper', this.el, this.currentIndex) if (this.getNavItem(this.currentIndex)?.isProcessed) { this.disableAll() return false } this.handleFinishButtonClick() } private resetBtnClick() { this.handleResetButtonClick() } private init() { this.createCollection(window.$hsStepperCollection, this) this.buildNav() this.buildContent() this.buildButtons() this.setTotalSteps() } private getUncompletedSteps(inIncludedSkipped: boolean = false) { return this.navItems.filter(({ isCompleted, isSkip }) => inIncludedSkipped ? !isCompleted || isSkip : !isCompleted && !isSkip ) } private setTotalSteps() { this.navItems.forEach(item => { const { index } = item if (index > this.totalSteps) this.totalSteps = index }) } // Nav private buildNav() { this.el.querySelectorAll('[data-stepper-nav-item]').forEach(el => this.addNavItem(el as HTMLElement)) this.navItems.forEach(item => this.buildNavItem(item)) } private buildNavItem(item: IStepperItem) { const { index, isDisabled, el } = item if (index === this.currentIndex) this.setCurrentNavItem() if (this.mode !== 'linear' || isDisabled) { this.onNavItemClickListener.push({ el, fn: () => this.navItemClick(item) }) el.addEventListener('click', this.onNavItemClickListener.find(navItem => navItem.el === el).fn) } } private addNavItem(el: HTMLElement) { const { index, isFinal = false, isCompleted = false, isSkip = false, isOptional = false, isDisabled = false, isProcessed = false, isInvalid = false } = JSON.parse(el.getAttribute('data-stepper-nav-item')) if (isCompleted) el.classList.add('is-valid') if (isSkip) el.classList.add('skipped') if (isDisabled) { if (el.tagName === 'BUTTON' || el.tagName === 'INPUT') { el.setAttribute('disabled', 'disabled') } el.classList.add('disabled') } if (isInvalid) el.classList.add('is-invalid') this.navItems.push({ index, isFinal, isCompleted, isSkip, isOptional, isDisabled, isProcessed, isInvalid, el: el as HTMLElement }) } private setCurrentNavItem() { this.navItems.forEach(item => { const { index, el } = item if (index === this.currentIndex) this.setCurrentNavItemActions(el) else this.unsetCurrentNavItemActions(el) }) } private setCurrentNavItemActions(el: HTMLElement) { el.classList.add('active') this.fireEvent('active', this.currentIndex) dispatch('active.stepper', this.el, this.currentIndex) } private getNavItem(n = this.currentIndex) { return this.navItems.find(({ index }) => index === n) } private setProcessedNavItemActions(item: IStepperItem) { item.isProcessed = true item.el.classList.add('processed') } private setErrorNavItemActions(item: IStepperItem) { item.isInvalid = true item.el.classList.add('is-invalid') } private unsetCurrentNavItemActions(el: HTMLElement) { el.classList.remove('active') } private handleNavItemClick(item: IStepperItem) { const { index } = item this.currentIndex = index this.setCurrentNavItem() this.setCurrentContentItem() this.checkForTheFirstStep() } // Content private buildContent() { this.el.querySelectorAll('[data-stepper-content-item]').forEach(el => this.addContentItem(el as HTMLElement)) this.navItems.forEach(item => this.buildContentItem(item)) } private buildContentItem(item: IStepperItem) { const { index } = item if (index === this.currentIndex) this.setCurrentContentItem() } private addContentItem(el: HTMLElement) { const { index, isFinal = false, isCompleted = false, isSkip = false } = JSON.parse(el.getAttribute('data-stepper-content-item')) if (isCompleted) el.classList.add('is-valid') if (isSkip) el.classList.add('skipped') this.contentItems.push({ index, isFinal, isCompleted, isSkip, el: el as HTMLElement }) } private setCurrentContentItem() { if (this.isCompleted) { const finalContentItem = this.contentItems.find(({ isFinal }) => isFinal) const otherContentItems = this.contentItems.filter(({ isFinal }) => !isFinal) finalContentItem.el.style.display = '' otherContentItems.forEach(({ el }) => (el.style.display = 'none')) return false } this.contentItems.forEach(item => { const { index, el } = item if (index === this.currentIndex) this.setCurrentContentItemActions(el) else this.unsetCurrentContentItemActions(el) }) } private hideAllContentItems() { this.contentItems.forEach(({ el }) => (el.style.display = 'none')) } private setCurrentContentItemActions(el: HTMLElement) { el.style.display = '' } private unsetCurrentContentItemActions(el: HTMLElement) { el.style.display = 'none' } private disableAll() { const currentNavItem = this.getNavItem(this.currentIndex) currentNavItem.isInvalid = false currentNavItem.isCompleted = false currentNavItem.isDisabled = false currentNavItem.el.classList.remove('is-invalid', 'is-valid') this.disableButtons() } private disableNavItemActions(item: IStepperItem) { item.isDisabled = true item.el.classList.add('disabled') } private enableNavItemActions(item: IStepperItem) { item.isDisabled = false item.el.classList.remove('disabled') } // Buttons private buildButtons() { this.backBtn = this.el.querySelector('[data-stepper-back-btn]') this.nextBtn = this.el.querySelector('[data-stepper-next-btn]') this.skipBtn = this.el.querySelector('[data-stepper-skip-btn]') this.completeStepBtn = this.el.querySelector('[data-stepper-complete-step-btn]') this.finishBtn = this.el.querySelector('[data-stepper-finish-btn]') this.resetBtn = this.el.querySelector('[data-stepper-reset-btn]') this.buildBackButton() this.buildNextButton() this.buildSkipButton() this.buildCompleteStepButton() this.buildFinishButton() this.buildResetButton() } // back private buildBackButton() { if (!this.backBtn) return this.checkForTheFirstStep() this.onBackClickListener = () => this.backClick() this.backBtn.addEventListener('click', this.onBackClickListener) } private handleBackButtonClick() { if (this.currentIndex === 1) return if (this.mode === 'linear') { this.removeOptionalClasses() } this.currentIndex-- if (this.mode === 'linear') { this.removeOptionalClasses() } this.setCurrentNavItem() this.setCurrentContentItem() this.checkForTheFirstStep() if (this.completeStepBtn) { this.changeTextAndDisableCompleteButtonIfStepCompleted() } this.fireEvent('back', this.currentIndex) dispatch('back.stepper', this.el, this.currentIndex) } private checkForTheFirstStep() { if (this.currentIndex === 1) { this.setToDisabled(this.backBtn) } else { this.setToNonDisabled(this.backBtn) } } private setToDisabled(el: HTMLElement) { if (el.tagName === 'BUTTON' || el.tagName === 'INPUT') { el.setAttribute('disabled', 'disabled') } el.classList.add('disabled') } private setToNonDisabled(el: HTMLElement) { if (el.tagName === 'BUTTON' || el.tagName === 'INPUT') { el.removeAttribute('disabled') } el.classList.remove('disabled') } // next private buildNextButton() { if (!this.nextBtn) return this.onNextClickListener = () => this.nextClick() this.nextBtn.addEventListener('click', this.onNextClickListener) } private unsetProcessedNavItemActions(item: IStepperItem) { item.isProcessed = false item.el.classList.remove('processed') } private handleNextButtonClick(infinite = true) { if (infinite) { if (this.currentIndex === this.totalSteps) this.currentIndex = 1 else this.currentIndex++ } else { const nonCompletedSteps = this.getUncompletedSteps() if (nonCompletedSteps.length === 1) { const { index } = nonCompletedSteps[0] this.currentIndex = index } else { if (this.currentIndex === this.totalSteps) return this.currentIndex++ } } if (this.mode === 'linear') { this.removeOptionalClasses() } this.setCurrentNavItem() this.setCurrentContentItem() this.checkForTheFirstStep() if (this.completeStepBtn) { this.changeTextAndDisableCompleteButtonIfStepCompleted() } this.showSkipButton() this.showFinishButton() this.showCompleteStepButton() this.fireEvent('next', this.currentIndex) dispatch('next.stepper', this.el, this.currentIndex) } private removeOptionalClasses() { const currentNavItem = this.navItems.find(({ index }) => index === this.currentIndex) const currentContentItem = this.contentItems.find(({ index }) => index === this.currentIndex) currentNavItem.isSkip = false currentNavItem.isInvalid = false currentNavItem.isDisabled = false currentContentItem.isSkip = false currentNavItem.el.classList.remove('skipped', 'is-valid', 'is-invalid') currentContentItem.el.classList.remove('skipped', 'is-valid', 'is-invalid') } // skip private buildSkipButton() { if (!this.skipBtn) return this.showSkipButton() this.onSkipClickListener = () => this.skipClick() this.skipBtn.addEventListener('click', this.onSkipClickListener) } private setSkipItem(n?: number) { const targetNavItem = this.navItems.find(({ index }) => index === (n || this.currentIndex)) const targetContentItem = this.contentItems.find(({ index }) => index === (n || this.currentIndex)) if (!targetNavItem || !targetContentItem) return this.setSkipItemActions(targetNavItem) this.setSkipItemActions(targetContentItem) } private setSkipItemActions(item: IStepperItem) { item.isSkip = true item.el.classList.add('skipped') } private showSkipButton() { if (!this.skipBtn) return const { isOptional } = this.navItems.find(({ index }) => index === this.currentIndex) if (isOptional) this.skipBtn.style.display = '' else this.skipBtn.style.display = 'none' } private handleSkipButtonClick() { this.setSkipItem() this.handleNextButtonClick() this.fireEvent('skip', this.currentIndex) dispatch('skip.stepper', this.el, this.currentIndex) } // complete private buildCompleteStepButton() { if (!this.completeStepBtn) return this.completeStepBtnDefaultText = this.completeStepBtn.innerText this.onCompleteStepBtnClickListener = () => this.completeStepBtnClick() this.completeStepBtn.addEventListener('click', this.onCompleteStepBtnClickListener) } private changeTextAndDisableCompleteButtonIfStepCompleted() { const currentNavItem = this.navItems.find(({ index }) => index === this.currentIndex) const { completedText } = JSON.parse(this.completeStepBtn.getAttribute('data-stepper-complete-step-btn')) if (!currentNavItem) return if (currentNavItem.isCompleted) { this.completeStepBtn.innerText = completedText || this.completeStepBtnDefaultText this.completeStepBtn.setAttribute('disabled', 'disabled') this.completeStepBtn.classList.add('disabled') } else { this.completeStepBtn.innerText = this.completeStepBtnDefaultText this.completeStepBtn.removeAttribute('disabled') this.completeStepBtn.classList.remove('disabled') } } private setCompleteItem(n?: number) { const targetNavItem = this.navItems.find(({ index }) => index === (n || this.currentIndex)) const targetContentItem = this.contentItems.find(({ index }) => index === (n || this.currentIndex)) if (!targetNavItem || !targetContentItem) return this.setCompleteItemActions(targetNavItem) this.setCompleteItemActions(targetContentItem) } private setCompleteItemActions(item: IStepperItem) { item.isCompleted = true item.el.classList.add('is-valid') } private showCompleteStepButton() { if (!this.completeStepBtn) return const nonCompletedSteps = this.getUncompletedSteps() if (nonCompletedSteps.length === 1) { this.completeStepBtn.style.display = 'none' } else this.completeStepBtn.style.display = '' } private handleCompleteStepButtonClick() { this.setCompleteItem() this.fireEvent('complete', this.currentIndex) dispatch('complete.stepper', this.el, this.currentIndex) this.handleNextButtonClick(false) this.showFinishButton() this.showCompleteStepButton() this.checkForTheFirstStep() if (this.completeStepBtn) { this.changeTextAndDisableCompleteButtonIfStepCompleted() } this.showSkipButton() } // finish private buildFinishButton() { if (!this.finishBtn) return if (this.isCompleted) { this.setCompleted() } this.onFinishBtnClickListener = () => this.finishBtnClick() this.finishBtn.addEventListener('click', this.onFinishBtnClickListener) } private setCompleted() { this.el.classList.add('completed') } private unsetCompleted() { this.el.classList.remove('completed') } private showFinishButton() { if (!this.finishBtn) return const nonCompletedSteps = this.getUncompletedSteps() if (nonCompletedSteps.length === 1) this.finishBtn.style.display = '' else this.finishBtn.style.display = 'none' } private handleFinishButtonClick() { const uncompletedSteps = this.getUncompletedSteps() const uncompletedOrSkipSteps = this.getUncompletedSteps(true) const { el } = this.contentItems.find(({ isFinal }) => isFinal) if (uncompletedSteps.length) { uncompletedSteps.forEach(({ index }) => this.setCompleteItem(index)) } this.currentIndex = this.totalSteps this.setCurrentNavItem() this.hideAllContentItems() const currentNavItem = this.navItems.find(({ index }) => index === this.currentIndex) const currentNavItemEl = currentNavItem ? currentNavItem.el : null currentNavItemEl.classList.remove('active') el.style.display = 'block' if (this.backBtn) this.backBtn.style.display = 'none' if (this.nextBtn) this.nextBtn.style.display = 'none' if (this.skipBtn) this.skipBtn.style.display = 'none' if (this.completeStepBtn) this.completeStepBtn.style.display = 'none' if (this.finishBtn) this.finishBtn.style.display = 'none' if (this.resetBtn) this.resetBtn.style.display = '' if (uncompletedOrSkipSteps.length <= 1) { this.isCompleted = true this.setCompleted() } this.fireEvent('finish', this.currentIndex) dispatch('finish.stepper', this.el, this.currentIndex) } // reset private buildResetButton() { if (!this.resetBtn) return this.onResetBtnClickListener = () => this.resetBtnClick() this.resetBtn.addEventListener('click', this.onResetBtnClickListener) } private handleResetButtonClick() { if (this.backBtn) this.backBtn.style.display = '' if (this.nextBtn) this.nextBtn.style.display = '' if (this.completeStepBtn) { this.completeStepBtn.style.display = '' this.completeStepBtn.innerText = this.completeStepBtnDefaultText this.completeStepBtn.removeAttribute('disabled') this.completeStepBtn.classList.remove('disabled') } if (this.resetBtn) this.resetBtn.style.display = 'none' this.navItems.forEach(item => { const { el } = item item.isSkip = false item.isCompleted = false this.unsetCurrentNavItemActions(el) el.classList.remove('is-valid', 'skipped') }) this.contentItems.forEach(item => { const { el } = item item.isSkip = false item.isCompleted = false this.unsetCurrentContentItemActions(el) el.classList.remove('is-valid', 'skipped') }) this.currentIndex = 1 this.unsetCompleted() this.isCompleted = false this.showSkipButton() this.setCurrentNavItem() this.setCurrentContentItem() this.showFinishButton() this.showCompleteStepButton() this.checkForTheFirstStep() this.fireEvent('reset', this.currentIndex) dispatch('reset.stepper', this.el, this.currentIndex) } // Public methods public setProcessedNavItem(n?: number) { const targetNavItem = this.getNavItem(n) if (!targetNavItem) return this.setProcessedNavItemActions(targetNavItem) } public unsetProcessedNavItem(n?: number) { const targetNavItem = this.getNavItem(n) if (!targetNavItem) return this.unsetProcessedNavItemActions(targetNavItem) } public goToNext() { if (this.mode === 'linear') this.setCompleteItem() this.handleNextButtonClick(this.mode !== 'linear') if (this.mode === 'linear' && this.currentIndex === this.totalSteps) { if (this.nextBtn) this.nextBtn.style.display = 'none' if (this.completeStepBtn) this.completeStepBtn.style.display = 'none' } } public goToFinish() { this.handleFinishButtonClick() } public disableButtons() { if (this.backBtn) this.setToDisabled(this.backBtn) if (this.nextBtn) this.setToDisabled(this.nextBtn) } public enableButtons() { if (this.backBtn) this.setToNonDisabled(this.backBtn) if (this.nextBtn) this.setToNonDisabled(this.nextBtn) } public setErrorNavItem(n?: number) { const targetNavItem = this.getNavItem(n) if (!targetNavItem) return this.setErrorNavItemActions(targetNavItem) } public destroy() { // Remove classes this.el.classList.remove('completed') this.el.querySelectorAll('[data-stepper-nav-item]').forEach(el => { el.classList.remove('active', 'is-valid', 'skipped', 'disabled', 'is-invalid') if (el.tagName === 'BUTTON' || el.tagName === 'INPUT') { el.removeAttribute('disabled') } }) this.el.querySelectorAll('[data-stepper-content-item]').forEach(el => { el.classList.remove('is-valid', 'skipped') }) if (this.backBtn) this.backBtn.classList.remove('disabled') if (this.nextBtn) this.nextBtn.classList.remove('disabled') if (this.completeStepBtn) this.completeStepBtn.classList.remove('disabled') // Remove attributes if (this.backBtn) this.backBtn.style.display = '' if (this.nextBtn) this.nextBtn.style.display = '' if (this.skipBtn) this.skipBtn.style.display = '' if (this.finishBtn) this.finishBtn.style.display = 'none' if (this.resetBtn) this.resetBtn.style.display = 'none' // Remove listeners if (this.onNavItemClickListener.length) { this.onNavItemClickListener.forEach(({ el, fn }) => { el.removeEventListener('click', fn) }) } if (this.backBtn) { this.backBtn.removeEventListener('click', this.onBackClickListener) } if (this.nextBtn) { this.nextBtn.removeEventListener('click', this.onNextClickListener) } if (this.skipBtn) { this.skipBtn.removeEventListener('click', this.onSkipClickListener) } if (this.completeStepBtn) { this.completeStepBtn.removeEventListener('click', this.onCompleteStepBtnClickListener) } if (this.finishBtn) { this.finishBtn.removeEventListener('click', this.onFinishBtnClickListener) } if (this.resetBtn) { this.resetBtn.removeEventListener('click', this.onResetBtnClickListener) } window.$hsStepperCollection = window.$hsStepperCollection.filter(({ element }) => element.el !== this.el) } // Static methods static getInstance(target: HTMLElement | string, isInstance?: boolean) { const elInCollection = window.$hsStepperCollection.find( el => el.element.el === (typeof target === 'string' ? document.querySelector(target) : target) ) return elInCollection ? (isInstance ? elInCollection : elInCollection.element) : null } static autoInit() { if (!window.$hsStepperCollection) window.$hsStepperCollection = [] if (window.$hsStepperCollection) { window.$hsStepperCollection = window.$hsStepperCollection.filter(({ element }) => document.contains(element.el)) } document.querySelectorAll('[data-stepper]:not(.--prevent-on-load-init)').forEach((el: HTMLElement) => { if (!window.$hsStepperCollection.find(elC => (elC?.element?.el as HTMLElement) === el)) { new HSStepper(el) } }) } } declare global { interface Window { HSStepper: Function $hsStepperCollection: ICollectionItem<HSStepper>[] } } window.addEventListener('load', () => { HSStepper.autoInit() // Uncomment for debug // console.log('Stepper collection:', window.$hsStepperCollection); }) if (typeof window !== 'undefined') { window.HSStepper = HSStepper } export default HSStepper