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 + )} + ) }