7139f1604e
Contraseñas: "Compartir grupo" aplica el mismo share de solo lectura a todas las credenciales de un grupo de una vez (el grupo sigue sin ser una unidad de permiso -- es una accion de conveniencia). El grupo y cada credencial se marcan con el icono de compartido. "Destruir grupo" ahora tiene dos opciones: solo desagrupar (como antes) o destruir tambien las N credenciales, con advertencia explicita del conteo. Fix de paso en ComboBox (afecta a toda la app): el panel se cerraba solo al abrirse si el trigger estaba cerca del borde de un contenedor con scroll, por un micro-scroll automatico del navegador al enfocar el boton. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
120 lines
5.1 KiB
React
120 lines
5.1 KiB
React
import { useEffect, useLayoutEffect, 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); // { top, left, width, anchorTop, anchorBottom, ready }
|
|
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,
|
|
anchorTop: rect.top, anchorBottom: rect.bottom, ready: false,
|
|
});
|
|
setOpen(true);
|
|
}
|
|
|
|
// Fase 2 (igual tecnica que Flyout): con el panel ya en el DOM (oculto) se mide su alto real y,
|
|
// si no cabe hacia abajo, se voltea hacia arriba del trigger -- nunca se deja cortado por el
|
|
// borde inferior del viewport (ver project-md-templates/leeme_primero.txt, seccion UI/UX).
|
|
useLayoutEffect(() => {
|
|
if (!open || !pos || pos.ready || !panelRef.current) return;
|
|
const panelRect = panelRef.current.getBoundingClientRect();
|
|
const margin = 8;
|
|
|
|
let top = pos.top;
|
|
if (pos.anchorBottom + 4 + panelRect.height > window.innerHeight - margin) {
|
|
const openUpTop = pos.anchorTop - 4 - panelRect.height;
|
|
top = openUpTop >= margin ? openUpTop : Math.max(margin, window.innerHeight - margin - panelRect.height);
|
|
}
|
|
|
|
let left = pos.left;
|
|
if (left + pos.width > window.innerWidth - margin) left = window.innerWidth - margin - pos.width;
|
|
if (left < margin) left = margin;
|
|
|
|
setPos((p) => ({ ...p, top, left, ready: true }));
|
|
}, [open, pos]);
|
|
|
|
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);
|
|
// El listener de scroll se activa un frame despues de abrir -- si el trigger esta cerca del
|
|
// borde de un contenedor con scroll (ej. el body de un DetailPopup), el navegador hace un
|
|
// micro-scroll automatico al enfocarlo, lo que cerraba el panel apenas se abria.
|
|
const raf = requestAnimationFrame(() => {
|
|
window.addEventListener('scroll', onScroll, true);
|
|
window.addEventListener('resize', onScroll);
|
|
});
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
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, visibility: pos.ready ? 'visible' : 'hidden' }}>
|
|
{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>
|
|
);
|
|
}
|