Skip to content

Commit 48a0aa6

Browse files
committed
merge: componente Stepper wizard multi-step con validazione, navigazione prev/next, stati completamento
2 parents c2be23a + 4d377c7 commit 48a0aa6

1 file changed

Lines changed: 194 additions & 0 deletions

File tree

web/components/Stepper.tsx

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
5+
export interface StepConfig {
6+
id: string
7+
label: string
8+
/** Icona opzionale (emoji o stringa) */
9+
icon?: string
10+
/** Se presente, chiamato prima di avanzare — ritorna true se ok, false/string se errore */
11+
validate?: () => boolean | string
12+
}
13+
14+
export interface StepperProps {
15+
steps: StepConfig[]
16+
/** Renderizza il contenuto dello step corrente */
17+
renderStep: (step: StepConfig, index: number) => React.ReactNode
18+
/** Chiamato quando l'utente completa l'ultimo step */
19+
onComplete?: () => void
20+
/** Label bottone completamento */
21+
completeLabel?: string
22+
/** Consente di tornare indietro dagli step già validati */
23+
allowBack?: boolean
24+
}
25+
26+
type StepState = 'pending' | 'active' | 'done' | 'error'
27+
28+
export default function Stepper({
29+
steps,
30+
renderStep,
31+
onComplete,
32+
completeLabel = 'Completa',
33+
allowBack = true,
34+
}: StepperProps) {
35+
const [current, setCurrent] = useState(0)
36+
const [completed, setCompleted] = useState<Set<number>>(new Set())
37+
const [errors, setErrors] = useState<Record<number, string>>({})
38+
39+
const stepState = (i: number): StepState => {
40+
if (errors[i]) return 'error'
41+
if (completed.has(i)) return 'done'
42+
if (i === current) return 'active'
43+
return 'pending'
44+
}
45+
46+
const goTo = (i: number) => {
47+
if (i === current) return
48+
// Avanti: solo se tutti gli step precedenti sono completati
49+
if (i > current) return
50+
// Indietro: solo se allowBack
51+
if (!allowBack) return
52+
setErrors(e => { const n = { ...e }; delete n[current]; return n })
53+
setCurrent(i)
54+
}
55+
56+
const handleNext = () => {
57+
const step = steps[current]
58+
if (step.validate) {
59+
const result = step.validate()
60+
if (result !== true) {
61+
setErrors(e => ({ ...e, [current]: typeof result === 'string' ? result : 'Completa questo step prima di continuare.' }))
62+
return
63+
}
64+
}
65+
setErrors(e => { const n = { ...e }; delete n[current]; return n })
66+
setCompleted(s => new Set(s).add(current))
67+
if (current === steps.length - 1) {
68+
onComplete?.()
69+
} else {
70+
setCurrent(c => c + 1)
71+
}
72+
}
73+
74+
const handleBack = () => {
75+
if (current > 0 && allowBack) setCurrent(c => c - 1)
76+
}
77+
78+
const isLast = current === steps.length - 1
79+
80+
return (
81+
<div className="flex flex-col gap-6">
82+
{/* Header steps */}
83+
<div className="flex items-center gap-0">
84+
{steps.map((step, i) => {
85+
const state = stepState(i)
86+
const isClickable = allowBack && (completed.has(i) || i < current)
87+
return (
88+
<div key={step.id} className="flex items-center flex-1">
89+
{/* Step circle */}
90+
<button
91+
onClick={() => isClickable ? goTo(i) : undefined}
92+
disabled={!isClickable && i !== current}
93+
style={{
94+
display: 'flex', alignItems: 'center', justifyContent: 'center',
95+
width: 32, height: 32, borderRadius: '50%', flexShrink: 0,
96+
border: `2px solid ${
97+
state === 'active' ? 'var(--color-green)' :
98+
state === 'done' ? 'var(--color-green)' :
99+
state === 'error' ? 'var(--color-red)' :
100+
'var(--color-border)'
101+
}`,
102+
background: state === 'done' ? 'var(--color-green)' : 'var(--color-panel)',
103+
color: state === 'done' ? '#000' : state === 'active' ? 'var(--color-green)' : state === 'error' ? 'var(--color-red)' : 'var(--color-dim)',
104+
cursor: isClickable ? 'pointer' : 'default',
105+
fontSize: 12, fontWeight: 700, transition: 'all 0.2s ease',
106+
}}>
107+
{state === 'done' ? '✓' : state === 'error' ? '!' : step.icon ?? (i + 1)}
108+
</button>
109+
110+
{/* Label sotto */}
111+
<div style={{ position: 'absolute', marginTop: 48 }}>
112+
<span style={{
113+
fontSize: 9, whiteSpace: 'nowrap',
114+
color: state === 'active' ? 'var(--color-bright)' : state === 'done' ? 'var(--color-green)' : 'var(--color-dim)',
115+
fontWeight: state === 'active' ? 700 : 400,
116+
}}>{step.label}</span>
117+
</div>
118+
119+
{/* Linea connettore */}
120+
{i < steps.length - 1 && (
121+
<div style={{
122+
flex: 1, height: 2, margin: '0 4px',
123+
background: completed.has(i) ? 'var(--color-green)' : 'var(--color-border)',
124+
transition: 'background 0.3s ease',
125+
}} />
126+
)}
127+
</div>
128+
)
129+
})}
130+
</div>
131+
132+
{/* Contenuto step */}
133+
<div
134+
className="rounded-lg border p-5"
135+
style={{ borderColor: errors[current] ? 'var(--color-red)' : 'var(--color-border)', background: 'var(--color-panel)', marginTop: 16 }}>
136+
{/* Titolo step */}
137+
<div className="flex items-center gap-2 mb-4">
138+
{steps[current].icon && <span className="text-base">{steps[current].icon}</span>}
139+
<span className="text-[12px] font-semibold" style={{ color: 'var(--color-bright)' }}>
140+
{steps[current].label}
141+
</span>
142+
<span className="text-[10px]" style={{ color: 'var(--color-dim)' }}>
143+
— Step {current + 1} di {steps.length}
144+
</span>
145+
</div>
146+
147+
{/* Errore validazione */}
148+
{errors[current] && (
149+
<div className="mb-3 px-3 py-2 rounded text-[10px]" style={{ background: 'rgba(255,59,59,0.1)', color: 'var(--color-red)', border: '1px solid var(--color-red)' }}>
150+
{errors[current]}
151+
</div>
152+
)}
153+
154+
{renderStep(steps[current], current)}
155+
</div>
156+
157+
{/* Navigazione */}
158+
<div className="flex items-center justify-between">
159+
<button
160+
onClick={handleBack}
161+
disabled={current === 0 || !allowBack}
162+
className="px-4 py-2 rounded-lg text-[11px] font-semibold transition-all"
163+
style={{
164+
background: 'var(--color-panel)', border: '1px solid var(--color-border)',
165+
color: current === 0 || !allowBack ? 'var(--color-border)' : 'var(--color-muted)',
166+
cursor: current === 0 || !allowBack ? 'default' : 'pointer',
167+
}}>
168+
← Indietro
169+
</button>
170+
171+
{/* Indicatore dots */}
172+
<div className="flex gap-1.5">
173+
{steps.map((_, i) => (
174+
<div key={i} style={{
175+
width: i === current ? 16 : 6, height: 6, borderRadius: 3,
176+
background: completed.has(i) ? 'var(--color-green)' : i === current ? 'var(--color-muted)' : 'var(--color-border)',
177+
transition: 'all 0.2s ease',
178+
}} />
179+
))}
180+
</div>
181+
182+
<button
183+
onClick={handleNext}
184+
className="px-4 py-2 rounded-lg text-[11px] font-semibold transition-all"
185+
style={{
186+
background: 'var(--color-green)', border: '1px solid var(--color-green)',
187+
color: '#000', cursor: 'pointer',
188+
}}>
189+
{isLast ? completeLabel : 'Avanti →'}
190+
</button>
191+
</div>
192+
</div>
193+
)
194+
}

0 commit comments

Comments
 (0)