mirror of
https://github.com/DavidHDev/vue-bits.git
synced 2026-03-07 06:29:30 -07:00
Merge pull request #61 from Gazoon007/feat/stepper
Create <Stepper /> component
This commit is contained in:
@@ -83,6 +83,7 @@ export const CATEGORIES = [
|
||||
'Elastic Slider',
|
||||
'Stack',
|
||||
'Chroma Grid',
|
||||
'Stepper',
|
||||
'Bounce Cards',
|
||||
'Counter',
|
||||
'Rolling Gallery'
|
||||
|
||||
@@ -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'),
|
||||
|
||||
59
src/constants/code/Components/stepperCode.ts
Normal file
59
src/constants/code/Components/stepperCode.ts
Normal 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>`
|
||||
});
|
||||
283
src/content/Components/Stepper/Stepper.vue
Normal file
283
src/content/Components/Stepper/Stepper.vue
Normal 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>
|
||||
199
src/demo/Components/StepperDemo.vue
Normal file
199
src/demo/Components/StepperDemo.vue
Normal 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>
|
||||
Reference in New Issue
Block a user