@coreui/coreui-pro
Version:
The most popular front-end framework for developing responsive, mobile-first projects on the web rewritten by the CoreUI Team
675 lines (542 loc) • 18 kB
JavaScript
/**
* --------------------------------------------------------------------------
* CoreUI PRO stepper.js
* License (https://coreui.io/pro/license/)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import Manipulator from './dom/manipulator.js'
import SelectorEngine from './dom/selector-engine.js'
import {
defineJQueryPlugin, getNextActiveElement, getUID, isDisabled
} from './util/index.js'
/**
* Constants
*/
const NAME = 'stepper'
const DATA_KEY = 'coreui.stepper'
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_FINISH = `finish${EVENT_KEY}`
const EVENT_RESET = `reset${EVENT_KEY}`
const EVENT_STEP_CHANGE = `stepChange${EVENT_KEY}`
const EVENT_STEP_VALIDATION_COMPLETE = `stepValidationComplete${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}`
const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
const EVENT_LOAD_DATA_API = `load${EVENT_KEY}`
const CLASS_NAME_ACTIVE = 'active'
const CLASS_NAME_COMPLETE = 'complete'
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_STEPPER_STEP_CONNECTOR = 'stepper-step-connector'
const CLASS_NAME_STEPPER_STEP_INDICATOR_ICON = 'stepper-step-indicator-icon'
const CLASS_NAME_STEPPER_STEP_INDICATOR_TEXT = 'stepper-step-indicator-text'
const SELECTOR_DATA_TOGGLE = '[data-coreui-toggle="stepper"]'
const SELECTOR_STEPPER = '.stepper'
const SELECTOR_STEPPER_ACTION = '[data-coreui-stepper-action]'
const SELECTOR_STEPPER_STEP = '.stepper-step'
const SELECTOR_STEPPER_STEP_BUTTON = '.stepper-step-button'
const SELECTOR_STEPPER_STEP_CONTENT = '.stepper-step-content'
const SELECTOR_STEPPER_STEP_INDICATOR = '.stepper-step-indicator'
const SELECTOR_STEPPER_STEP_INDICATOR_ICON = '.stepper-step-indicator-icon'
const SELECTOR_STEPPER_STEPS = '.stepper-steps'
const SELECTOR_STEPPER_PANE = '.stepper-pane'
const ARROW_LEFT_KEY = 'ArrowLeft'
const ARROW_RIGHT_KEY = 'ArrowRight'
const ARROW_UP_KEY = 'ArrowUp'
const ARROW_DOWN_KEY = 'ArrowDown'
const HOME_KEY = 'Home'
const END_KEY = 'End'
const Default = {
linear: true,
skipValidation: false
}
const DefaultType = {
linear: 'boolean',
skipValidation: 'boolean'
}
/**
* Class definition
*/
class Stepper extends BaseComponent {
constructor(element, config) {
super(element, config)
this._stepButtons = this._getStepButtons()
this._activeStepButton = this._getActiveElem()
this._initialStepButton = this._activeStepButton
this._isFinished = false
this._addStepperConnector()
this._resetPanes(this._getTargetPane(this._activeStepButton))
this._wrapIndicatorText()
this._setInitialComplete()
this._updateStepButtonsDisabledState()
this._setupAccessibilityAttributes()
EventHandler.on(this._element, EVENT_KEYDOWN, event => this._keydown(event))
}
// Getters
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
}
// Public
showStep(buttonOrStepNumber) {
let button = buttonOrStepNumber
if (typeof buttonOrStepNumber === 'number') {
button = this._stepButtons[buttonOrStepNumber - 1]
}
if (!button) {
return
}
const active = this._getActiveElem()
if (active && !this._isCurrentStepValid(active)) {
return
}
if (this._elemIsActive(button)) {
return
}
if (this._config.linear) {
const steps = this._getEnabledStepButtons()
const targetIndex = steps.indexOf(button)
const activeIndex = steps.indexOf(active)
if (targetIndex > activeIndex + 1) {
return
}
}
const index = this._stepButtons.indexOf(button) + 1
EventHandler.trigger(this._element, EVENT_STEP_CHANGE, { index })
this._activeStepButton = button
this._deactivate(active)
this._activate(button)
this._updateStepButtonsDisabledState()
this._complete(button)
}
next() {
if (this._isFinished) {
return
}
if (!this._isCurrentStepValid(this._getActiveElem())) {
return
}
const steps = this._getEnabledStepButtons()
const active = this._getActiveElem()
const index = steps.indexOf(active)
const next = steps[index + 1]
if (next) {
this.showStep(next)
}
}
prev() {
if (this._isFinished) {
return
}
const steps = this._getEnabledStepButtons()
const active = this._getActiveElem()
const index = steps.indexOf(active)
const prev = steps[index - 1]
if (prev) {
this.showStep(prev)
}
}
finish() {
if (this._isFinished) {
return
}
if (!this._isCurrentStepValid(this._getActiveElem())) {
return
}
const steps = this._getEnabledStepButtons()
const active = this._getActiveElem()
const index = steps.indexOf(active)
if (index !== steps.length - 1) {
const next = steps[index + 1]
if (next) {
this.showStep(next)
}
return
}
const finishHandler = () => {
active.classList.remove(CLASS_NAME_ACTIVE)
this._markAsComplete(active)
EventHandler.trigger(this._element, EVENT_FINISH)
this._isFinished = true
this._disableStepButtons()
}
const pane = this._getTargetPane(active)
const stepContent = active.parentNode.querySelector(SELECTOR_STEPPER_STEP_CONTENT)
if (pane) {
pane.classList.remove(CLASS_NAME_ACTIVE, CLASS_NAME_SHOW)
finishHandler()
} else if (stepContent) {
this._animateHeight(stepContent, false, finishHandler)
} else {
finishHandler()
}
}
reset() {
const steps = this._getEnabledStepButtons()
if (!steps.length) {
return
}
for (const pane of SelectorEngine.find(SELECTOR_STEPPER_PANE, this._element)) {
pane.classList.remove(CLASS_NAME_ACTIVE, CLASS_NAME_SHOW)
pane.setAttribute('aria-hidden', 'true')
}
for (const content of SelectorEngine.find(SELECTOR_STEPPER_STEP_CONTENT, this._element)) {
content.classList.remove(CLASS_NAME_ACTIVE, CLASS_NAME_SHOW)
content.setAttribute('aria-hidden', 'true')
}
for (const btn of steps) {
btn.classList.remove(CLASS_NAME_ACTIVE, CLASS_NAME_COMPLETE)
this._removeIndicatorIcon(btn)
btn.disabled = false
}
for (const form of this._element.querySelectorAll(`${SELECTOR_STEPPER_PANE} form, ${SELECTOR_STEPPER_STEP_CONTENT} form`)) {
form.reset()
}
const firstStep = this._initialStepButton || steps[0]
firstStep.classList.add(CLASS_NAME_ACTIVE)
const pane = this._getTargetPane(firstStep)
if (pane) {
pane.classList.add(CLASS_NAME_ACTIVE, CLASS_NAME_SHOW)
pane.setAttribute('aria-hidden', 'false')
} else {
const stepContent = firstStep.parentNode.querySelector(SELECTOR_STEPPER_STEP_CONTENT)
if (stepContent) {
stepContent.classList.add(CLASS_NAME_ACTIVE, CLASS_NAME_SHOW)
stepContent.setAttribute('aria-hidden', 'false')
}
}
this._updateCompleteStates(this._stepButtons.indexOf(firstStep))
this._activeStepButton = firstStep
this._isFinished = false
this._updateStepButtonsDisabledState()
EventHandler.trigger(this._element, EVENT_RESET)
}
// Private
_getStepButtons() {
return SelectorEngine.find(SELECTOR_STEPPER_STEP_BUTTON, this._element)
}
_getEnabledStepButtons() {
return this._getStepButtons().filter(el => !isDisabled(el))
}
_getActiveElem() {
return this._stepButtons.find(child => this._elemIsActive(child)) || null
}
_getTargetPane(element) {
return SelectorEngine.getElementFromSelector(element)
}
_elemIsActive(elem) {
return elem.classList.contains(CLASS_NAME_ACTIVE)
}
_isCurrentStepValid(element) {
if (this._config.skipValidation) {
return true
}
const pane = this._getTargetPane(element)
const target = pane ?? element.parentNode.querySelector(SELECTOR_STEPPER_STEP_CONTENT)
if (!target) {
return true
}
const form = target.querySelector('form')
if (!form) {
return true
}
const isValid = form.checkValidity()
EventHandler.trigger(this._element, EVENT_STEP_VALIDATION_COMPLETE, {
stepIndex: this._stepButtons.indexOf(element) + 1,
isValid
})
if (!isValid) {
if (form.noValidate) {
form.classList.add('was-validated')
} else {
form.reportValidity()
}
return false
}
return true
}
_activate(element) {
if (!element) {
return
}
element.classList.add(CLASS_NAME_ACTIVE)
element.setAttribute('aria-selected', 'true')
element.setAttribute('tabIndex', '0')
const pane = this._getTargetPane(element)
if (pane) {
pane.classList.add(CLASS_NAME_ACTIVE, CLASS_NAME_SHOW)
pane.setAttribute('aria-hidden', 'false')
}
const stepContentElement = SelectorEngine.findOne(SELECTOR_STEPPER_STEP_CONTENT, element.parentNode)
if (stepContentElement) {
this._animateHeight(stepContentElement, true)
}
}
_deactivate(element) {
this._resetPanes()
if (!element) {
return
}
element.setAttribute('aria-selected', 'false')
element.setAttribute('tabIndex', '-1')
const stepContentElement = SelectorEngine.findOne(SELECTOR_STEPPER_STEP_CONTENT, element.parentNode)
if (stepContentElement) {
this._animateHeight(stepContentElement, false, () => element.classList.remove(CLASS_NAME_ACTIVE))
} else {
element.classList.remove(CLASS_NAME_ACTIVE)
}
}
_complete(activeBtn) {
const stepsContainer = activeBtn.closest(SELECTOR_STEPPER_STEPS) || document
const steps = SelectorEngine.find(SELECTOR_STEPPER_STEP, stepsContainer)
const activeStepIdx = steps.indexOf(activeBtn.parentNode)
if (activeStepIdx === -1) {
return
}
this._updateCompleteStates(activeStepIdx)
}
_markAsComplete(button) {
const activeStep = button.closest(SELECTOR_STEPPER_STEP)
if (activeStep) {
const stepButton = SelectorEngine.findOne(SELECTOR_STEPPER_STEP_BUTTON, activeStep)
if (stepButton) {
stepButton.classList.add(CLASS_NAME_COMPLETE)
this._appendIndicatorIcon(stepButton)
}
}
}
_updateCompleteStates(activeIndex) {
for (const [idx, stepButton] of this._stepButtons.entries()) {
const isComplete = idx < activeIndex
stepButton.classList.toggle(CLASS_NAME_COMPLETE, isComplete)
if (isComplete) {
this._appendIndicatorIcon(stepButton)
} else {
this._removeIndicatorIcon(stepButton)
}
}
}
_setInitialComplete() {
const steps = SelectorEngine.find(SELECTOR_STEPPER_STEP, this._element)
const activeBtn = this._getActiveElem()
if (!activeBtn) {
return
}
const activeIdx = steps.indexOf(activeBtn.closest(SELECTOR_STEPPER_STEP))
if (activeIdx === -1) {
return
}
this._updateCompleteStates(activeIdx)
}
_appendIndicatorIcon(button) {
const indicator = SelectorEngine.findOne(SELECTOR_STEPPER_STEP_INDICATOR, button)
if (indicator && !SelectorEngine.findOne(SELECTOR_STEPPER_STEP_INDICATOR_ICON, indicator)) {
const icon = document.createElement('span')
icon.classList.add(CLASS_NAME_STEPPER_STEP_INDICATOR_ICON)
indicator.append(icon)
}
}
_removeIndicatorIcon(button) {
const indicator = SelectorEngine.findOne(SELECTOR_STEPPER_STEP_INDICATOR, button)
if (!indicator) {
return
}
const icon = SelectorEngine.findOne(SELECTOR_STEPPER_STEP_INDICATOR_ICON, indicator)
if (icon) {
icon.remove()
}
}
_updateStepButtonsDisabledState() {
const activeIndex = this._stepButtons.indexOf(this._activeStepButton)
for (const [index, button] of this._stepButtons.entries()) {
button.disabled = this._config.linear && index > activeIndex + 1
}
}
_disableStepButtons() {
for (const stepButton of this._stepButtons) {
stepButton.disabled = true
}
}
_animateHeight(element, expand, callback) {
const startHeight = expand ? 0 : element.scrollHeight
const endHeight = expand ? element.scrollHeight : 0
element.style.height = `${startHeight}px`
element.style.overflow = 'hidden'
// ensure reflow
// eslint-disable-next-line no-unused-expressions
element.offsetHeight
requestAnimationFrame(() => {
element.style.height = `${endHeight}px`
this._queueCallback(() => {
element.style.overflow = 'initial'
if (expand) {
element.style.height = 'auto'
}
callback?.()
}, element, true)
})
}
_resetPanes(activePane = null) {
for (const pane of SelectorEngine.find(SELECTOR_STEPPER_PANE, this._element)) {
const isActive = pane === activePane
pane.classList.toggle(CLASS_NAME_ACTIVE, isActive)
pane.classList.toggle(CLASS_NAME_SHOW, isActive)
pane.setAttribute('aria-hidden', !isActive)
}
}
_addStepperConnector() {
for (const [index, stepButton] of this._stepButtons.entries()) {
if (index < this._stepButtons.length - 1) {
const next = stepButton.nextElementSibling
if (!next || !next.classList.contains(CLASS_NAME_STEPPER_STEP_CONNECTOR)) {
const connectorElement = document.createElement('div')
connectorElement.classList.add(CLASS_NAME_STEPPER_STEP_CONNECTOR)
stepButton.after(connectorElement)
}
}
}
}
_wrapIndicatorText() {
for (const stepButton of this._stepButtons) {
const indicator = SelectorEngine.findOne(SELECTOR_STEPPER_STEP_INDICATOR, stepButton)
if (!indicator) {
continue
}
const childNodes = Array.from(indicator.childNodes)
const visibleNodes = childNodes.filter(node => {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent.trim() !== ''
}
if (node.nodeType === Node.ELEMENT_NODE) {
return true
}
return false
})
if (visibleNodes.length !== 1 || visibleNodes[0].nodeType !== Node.TEXT_NODE) {
continue
}
const textNode = visibleNodes[0]
const wrapper = document.createElement('span')
wrapper.classList.add(CLASS_NAME_STEPPER_STEP_INDICATOR_TEXT)
wrapper.textContent = textNode.textContent.trim()
textNode.replaceWith(wrapper)
}
}
_setupAccessibilityAttributes() {
const uId = getUID(this.constructor.NAME).toString()
for (const [index, stepButton] of this._stepButtons.entries()) {
const parentStepItem = stepButton.closest(SELECTOR_STEPPER_STEP)
if (parentStepItem) {
parentStepItem.setAttribute('role', 'presentation')
}
stepButton.setAttribute('role', 'tab')
if (!stepButton.id) {
stepButton.id = `${uId}${index + 1}`
}
const pane = SelectorEngine.getElementFromSelector(stepButton)
if (pane) {
stepButton.setAttribute('aria-controls', pane.id)
pane.setAttribute('role', 'tabpanel')
pane.setAttribute('aria-labelledby', stepButton.id)
pane.setAttribute('aria-live', 'polite')
pane.setAttribute('aria-hidden', !this._elemIsActive(stepButton))
}
if (this._elemIsActive(stepButton)) {
stepButton.setAttribute('aria-selected', 'true')
stepButton.setAttribute('tabIndex', '0')
} else {
stepButton.setAttribute('aria-selected', 'false')
stepButton.setAttribute('tabIndex', '-1')
}
}
}
_keydown(event) {
if (![ARROW_LEFT_KEY, ARROW_RIGHT_KEY, ARROW_UP_KEY, ARROW_DOWN_KEY, HOME_KEY, END_KEY].includes(event.key)) {
return
}
event.stopPropagation()
event.preventDefault()
const children = this._getEnabledStepButtons()
let nextActiveElement
switch (event.key) {
case HOME_KEY: {
nextActiveElement = children[0]
break
}
case END_KEY: {
nextActiveElement = children[children.length - 1]
break
}
case ARROW_RIGHT_KEY:
case ARROW_DOWN_KEY: {
nextActiveElement = getNextActiveElement(children, event.target, true, true)
break
}
case ARROW_LEFT_KEY:
case ARROW_UP_KEY: {
nextActiveElement = getNextActiveElement(children, event.target, false, true)
break
}
default: {
break
}
}
nextActiveElement?.focus({ preventScroll: true })
}
// Static
static jQueryInterface(config) {
return this.each(function () {
const data = Stepper.getOrCreateInstance(this)
if (typeof config !== 'string') {
return
}
if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
throw new TypeError(`No method named "${config}"`)
}
data[config]()
})
}
}
/**
* Data API implementation
*/
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_STEPPER_STEP_BUTTON, function (event) {
if (['A', 'AREA'].includes(this.tagName)) {
event.preventDefault()
}
if (isDisabled(this)) {
return
}
const stepperElement = this.closest(SELECTOR_STEPPER)
if (!stepperElement) {
return
}
const stepper = Stepper.getOrCreateInstance(stepperElement)
stepper.showStep(this)
})
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_STEPPER_ACTION, function () {
const action = Manipulator.getDataAttribute(this, 'stepper-action')
const stepperElement = this.closest(SELECTOR_STEPPER)
if (!stepperElement) {
return
}
const stepper = Stepper.getOrCreateInstance(stepperElement)
if (stepper && typeof stepper[action] === 'function') {
stepper[action]()
}
})
EventHandler.on(window, EVENT_LOAD_DATA_API, () => {
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
Stepper.getOrCreateInstance(element)
}
})
/**
* jQuery integration
*/
defineJQueryPlugin(Stepper)
export default Stepper