diff --git a/packages/emcn/src/components/tooltip/tooltip.test.tsx b/packages/emcn/src/components/tooltip/tooltip.test.tsx new file mode 100644 index 00000000000..dc7ce0ca2ee --- /dev/null +++ b/packages/emcn/src/components/tooltip/tooltip.test.tsx @@ -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('[role="tooltip"]') +} + +function tooltipUi(withTrigger: boolean) { + return ( + + {withTrigger && Hover me} + Delete column + + ) +} + +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() + }) +}) diff --git a/packages/emcn/src/components/tooltip/tooltip.tsx b/packages/emcn/src/components/tooltip/tooltip.tsx index e7c02249646..247b3974c1f 100644 --- a/packages/emcn/src/components/tooltip/tooltip.tsx +++ b/packages/emcn/src/components/tooltip/tooltip.tsx @@ -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: @@ -94,20 +97,22 @@ export function useFloatingTooltip(canShow: (target: HTMLElement) => boolean): { const lastPointerRef = React.useRef(null) const velocityRef = React.useRef({ x: 0, magnitude: 0 }) + const triggerRef = React.useRef(null) const [state, setState] = React.useState(HIDDEN_STATE) - const handlers = React.useMemo(() => { - 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(() => { const apply = (clientX: number, clientY: number, motion: TooltipMotion) => { const next = { ...getTooltipPosition(clientX, clientY), ...motion } setState((current) => @@ -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 @@ -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 } } @@ -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))