Преглед на файлове

Rework WizardStepper into a center-focused single-line stepper

The stepper now keeps the current step centered on one line: the track translates
with a 550ms ease-out transition as steps advance, and a mask gradient fades
overflowing steps so nothing is visible at the extremities. Visited steps remain
clickable; the position recalculates on step-count changes and window resize.
master
Perplexity преди 3 часа
родител
ревизия
207b15e3bc
променени са 2 файла, в които са добавени 131 реда и са изтрити 28 реда
  1. +130
    -27
      src/components/wizard/WizardStepper.vue
  2. +1
    -1
      tsconfig.app.tsbuildinfo

+ 130
- 27
src/components/wizard/WizardStepper.vue Целия файл

@@ -1,32 +1,35 @@
<template>
<ol class="flex items-center gap-y-2">
<li
v-for="(step, index) in steps"
:key="step.id"
class="flex items-center"
>
<button
type="button"
class="flex items-center gap-2 rounded-lg px-1.5 py-1 transition"
:class="stepClasses(index)"
:disabled="index > maxVisited"
@click="emit('goTo', index)"
<nav ref="viewportRef" class="wizard-stepper" aria-label="Proposal creation progress">
<ol ref="trackRef" class="wizard-stepper-track" :style="trackStyle">
<li
v-for="(step, index) in steps"
:key="step.id"
:ref="(el) => setStepRef(el, index)"
class="flex shrink-0 items-center"
>
<span
class="flex h-6 w-6 items-center justify-center rounded-full text-xs font-semibold"
:class="badgeClasses(index)"
<button
type="button"
class="wizard-step"
:class="stepClasses(index)"
:disabled="index > maxVisited"
:aria-current="index === current ? 'step' : undefined"
@click="emit('goTo', index)"
>
{{ index + 1 }}
</span>
<span class="hidden text-sm font-medium sm:inline">{{ step.label }}</span>
</button>

<span v-if="index < steps.length - 1" class="mx-1 h-px w-4 bg-slate-300 sm:w-6" aria-hidden="true" />
</li>
</ol>
<span class="wizard-step-badge" :class="badgeClasses(index)">
{{ index + 1 }}
</span>
<span class="text-sm font-medium">{{ step.label }}</span>
</button>

<span v-if="index < steps.length - 1" class="mx-2 h-px w-5 shrink-0 bg-slate-300 sm:w-8" aria-hidden="true" />
</li>
</ol>
</nav>
</template>

<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from "vue";
import { useWindowSize } from "@vueuse/core";
import type { WizardStep } from "../../composables/useProposalWizard";

const props = defineProps<{
@@ -39,15 +42,115 @@ const emit = defineEmits<{
goTo: [index: number];
}>();

const viewportRef = ref<HTMLElement | null>(null);
const trackRef = ref<HTMLElement | null>(null);
const stepRefs = new Map<number, HTMLElement>();

const translateX = ref(0);
/** Suppresses the transition until the first centering, so the initial position snaps into place. */
const animated = ref(false);

const setStepRef = (el: Element | any, index: number) => {
if (el instanceof HTMLElement) {
stepRefs.set(index, el);
} else {
stepRefs.delete(index);
}
};

/** Centers the current step in the viewport; overflow at the extremities is masked by the fade. */
const centerCurrentStep = () => {
const viewport = viewportRef.value;
const track = trackRef.value;
const step = stepRefs.get(props.current);
if (!viewport || !track || !step) return;

const stepRect = step.getBoundingClientRect();
const trackRect = track.getBoundingClientRect();

// Rects already include the current transform, so adjust from where the step is now.
const stepCenter = stepRect.left + stepRect.width / 2 - trackRect.left;
translateX.value += viewport.clientWidth / 2 - stepCenter;
};

const recenter = async (withAnimation: boolean) => {
if (!withAnimation) animated.value = false;
await nextTick();
centerCurrentStep();
if (!withAnimation) {
// Re-arm the transition once the snapped position has been painted
requestAnimationFrame(() => requestAnimationFrame(() => (animated.value = true)));
}
};

const trackStyle = computed(() => ({
transform: `translateX(${translateX.value}px)`,
transition: animated.value
? "transform 550ms cubic-bezier(0.22, 1, 0.36, 1)"
: "none",
}));

const { width: windowWidth } = useWindowSize();

watch(() => props.current, () => recenter(true));
watch(() => props.steps, () => recenter(false), { deep: true });
watch(windowWidth, () => recenter(false));

onMounted(() => recenter(false));

const stepClasses = (index: number) => {
if (index === props.current) return "text-slate-900";
if (index <= props.maxVisited) return "text-slate-600 hover:bg-slate-100 cursor-pointer";
return "text-slate-400 cursor-not-allowed";
if (index === props.current) return "text-slate-900 opacity-100";
if (index <= props.maxVisited) return "text-slate-500 opacity-75 hover:opacity-100 cursor-pointer";
return "text-slate-400 opacity-60 cursor-not-allowed";
};

const badgeClasses = (index: number) => {
if (index === props.current) return "bg-amber-500 text-white";
if (index === props.current) return "bg-amber-500 text-white scale-110";
if (index < props.current) return "bg-slate-700 text-white";
return "bg-slate-200 text-slate-500";
};
</script>

<style scoped>
.wizard-stepper {
/* Single line; steps that overflow are faded out by the mask, so the extremities
show nothing rather than a hard clip or a scrollbar */
overflow: hidden;
white-space: nowrap;
mask-image: linear-gradient(to right, transparent 0, black 18%, black 82%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0, black 18%, black 82%, transparent 100%);
}

.wizard-stepper-track {
display: flex;
align-items: center;
width: max-content;
will-change: transform;
}

.wizard-step {
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: 0.5rem;
padding: 0.375rem 0.5rem;
transition: opacity 300ms ease, color 300ms ease, background-color 300ms ease;
}

.wizard-step:hover:not(:disabled) {
background-color: #f1f5f9;
}

.wizard-step-badge {
display: flex;
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1), background-color 300ms ease, color 300ms ease;
}
</style>

+ 1
- 1
tsconfig.app.tsbuildinfo
Файловите разлики са ограничени, защото са твърде много
Целия файл


Зареждане…
Отказ
Запис