Skip to content

Commit 65ed76c

Browse files
committed
feat(components): Dropdown generico con keyboard nav, icone, shortcut, separator e align left/right
2 parents bd1a54e + 4c7f2e6 commit 65ed76c

1 file changed

Lines changed: 147 additions & 0 deletions

File tree

web/components/Dropdown.tsx

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
'use client'
2+
3+
import { useEffect, useRef, useState } from 'react'
4+
5+
export type DropdownAlign = 'left' | 'right'
6+
7+
export interface DropdownItem {
8+
id: string
9+
label: string
10+
icon?: string
11+
shortcut?: string
12+
disabled?: boolean
13+
danger?: boolean
14+
/** Se presente, inserisce separatore PRIMA di questo item */
15+
separator?: boolean
16+
}
17+
18+
export interface DropdownProps {
19+
/** Elemento trigger — se stringa, renderizza un button con quella label */
20+
trigger: React.ReactNode | string
21+
items: DropdownItem[]
22+
onSelect: (item: DropdownItem) => void
23+
align?: DropdownAlign
24+
/** Larghezza minima menu in px */
25+
minWidth?: number
26+
disabled?: boolean
27+
}
28+
29+
export default function Dropdown({ trigger, items, onSelect, align = 'left', minWidth = 180, disabled = false }: DropdownProps) {
30+
const [open, setOpen] = useState(false)
31+
const [focusIdx, setFocusIdx] = useState(-1)
32+
const containerRef = useRef<HTMLDivElement>(null)
33+
const menuRef = useRef<HTMLDivElement>(null)
34+
35+
// Indici navigabili (no separator, no disabled)
36+
const navigable = items.filter(i => !i.disabled)
37+
38+
// Chiudi su click fuori
39+
useEffect(() => {
40+
const fn = (e: MouseEvent) => { if (!containerRef.current?.contains(e.target as Node)) { setOpen(false); setFocusIdx(-1) } }
41+
document.addEventListener('mousedown', fn)
42+
return () => document.removeEventListener('mousedown', fn)
43+
}, [])
44+
45+
// Focus prima voce all'apertura
46+
useEffect(() => { if (open) setFocusIdx(-1) }, [open])
47+
48+
const handleTriggerKey = (e: React.KeyboardEvent) => {
49+
if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); setOpen(true); setFocusIdx(0) }
50+
if (e.key === 'Escape') setOpen(false)
51+
}
52+
53+
const handleMenuKey = (e: React.KeyboardEvent) => {
54+
if (e.key === 'Escape') { setOpen(false); setFocusIdx(-1); return }
55+
if (e.key === 'ArrowDown') {
56+
e.preventDefault()
57+
setFocusIdx(i => {
58+
for (let next = i + 1; next < items.length; next++) if (!items[next]?.disabled) return next
59+
return i
60+
})
61+
}
62+
if (e.key === 'ArrowUp') {
63+
e.preventDefault()
64+
setFocusIdx(i => {
65+
for (let prev = i - 1; prev >= 0; prev--) if (!items[prev]?.disabled) return prev
66+
return i
67+
})
68+
}
69+
if (e.key === 'Enter' && focusIdx >= 0) {
70+
e.preventDefault()
71+
const item = items[focusIdx]
72+
if (item && !item.disabled) { onSelect(item); setOpen(false); setFocusIdx(-1) }
73+
}
74+
if (e.key === 'Tab') { setOpen(false); setFocusIdx(-1) }
75+
}
76+
77+
const handleSelect = (item: DropdownItem) => {
78+
if (item.disabled) return
79+
onSelect(item)
80+
setOpen(false)
81+
setFocusIdx(-1)
82+
}
83+
84+
return (
85+
<div ref={containerRef} className="relative inline-block">
86+
{/* Trigger */}
87+
<div
88+
onClick={() => !disabled && setOpen(v => !v)}
89+
onKeyDown={handleTriggerKey}
90+
tabIndex={disabled ? -1 : 0}
91+
role="button"
92+
aria-haspopup="menu"
93+
aria-expanded={open}
94+
style={{ cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.5 : 1, outline: 'none' }}
95+
>
96+
{typeof trigger === 'string' ? (
97+
<button
98+
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-semibold transition-all"
99+
style={{ background: open ? 'var(--color-row)' : 'var(--color-panel)', border: `1px solid ${open ? 'var(--color-border-glow)' : 'var(--color-border)'}`, color: 'var(--color-muted)', cursor: 'inherit' }}
100+
tabIndex={-1}>
101+
{trigger} <span style={{ fontSize: 8, marginLeft: 2, opacity: 0.6 }}></span>
102+
</button>
103+
) : trigger}
104+
</div>
105+
106+
{/* Menu */}
107+
{open && (
108+
<div
109+
ref={menuRef}
110+
role="menu"
111+
onKeyDown={handleMenuKey}
112+
tabIndex={-1}
113+
className="absolute z-50 rounded-lg border overflow-hidden py-1 mt-1"
114+
style={{
115+
[align === 'right' ? 'right' : 'left']: 0,
116+
minWidth,
117+
borderColor: 'var(--color-border)',
118+
background: 'var(--color-panel)',
119+
boxShadow: '0 4px 20px rgba(0,0,0,0.35)',
120+
animation: 'fade-in 0.1s ease both',
121+
}}>
122+
{items.map((item, i) => (
123+
<div key={item.id}>
124+
{item.separator && <div className="my-1" style={{ height: 1, background: 'var(--color-border)' }} />}
125+
<button
126+
role="menuitem"
127+
disabled={item.disabled}
128+
onClick={() => handleSelect(item)}
129+
onMouseEnter={() => !item.disabled && setFocusIdx(i)}
130+
className="w-full flex items-center gap-2.5 px-3 py-2 text-[11px] text-left transition-colors"
131+
style={{
132+
background: i === focusIdx ? 'var(--color-row)' : 'transparent',
133+
color: item.disabled ? 'var(--color-border)' : item.danger ? 'var(--color-red)' : 'var(--color-muted)',
134+
cursor: item.disabled ? 'default' : 'pointer',
135+
border: 'none', display: 'flex',
136+
}}>
137+
{item.icon && <span className="text-sm flex-shrink-0">{item.icon}</span>}
138+
<span className="flex-1">{item.label}</span>
139+
{item.shortcut && <span className="text-[9px] font-mono flex-shrink-0" style={{ color: 'var(--color-dim)' }}>{item.shortcut}</span>}
140+
</button>
141+
</div>
142+
))}
143+
</div>
144+
)}
145+
</div>
146+
)
147+
}

0 commit comments

Comments
 (0)