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 { 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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user