From 1c428dda8b50caab610d2ef16d410438803f2d9b Mon Sep 17 00:00:00 2001 From: dtoro Date: Wed, 8 Apr 2026 12:53:04 +0200 Subject: [PATCH] fix: portal FilterPill popover so it escapes overflow clipping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/components/filter/FilterPill.tsx | 82 +++++++++++++------ 1 file changed, 59 insertions(+), 23 deletions(-) diff --git a/frontend/src/components/filter/FilterPill.tsx b/frontend/src/components/filter/FilterPill.tsx index a9f71bd..ea7450c 100644 --- a/frontend/src/components/filter/FilterPill.tsx +++ b/frontend/src/components/filter/FilterPill.tsx @@ -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(null) + const buttonRef = useRef(null) + const popoverRef = useRef(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 ( -
+ <> - {open && ( -
- {children} -
- )} -
+ {open && + createPortal( +
+ {children} +
, + document.body + )} + ) }