diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ce103..ed71648 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 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 ### Added - Contraseñas: grupos de un solo nivel (ej. "Correos") para organizar las credenciales — se diff --git a/VERSION b/VERSION index fb2c076..94f15e9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.13.0 +2.13.1 diff --git a/backend/package.json b/backend/package.json index 348014a..2b809ac 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "project-linkedcorp-backend", - "version": "2.13.0", + "version": "2.13.1", "private": true, "type": "module", "main": "src/server.js", diff --git a/frontend/package.json b/frontend/package.json index ffffbe3..01b6d14 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "project-linkedcorp-frontend", "private": true, - "version": "2.13.0", + "version": "2.13.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/components/ComboBox.jsx b/frontend/src/components/ComboBox.jsx index e7bf68a..7cb1315 100644 --- a/frontend/src/components/ComboBox.jsx +++ b/frontend/src/components/ComboBox.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; 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 }) { const [open, setOpen] = useState(false); 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 panelRef = useRef(null); function openPanel() { 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); } + // 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) { @@ -57,7 +81,7 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr {open && pos && createPortal( -
+
{options.length >= searchThreshold && (
diff --git a/frontend/src/components/Flyout.jsx b/frontend/src/components/Flyout.jsx index a509e0e..2918ff4 100644 --- a/frontend/src/components/Flyout.jsx +++ b/frontend/src/components/Flyout.jsx @@ -1,21 +1,53 @@ -import { useEffect, useRef, useState } from '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({ top: 0, left: 0 }); + 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); } @@ -26,12 +58,13 @@ export function Flyout({ trigger, children, align = 'end' }) { return ( <> {trigger({ onClick: () => (open ? setOpen(false) : openFlyout()) })} - {open && createPortal( + {open && pos && createPortal( <>
setOpen(false)} />
setOpen(false)} > {children}