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:
2026-04-08 12:53:04 +02:00
parent 2f1e9033ae
commit 1c428dda8b

View File

@@ -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
)}
</>
)
}