From 051507717c321ac7ffa1f04be76ce8431f2e4329 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 21 Apr 2026 18:17:39 +0700 Subject: [PATCH] Fix sticky workspace hover card --- .../src/components/workspace-hover-card.tsx | 12 +- .../src/hooks/use-hover-safe-zone.test.tsx | 141 ++++++++++++++++++ packages/app/src/hooks/use-hover-safe-zone.ts | 28 +++- 3 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/hooks/use-hover-safe-zone.test.tsx diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index e51b098ad..c70c94b90 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -132,7 +132,8 @@ function WorkspaceHoverCardDesktop({ const handleTriggerLeave = useCallback(() => { triggerHoveredRef.current = false; - }, []); + scheduleClose(); + }, [scheduleClose]); // While open, the safe zone covers trigger + content + the bridge between // them. Close only fires when the pointer leaves the safe zone; re-entering @@ -155,11 +156,16 @@ function WorkspaceHoverCardDesktop({ // When content becomes available while trigger is already hovered, open the card. useEffect(() => { - if (!hasContent || isDragging) return; + if (!hasContent) { + clearGraceTimer(); + setOpen(false); + return; + } + if (isDragging) return; if (triggerHoveredRef.current) { setOpen(true); } - }, [hasContent, isDragging]); + }, [clearGraceTimer, hasContent, isDragging]); // Cleanup on unmount useEffect(() => { diff --git a/packages/app/src/hooks/use-hover-safe-zone.test.tsx b/packages/app/src/hooks/use-hover-safe-zone.test.tsx new file mode 100644 index 000000000..cd5860327 --- /dev/null +++ b/packages/app/src/hooks/use-hover-safe-zone.test.tsx @@ -0,0 +1,141 @@ +/** + * @vitest-environment jsdom + */ +import React, { useRef, type RefObject } from "react"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type { View } from "react-native"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useHoverSafeZone } from "./use-hover-safe-zone"; + +vi.mock("@/constants/platform", () => ({ + isWeb: true, +})); + +type RectInput = { + left: number; + right: number; + top: number; + bottom: number; +}; + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +function installRect(node: HTMLDivElement | null, rect: RectInput): void { + if (!node) return; + node.getBoundingClientRect = () => + ({ + ...rect, + x: rect.left, + y: rect.top, + width: rect.right - rect.left, + height: rect.bottom - rect.top, + toJSON: () => rect, + }) as DOMRect; +} + +function Harness({ + onEnterSafeZone, + onLeaveSafeZone, +}: { + onEnterSafeZone: () => void; + onLeaveSafeZone: () => void; +}) { + const triggerRef = useRef(null); + const contentRef = useRef(null); + + useHoverSafeZone({ + enabled: true, + triggerRef: triggerRef as unknown as RefObject, + contentRef: contentRef as unknown as RefObject, + onEnterSafeZone, + onLeaveSafeZone, + }); + + return ( + <> +
{ + triggerRef.current = node; + installRect(node, { left: 0, right: 100, top: 20, bottom: 60 }); + }} + /> +
{ + contentRef.current = node; + installRect(node, { left: 120, right: 240, top: 20, bottom: 120 }); + }} + /> + + ); +} + +function pointerMove(x: number, y: number): void { + document.dispatchEvent( + new window.MouseEvent("pointermove", { + bubbles: true, + clientX: x, + clientY: y, + }), + ); +} + +describe("useHoverSafeZone", () => { + it("tracks transitions across trigger, bridge, content, and outside space", () => { + const onEnterSafeZone = vi.fn(); + const onLeaveSafeZone = vi.fn(); + + act(() => { + root?.render(); + }); + + act(() => pointerMove(110, 40)); + expect(onLeaveSafeZone).not.toHaveBeenCalled(); + + act(() => pointerMove(300, 40)); + expect(onLeaveSafeZone).toHaveBeenCalledTimes(1); + + act(() => pointerMove(130, 40)); + expect(onEnterSafeZone).toHaveBeenCalledTimes(1); + }); + + it("treats leaving the browser window as leaving the safe zone", () => { + const onEnterSafeZone = vi.fn(); + const onLeaveSafeZone = vi.fn(); + + act(() => { + root?.render(); + }); + + act(() => { + window.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true })); + }); + expect(onLeaveSafeZone).toHaveBeenCalledTimes(1); + + act(() => { + window.dispatchEvent(new window.FocusEvent("blur")); + }); + expect(onLeaveSafeZone).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/hooks/use-hover-safe-zone.ts b/packages/app/src/hooks/use-hover-safe-zone.ts index 6b3e27351..70c00e042 100644 --- a/packages/app/src/hooks/use-hover-safe-zone.ts +++ b/packages/app/src/hooks/use-hover-safe-zone.ts @@ -35,6 +35,18 @@ export function useHoverSafeZone({ // The pointer opened the card, so we start inside. let wasInside = true; + function enterSafeZone() { + if (wasInside) return; + wasInside = true; + onEnterSafeZone(); + } + + function leaveSafeZone() { + if (!wasInside) return; + wasInside = false; + onLeaveSafeZone(); + } + function handlePointerMove(event: PointerEvent) { const triggerNode = triggerRef.current as unknown as Element | null; const contentNode = contentRef.current as unknown as Element | null; @@ -42,15 +54,23 @@ export function useHoverSafeZone({ const contentRect = contentNode ? contentNode.getBoundingClientRect() : null; const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY); - if (inside === wasInside) return; - wasInside = inside; - if (inside) onEnterSafeZone(); - else onLeaveSafeZone(); + if (inside) enterSafeZone(); + else leaveSafeZone(); + } + + function handlePointerOut(event: PointerEvent) { + if (event.relatedTarget === null) { + leaveSafeZone(); + } } document.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerout", handlePointerOut); + window.addEventListener("blur", leaveSafeZone); return () => { document.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerout", handlePointerOut); + window.removeEventListener("blur", leaveSafeZone); }; }, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]); }