Files
project.linkedcorp.net/frontend/src/components/ComboBox.jsx
T
Milton Gonzalez 6b61e5cea7 fix(ui): ComboBox y Flyout se portan a document.body con position:fixed
El panel desplegable de ComboBox se posicionaba con position:absolute dentro
de su propio contenedor. Cualquier ancestro con overflow:hidden/auto (un
Expander colapsable, el body de un DetailPopup, una tarjeta con scroll propio)
lo recortaba, dejando la lista invisible o cortada a la mitad -reproducido en
Configuracion > Apariencia, dentro del Expander-.

Fix: igual tecnica que Flyout -createPortal a document.body, position:fixed
calculado desde getBoundingClientRect() del trigger-, con cierre en click
afuera y en scroll de cualquier ancestro (capture:true, el scroll no
burbujea). De paso Flyout pasa de position:absolute a position:fixed por el
mismo motivo (evita un bug latente de desalineacion si la pagina esta
scrolleada).

Regla documentada en project-md-templates/UI-UX-WINDOWS.md (Kit de
componentes + do's/don'ts) para que no se repita en este ni otros proyectos.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 21:50:09 -05:00

90 lines
3.6 KiB
React

import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Search } from 'lucide-react';
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
// El panel desplegable se porta a document.body y se posiciona con `position: fixed` calculado
// desde el trigger (igual tecnica que Flyout) — evita que un ancestro con overflow:hidden/auto
// (Expander, DetailPopup, cualquier tarjeta con scroll propio) recorte la lista desplegada.
// options: [{ value, label }]
export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [pos, setPos] = useState(null);
const triggerRef = useRef(null);
const panelRef = useRef(null);
function openPanel() {
const rect = triggerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width });
setOpen(true);
}
useEffect(() => {
if (!open) return;
function onDocMouseDown(e) {
if (triggerRef.current?.contains(e.target)) return;
if (panelRef.current?.contains(e.target)) return;
setOpen(false);
}
// capture:true para detectar scroll en CUALQUIER ancestro (los eventos de scroll no burbujean).
function onScroll() { setOpen(false); }
document.addEventListener('mousedown', onDocMouseDown);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
return () => {
document.removeEventListener('mousedown', onDocMouseDown);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
};
}, [open]);
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">
<button
type="button"
ref={triggerRef}
className="input"
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}
onClick={() => (open ? setOpen(false) : openPanel())}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
<ChevronDown size={14} />
</button>
{open && pos && createPortal(
<div ref={panelRef} className="combobox-panel" style={{ top: pos.top, left: pos.left, width: pos.width }}>
{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>,
document.body
)}
</div>
);
}