chore: estructura inicial del MVP
Backend (Express + MariaDB) y frontend (React + Vite, identidad Fluent/Windows) con el nucleo funcional: seguridad, organizacion, proyectos, actividades, subtareas, dependencias, dashboard, calendario, gantt, comentarios, adjuntos, notificaciones in-app y catalogos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, Search } from 'lucide-react';
|
||||
|
||||
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
|
||||
// options: [{ value, label }]
|
||||
export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const rootRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onDocClick(e) {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, []);
|
||||
|
||||
const selected = options.find((o) => String(o.value) === String(value));
|
||||
const filtered = query
|
||||
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: options;
|
||||
|
||||
return (
|
||||
<div className="combobox" ref={rootRef}>
|
||||
<button type="button" className="input" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }} onClick={() => setOpen((v) => !v)}>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="combobox-panel">
|
||||
{options.length >= searchThreshold && (
|
||||
<div className="combobox-search" style={{ position: 'relative' }}>
|
||||
<Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} />
|
||||
<input
|
||||
autoFocus
|
||||
className="input"
|
||||
style={{ paddingLeft: 26 }}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Buscar..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{filtered.length === 0 && <div className="combobox-empty">Sin resultados</div>}
|
||||
{filtered.map((o) => (
|
||||
<div
|
||||
key={o.value}
|
||||
className={`combobox-option ${String(o.value) === String(value) ? 'active' : ''}`}
|
||||
onClick={() => { onChange(o.value); setOpen(false); setQuery(''); }}
|
||||
>
|
||||
{o.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user