Merge pull request #61 from Gazoon007/feat/stepper

Create <Stepper /> component
This commit is contained in:
David
2025-08-01 21:24:40 +03:00
committed by GitHub
5 changed files with 543 additions and 0 deletions

View File

@@ -83,6 +83,7 @@ export const CATEGORIES = [
'Elastic Slider',
'Stack',
'Chroma Grid',
'Stepper',
'Bounce Cards',
'Counter',
'Rolling Gallery'

View File

@@ -70,6 +70,7 @@ const components = {
'tilted-card': () => import('../demo/Components/TiltedCardDemo.vue'),
'stack': () => import('../demo/Components/StackDemo.vue'),
'chroma-grid': () => import('../demo/Components/ChromaGridDemo.vue'),
'stepper': () => import('../demo/Components/StepperDemo.vue'),
'bounce-cards': () => import('../demo/Components/BounceCardsDemo.vue'),
'counter': () => import('../demo/Components/CounterDemo.vue'),
'rolling-gallery': () => import('../demo/Components/RollingGalleryDemo.vue'),

View File

@@ -0,0 +1,59 @@
import code from '@content/Components/Stepper/Stepper.vue?raw';
import { createCodeObject } from '@/types/code';
export const stepper = createCodeObject(code, 'Components/Stepper', {
installation: `npm install motion-v`,
usage: `<template>
<Stepper
:initial-step="1"
:on-step-change="handleStepChange"
:on-final-step-completed="handleFinalStepCompleted"
back-button-text="Previous"
next-button-text="Next"
>
<div>
<h2>Welcome to the Vue Bits stepper!</h2>
<p>Check out the next step!</p>
</div>
<div>
<h2>Step 2</h2>
<img
style="height: 100px; width: 100%; object-fit: cover; border-radius: 15px; margin-top: 1em;"
src="https://example.com/image.jpg"
alt="Example"
/>
<p>Custom step content!</p>
</div>
<div>
<h2>How about an input?</h2>
<input
v-model="name"
class="mt-2 px-3 py-2 border border-gray-300 rounded-md w-full"
placeholder="Your name?"
/>
</div>
<div>
<h2>Final Step</h2>
<p>You made it!</p>
</div>
</Stepper>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import Stepper from "./Stepper.vue"
const name = ref('')
const handleStepChange = (step) => {
console.log('Step changed to:', step)
}
const handleFinalStepCompleted = () => {
console.log('Stepper completed!')
}
</script>`
});

View File

@@ -0,0 +1,283 @@
<template>
<div class="flex justify-center items-center w-full h-full" v-bind="$attrs">
<div
: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}`"
style="border: 1px solid #222"
>
<div
:class="`flex items-center justify-center w-full ${stepContainerClassName}`"
:style="{ marginBottom: isCompleted ? '0' : '2rem' }"
>
<template v-for="(_, index) in stepsArray" :key="index + 1">
<div
v-if="!renderStepIndicator"
@click="() => handleStepClick(index + 1)"
:class="[
'relative outline-none flex h-8 w-8 items-center justify-center rounded-full font-semibold',
isCompleted && lockOnComplete ? 'cursor-default' : 'cursor-pointer'
]"
:style="getStepIndicatorStyle(index + 1)"
>
<svg
v-if="getStepStatus(index + 1) === 'complete'"
class="h-4 w-4 text-white stroke-white"
fill="none"
stroke="currentColor"
:stroke-width="2"
viewBox="0 0 24 24"
>
<Motion
as="path"
d="M5 13l4 4L19 7"
stroke-linecap="round"
stroke-linejoin="round"
:initial="{ pathLength: 0, opacity: 0 }"
:animate="
getStepStatus(index + 1) === 'complete'
? { pathLength: 1, opacity: 1 }
: { pathLength: 0, opacity: 0 }
"
/>
</svg>
<div v-else-if="getStepStatus(index + 1) === 'active'" class="h-3 w-3 rounded-full bg-white" />
<span v-else class="text-sm">{{ index + 1 }}</span>
</div>
<component
v-else
:is="renderStepIndicator"
:step="index + 1"
:current-step="currentStep"
:on-step-click="handleCustomStepClick"
/>
<div
v-if="index < totalSteps - 1"
class="relative ml-2 mr-2 h-0.5 flex-1 overflow-hidden rounded bg-zinc-600"
>
<Motion
as="div"
class="absolute left-0 top-0 h-full"
:initial="{ width: 0, backgroundColor: '#52525b' }"
:animate="
currentStep > index + 1
? { width: '100%', backgroundColor: '#27ff64' }
: { width: 0, backgroundColor: '#52525b' }
"
:transition="{ type: 'spring', stiffness: 100, damping: 15, duration: 0.4 }"
/>
</div>
</template>
</div>
<Motion
as="div"
:class="`w-full ${contentClassName}`"
:style="{
position: 'relative',
overflow: 'hidden',
marginBottom: isCompleted ? '0' : '2rem'
}"
:animate="{ height: isCompleted ? 0 : `${parentHeight + 1}px` }"
:transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.4 }"
>
<AnimatePresence :initial="false" mode="sync" :custom="direction">
<Motion
v-if="!isCompleted"
ref="containerRef"
as="div"
:key="currentStep"
:initial="getStepContentInitial()"
:animate="{ x: '0%', opacity: 1 }"
:exit="getStepContentExit()"
:transition="{ type: 'tween', stiffness: 300, damping: 30, duration: 0.4 }"
:style="{ position: 'absolute', left: 0, right: 0, top: 0 }"
>
<div ref="contentRef" v-if="slots.default && slots.default()[currentStep - 1]">
<component :is="slots.default()[currentStep - 1]" />
</div>
</Motion>
</AnimatePresence>
</Motion>
<div v-if="!isCompleted" :class="`w-full ${footerClassName}`">
<div :class="`flex w-full ${currentStep !== 1 ? 'justify-between' : 'justify-end'}`">
<button
v-if="currentStep !== 1"
@click="handleBack"
:disabled="backButtonProps?.disabled"
: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' : ''}`"
v-bind="backButtonProps"
>
{{ backButtonText }}
</button>
<button
@click="isLastStep ? handleComplete() : handleNext()"
:disabled="nextButtonProps?.disabled"
: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`"
>
{{ isLastStep ? 'Complete' : nextButtonText }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
computed,
useSlots,
watch,
onMounted,
nextTick,
useTemplateRef,
type VNode,
type ButtonHTMLAttributes,
type Component
} from 'vue';
import { Motion, AnimatePresence } from 'motion-v';
interface StepperProps {
children?: VNode[];
initialStep?: number;
onStepChange?: (step: number) => void;
onFinalStepCompleted?: () => void;
stepCircleContainerClassName?: string;
stepContainerClassName?: string;
contentClassName?: string;
footerClassName?: string;
backButtonProps?: ButtonHTMLAttributes;
nextButtonProps?: ButtonHTMLAttributes;
backButtonText?: string;
nextButtonText?: string;
disableStepIndicators?: boolean;
renderStepIndicator?: Component;
lockOnComplete?: boolean;
}
const props = withDefaults(defineProps<StepperProps>(), {
initialStep: 1,
onStepChange: () => {},
onFinalStepCompleted: () => {},
stepCircleContainerClassName: '',
stepContainerClassName: '',
contentClassName: '',
footerClassName: '',
backButtonProps: () => ({}),
nextButtonProps: () => ({}),
backButtonText: 'Back',
nextButtonText: 'Continue',
disableStepIndicators: false,
renderStepIndicator: undefined,
lockOnComplete: true
});
const slots = useSlots();
const currentStep = ref(props.initialStep);
const direction = ref(1);
const isCompleted = ref(false);
const parentHeight = ref(0);
const containerRef = useTemplateRef<HTMLDivElement>('containerRef');
const contentRef = useTemplateRef<HTMLDivElement>('contentRef');
const stepsArray = computed(() => slots.default?.() || []);
const totalSteps = computed(() => stepsArray.value.length);
const isLastStep = computed(() => currentStep.value === totalSteps.value);
const getStepStatus = (step: number) => {
if (isCompleted.value || currentStep.value > step) return 'complete';
if (currentStep.value === step) return 'active';
return 'inactive';
};
const getStepIndicatorStyle = (step: number) => {
const status = getStepStatus(step);
switch (status) {
case 'active':
case 'complete':
return { backgroundColor: '#27FF64', color: '#fff' };
default:
return { backgroundColor: '#222', color: '#a3a3a3' };
}
};
const getStepContentInitial = () => ({
x: direction.value >= 0 ? '-100%' : '100%',
opacity: 0
});
const getStepContentExit = () => ({
x: direction.value >= 0 ? '50%' : '-50%',
opacity: 0
});
const handleStepClick = (step: number) => {
if (isCompleted.value && props.lockOnComplete) return;
if (!props.disableStepIndicators) {
direction.value = step > currentStep.value ? 1 : -1;
updateStep(step);
}
};
const handleCustomStepClick = (clicked: number) => {
if (isCompleted.value && props.lockOnComplete) return;
if (clicked !== currentStep.value && !props.disableStepIndicators) {
direction.value = clicked > currentStep.value ? 1 : -1;
updateStep(clicked);
}
};
const measureHeight = () => {
nextTick(() => {
if (contentRef.value) {
const height = contentRef.value.offsetHeight;
if (height > 0 && height !== parentHeight.value) {
parentHeight.value = height;
}
}
});
};
const updateStep = (newStep: number) => {
if (newStep >= 1 && newStep <= totalSteps.value) {
currentStep.value = newStep;
}
};
const handleBack = () => {
direction.value = -1;
updateStep(currentStep.value - 1);
};
const handleNext = () => {
direction.value = 1;
updateStep(currentStep.value + 1);
};
const handleComplete = () => {
isCompleted.value = true;
props.onFinalStepCompleted?.();
};
watch(
currentStep,
(newStep, oldStep) => {
props.onStepChange?.(newStep);
if (newStep !== oldStep && !isCompleted.value) {
nextTick(measureHeight);
} else if (!props.lockOnComplete && isCompleted.value) {
isCompleted.value = false;
nextTick(measureHeight);
}
}
);
onMounted(() => {
if (props.initialStep !== 1) {
currentStep.value = props.initialStep;
}
measureHeight();
});
</script>

View File

@@ -0,0 +1,199 @@
<template>
<Toaster
position="bottom-right"
:visibleToasts="1"
:toastOptions="{
style: {
fontSize: '12px',
borderRadius: '0.75rem',
border: '1px solid #333',
color: '#fff',
backgroundColor: '#0b0b0b'
}
}"
/>
<TabbedLayout>
<template #preview>
<div class="relative demo-container h-[500px] overflow-hidden">
<Stepper
:initial-step="step"
:on-step-change="handleStepChange"
:on-final-step-completed="handleFinalStepCompleted"
:next-button-props="{ disabled: step === 3 && !name }"
:disable-step-indicators="step === 3 && !name"
:lock-on-complete="false"
back-button-text="Previous"
next-button-text="Next"
>
<div>
<h2 class="text-[#27FF64] text-xl font-semibold">Welcome to the Vue Bits stepper!</h2>
<p>Check out the next step!</p>
</div>
<div>
<h2 class="mb-4">Step 2</h2>
<img
class="h-[100px] w-full object-cover object-[center_-70px] rounded-[15px] mt-4"
src="https://www.purrfectcatgifts.co.uk/cdn/shop/collections/Funny_Cat_Cards_640x640.png?v=1663150894"
alt="Cat cards"
/>
<p class="mt-4">Custom step content!</p>
</div>
<div>
<h2 class="mb-4">How about an input?</h2>
<input
v-model="name"
class="py-3 px-4 border border-[#333] rounded-xl w-full bg-[#0b0b0b] text-white text-sm transition-all duration-200 ease-in-out placeholder-[#888] focus:outline-none focus:border-[#27FF64] focus:shadow-[0_0_0_2px_rgba(39,255,100,0.1)]"
placeholder="Your name?"
/>
</div>
<div>
<h2 class="text-[#27FF64] text-xl font-semibold">Final Step</h2>
<p>You made it!</p>
</div>
</Stepper>
</div>
<PropTable :data="propData" />
<Dependencies :dependency-list="['motion-v']" />
</template>
<template #code>
<CodeExample :code-object="stepper" />
</template>
<template #cli>
<CliInstallation :command="stepper.cli" />
</template>
</TabbedLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { toast, Toaster } from 'vue-sonner';
import 'vue-sonner/style.css'
import TabbedLayout from '@/components/common/TabbedLayout.vue';
import PropTable from '@/components/common/PropTable.vue';
import CodeExample from '@/components/code/CodeExample.vue';
import CliInstallation from '@/components/code/CliInstallation.vue';
import Dependencies from '@/components/code/Dependencies.vue';
import Stepper from '@/content/Components/Stepper/Stepper.vue';
import { stepper } from '@/constants/code/Components/stepperCode.ts';
const name = ref('');
const step = ref(1);
const propData = [
{
name: 'children',
type: 'VNode[]',
default: '-',
description: 'The Step components (or any custom content) rendered inside the stepper.'
},
{
name: 'initialStep',
type: 'number',
default: '1',
description: 'The first step to display when the stepper is initialized.'
},
{
name: 'onStepChange',
type: '(step: number) => void',
default: '() => {}',
description: 'Callback fired whenever the step changes.'
},
{
name: 'onFinalStepCompleted',
type: '() => void',
default: '() => {}',
description: 'Callback fired when the stepper completes its final step.'
},
{
name: 'stepCircleContainerClassName',
type: 'string',
default: "-",
description: 'Custom class name for the container holding the step indicators.'
},
{
name: 'stepContainerClassName',
type: 'string',
default: "-",
description: 'Custom class name for the row holding the step circles/connectors.'
},
{
name: 'contentClassName',
type: 'string',
default: "-",
description: "Custom class name for the step's main content container."
},
{
name: 'footerClassName',
type: 'string',
default: "-",
description: 'Custom class name for the footer area containing navigation buttons.'
},
{
name: 'backButtonProps',
type: 'ButtonHTMLAttributes',
default: '{}',
description: 'Extra props passed to the Back button.'
},
{
name: 'nextButtonProps',
type: 'ButtonHTMLAttributes',
default: '{}',
description: 'Extra props passed to the Next/Complete button.'
},
{
name: 'backButtonText',
type: 'string',
default: "'Back'",
description: 'Text for the Back button.'
},
{
name: 'nextButtonText',
type: 'string',
default: "'Continue'",
description: 'Text for the Next button when not on the last step.'
},
{
name: 'disableStepIndicators',
type: 'boolean',
default: 'false',
description: 'Disables click interaction on step indicators.'
},
{
name: 'renderStepIndicator',
type: '(props: RenderStepIndicatorProps) => VNode',
default: 'undefined',
description: 'Renders a custom step indicator component.'
},
{
name: 'lockOnComplete',
type: 'boolean',
default: 'false',
description: 'Prevents returning to previous steps after completing the stepper.'
}
];
const handleFinalStepCompleted = () => {
toast('✅ All steps completed!');
};
const handleStepChange = (newStep: number) => {
step.value = newStep;
if (newStep === 4) {
if (name.value) {
toast(`👋🏻 Hello ${name.value}!`)
} else {
toast(`You didn't provide your name :(`)
}
} else {
toast(`✅ Step ${newStep}!`)
}
};
</script>