Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/fluux/src/components/Tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,64 @@ describe('Tooltip', () => {
})
})

describe('scroll dismissal', () => {
it('keeps the tooltip open when an unrelated element scrolls', async () => {
vi.useFakeTimers()
render(
<div>
<div data-testid="unrelated-scroller">Some scrollable content</div>
<Tooltip content="Test tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>
</div>
)

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(
<Tooltip content="Test tooltip" delay={0}>
<button>Hover me</button>
</Tooltip>
)

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()
Expand Down
50 changes: 33 additions & 17 deletions apps/fluux/src/components/Tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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<TooltipPosition, string> = {
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 (
Expand Down Expand Up @@ -246,26 +260,28 @@ export function Tooltip({

{isVisible && createPortal(
<div
ref={tooltipRef}
role="tooltip"
style={{
position: 'fixed',
left: coords.x,
top: coords.y,
maxWidth,
pointerEvents: 'none',
// CSS variable for arrow color matching
['--tooltip-bg' as string]: 'var(--fluux-sidebar)',
zIndex: 9999,
}}
className="px-3 py-2 rounded-lg bg-fluux-sidebar text-fluux-text text-sm
shadow-[0_4px_16px_rgba(0,0,0,0.25)] border border-fluux-border
animate-tooltip-in"
className="animate-tooltip-in"
>
{content}
{/* Arrow */}
<div
className={`absolute size-0 border-[6px] ${arrowStyles[actualPosition]}`}
ref={tooltipRef}
role="tooltip"
style={{ maxWidth }}
className="relative px-3 py-2 rounded-lg bg-fluux-float text-fluux-text text-sm
shadow-[0_4px_16px_rgba(0,0,0,0.25)] border border-fluux-border"
>
{content}
</div>
{/* Arrow — painted after (over) the bubble; see arrowStyles above. */}
<div
aria-hidden
className={`absolute size-2.5 rotate-45 bg-fluux-float border-fluux-border ${arrowStyles[actualPosition]}`}
/>
</div>,
document.body
Expand Down
13 changes: 10 additions & 3 deletions apps/fluux/src/components/conversation/UserInfoPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
Loading