|
| 1 | +<template> |
| 2 | + <div class="flex justify-center items-center w-full h-full" v-bind="$attrs"> |
| 3 | + <div |
| 4 | + :class="`w-full max-w-md p-8 rounded-[2rem] shadow-[0_20px_25px_-5px_rgba(0,0,0,0.1),0_10px_10px_-5px_rgba(0,0,0,0.04)] ${stepCircleContainerClassName}`" |
| 5 | + style="border: 1px solid #222" |
| 6 | + > |
| 7 | + <div |
| 8 | + :class="`flex items-center justify-center w-full ${stepContainerClassName}`" |
| 9 | + :style="{ marginBottom: isCompleted ? '0' : '2rem' }" |
| 10 | + > |
| 11 | + <template v-for="(_, index) in stepsArray" :key="index + 1"> |
| 12 | + <div |
| 13 | + v-if="!renderStepIndicator" |
| 14 | + @click="() => handleStepClick(index + 1)" |
| 15 | + :class="[ |
| 16 | + 'relative outline-none flex h-8 w-8 items-center justify-center rounded-full font-semibold', |
| 17 | + isCompleted ? 'cursor-default' : 'cursor-pointer' |
| 18 | + ]" |
| 19 | + :style="getStepIndicatorStyle(index + 1)" |
| 20 | + > |
| 21 | + <svg |
| 22 | + v-if="getStepStatus(index + 1) === 'complete'" |
| 23 | + class="h-4 w-4 text-white stroke-white" |
| 24 | + fill="none" |
| 25 | + stroke="currentColor" |
| 26 | + :stroke-width="2" |
| 27 | + viewBox="0 0 24 24" |
| 28 | + > |
| 29 | + <Motion |
| 30 | + as="path" |
| 31 | + d="M5 13l4 4L19 7" |
| 32 | + stroke-linecap="round" |
| 33 | + stroke-linejoin="round" |
| 34 | + :initial="{ pathLength: 0, opacity: 0 }" |
| 35 | + :animate=" |
| 36 | + getStepStatus(index + 1) === 'complete' |
| 37 | + ? { pathLength: 1, opacity: 1 } |
| 38 | + : { pathLength: 0, opacity: 0 } |
| 39 | + " |
| 40 | + /> |
| 41 | + </svg> |
| 42 | + <div v-else-if="getStepStatus(index + 1) === 'active'" class="h-3 w-3 rounded-full bg-white" /> |
| 43 | + <span v-else class="text-sm">{{ index + 1 }}</span> |
| 44 | + </div> |
| 45 | + |
| 46 | + <component |
| 47 | + v-else |
| 48 | + :is="renderStepIndicator" |
| 49 | + :step="index + 1" |
| 50 | + :current-step="currentStep" |
| 51 | + :on-step-click="handleCustomStepClick" |
| 52 | + /> |
| 53 | + |
| 54 | + <div |
| 55 | + v-if="index < totalSteps - 1" |
| 56 | + class="relative ml-2 mr-2 h-0.5 flex-1 overflow-hidden rounded bg-zinc-600" |
| 57 | + > |
| 58 | + <Motion |
| 59 | + as="div" |
| 60 | + class="absolute left-0 top-0 h-full" |
| 61 | + :initial="{ width: 0, backgroundColor: '#52525b' }" |
| 62 | + :animate=" |
| 63 | + currentStep > index + 1 |
| 64 | + ? { width: '100%', backgroundColor: '#27ff64' } |
| 65 | + : { width: 0, backgroundColor: '#52525b' } |
| 66 | + " |
| 67 | + :transition="{ type: 'spring', stiffness: 100, damping: 15, duration: 0.4 }" |
| 68 | + /> |
| 69 | + </div> |
| 70 | + </template> |
| 71 | + </div> |
| 72 | + |
| 73 | + <Motion |
| 74 | + as="div" |
| 75 | + :class="`w-full ${contentClassName}`" |
| 76 | + :style="{ |
| 77 | + position: 'relative', |
| 78 | + overflow: 'hidden', |
| 79 | + marginBottom: isCompleted ? '0' : '2rem' |
| 80 | + }" |
| 81 | + :animate="{ height: isCompleted ? 0 : parentHeight }" |
| 82 | + :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.4 }" |
| 83 | + > |
| 84 | + <AnimatePresence :initial="false" mode="sync" :custom="direction"> |
| 85 | + <Motion |
| 86 | + v-if="!isCompleted" |
| 87 | + ref="containerRef" |
| 88 | + as="div" |
| 89 | + :key="currentStep" |
| 90 | + :initial="getStepContentInitial()" |
| 91 | + :animate="{ x: '0%', opacity: 1 }" |
| 92 | + :exit="getStepContentExit()" |
| 93 | + :transition="{ type: 'spring', stiffness: 300, damping: 30, duration: 0.4 }" |
| 94 | + :style="{ position: 'absolute', left: 0, right: 0, top: 0 }" |
| 95 | + > |
| 96 | + <div ref="contentRef" v-if="slots.default && slots.default()[currentStep - 1]"> |
| 97 | + <component :is="slots.default()[currentStep - 1]" /> |
| 98 | + </div> |
| 99 | + </Motion> |
| 100 | + </AnimatePresence> |
| 101 | + </Motion> |
| 102 | + |
| 103 | + <div v-if="!isCompleted" :class="`w-full ${footerClassName}`"> |
| 104 | + <div :class="`flex w-full ${currentStep !== 1 ? 'justify-between' : 'justify-end'}`"> |
| 105 | + <button |
| 106 | + v-if="currentStep !== 1" |
| 107 | + @click="handleBack" |
| 108 | + :disabled="backButtonProps?.disabled" |
| 109 | + :class="`text-zinc-400 bg-transparent cursor-pointer transition-all duration-[350ms] rounded px-2 py-1 border-none hover:text-white ${currentStep === 1 ? 'opacity-50 cursor-not-allowed' : ''}`" |
| 110 | + v-bind="backButtonProps" |
| 111 | + > |
| 112 | + {{ backButtonText }} |
| 113 | + </button> |
| 114 | + <button |
| 115 | + @click="isLastStep ? handleComplete() : handleNext()" |
| 116 | + :disabled="nextButtonProps?.disabled" |
| 117 | + :class="`border-none bg-[#27ff64] transition-all duration-[350ms] flex items-center justify-center rounded-full text-white font-medium tracking-tight px-3.5 py-1.5 cursor-pointer hover:bg-[#22e55c] disabled:opacity-50 disabled:cursor-not-allowed`" |
| 118 | + > |
| 119 | + {{ isLastStep ? 'Complete' : nextButtonText }} |
| 120 | + </button> |
| 121 | + </div> |
| 122 | + </div> |
| 123 | + </div> |
| 124 | + </div> |
| 125 | +</template> |
| 126 | + |
| 127 | +<script setup lang="ts"> |
| 128 | +import { |
| 129 | + ref, |
| 130 | + computed, |
| 131 | + useSlots, |
| 132 | + watch, |
| 133 | + onMounted, |
| 134 | + nextTick, |
| 135 | + useTemplateRef, |
| 136 | + type VNode, |
| 137 | + type ButtonHTMLAttributes, |
| 138 | + type Component |
| 139 | +} from 'vue'; |
| 140 | +import { Motion, AnimatePresence } from 'motion-v'; |
| 141 | +
|
| 142 | +interface StepperProps { |
| 143 | + children?: VNode[]; |
| 144 | + initialStep?: number; |
| 145 | + onStepChange?: (step: number) => void; |
| 146 | + onFinalStepCompleted?: () => void; |
| 147 | + stepCircleContainerClassName?: string; |
| 148 | + stepContainerClassName?: string; |
| 149 | + contentClassName?: string; |
| 150 | + footerClassName?: string; |
| 151 | + backButtonProps?: ButtonHTMLAttributes; |
| 152 | + nextButtonProps?: ButtonHTMLAttributes; |
| 153 | + backButtonText?: string; |
| 154 | + nextButtonText?: string; |
| 155 | + disableStepIndicators?: boolean; |
| 156 | + renderStepIndicator?: Component; |
| 157 | +} |
| 158 | +
|
| 159 | +const props = withDefaults(defineProps<StepperProps>(), { |
| 160 | + initialStep: 1, |
| 161 | + onStepChange: () => {}, |
| 162 | + onFinalStepCompleted: () => {}, |
| 163 | + stepCircleContainerClassName: '', |
| 164 | + stepContainerClassName: '', |
| 165 | + contentClassName: '', |
| 166 | + footerClassName: '', |
| 167 | + backButtonProps: () => ({}), |
| 168 | + nextButtonProps: () => ({}), |
| 169 | + backButtonText: 'Back', |
| 170 | + nextButtonText: 'Continue', |
| 171 | + disableStepIndicators: false, |
| 172 | + renderStepIndicator: undefined |
| 173 | +}); |
| 174 | +
|
| 175 | +const slots = useSlots(); |
| 176 | +const currentStep = ref(props.initialStep); |
| 177 | +const direction = ref(1); |
| 178 | +const isCompleted = ref(false); |
| 179 | +const parentHeight = ref(0); |
| 180 | +const containerRef = useTemplateRef<HTMLDivElement>('containerRef'); |
| 181 | +const contentRef = useTemplateRef<HTMLDivElement>('contentRef'); |
| 182 | +
|
| 183 | +const stepsArray = computed(() => slots.default?.() || []); |
| 184 | +const totalSteps = computed(() => stepsArray.value.length); |
| 185 | +const isLastStep = computed(() => currentStep.value === totalSteps.value); |
| 186 | +
|
| 187 | +const getStepStatus = (step: number) => { |
| 188 | + if (isCompleted.value || currentStep.value > step) return 'complete'; |
| 189 | + if (currentStep.value === step) return 'active'; |
| 190 | + return 'inactive'; |
| 191 | +}; |
| 192 | +
|
| 193 | +const getStepIndicatorStyle = (step: number) => { |
| 194 | + const status = getStepStatus(step); |
| 195 | + switch (status) { |
| 196 | + case 'active': |
| 197 | + case 'complete': |
| 198 | + return { backgroundColor: '#27FF64', color: '#fff' }; |
| 199 | + default: |
| 200 | + return { backgroundColor: '#222', color: '#a3a3a3' }; |
| 201 | + } |
| 202 | +}; |
| 203 | +
|
| 204 | +const getStepContentInitial = () => ({ |
| 205 | + x: direction.value >= 0 ? '-100%' : '100%', |
| 206 | + opacity: 0 |
| 207 | +}); |
| 208 | +
|
| 209 | +const getStepContentExit = () => ({ |
| 210 | + x: direction.value >= 0 ? '50%' : '-50%', |
| 211 | + opacity: 0 |
| 212 | +}); |
| 213 | +
|
| 214 | +const handleStepClick = (step: number) => { |
| 215 | + if (isCompleted.value) return; |
| 216 | + if (!props.disableStepIndicators) { |
| 217 | + direction.value = step > currentStep.value ? 1 : -1; |
| 218 | + updateStep(step); |
| 219 | + } |
| 220 | +}; |
| 221 | +
|
| 222 | +const handleCustomStepClick = (clicked: number) => { |
| 223 | + if (isCompleted.value) return; |
| 224 | + if (clicked !== currentStep.value && !props.disableStepIndicators) { |
| 225 | + direction.value = clicked > currentStep.value ? 1 : -1; |
| 226 | + updateStep(clicked); |
| 227 | + } |
| 228 | +}; |
| 229 | +
|
| 230 | +const measureHeight = () => { |
| 231 | + nextTick(() => { |
| 232 | + if (contentRef.value) { |
| 233 | + const height = contentRef.value.offsetHeight; |
| 234 | + if (height > 0 && height !== parentHeight.value) { |
| 235 | + parentHeight.value = height; |
| 236 | + } |
| 237 | + } |
| 238 | + }); |
| 239 | +}; |
| 240 | +
|
| 241 | +const updateStep = (newStep: number) => { |
| 242 | + if (newStep >= 1 && newStep <= totalSteps.value) { |
| 243 | + currentStep.value = newStep; |
| 244 | + } |
| 245 | +}; |
| 246 | +
|
| 247 | +const handleBack = () => { |
| 248 | + direction.value = -1; |
| 249 | + updateStep(currentStep.value - 1); |
| 250 | +}; |
| 251 | +
|
| 252 | +const handleNext = () => { |
| 253 | + direction.value = 1; |
| 254 | + updateStep(currentStep.value + 1); |
| 255 | +}; |
| 256 | +
|
| 257 | +const handleComplete = () => { |
| 258 | + isCompleted.value = true; |
| 259 | + props.onFinalStepCompleted?.(); |
| 260 | +}; |
| 261 | +
|
| 262 | +watch(currentStep, newStep => { |
| 263 | + props.onStepChange?.(newStep); |
| 264 | + if (!isCompleted.value) { |
| 265 | + measureHeight(); |
| 266 | + } |
| 267 | +}); |
| 268 | +
|
| 269 | +onMounted(() => { |
| 270 | + if (props.initialStep !== 1) { |
| 271 | + currentStep.value = props.initialStep; |
| 272 | + } |
| 273 | + measureHeight(); |
| 274 | +}); |
| 275 | +</script> |
0 commit comments