Skip to content

Commit b7562ef

Browse files
committed
Create <Counter /> component
1 parent 53c5b5e commit b7562ef

File tree

5 files changed

+529
-1
lines changed

5 files changed

+529
-1
lines changed

src/constants/Categories.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,8 @@ export const CATEGORIES = [
8080
'Flowing Menu',
8181
'Elastic Slider',
8282
'Stack',
83-
'Chroma Grid'
83+
'Chroma Grid',
84+
'Stepper'
8485
]
8586
},
8687
{

src/constants/Components.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const components = {
6969
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
7070
'stack': () => import('../demo/Components/StackDemo.vue'),
7171
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
72+
'stepper': () => import('../demo/Components/StepperDemo.vue'),
7273
};
7374

7475
const backgrounds = {
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: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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 + 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+
}
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

Comments
 (0)