@coreui/vue-pro
Version:
UI Components Library for Vue.js
383 lines (344 loc) • 11.7 kB
text/typescript
import { defineComponent, h, ref, watch, computed, nextTick, shallowRef } from 'vue'
import { CCollapse } from '../collapse'
import type { StepperStepData } from './types'
export const CStepper = defineComponent({
name: 'CStepper',
inheritAttrs: false,
props: {
/**
* The default active step index when not using `v-model`.
*/
activeStepNumber: {
type: Number,
default: 1,
},
/**
* Optional unique ID used for accessibility attributes like `aria-labelledby`.
*/
id: String,
/**
* Sets the layout direction of the Vue Stepper component.
*
* - `'horizontal'` – Steps are placed side-by-side.
* - `'vertical'` – Steps are stacked vertically (ideal for mobile).
*
* This makes the Vue Form Wizard adaptable to various screen sizes.
*/
layout: {
type: String as () => 'horizontal' | 'vertical',
default: 'horizontal',
},
/**
* Enables linear step progression in the Vue Form Wizard.
*
* - `true`: Steps must be completed in order.
* - `false`: Users can navigate freely between steps.
*
* Useful for enforcing validation and structured data entry in Vue multi-step forms.
*/
linear: {
type: Boolean,
default: true,
},
/**
* The current active step index of the Vue Stepper (used for controlled mode with `v-model`).
*
* If this prop is not provided, the Vue Form Wizard will use `activeStepNumber` as its initial value.
*/
modelValue: Number,
/**
* Defines the list of steps in the Vue Stepper.
*
* Each step should include:
* - `label`: The title displayed in the step.
* - `indicator` (optional): Custom icon, number, or marker.
* - `formRef` (optional): A reference to the `<form>` used for validation in Vue Form Wizard scenarios.
*/
steps: {
type: Array as () => (StepperStepData | string)[],
required: true,
},
/**
* Controls the layout of the step indicator and label.
*
* - `'horizontal'`: Icon and label are side-by-side.
* - `'vertical'`: Label is shown below the icon.
*
* Applies only when `layout` is set to `'horizontal'`.
*/
stepButtonLayout: {
type: String as () => 'horizontal' | 'vertical',
default: 'horizontal',
},
/**
* Enables validation of forms within each step of the Vue Form Wizard.
*
* When set to `true`, the user cannot proceed unless the current step's form passes `checkValidity()`.
* Each step must expose a native form element via the `formRef` slot binding.
*/
validation: {
type: Boolean,
default: true,
},
},
emits: [
/**
* Emitted when the user successfully finishes all steps in the Vue Form Wizard.
*/
'finish',
/**
* Emitted when the stepper is reset to its initial state.
*/
'reset',
/**
* Emitted on any manual or programmatic step change.
*/
'stepChange',
/**
* Emitted after each step's form is validated (when `validation: true`).
*/
'stepValidationComplete',
/**
* Emitted when the current active step changes.
*
* Useful for syncing state with `v-model`.
*/
'update:modelValue',
],
setup(props, { emit, slots, attrs, expose }) {
const activeStepNumber = ref<number>(props.modelValue ?? props.activeStepNumber)
const isControlled = computed(() => props.modelValue !== undefined)
const isFinished = ref(false)
const stepsRef = ref<HTMLOListElement | null>(null)
const stepButtonRefs = shallowRef<(HTMLButtonElement | null)[]>([])
const formRefs = shallowRef<(HTMLFormElement | null)[]>([])
const registerFormRef = (stepNumber: number) => (el: HTMLFormElement | null) => {
formRefs.value[stepNumber - 1] = el
}
watch(
() => props.modelValue,
(val) => {
if (val !== undefined) activeStepNumber.value = val
}
)
watch(activeStepNumber, (val) => {
if (isControlled.value) emit('update:modelValue', val)
})
const isStepValid = (stepNumber: number): boolean => {
if (!props.validation) {
return true
}
const form = formRefs.value[stepNumber - 1]
if (form) {
const isValid = form.checkValidity()
emit('stepValidationComplete', { stepNumber: stepNumber, isValid: isValid })
if (form && !isValid) {
if (!form.noValidate) {
form.reportValidity()
}
return false
}
}
return true
}
const setActiveStep = (stepNumber: number, bypassValidation = false) => {
if (
stepNumber < 1 ||
stepNumber > props.steps.length ||
stepNumber === activeStepNumber.value
) {
return
}
if (
!bypassValidation &&
stepNumber > activeStepNumber.value &&
!isStepValid(activeStepNumber.value)
)
return
activeStepNumber.value = stepNumber
emit('stepChange', stepNumber)
}
const next = () => {
if (activeStepNumber.value <= props.steps.length) {
setActiveStep(activeStepNumber.value + 1)
} else {
finish()
}
}
const prev = () => {
if (activeStepNumber.value > 1) {
setActiveStep(activeStepNumber.value - 1, true)
}
}
const finish = () => {
if (activeStepNumber.value === props.steps.length && isStepValid(activeStepNumber.value)) {
isFinished.value = true
emit('finish')
}
}
const reset = () => {
formRefs.value.forEach((form) => form?.reset?.())
activeStepNumber.value = props.activeStepNumber
isFinished.value = false
emit('reset')
emit('stepChange', props.activeStepNumber)
nextTick(() => {
stepButtonRefs.value[props.activeStepNumber - 1]?.focus()
})
}
const handleKeyDown = (event: KeyboardEvent) => {
const buttons = stepButtonRefs.value
const current = event.target as HTMLButtonElement
const index = buttons.indexOf(current)
if (index === -1) return
let nextIndex = index
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown': {
nextIndex = (index + 1) % buttons.length
break
}
case 'ArrowLeft':
case 'ArrowUp': {
nextIndex = (index - 1 + buttons.length) % buttons.length
break
}
case 'Home': {
nextIndex = 0
break
}
case 'End': {
nextIndex = buttons.length - 1
break
}
default: {
return
}
}
event.preventDefault()
buttons[nextIndex]?.focus()
}
expose({ next, prev, finish, reset })
return () => {
const isVertical = props.layout === 'vertical'
stepButtonRefs.value = []
return h(
'div',
{
...attrs,
class: ['stepper', { 'stepper-vertical': isVertical }, attrs.class],
},
[
h(
'ol',
{
class: 'stepper-steps',
role: 'tablist',
'aria-orientation': isVertical ? 'vertical' : 'horizontal',
onKeydown: handleKeyDown,
ref: stepsRef,
},
props.steps.map((step, index) => {
const stepNumber = index + 1
const isActive = !isFinished.value && stepNumber === activeStepNumber.value
const isComplete = isFinished.value || stepNumber < activeStepNumber.value
const isDisabled =
isFinished.value || (props.linear && stepNumber > activeStepNumber.value + 1)
const stepId = `step-${props.id || 'stepper'}-${stepNumber}`
const panelId = `panel-${props.id || 'stepper'}-${stepNumber}`
return h(
'li',
{
key: stepNumber,
class: ['stepper-step', props.stepButtonLayout],
role: 'presentation',
},
[
h(
'button',
{
type: 'button',
class: ['stepper-step-button', { active: isActive, complete: isComplete }],
disabled: isDisabled,
id: stepId,
role: 'tab',
'aria-selected': isActive,
tabindex: isActive ? 0 : -1,
'aria-controls': panelId,
onClick: () =>
setActiveStep(
stepNumber,
!props.linear || stepNumber <= activeStepNumber.value
),
ref: (el) => (stepButtonRefs.value[index] = el as HTMLButtonElement),
},
[
h('span', { class: 'stepper-step-indicator' }, [
isComplete
? h('span', { class: 'stepper-step-indicator-icon' })
: h(
'span',
{ class: 'stepper-step-indicator-text' },
typeof step === 'object' && 'indicator' in step
? step.indicator
: stepNumber
),
]),
h(
'span',
{ class: 'stepper-step-label' },
typeof step === 'object' && 'label' in step ? step.label : step
),
]
),
stepNumber < props.steps.length && h('div', { class: 'stepper-step-connector' }),
isVertical &&
h(
CCollapse,
{
class: 'stepper-step-content',
id: panelId,
role: 'tabpanel',
visible: isActive,
'aria-hidden': !isActive,
'aria-labelledby': stepId,
'aria-live': 'polite',
},
() => slots[`step-${stepNumber}`]?.({ formRef: registerFormRef(stepNumber) })
),
]
)
})
),
!isVertical &&
h(
'div',
{ class: 'stepper-content' },
props.steps.map((_, index) => {
const stepNumber = index + 1
const isActive = !isFinished.value && stepNumber === activeStepNumber.value
const stepId = `step-${props.id || 'stepper'}-${stepNumber}`
const panelId = `panel-${props.id || 'stepper'}-${stepNumber}`
return h(
'div',
{
key: stepNumber,
id: panelId,
role: 'tabpanel',
'aria-hidden': !isActive,
'aria-labelledby': stepId,
'aria-live': 'polite',
class: ['stepper-pane', { active: isActive, show: isActive }],
},
{
default: () =>
slots[`step-${stepNumber}`]?.({ formRef: registerFormRef(stepNumber) }),
}
)
})
),
]
)
}
},
})