diff --git a/apps/fluux/src/components/Tooltip.test.tsx b/apps/fluux/src/components/Tooltip.test.tsx index 1771f199..0f29151c 100644 --- a/apps/fluux/src/components/Tooltip.test.tsx +++ b/apps/fluux/src/components/Tooltip.test.tsx @@ -142,6 +142,64 @@ describe('Tooltip', () => { }) }) + describe('scroll dismissal', () => { + it('keeps the tooltip open when an unrelated element scrolls', async () => { + vi.useFakeTimers() + render( +
+
Some scrollable content
+ + + +
+ ) + + const trigger = screen.getByText('Hover me').parentElement! + fireEvent.mouseEnter(trigger) + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + expect(screen.getByRole('tooltip')).toBeInTheDocument() + + // An unrelated container (e.g. the message list auto-scrolling to bottom + // when the main view mounts) scrolls — this must NOT dismiss the tooltip. + const unrelated = screen.getByTestId('unrelated-scroller') + act(() => { + unrelated.dispatchEvent(new Event('scroll', { bubbles: false })) + }) + + expect(screen.getByRole('tooltip')).toBeInTheDocument() + }) + + it('hides the tooltip when a scroll moves the trigger', async () => { + vi.useFakeTimers() + render( + + + + ) + + const trigger = screen.getByText('Hover me').parentElement! + fireEvent.mouseEnter(trigger) + + await act(async () => { + await vi.advanceTimersByTimeAsync(0) + }) + + expect(screen.getByRole('tooltip')).toBeInTheDocument() + + // A scroll whose target contains the trigger (e.g. page/ancestor scroll) + // can move the trigger, so the tooltip should hide. + act(() => { + document.dispatchEvent(new Event('scroll')) + }) + + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) + }) + describe('disabled state', () => { it('should not show tooltip when disabled', async () => { vi.useFakeTimers() diff --git a/apps/fluux/src/components/Tooltip.tsx b/apps/fluux/src/components/Tooltip.tsx index f672ae88..cbc1e727 100644 --- a/apps/fluux/src/components/Tooltip.tsx +++ b/apps/fluux/src/components/Tooltip.tsx @@ -103,7 +103,17 @@ export function Tooltip({ useEffect(() => { if (!isVisible) return - const handleScroll = () => hideTooltip() + // Only dismiss on scrolls that could actually move the trigger — i.e. the + // scrolled element contains the trigger. A global capture listener would + // otherwise fire for any scroll anywhere (e.g. the message list + // auto-scrolling to bottom when the main view mounts), yanking the tooltip + // away for unrelated reasons. + const handleScroll = (e: Event) => { + const target = e.target + if (target instanceof Node && target.contains(triggerRef.current)) { + hideTooltip() + } + } const handleBlur = () => hideTooltip() const handlePointerDown = () => hideTooltip() @@ -209,12 +219,16 @@ export function Tooltip({ } }, []) - // Arrow styles based on position + // Arrow: a rotated square centred on the bubble edge and painted ON TOP of + // the bubble, so its fill covers the bubble's border at the junction (no + // "internal" line across the base). Only the two OUTER edges carry a border, + // so the visible half-diamond reads as a seamless continuation of the + // bubble's outline pointing at the trigger — not a separate diamond. const arrowStyles: Record = { - top: 'bottom-0 left-1/2 -translate-x-1/2 translate-y-full border-t-[var(--tooltip-bg)] border-x-transparent border-b-transparent', - bottom: 'top-0 left-1/2 -translate-x-1/2 -translate-y-full border-b-[var(--tooltip-bg)] border-x-transparent border-t-transparent', - left: 'right-0 top-1/2 -translate-y-1/2 translate-x-full border-l-[var(--tooltip-bg)] border-y-transparent border-r-transparent', - right: 'left-0 top-1/2 -translate-y-1/2 -translate-x-full border-r-[var(--tooltip-bg)] border-y-transparent border-l-transparent', + top: 'left-1/2 top-full -translate-x-1/2 -translate-y-1/2 border-b border-r', + bottom: 'left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 border-t border-l', + left: 'left-full top-1/2 -translate-x-1/2 -translate-y-1/2 border-t border-r', + right: 'left-0 top-1/2 -translate-x-1/2 -translate-y-1/2 border-b border-l', } return ( @@ -246,26 +260,28 @@ export function Tooltip({ {isVisible && createPortal(
- {content} - {/* Arrow */}
+ {content} +
+ {/* Arrow — painted after (over) the bubble; see arrowStyles above. */} +
, document.body diff --git a/apps/fluux/src/components/conversation/UserInfoPopover.tsx b/apps/fluux/src/components/conversation/UserInfoPopover.tsx index fe7d290c..d56f18ea 100644 --- a/apps/fluux/src/components/conversation/UserInfoPopover.tsx +++ b/apps/fluux/src/components/conversation/UserInfoPopover.tsx @@ -91,12 +91,19 @@ export function UserInfoPopover({ contact, jid, occupantJid, role, affiliation, useClickOutside(popoverRef, () => setIsOpen(false), isOpen) useFocusTrap(popoverRef, { active: isOpen }) - // Close on scroll (message list or any parent) + // Close on scroll of a container that actually holds the trigger (e.g. the + // message list the anchor lives in). Capture phase catches scrolls on any + // ancestor, but we ignore unrelated scrolls elsewhere in the app so the + // popover isn't yanked away when, say, the sidebar list scrolls. useEffect(() => { if (!isOpen) return - const handleScroll = () => setIsOpen(false) - // Use capture to catch scroll events from any scrolling container + const handleScroll = (e: Event) => { + const target = e.target + if (target instanceof Node && target.contains(triggerRef.current)) { + setIsOpen(false) + } + } window.addEventListener('scroll', handleScroll, true) return () => window.removeEventListener('scroll', handleScroll, true) }, [isOpen])