|
| 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