fix: portal FilterPill popover so it escapes overflow clipping
The FilterBar uses overflow-x-auto for horizontal scroll, which forces overflow-y to auto as well — that was clipping the absolutely-positioned pill popovers below the bar. Render the popover into document.body via a portal with fixed coordinates derived from getBoundingClientRect(), and clamp the left edge so right-most pills don't push the popover off-screen. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ChevronDown, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
@@ -17,9 +18,6 @@ interface FilterPillProps {
|
||||
children: React.ReactNode
|
||||
/** Force the popover open programmatically (rare). */
|
||||
defaultOpen?: boolean
|
||||
/** Right-align the popover instead of left (for pills near the right
|
||||
* edge so they don't overflow the viewport). */
|
||||
alignRight?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -35,19 +33,50 @@ export function FilterPill({
|
||||
onClear,
|
||||
children,
|
||||
defaultOpen = false,
|
||||
alignRight = false,
|
||||
}: FilterPillProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const [popoverPos, setPopoverPos] = useState<{ top: number; left: number } | null>(null)
|
||||
|
||||
// Close on outside click + Escape.
|
||||
// Compute the popover's screen position from the trigger button. Done
|
||||
// imperatively (not via CSS absolute) so the popover can live in a portal
|
||||
// and escape the FilterBar's overflow-x-auto clipping. Re-computed on
|
||||
// open, scroll, and resize.
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return
|
||||
const update = () => {
|
||||
const btn = buttonRef.current
|
||||
if (!btn) return
|
||||
const rect = btn.getBoundingClientRect()
|
||||
// Default left-align under the trigger; clamp to viewport so the
|
||||
// last pill on the right doesn't overflow.
|
||||
const popWidth = popoverRef.current?.offsetWidth ?? 240
|
||||
const margin = 8
|
||||
let left = rect.left
|
||||
if (left + popWidth + margin > window.innerWidth) {
|
||||
left = Math.max(margin, window.innerWidth - popWidth - margin)
|
||||
}
|
||||
setPopoverPos({ top: rect.bottom + 4, left })
|
||||
}
|
||||
update()
|
||||
window.addEventListener('resize', update)
|
||||
window.addEventListener('scroll', update, true)
|
||||
return () => {
|
||||
window.removeEventListener('resize', update)
|
||||
window.removeEventListener('scroll', update, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
// Close on outside click + Escape. Outside means neither the trigger
|
||||
// button nor the (portaled) popover.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
if (!wrapperRef.current) return
|
||||
if (!wrapperRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
const target = e.target as Node
|
||||
if (buttonRef.current?.contains(target)) return
|
||||
if (popoverRef.current?.contains(target)) return
|
||||
setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
@@ -61,8 +90,9 @@ export function FilterPill({
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={clsx(
|
||||
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors',
|
||||
@@ -92,16 +122,22 @@ export function FilterPill({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute top-full z-30 mt-1 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl',
|
||||
alignRight ? 'right-0' : 'left-0'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popoverRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: popoverPos?.top ?? -9999,
|
||||
left: popoverPos?.left ?? -9999,
|
||||
visibility: popoverPos ? 'visible' : 'hidden',
|
||||
}}
|
||||
className="z-50 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl"
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user