Fix sticky workspace hover card
This commit is contained in:
@@ -132,7 +132,8 @@ function WorkspaceHoverCardDesktop({
|
|||||||
|
|
||||||
const handleTriggerLeave = useCallback(() => {
|
const handleTriggerLeave = useCallback(() => {
|
||||||
triggerHoveredRef.current = false;
|
triggerHoveredRef.current = false;
|
||||||
}, []);
|
scheduleClose();
|
||||||
|
}, [scheduleClose]);
|
||||||
|
|
||||||
// While open, the safe zone covers trigger + content + the bridge between
|
// While open, the safe zone covers trigger + content + the bridge between
|
||||||
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
// 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.
|
// When content becomes available while trigger is already hovered, open the card.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasContent || isDragging) return;
|
if (!hasContent) {
|
||||||
|
clearGraceTimer();
|
||||||
|
setOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isDragging) return;
|
||||||
if (triggerHoveredRef.current) {
|
if (triggerHoveredRef.current) {
|
||||||
setOpen(true);
|
setOpen(true);
|
||||||
}
|
}
|
||||||
}, [hasContent, isDragging]);
|
}, [clearGraceTimer, hasContent, isDragging]);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
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.
|
// The pointer opened the card, so we start inside.
|
||||||
let wasInside = true;
|
let wasInside = true;
|
||||||
|
|
||||||
|
function enterSafeZone() {
|
||||||
|
if (wasInside) return;
|
||||||
|
wasInside = true;
|
||||||
|
onEnterSafeZone();
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveSafeZone() {
|
||||||
|
if (!wasInside) return;
|
||||||
|
wasInside = false;
|
||||||
|
onLeaveSafeZone();
|
||||||
|
}
|
||||||
|
|
||||||
function handlePointerMove(event: PointerEvent) {
|
function handlePointerMove(event: PointerEvent) {
|
||||||
const triggerNode = triggerRef.current as unknown as Element | null;
|
const triggerNode = triggerRef.current as unknown as Element | null;
|
||||||
const contentNode = contentRef.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 contentRect = contentNode ? contentNode.getBoundingClientRect() : null;
|
||||||
|
|
||||||
const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY);
|
const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY);
|
||||||
if (inside === wasInside) return;
|
if (inside) enterSafeZone();
|
||||||
wasInside = inside;
|
else leaveSafeZone();
|
||||||
if (inside) onEnterSafeZone();
|
}
|
||||||
else onLeaveSafeZone();
|
|
||||||
|
function handlePointerOut(event: PointerEvent) {
|
||||||
|
if (event.relatedTarget === null) {
|
||||||
|
leaveSafeZone();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("pointermove", handlePointerMove);
|
document.addEventListener("pointermove", handlePointerMove);
|
||||||
|
window.addEventListener("pointerout", handlePointerOut);
|
||||||
|
window.addEventListener("blur", leaveSafeZone);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("pointermove", handlePointerMove);
|
document.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerout", handlePointerOut);
|
||||||
|
window.removeEventListener("blur", leaveSafeZone);
|
||||||
};
|
};
|
||||||
}, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]);
|
}, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user