fix(ui): ComboBox y Flyout se portan a document.body con position:fixed
El panel desplegable de ComboBox se posicionaba con position:absolute dentro de su propio contenedor. Cualquier ancestro con overflow:hidden/auto (un Expander colapsable, el body de un DetailPopup, una tarjeta con scroll propio) lo recortaba, dejando la lista invisible o cortada a la mitad -reproducido en Configuracion > Apariencia, dentro del Expander-. Fix: igual tecnica que Flyout -createPortal a document.body, position:fixed calculado desde getBoundingClientRect() del trigger-, con cierre en click afuera y en scroll de cualquier ancestro (capture:true, el scroll no burbujea). De paso Flyout pasa de position:absolute a position:fixed por el mismo motivo (evita un bug latente de desalineacion si la pagina esta scrolleada). Regla documentada en project-md-templates/UI-UX-WINDOWS.md (Kit de componentes + do's/don'ts) para que no se repita en este ni otros proyectos. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+15
-2
@@ -111,9 +111,19 @@ openDialog({
|
|||||||
- **Campos** — `TextBox`, `NumberBox` (con spinner +/-), `ComboBox` propio (desplegable redondeado,
|
- **Campos** — `TextBox`, `NumberBox` (con spinner +/-), `ComboBox` propio (desplegable redondeado,
|
||||||
con buscador si la lista tiene **≥10 ítems**; nunca el `<select>`/combo nativo del SO — no se puede
|
con buscador si la lista tiene **≥10 ítems**; nunca el `<select>`/combo nativo del SO — no se puede
|
||||||
redondear ni estilar), `Textarea`, `CheckBox`, `RadioButtons`, `ToggleSwitch` (interruptor), `Slider`.
|
redondear ni estilar), `Textarea`, `CheckBox`, `RadioButtons`, `ToggleSwitch` (interruptor), `Slider`.
|
||||||
|
**Regla dura de implementación (bug real, recurrente):** el panel desplegable de `ComboBox` (y de
|
||||||
|
cualquier control propio con un panel flotante — `DatePicker`, `Flyout`) **nunca** se posiciona con
|
||||||
|
`position: absolute` dentro de su propio contenedor. Un ancestro con `overflow: hidden` o `overflow:
|
||||||
|
auto` (un `Expander` colapsable, el body de un `DetailPopup`, una tarjeta con scroll propio) lo
|
||||||
|
recorta y la lista queda invisible o cortada a la mitad. Portar el panel a `document.body` con
|
||||||
|
`createPortal` y posicionarlo con `position: fixed` calculado desde `getBoundingClientRect()` del
|
||||||
|
trigger (misma técnica que `Flyout`) — así queda fuera del árbol/clipping del contenedor. Cerrar el
|
||||||
|
panel en scroll (`window.addEventListener('scroll', fn, true)`, con `capture:true` porque el scroll
|
||||||
|
de un ancestro no burbujea) y en click afuera.
|
||||||
- **DatePicker/TimePicker** — calendario propio en un **flyout** anclado al campo (navegación de mes,
|
- **DatePicker/TimePicker** — calendario propio en un **flyout** anclado al campo (navegación de mes,
|
||||||
día de hoy marcado, selección resaltada con `accent`); igual criterio que `ComboBox`: no usar el
|
día de hoy marcado, selección resaltada con `accent`); igual criterio que `ComboBox`: no usar el
|
||||||
selector nativo del SO por falta de control visual.
|
selector nativo del SO por falta de control visual, y el mismo requisito de portal +
|
||||||
|
`position: fixed` de arriba (no recortarse dentro de un contenedor con overflow).
|
||||||
- **ListView/DataGrid** — tabla con encabezados **ordenables** (clic ordena asc/desc), columna de
|
- **ListView/DataGrid** — tabla con encabezados **ordenables** (clic ordena asc/desc), columna de
|
||||||
**selección múltiple** (checkbox), fila con **hover** y **estado seleccionado** (`accent/.14`), y
|
**selección múltiple** (checkbox), fila con **hover** y **estado seleccionado** (`accent/.14`), y
|
||||||
**clic derecho** que abre el menú contextual de esa fila (ver §6). Alternativa por fila sin mouse
|
**clic derecho** que abre el menú contextual de esa fila (ver §6). Alternativa por fila sin mouse
|
||||||
@@ -304,7 +314,10 @@ Alt+Tab); si es una decisión puntual ligada al contexto actual, es un `ContentD
|
|||||||
debajo del listado en pantallas de gestión (usar `DetailPopup`). ❌ que un `DetailPopup` cambie de
|
debajo del listado en pantallas de gestión (usar `DetailPopup`). ❌ que un `DetailPopup` cambie de
|
||||||
tamaño al moverse entre sus pestañas/secciones internas. ❌ montar `ContentDialog`/`DetailPopup` sin
|
tamaño al moverse entre sus pestañas/secciones internas. ❌ montar `ContentDialog`/`DetailPopup` sin
|
||||||
portal a `document.body` (queda atrapado en el stacking context de un ancestro y puede terminar
|
portal a `document.body` (queda atrapado en el stacking context de un ancestro y puede terminar
|
||||||
tapado por otro overlay).
|
tapado por otro overlay). ❌ panel desplegable de `ComboBox`/`DatePicker`/`Flyout` con
|
||||||
|
`position: absolute` dentro de su propio contenedor (un `Expander`, el body de un `DetailPopup` o
|
||||||
|
cualquier tarjeta con `overflow` lo recorta o lo oculta) — portar a `document.body` y usar
|
||||||
|
`position: fixed` calculado desde el trigger.
|
||||||
|
|
||||||
> Para que una app "se sienta Windows moderna": misma barra de título, mismo panel de navegación,
|
> Para que una app "se sienta Windows moderna": misma barra de título, mismo panel de navegación,
|
||||||
> mismos cuatro mecanismos de aviso (`ContentDialog`/`InfoBar`/`Toast`/`Flyout`) bien diferenciados,
|
> mismos cuatro mecanismos de aviso (`ContentDialog`/`InfoBar`/`Toast`/`Flyout`) bien diferenciados,
|
||||||
|
|||||||
@@ -1,20 +1,43 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { ChevronDown, Search } from 'lucide-react';
|
import { ChevronDown, Search } from 'lucide-react';
|
||||||
|
|
||||||
// Combo con buscador (regla del grupo: nunca el <select> nativo para listas >= 10 items).
|
// 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 }]
|
// options: [{ value, label }]
|
||||||
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 rootRef = useRef(null);
|
const [pos, setPos] = useState(null);
|
||||||
|
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 });
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onDocClick(e) {
|
if (!open) return;
|
||||||
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
function onDocMouseDown(e) {
|
||||||
|
if (triggerRef.current?.contains(e.target)) return;
|
||||||
|
if (panelRef.current?.contains(e.target)) return;
|
||||||
|
setOpen(false);
|
||||||
}
|
}
|
||||||
document.addEventListener('mousedown', onDocClick);
|
// capture:true para detectar scroll en CUALQUIER ancestro (los eventos de scroll no burbujean).
|
||||||
return () => document.removeEventListener('mousedown', onDocClick);
|
function onScroll() { setOpen(false); }
|
||||||
}, []);
|
document.addEventListener('mousedown', onDocMouseDown);
|
||||||
|
window.addEventListener('scroll', onScroll, true);
|
||||||
|
window.addEventListener('resize', onScroll);
|
||||||
|
return () => {
|
||||||
|
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 selected = options.find((o) => String(o.value) === String(value));
|
||||||
const filtered = query
|
const filtered = query
|
||||||
@@ -22,13 +45,19 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
|
|||||||
: options;
|
: options;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="combobox" ref={rootRef}>
|
<div className="combobox">
|
||||||
<button type="button" className="input" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }} onClick={() => setOpen((v) => !v)}>
|
<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>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{selected ? selected.label : (placeholder || '—')}</span>
|
||||||
<ChevronDown size={14} />
|
<ChevronDown size={14} />
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && pos && createPortal(
|
||||||
<div className="combobox-panel">
|
<div ref={panelRef} className="combobox-panel" style={{ top: pos.top, left: pos.left, width: pos.width }}>
|
||||||
{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 }} />
|
||||||
@@ -52,7 +81,8 @@ export function ComboBox({ options, value, onChange, placeholder = '', searchThr
|
|||||||
{o.label}
|
{o.label}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -105,10 +105,13 @@ button { font-family: inherit; }
|
|||||||
.field-row > .field { flex: 1; min-width: 180px; }
|
.field-row > .field { flex: 1; min-width: 180px; }
|
||||||
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: .82rem; }
|
.checkbox-row { display: flex; align-items: center; gap: 8px; font-size: .82rem; }
|
||||||
|
|
||||||
/* --- ComboBox --- */
|
/* --- ComboBox ---
|
||||||
|
El panel es un portal a document.body posicionado con `position: fixed` (top/left/width en
|
||||||
|
linea, calculados desde el trigger) — NUNCA anidado ni con position:absolute dentro de .combobox,
|
||||||
|
para no quedar recortado por un ancestro con overflow:hidden/auto (Expander, DetailPopup, etc.). */
|
||||||
.combobox { position: relative; }
|
.combobox { position: relative; }
|
||||||
.combobox-panel {
|
.combobox-panel {
|
||||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 40;
|
position: fixed; z-index: 220;
|
||||||
background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg);
|
background: rgb(var(--surface)); border: 1px solid rgb(var(--line)); border-radius: var(--r-lg);
|
||||||
box-shadow: 0 8px 24px rgb(0 0 0 / .35); max-height: 240px; overflow-y: auto; padding: 6px;
|
box-shadow: 0 8px 24px rgb(0 0 0 / .35); max-height: 240px; overflow-y: auto; padding: 6px;
|
||||||
}
|
}
|
||||||
@@ -213,7 +216,7 @@ table.list-table { width: 100%; border-collapse: collapse; font-size: .8rem; }
|
|||||||
/* --- Flyout / menú contextual --- */
|
/* --- Flyout / menú contextual --- */
|
||||||
.flyout-backdrop { position: fixed; inset: 0; z-index: 90; }
|
.flyout-backdrop { position: fixed; inset: 0; z-index: 90; }
|
||||||
.flyout-panel {
|
.flyout-panel {
|
||||||
position: absolute; z-index: 100; background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
|
position: fixed; z-index: 100; background: rgb(var(--surface)); border: 1px solid rgb(var(--line));
|
||||||
border-radius: var(--r-lg); box-shadow: 0 10px 28px rgb(0 0 0 / .35); padding: 6px; min-width: 180px;
|
border-radius: var(--r-lg); box-shadow: 0 10px 28px rgb(0 0 0 / .35); padding: 6px; min-width: 180px;
|
||||||
}
|
}
|
||||||
.flyout-item {
|
.flyout-item {
|
||||||
|
|||||||
Reference in New Issue
Block a user