Fix sticky workspace hover card
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user