Skip to content

Commit 17037a3

Browse files
committed
merge: componente Accordion — sezioni espandibili, chevron animata, multi/single mode, 3 varianti
2 parents 68ca14e + f6f53ba commit 17037a3

1 file changed

Lines changed: 175 additions & 0 deletions

File tree

web/components/Accordion.tsx

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
5+
export interface AccordionItem {
6+
id: string
7+
title: string
8+
/** Icona opzionale a sinistra del titolo */
9+
icon?: string
10+
/** Badge/conteggio a destra del titolo */
11+
badge?: string | number
12+
content: React.ReactNode
13+
/** Disabilita questo item */
14+
disabled?: boolean
15+
/** Aperto di default */
16+
defaultOpen?: boolean
17+
}
18+
19+
export interface AccordionProps {
20+
items: AccordionItem[]
21+
/** 'single' = uno aperto alla volta, 'multi' = più aperti insieme */
22+
mode?: 'single' | 'multi'
23+
/** Bordo separatore tra items */
24+
divided?: boolean
25+
/** Variante visiva */
26+
variant?: 'default' | 'bordered' | 'ghost'
27+
}
28+
29+
function AccordionPanel({ item, open, onToggle, divided, variant }: {
30+
item: AccordionItem
31+
open: boolean
32+
onToggle: () => void
33+
divided: boolean
34+
variant: 'default' | 'bordered' | 'ghost'
35+
}) {
36+
const bodyRef = useRef<HTMLDivElement>(null)
37+
const [height, setHeight] = useState<number | 'auto'>(open ? 'auto' : 0)
38+
const [visible, setVisible] = useState(open)
39+
40+
useEffect(() => {
41+
const el = bodyRef.current
42+
if (!el) return
43+
if (open) {
44+
setVisible(true)
45+
setHeight(el.scrollHeight)
46+
const t = window.setTimeout(() => setHeight('auto'), 260)
47+
return () => clearTimeout(t)
48+
} else {
49+
setHeight(el.scrollHeight)
50+
requestAnimationFrame(() => requestAnimationFrame(() => setHeight(0)))
51+
const t = window.setTimeout(() => setVisible(false), 260)
52+
return () => clearTimeout(t)
53+
}
54+
}, [open])
55+
56+
const isBordered = variant === 'bordered'
57+
const isGhost = variant === 'ghost'
58+
59+
return (
60+
<div
61+
style={{
62+
borderRadius: isBordered ? 8 : 0,
63+
border: isBordered ? `1px solid ${open ? 'var(--color-border-glow, var(--color-border))' : 'var(--color-border)'}` : 'none',
64+
marginBottom: isBordered ? 6 : 0,
65+
overflow: 'hidden',
66+
transition: 'border-color 0.2s ease',
67+
}}>
68+
{/* Header */}
69+
<button
70+
onClick={() => !item.disabled && onToggle()}
71+
disabled={item.disabled}
72+
style={{
73+
width: '100%', display: 'flex', alignItems: 'center', gap: 10,
74+
padding: isGhost ? '8px 4px' : '10px 14px',
75+
background: open && !isGhost ? 'var(--color-row)' : 'transparent',
76+
border: 'none',
77+
borderBottom: divided && !isBordered ? `1px solid var(--color-border)` : 'none',
78+
cursor: item.disabled ? 'default' : 'pointer',
79+
opacity: item.disabled ? 0.45 : 1,
80+
transition: 'background 0.15s ease',
81+
textAlign: 'left',
82+
}}>
83+
{item.icon && (
84+
<span style={{ fontSize: 14, flexShrink: 0 }}>{item.icon}</span>
85+
)}
86+
<span style={{
87+
flex: 1, fontSize: 12, fontWeight: 600,
88+
color: open ? 'var(--color-bright)' : 'var(--color-muted)',
89+
transition: 'color 0.15s ease',
90+
}}>
91+
{item.title}
92+
</span>
93+
{item.badge !== undefined && (
94+
<span style={{
95+
fontSize: 9, fontWeight: 700, padding: '1px 6px', borderRadius: 10,
96+
background: 'var(--color-border)', color: 'var(--color-dim)',
97+
flexShrink: 0,
98+
}}>
99+
{item.badge}
100+
</span>
101+
)}
102+
{/* Chevron */}
103+
<span style={{
104+
fontSize: 10, flexShrink: 0,
105+
color: open ? 'var(--color-green)' : 'var(--color-dim)',
106+
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
107+
transition: 'transform 0.25s ease, color 0.15s ease',
108+
display: 'inline-block',
109+
}}></span>
110+
</button>
111+
112+
{/* Body animato */}
113+
<div
114+
ref={bodyRef}
115+
style={{
116+
height: height === 'auto' ? 'auto' : `${height}px`,
117+
overflow: height === 'auto' ? 'visible' : 'hidden',
118+
transition: 'height 0.25s ease',
119+
}}>
120+
{visible && (
121+
<div style={{
122+
padding: isGhost ? '8px 4px 4px' : '12px 14px',
123+
fontSize: 11,
124+
color: 'var(--color-muted)',
125+
borderTop: !isBordered && open ? `1px solid var(--color-border)` : 'none',
126+
}}>
127+
{item.content}
128+
</div>
129+
)}
130+
</div>
131+
</div>
132+
)
133+
}
134+
135+
export default function Accordion({
136+
items,
137+
mode = 'single',
138+
divided = true,
139+
variant = 'default',
140+
}: AccordionProps) {
141+
const defaultOpen = new Set(items.filter(i => i.defaultOpen).map(i => i.id))
142+
const [openIds, setOpenIds] = useState<Set<string>>(defaultOpen)
143+
144+
const toggle = (id: string) => {
145+
setOpenIds(prev => {
146+
const next = new Set(prev)
147+
if (next.has(id)) {
148+
next.delete(id)
149+
} else {
150+
if (mode === 'single') next.clear()
151+
next.add(id)
152+
}
153+
return next
154+
})
155+
}
156+
157+
return (
158+
<div style={{ width: '100%' }}>
159+
{items.map((item, i) => (
160+
<div key={item.id}>
161+
{divided && variant === 'default' && i > 0 && (
162+
<div style={{ height: 1, background: 'var(--color-border)' }} />
163+
)}
164+
<AccordionPanel
165+
item={item}
166+
open={openIds.has(item.id)}
167+
onToggle={() => toggle(item.id)}
168+
divided={divided}
169+
variant={variant}
170+
/>
171+
</div>
172+
))}
173+
</div>
174+
)
175+
}

0 commit comments

Comments
 (0)