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
136 changes: 136 additions & 0 deletions packages/emcn/src/components/tooltip/tooltip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* @vitest-environment jsdom
*
* The floating tooltip hides from pointer/focus events on its trigger — but a keyboard- or
* script-driven UI change (e.g. tiptap's bubble menu setting `visibility: hidden` on Cmd+A) hides
* the trigger with no such event, and browsers don't re-dispatch boundary events until the pointer
* moves. These tests cover the visibility watcher that dismisses a shown tooltip once its trigger
* is hidden or removed, and that it leaves a still-visible trigger's tooltip alone.
*/
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Tooltip } from './tooltip'

let root: Root | null = null
let container: HTMLDivElement | null = null

function mount(ui: ReactNode) {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
act(() => root?.render(ui))
}

beforeEach(() => {
vi.useFakeTimers()
})

afterEach(() => {
if (root) act(() => root?.unmount())
container?.remove()
root = null
container = null
vi.useRealTimers()
})

function trigger(): HTMLButtonElement {
const node = container?.querySelector('button')
if (!node) throw new Error('Trigger did not render')
return node
}

function hover(node: HTMLElement) {
act(() => {
node.dispatchEvent(new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }))
})
}

/** Generously past TRIGGER_VISIBILITY_INTERVAL_MS so at least one watcher tick has run. */
function runWatcherTick() {
act(() => {
vi.advanceTimersByTime(500)
})
}

function tooltipElement(): HTMLElement | null {
return document.querySelector<HTMLElement>('[role="tooltip"]')
}

function tooltipUi(withTrigger: boolean) {
return (
<Tooltip.Root>
{withTrigger && <Tooltip.Trigger>Hover me</Tooltip.Trigger>}
<Tooltip.Content>Delete column</Tooltip.Content>
</Tooltip.Root>
)
}

function mountTooltip() {
mount(tooltipUi(true))
}

describe('floating tooltip trigger visibility watcher', () => {
it('keeps the tooltip shown while the trigger stays visible', () => {
mountTooltip()
hover(trigger())
expect(tooltipElement()).not.toBeNull()

runWatcherTick()
expect(tooltipElement()).not.toBeNull()
})

it('dismisses the tooltip when the trigger is hidden without a pointer event', () => {
mountTooltip()
hover(trigger())
expect(tooltipElement()).not.toBeNull()

trigger().style.visibility = 'hidden'
runWatcherTick()
expect(tooltipElement()).toBeNull()
})

it('dismisses the tooltip when the trigger is display: none without a pointer event', () => {
mountTooltip()
hover(trigger())
expect(tooltipElement()).not.toBeNull()

trigger().style.display = 'none'
runWatcherTick()
expect(tooltipElement()).toBeNull()
})

it('dismisses the tooltip when an ancestor becomes display: none', () => {
mountTooltip()
hover(trigger())
expect(tooltipElement()).not.toBeNull()

if (!container) throw new Error('Container did not mount')
container.style.display = 'none'
runWatcherTick()
expect(tooltipElement()).toBeNull()
})

it('dismisses the tooltip when the trigger unmounts from under a static pointer', () => {
mountTooltip()
hover(trigger())
expect(tooltipElement()).not.toBeNull()

act(() => root?.render(tooltipUi(false)))
runWatcherTick()
expect(tooltipElement()).toBeNull()
})

it('still hides on pointer leave', () => {
mountTooltip()
const node = trigger()
hover(node)
expect(tooltipElement()).not.toBeNull()

act(() => {
node.dispatchEvent(new MouseEvent('pointerout', { bubbles: true }))
})
expect(tooltipElement()).toBeNull()
})
})
65 changes: 54 additions & 11 deletions packages/emcn/src/components/tooltip/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const EDGE_GUTTER = 16
const EDGE_THRESHOLD = 360
const MIN_FRAME_MS = 16

/** How often a visible tooltip re-verifies that its trigger is still visibly rendered, in ms. */
const TRIGGER_VISIBILITY_INTERVAL_MS = 150

/**
* Exponential time constant for smoothing the pointer velocity that drives the
* flourish, in ms. The flourish is deliberately never handed to a CSS transition:
Expand Down Expand Up @@ -94,20 +97,22 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {

const lastPointerRef = React.useRef<PointerSnapshot | null>(null)
const velocityRef = React.useRef({ x: 0, magnitude: 0 })
const triggerRef = React.useRef<HTMLElement | null>(null)
const [state, setState] = React.useState<FloatingTooltipState>(HIDDEN_STATE)

const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
const reset = () => {
lastPointerRef.current = null
velocityRef.current.x = 0
velocityRef.current.magnitude = 0
}
const reset = React.useCallback(() => {
lastPointerRef.current = null
velocityRef.current.x = 0
velocityRef.current.magnitude = 0
}, [])

const hide = () => {
reset()
setState((current) => (current.visible ? HIDDEN_STATE : current))
}
const hide = React.useCallback(() => {
reset()
triggerRef.current = null
setState((current) => (current.visible ? HIDDEN_STATE : current))
}, [reset])

const handlers = React.useMemo<FloatingTooltipHandlers>(() => {
const apply = (clientX: number, clientY: number, motion: TooltipMotion) => {
const next = { ...getTooltipPosition(clientX, clientY), ...motion }
setState((current) =>
Expand Down Expand Up @@ -145,10 +150,12 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
return {
onPointerEnter: (event) => {
if (!canShowRef.current(event.currentTarget)) return
triggerRef.current = event.currentTarget
showFromPointer(event.clientX, event.clientY)
},
onPointerMove: (event) => {
if (!canShowRef.current(event.currentTarget)) return
triggerRef.current = event.currentTarget
const now = performance.now()
const previous = lastPointerRef.current
const delta = previous ? Math.max(now - previous.time, 1) : MIN_FRAME_MS
Expand Down Expand Up @@ -178,12 +185,28 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): {
const target = event.currentTarget
if (!canShowRef.current(target)) return
if (!isFocusVisible(target)) return
triggerRef.current = target
const rect = target.getBoundingClientRect()
showFromElement(rect.left + rect.width / 2, rect.bottom)
},
onBlur: hide,
}
}, [])
}, [hide, reset])

/**
* A keyboard- or script-driven UI change can hide the trigger with no pointer or focus event —
* browsers don't re-dispatch boundary events until the pointer next moves (e.g. an editor bubble
* menu set to `visibility: hidden` by Cmd+A while a toolbar tooltip is open) — so while visible,
* the tooltip re-verifies its trigger and dismisses itself once the trigger is gone or hidden.
*/
React.useEffect(() => {
if (!state.visible) return undefined
const intervalId = window.setInterval(() => {
const trigger = triggerRef.current
if (!trigger || !isVisiblyRendered(trigger)) hide()
}, TRIGGER_VISIBILITY_INTERVAL_MS)
return () => window.clearInterval(intervalId)
}, [state.visible, hide])

return { state, handlers }
}
Expand Down Expand Up @@ -242,6 +265,26 @@ export function isTextClipped(element: HTMLElement): boolean {
return element.scrollWidth > element.clientWidth + 1
}

/**
* Whether a tooltip trigger is still visibly rendered. `checkVisibility` (where available) catches
* `display: none` and an inherited `visibility: hidden` anywhere up the tree. The fallback for
* engines without it (Safari < 17.4, jsdom) reads the element's computed `visibility` — which
* inherits from hidden ancestors — and then walks the ancestor chain for `display: none`, which
* does not inherit. Computed styles, not layout (`getClientRects`/`offsetParent`), on purpose:
* jsdom does no layout, so a layout-based check would misread every trigger as hidden in tests.
*/
function isVisiblyRendered(element: HTMLElement): boolean {
if (!element.isConnected) return false
if (typeof element.checkVisibility === 'function') {
return element.checkVisibility({ checkVisibilityCSS: true, visibilityProperty: true })
}
if (getComputedStyle(element).visibility === 'hidden') return false
for (let node: HTMLElement | null = element; node; node = node.parentElement) {
if (getComputedStyle(node).display === 'none') return false
}
return true
}

/** Clamps `value` to the inclusive `[min, max]` range. */
export function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
Expand Down
Loading