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>
This commit is contained in:
Milton Gonzalez
2026-10-14 16:37:02 -05:00
parent 0832836033
commit 70b601307f
6 changed files with 76 additions and 11 deletions
+8
View File
@@ -1,5 +1,13 @@
# Changelog - project.linkedcorp.net # Changelog - project.linkedcorp.net
## [2.13.1] - 2026-10-14
### Fixed
- ComboBox y Flyout (menu de 3 puntos): el panel desplegable se quedaba cortado por el borde
inferior/derecho de la ventana cuando el trigger estaba cerca de ese borde (ej. compartir una
credencial, "Eliminar" en una fila al final de la grilla). Ahora se mide el tamaño real del
panel y se voltea hacia arriba / se recorta hacia adentro si no cabe — siempre visible dentro
de la ventana.
## [2.13.0] - 2026-10-14 ## [2.13.0] - 2026-10-14
### Added ### Added
- Contraseñas: grupos de un solo nivel (ej. "Correos") para organizar las credenciales — se - Contraseñas: grupos de un solo nivel (ej. "Correos") para organizar las credenciales — se
+1 -1
View File
@@ -1 +1 @@
2.13.0 2.13.1
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "project-linkedcorp-backend", "name": "project-linkedcorp-backend",
"version": "2.13.0", "version": "2.13.1",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/server.js", "main": "src/server.js",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "project-linkedcorp-frontend", "name": "project-linkedcorp-frontend",
"private": true, "private": true,
"version": "2.13.0", "version": "2.13.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+28 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ChevronDown, Search } from 'lucide-react'; import { ChevronDown, Search } from 'lucide-react';
@@ -10,16 +10,40 @@ import { ChevronDown, Search } from 'lucide-react';
export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) { export function ComboBox({ options, value, onChange, placeholder = '', searchThreshold = 10 }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [pos, setPos] = useState(null); const [pos, setPos] = useState(null); // { top, left, width, anchorTop, anchorBottom, ready }
const triggerRef = useRef(null); const triggerRef = useRef(null);
const panelRef = useRef(null); const panelRef = useRef(null);
function openPanel() { function openPanel() {
const rect = triggerRef.current.getBoundingClientRect(); const rect = triggerRef.current.getBoundingClientRect();
setPos({ top: rect.bottom + 4, left: rect.left, width: rect.width }); setPos({
top: rect.bottom + 4, left: rect.left, width: rect.width,
anchorTop: rect.top, anchorBottom: rect.bottom, ready: false,
});
setOpen(true); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onDocMouseDown(e) { function onDocMouseDown(e) {
@@ -57,7 +81,7 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
<ChevronDown size={14} /> <ChevronDown size={14} />
</button> </button>
{open && pos && createPortal( {open && pos && createPortal(
<div ref={panelRef} className="combobox-panel" style={{ top: pos.top, left: pos.left, width: pos.width }}> <div ref={panelRef} className="combobox-panel" style={{ top: pos.top, left: pos.left, width: pos.width, visibility: pos.ready ? 'visible' : 'hidden' }}>
{options.length >= searchThreshold && ( {options.length >= searchThreshold && (
<div className="combobox-search" style={{ position: 'relative' }}> <div className="combobox-search" style={{ position: 'relative' }}>
<Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} /> <Search size={13} style={{ position: 'absolute', left: 8, top: 9, opacity: .6 }} />
+37 -4
View File
@@ -1,21 +1,53 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
// Flyout generico con light-dismiss: click fuera o Esc cierra. trigger recibe { onClick, ref }. // 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' }) { export function Flyout({ trigger, children, align = 'end' }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0 }); const [pos, setPos] = useState(null); // { top, left, anchorTop, anchorBottom, anchorLeft, anchorRight, ready }
const anchorRef = useRef(null); const anchorRef = useRef(null);
const panelRef = useRef(null);
function openFlyout() { function openFlyout() {
const rect = anchorRef.current.getBoundingClientRect(); const rect = anchorRef.current.getBoundingClientRect();
setPos({ setPos({
top: rect.bottom + 6, top: rect.bottom + 6,
left: align === 'end' ? rect.right : rect.left, left: align === 'end' ? rect.right : rect.left,
anchorTop: rect.top,
anchorBottom: rect.bottom,
anchorLeft: rect.left,
anchorRight: rect.right,
ready: false,
}); });
setOpen(true); 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(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onKey(e) { if (e.key === 'Escape') setOpen(false); } function onKey(e) { if (e.key === 'Escape') setOpen(false); }
@@ -26,12 +58,13 @@ export function Flyout({ trigger, children, align = 'end' }) {
return ( return (
<> <>
<span ref={anchorRef}>{trigger({ onClick: () => (open ? setOpen(false) : openFlyout()) })}</span> <span ref={anchorRef}>{trigger({ onClick: () => (open ? setOpen(false) : openFlyout()) })}</span>
{open && createPortal( {open && pos && createPortal(
<> <>
<div className="flyout-backdrop" onClick={() => setOpen(false)} /> <div className="flyout-backdrop" onClick={() => setOpen(false)} />
<div <div
ref={panelRef}
className="flyout-panel" className="flyout-panel"
style={{ top: pos.top, left: align === 'end' ? pos.left - 220 : pos.left }} style={{ top: pos.top, left: pos.left, visibility: pos.ready ? 'visible' : 'hidden' }}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
{children} {children}