Fix sticky workspace hover card

This commit is contained in:
Mohamed Boudra
2026-04-21 18:17:39 +07:00
parent f3cb19529c
commit 051507717c
3 changed files with 174 additions and 7 deletions
@@ -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(() => {
@@ -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<HTMLDivElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
useHoverSafeZone({
enabled: true,
triggerRef: triggerRef as unknown as RefObject<View | null>,
contentRef: contentRef as unknown as RefObject<View | null>,
onEnterSafeZone,
onLeaveSafeZone,
});
return (
<>
<div
ref={(node) => {
triggerRef.current = node;
installRect(node, { left: 0, right: 100, top: 20, bottom: 60 });
}}
/>
<div
ref={(node) => {
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(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
});
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(<Harness onEnterSafeZone={onEnterSafeZone} onLeaveSafeZone={onLeaveSafeZone} />);
});
act(() => {
window.dispatchEvent(new window.MouseEvent("pointerout", { bubbles: true }));
});
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(new window.FocusEvent("blur"));
});
expect(onLeaveSafeZone).toHaveBeenCalledTimes(1);
});
});
+24 -4
View File
@@ -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]);
}