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 { ChevronDown, X } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -17,9 +18,6 @@ interface FilterPillProps {
children: React.ReactNode children: React.ReactNode
/** Force the popover open programmatically (rare). */ /** Force the popover open programmatically (rare). */
defaultOpen?: boolean 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, onClear,
children, children,
defaultOpen = false, defaultOpen = false,
alignRight = false,
}: FilterPillProps) { }: FilterPillProps) {
const [open, setOpen] = useState(defaultOpen) 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(() => { useEffect(() => {
if (!open) return if (!open) return
const onDocMouseDown = (e: MouseEvent) => { const onDocMouseDown = (e: MouseEvent) => {
if (!wrapperRef.current) return const target = e.target as Node
if (!wrapperRef.current.contains(e.target as Node)) { if (buttonRef.current?.contains(target)) return
setOpen(false) if (popoverRef.current?.contains(target)) return
} setOpen(false)
} }
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false) if (e.key === 'Escape') setOpen(false)
@@ -61,8 +90,9 @@ export function FilterPill({
}, [open]) }, [open])
return ( return (
<div ref={wrapperRef} className="relative"> <>
<button <button
ref={buttonRef}
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className={clsx( className={clsx(
'flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs transition-colors', '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> </button>
{open && ( {open &&
<div createPortal(
className={clsx( <div
'absolute top-full z-30 mt-1 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl', ref={popoverRef}
alignRight ? 'right-0' : 'left-0' style={{
)} position: 'fixed',
> top: popoverPos?.top ?? -9999,
{children} left: popoverPos?.left ?? -9999,
</div> visibility: popoverPos ? 'visible' : 'hidden',
)} }}
</div> className="z-50 min-w-[220px] rounded-lg border border-border bg-surface p-3 shadow-xl"
>
{children}
</div>,
document.body
)}
</>
) )
} }