fix(ui): render select dropdown in portal with top-level z-index to avoid stacking issues
This commit is contained in:
parent
f8b8bc14b8
commit
e141ad0721
1 changed files with 163 additions and 60 deletions
|
|
@ -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<T = string | number> {
|
||||
|
|
@ -29,6 +30,8 @@ export interface CustomSelectProps<T = string | number> {
|
|||
* - 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<T extends string | number>({
|
||||
value,
|
||||
|
|
@ -44,14 +47,96 @@ export function CustomSelect<T extends string | number>({
|
|||
}: CustomSelectProps<T>) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(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<T extends string | number>({
|
|||
const isSmall = size === 'sm'
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`relative w-full text-left ${className}`}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`relative w-full text-left ${isOpen ? 'z-30' : ''} ${className}`}
|
||||
>
|
||||
{/* Label opcional no estilo HeroUI */}
|
||||
{label && (
|
||||
<label className="block text-xs font-bold text-foreground mb-1.5 tracking-tight">
|
||||
|
|
@ -141,12 +229,26 @@ export function CustomSelect<T extends string | number>({
|
|||
</div>
|
||||
</button>
|
||||
|
||||
{/* Popover ListBox - Estilo HeroUI v3 Dropdown */}
|
||||
{isOpen && (
|
||||
{/* Popover ListBox - Renderizado no body via createPortal para z-index absoluto */}
|
||||
{isOpen &&
|
||||
coords &&
|
||||
typeof document !== 'undefined' &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
role="listbox"
|
||||
tabIndex={-1}
|
||||
className="absolute left-0 right-0 mt-1.5 z-50 min-w-[200px] max-h-64 overflow-y-auto rounded-2xl border border-border-soft bg-surface/95 dark:bg-zinc-900/95 backdrop-blur-xl p-1.5 shadow-2xl animate-in fade-in zoom-in-95 duration-150 ease-out"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: coords.top !== undefined ? `${coords.top}px` : 'auto',
|
||||
bottom: coords.bottom !== undefined ? `${coords.bottom}px` : 'auto',
|
||||
left: `${coords.left}px`,
|
||||
width: `${coords.width}px`,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className={`max-h-64 overflow-y-auto rounded-2xl border border-border-soft bg-surface/95 dark:bg-zinc-900/95 backdrop-blur-xl p-1.5 shadow-2xl animate-in fade-in zoom-in-95 duration-150 ease-out ${
|
||||
coords.openUpwards ? 'origin-bottom' : 'origin-top'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{options.map((option) => {
|
||||
|
|
@ -197,7 +299,8 @@ export function CustomSelect<T extends string | number>({
|
|||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue