From e141ad0721c5319a82d62d305eceed867c728b53 Mon Sep 17 00:00:00 2001 From: Gabriel Menezes Date: Thu, 22 Oct 2026 10:47:47 -0400 Subject: [PATCH] fix(ui): render select dropdown in portal with top-level z-index to avoid stacking issues --- frontend/src/components/ui/Select.tsx | 223 +++++++++++++++++++------- 1 file changed, 163 insertions(+), 60 deletions(-) diff --git a/frontend/src/components/ui/Select.tsx b/frontend/src/components/ui/Select.tsx index 3cc4fd6..5cbc5e7 100644 --- a/frontend/src/components/ui/Select.tsx +++ b/frontend/src/components/ui/Select.tsx @@ -1,4 +1,5 @@ -import { useState, useRef, useEffect, type ReactNode } from 'react' +import { useState, useRef, useEffect, useLayoutEffect, useCallback, type ReactNode } from 'react' +import { createPortal } from 'react-dom' import { ChevronDown, Check } from 'lucide-react' export interface SelectOption { @@ -29,6 +30,8 @@ export interface CustomSelectProps { * - Foco dinâmico com ring colorido e leve elevação * - Popover em glassmorphism com animação de zoom/fade * - Itens com highlight de hover e seleção com checkmark + * - Renderização via Portal (document.body) com z-index de nível superior (z-[9999]), + * evitando que o menu fique atrás de outros componentes, cards, modais ou tabelas. */ export function CustomSelect({ value, @@ -44,14 +47,96 @@ export function CustomSelect({ }: CustomSelectProps) { const [isOpen, setIsOpen] = useState(false) const containerRef = useRef(null) + const popoverRef = useRef(null) + const [coords, setCoords] = useState<{ + top?: number + bottom?: number + left: number + width: number + openUpwards: boolean + } | null>(null) // Opção atualmente selecionada const selectedOption = options.find((opt) => opt.value === value) - // Fechar ao clicar fora + const updatePosition = useCallback(() => { + if (!containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + + // Elemento oculto ou desmontado + if (rect.width === 0 && rect.height === 0) return + + // Se saiu completamente da viewport ao rolar + if (rect.bottom < 0 || rect.top > window.innerHeight) { + setIsOpen(false) + return + } + + const estimatedHeight = Math.min(options.length * 44 + 20, 260) + const spaceBelow = window.innerHeight - rect.bottom + const spaceAbove = rect.top + + const openUpwards = spaceBelow < estimatedHeight && spaceAbove > spaceBelow + + const width = Math.max(rect.width, 180) + let left = rect.left + + // Evitar transbordar para fora da tela no mobile / resoluções estreitas + if (left + width > window.innerWidth - 8) { + left = Math.max(8, window.innerWidth - width - 8) + } + + if (openUpwards) { + setCoords({ + bottom: window.innerHeight - rect.top + 6, + left, + width, + openUpwards: true, + }) + } else { + setCoords({ + top: rect.bottom + 6, + left, + width, + openUpwards: false, + }) + } + }, [options.length]) + + // Atualizar posição antes do primeiro paint ao abrir + useLayoutEffect(() => { + if (isOpen) { + updatePosition() + } + }, [isOpen, updatePosition]) + + // Acompanhar resize e scroll de qualquer elemento pai (modal, window, tabela) + useEffect(() => { + if (!isOpen) return + + const handleScrollOrResize = () => { + updatePosition() + } + + window.addEventListener('resize', handleScrollOrResize) + window.addEventListener('scroll', handleScrollOrResize, true) + + return () => { + window.removeEventListener('resize', handleScrollOrResize) + window.removeEventListener('scroll', handleScrollOrResize, true) + } + }, [isOpen, updatePosition]) + + // Fechar ao clicar fora (verificando tanto o container quanto o portal) useEffect(() => { const handleOutsideClick = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + const target = event.target as Node + if ( + containerRef.current && + !containerRef.current.contains(target) && + popoverRef.current && + !popoverRef.current.contains(target) + ) { setIsOpen(false) } } @@ -83,7 +168,10 @@ export function CustomSelect({ const isSmall = size === 'sm' return ( -
+
{/* Label opcional no estilo HeroUI */} {label && (
- {/* Popover ListBox - Estilo HeroUI v3 Dropdown */} - {isOpen && ( -
-
- {options.map((option) => { - const isSelected = option.value === value - return ( -
- {/* Checkmark indicator for selected item */} - {isSelected && ( - - )} - - ) - })} -
-
- )} + {/* Icon */} + {option.icon && ( + {option.icon} + )} + + {/* Label and description */} +
+

{option.label}

+ {option.description && ( +

+ {option.description} +

+ )} +
+ + + {/* Checkmark indicator for selected item */} + {isSelected && ( + + )} + + ) + })} + + , + document.body + )} ) }