Skip to content

Commit f0db19d

Browse files
authored
Merge pull request #61 from Gazoon007/feat/stepper
Create <Stepper /> component
2 parents 19e8be9 + 863752d commit f0db19d

File tree

5 files changed

+543
-0
lines changed

5 files changed

+543
-0
lines changed

src/constants/Categories.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const CATEGORIES = [
8383
'Elastic Slider',
8484
'Stack',
8585
'Chroma Grid',
86+
'Stepper',
8687
'Bounce Cards',
8788
'Counter',
8889
'Rolling Gallery'

src/constants/Components.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const components = {
7070
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
7171
'stack': () => import('../demo/Components/StackDemo.vue'),
7272
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
73+
'stepper': () => import('../demo/Components/StepperDemo.vue'),
7374
'bounce-cards': () => import('../demo/Components/BounceCardsDemo.vue'),
7475
'counter': () => import('../demo/Components/CounterDemo.vue'),
7576
'rolling-gallery': () => import('../demo/Components/RollingGalleryDemo.vue'),
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import code from '@content/Components/Stepper/Stepper.vue?raw';
2+
import { createCodeObject } from '@/types/code';
3+
4+
export const stepper = createCodeObject(code, 'Components/Stepper', {
5+
installation: `npm install motion-v`,
6+
usage: `<template>
7+
<Stepper
8+
:initial-step="1"
9+
:on-step-change="handleStepChange"
10+
:on-final-step-completed="handleFinalStepCompleted"
11+
back-button-text="Previous"
12+
next-button-text="Next"
13+
>
14+
<div>
15+
<h2>Welcome to the Vue Bits stepper!</h2>
16+
<p>Check out the next step!</p>
17+
</div>
18+
19+
<div>
20+
<h2>Step 2</h2>
21+
<img
22+
style="height: 100px; width: 100%; object-fit: cover; border-radius: 15px; margin-top: 1em;"
23+
src="https://example.com/image.jpg"
24+
alt="Example"
25+
/>
26+
<p>Custom step content!</p>
27+
</div>
28+
29+
<div>
30+
<h2>How about an input?</h2>
31+
<input
32+
v-model="name"
33+
class="mt-2 px-3 py-2 border border-gray-300 rounded-md w-full"
34+
placeholder="Your name?"
35+
/>
36+
</div>
37+
38+
<div>
39+
<h2>Final Step</h2>
40+
<p>You made it!</p>
41+
</div>
42+
</Stepper>
43+
</template>
44+
45+
<script setup lang="ts">
46+
import { ref } from 'vue'
47+
import Stepper from "./Stepper.vue"
48+
49+
const name = ref('')
50+
51+
const handleStepChange = (step) => {
52+
console.log('Step changed to:', step)
53+
}
54+
55+
const handleFinalStepCompleted = () => {
56+
console.log('Stepper completed!')
57+
}
58+
</script>`
59+
});
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
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 && lockOnComplete ? '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 + 1}px` }"
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: 'tween', 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+
lockOnComplete?: boolean;
158+
}
159+
160+
const props = withDefaults(defineProps<StepperProps>(), {
161+
initialStep: 1,
162+
onStepChange: () => {},
163+
onFinalStepCompleted: () => {},
164+
stepCircleContainerClassName: '',
165+
stepContainerClassName: '',
166+
contentClassName: '',
167+
footerClassName: '',
168+
backButtonProps: () => ({}),
169+
nextButtonProps: () => ({}),
170+
backButtonText: 'Back',
171+
nextButtonText: 'Continue',
172+
disableStepIndicators: false,
173+
renderStepIndicator: undefined,
174+
lockOnComplete: true
175+
});
176+
177+
const slots = useSlots();
178+
const currentStep = ref(props.initialStep);
179+
const direction = ref(1);
180+
const isCompleted = ref(false);
181+
const parentHeight = ref(0);
182+
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
183+
const contentRef = useTemplateRef<HTMLDivElement>('contentRef');
184+
185+
const stepsArray = computed(() => slots.default?.() || []);
186+
const totalSteps = computed(() => stepsArray.value.length);
187+
const isLastStep = computed(() => currentStep.value === totalSteps.value);
188+
189+
const getStepStatus = (step: number) => {
190+
if (isCompleted.value || currentStep.value > step) return 'complete';
191+
if (currentStep.value === step) return 'active';
192+
return 'inactive';
193+
};
194+
195+
const getStepIndicatorStyle = (step: number) => {
196+
const status = getStepStatus(step);
197+
switch (status) {
198+
case 'active':
199+
case 'complete':
200+
return { backgroundColor: '#27FF64', color: '#fff' };
201+
default:
202+
return { backgroundColor: '#222', color: '#a3a3a3' };
203+
}
204+
};
205+
206+
const getStepContentInitial = () => ({
207+
x: direction.value >= 0 ? '-100%' : '100%',
208+
opacity: 0
209+
});
210+
211+
const getStepContentExit = () => ({
212+
x: direction.value >= 0 ? '50%' : '-50%',
213+
opacity: 0
214+
});
215+
216+
const handleStepClick = (step: number) => {
217+
if (isCompleted.value && props.lockOnComplete) return;
218+
if (!props.disableStepIndicators) {
219+
direction.value = step > currentStep.value ? 1 : -1;
220+
updateStep(step);
221+
}
222+
};
223+
224+
const handleCustomStepClick = (clicked: number) => {
225+
if (isCompleted.value && props.lockOnComplete) return;
226+
if (clicked !== currentStep.value && !props.disableStepIndicators) {
227+
direction.value = clicked > currentStep.value ? 1 : -1;
228+
updateStep(clicked);
229+
}
230+
};
231+
232+
const measureHeight = () => {
233+
nextTick(() => {
234+
if (contentRef.value) {
235+
const height = contentRef.value.offsetHeight;
236+
if (height > 0 && height !== parentHeight.value) {
237+
parentHeight.value = height;
238+
}
239+
}
240+
});
241+
};
242+
243+
const updateStep = (newStep: number) => {
244+
if (newStep >= 1 && newStep <= totalSteps.value) {
245+
currentStep.value = newStep;
246+
}
247+
};
248+
249+
const handleBack = () => {
250+
direction.value = -1;
251+
updateStep(currentStep.value - 1);
252+
};
253+
254+
const handleNext = () => {
255+
direction.value = 1;
256+
updateStep(currentStep.value + 1);
257+
};
258+
259+
const handleComplete = () => {
260+
isCompleted.value = true;
261+
props.onFinalStepCompleted?.();
262+
};
263+
264+
watch(
265+
currentStep,
266+
(newStep, oldStep) => {
267+
props.onStepChange?.(newStep);
268+
if (newStep !== oldStep && !isCompleted.value) {
269+
nextTick(measureHeight);
270+
} else if (!props.lockOnComplete && isCompleted.value) {
271+
isCompleted.value = false;
272+
nextTick(measureHeight);
273+
}
274+
}
275+
);
276+
277+
onMounted(() => {
278+
if (props.initialStep !== 1) {
279+
currentStep.value = props.initialStep;
280+
}
281+
measureHeight();
282+
});
283+
</script>

0 commit comments

Comments
 (0)