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:
@@ -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,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,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",
|
||||||
|
|||||||
@@ -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 }} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user