Files
project.linkedcorp.net/frontend/src/components/Flyout.jsx
T
Milton Gonzalez 70b601307f fix: version 2.13.1 -- ComboBox/Flyout no se salen del viewport
El panel se posicionaba una sola vez hacia abajo/derecha del trigger sin revisar
si cabia. Ahora se mide el tamaño ya renderizado y se voltea hacia arriba /
se recorta hacia adentro si no hay espacio -- regla agregada tambien al
orquestador del grupo (project-md-templates/leeme_primero.txt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-14 16:37:02 -05:00

91 lines
3.4 KiB
React

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
// Flyout generico con light-dismiss: click fuera o Esc cierra. trigger recibe { onClick, ref }.
// Posicion en dos fases (igual tecnica que ComboBox): se abre debajo/alineado al trigger, se mide
// el panel ya renderizado y, si se sale del viewport, se voltea hacia arriba / se recorta hacia
// adentro -- nunca se deja que el menu quede cortado por el borde de la ventana (ver
// project-md-templates/leeme_primero.txt, seccion UI/UX: "menus flotantes siempre dentro del
// viewport").
export function Flyout({ trigger, children, align = 'end' }) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null); // { top, left, anchorTop, anchorBottom, anchorLeft, anchorRight, ready }
const anchorRef = useRef(null);
const panelRef = useRef(null);
function openFlyout() {
const rect = anchorRef.current.getBoundingClientRect();
setPos({
top: rect.bottom + 6,
left: align === 'end' ? rect.right : rect.left,
anchorTop: rect.top,
anchorBottom: rect.bottom,
anchorLeft: rect.left,
anchorRight: rect.right,
ready: false,
});
setOpen(true);
}
// Fase 2: ya con el panel en el DOM (oculto), se mide su tamaño real y se ajusta top/left para
// que quepa entero en el viewport antes de mostrarlo -- sin esto, un trigger cerca del borde
// inferior/derecho deja el menu cortado por el borde de la ventana.
useLayoutEffect(() => {
if (!open || !pos || pos.ready || !panelRef.current) return;
const panelRect = panelRef.current.getBoundingClientRect();
const margin = 8;
let top = pos.top;
if (pos.anchorBottom + 6 + panelRect.height > window.innerHeight - margin) {
const openUpTop = pos.anchorTop - 6 - panelRect.height;
top = openUpTop >= margin ? openUpTop : Math.max(margin, window.innerHeight - margin - panelRect.height);
}
let left = align === 'end' ? pos.anchorRight - panelRect.width : pos.anchorLeft;
if (left + panelRect.width > window.innerWidth - margin) left = window.innerWidth - margin - panelRect.width;
if (left < margin) left = margin;
setPos((p) => ({ ...p, top, left, ready: true }));
}, [open, pos]);
useEffect(() => {
if (!open) return;
function onKey(e) { if (e.key === 'Escape') setOpen(false); }
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [open]);
return (
<>
<span ref={anchorRef}>{trigger({ onClick: () => (open ? setOpen(false) : openFlyout()) })}</span>
{open && pos && createPortal(
<>
<div className="flyout-backdrop" onClick={() => setOpen(false)} />
<div
ref={panelRef}
className="flyout-panel"
style={{ top: pos.top, left: pos.left, visibility: pos.ready ? 'visible' : 'hidden' }}
onClick={() => setOpen(false)}
>
{children}
</div>
</>,
document.body
)}
</>
);
}
export function FlyoutItem({ icon: Icon, danger, children, ...rest }) {
return (
<button className={`flyout-item ${danger ? 'danger' : ''}`} {...rest}>
{Icon && <Icon size={15} />}
<span>{children}</span>
</button>
);
}
export function FlyoutSeparator() {
return <div className="flyout-separator" />;
}