UNPKG

@coreui/vue-pro

Version:

UI Components Library for Vue.js

383 lines (344 loc) 11.7 kB
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) }), } ) }) ), ] ) } }, })