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])