fix(app): bridge trigger and content in workspace hover card

Closes the hover card via a safe-zone hook that covers the trigger, the
content, and the rectangular bridge between them, so crossing the visual
gap no longer drops the hover. Replaces the event-based close flow whose
dropped pointerleave events left cards stuck open.
This commit is contained in:
Mohamed Boudra
2026-04-18 10:58:17 +07:00
parent d8ff049aff
commit 59d9b2ebdd
2 changed files with 101 additions and 25 deletions
@@ -19,6 +19,7 @@ import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"
import type { PrHint } from "@/hooks/use-checkout-pr-status-query";
import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list";
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
interface Rect {
x: number;
@@ -99,10 +100,10 @@ function WorkspaceHoverCardDesktop({
children,
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
const triggerRef = useRef<View>(null);
const contentRef = useRef<View>(null);
const [open, setOpen] = useState(false);
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const triggerHoveredRef = useRef(false);
const contentHoveredRef = useRef(false);
const hasContent = prHint !== null || !!workspace.diffStat;
@@ -114,14 +115,12 @@ function WorkspaceHoverCardDesktop({
}, []);
const scheduleClose = useCallback(() => {
clearGraceTimer();
if (graceTimerRef.current) return;
graceTimerRef.current = setTimeout(() => {
if (!triggerHoveredRef.current && !contentHoveredRef.current) {
setOpen(false);
}
graceTimerRef.current = null;
setOpen(false);
}, HOVER_GRACE_MS);
}, [clearGraceTimer]);
}, []);
const handleTriggerEnter = useCallback(() => {
triggerHoveredRef.current = true;
@@ -133,18 +132,18 @@ function WorkspaceHoverCardDesktop({
const handleTriggerLeave = useCallback(() => {
triggerHoveredRef.current = false;
scheduleClose();
}, [scheduleClose]);
}, []);
const handleContentEnter = useCallback(() => {
contentHoveredRef.current = true;
clearGraceTimer();
}, [clearGraceTimer]);
const handleContentLeave = useCallback(() => {
contentHoveredRef.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
// it (including the bridge) cancels the pending close.
useHoverSafeZone({
enabled: open,
triggerRef,
contentRef,
onEnterSafeZone: clearGraceTimer,
onLeaveSafeZone: scheduleClose,
});
// Close when drag starts
useEffect(() => {
@@ -182,8 +181,7 @@ function WorkspaceHoverCardDesktop({
workspace={workspace}
prHint={prHint}
triggerRef={triggerRef}
onContentEnter={handleContentEnter}
onContentLeave={handleContentLeave}
contentRef={contentRef}
/>
) : null}
</View>
@@ -194,14 +192,12 @@ function WorkspaceHoverCardContent({
workspace,
prHint,
triggerRef,
onContentEnter,
onContentLeave,
contentRef,
}: {
workspace: SidebarWorkspaceEntry;
prHint: PrHint | null;
triggerRef: React.RefObject<View | null>;
onContentEnter: () => void;
onContentLeave: () => void;
contentRef: React.RefObject<View | null>;
}): ReactElement | null {
const { theme } = useUnistyles();
const bottomSheetInternal = useBottomSheetModalInternal(true);
@@ -250,12 +246,11 @@ function WorkspaceHoverCardContent({
<Portal hostName={bottomSheetInternal?.hostName}>
<View pointerEvents="box-none" style={styles.portalOverlay}>
<Animated.View
ref={contentRef}
entering={FadeIn.duration(80)}
exiting={FadeOut.duration(80)}
collapsable={false}
onLayout={handleLayout}
onPointerEnter={onContentEnter}
onPointerLeave={onContentLeave}
accessibilityRole="menu"
accessibilityLabel="Workspace scripts"
testID="workspace-hover-card"
@@ -0,0 +1,81 @@
import { useEffect, type RefObject } from "react";
import type { View } from "react-native";
import { isWeb } from "@/constants/platform";
interface UseHoverSafeZoneParams {
enabled: boolean;
triggerRef: RefObject<View | null>;
contentRef: RefObject<View | null>;
onEnterSafeZone: () => void;
onLeaveSafeZone: () => void;
}
interface RectLike {
left: number;
right: number;
top: number;
bottom: number;
}
// Tracks the pointer's position relative to a hover card's "safe zone": the
// trigger, the content, and the rectangular bridge between them. The bridge
// lets the pointer cross the visual gap without dropping the hover. Fires
// `onEnterSafeZone` / `onLeaveSafeZone` on transitions only. Web-only; no-op
// on native.
export function useHoverSafeZone({
enabled,
triggerRef,
contentRef,
onEnterSafeZone,
onLeaveSafeZone,
}: UseHoverSafeZoneParams): void {
useEffect(() => {
if (!isWeb || !enabled) return;
// The pointer opened the card, so we start inside.
let wasInside = true;
function handlePointerMove(event: PointerEvent) {
const triggerNode = triggerRef.current as unknown as Element | null;
const contentNode = contentRef.current as unknown as Element | null;
const triggerRect = triggerNode ? triggerNode.getBoundingClientRect() : null;
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();
}
document.addEventListener("pointermove", handlePointerMove);
return () => {
document.removeEventListener("pointermove", handlePointerMove);
};
}, [enabled, triggerRef, contentRef, onEnterSafeZone, onLeaveSafeZone]);
}
function isInsideRect(rect: RectLike | null, x: number, y: number): boolean {
if (!rect) return false;
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
function isInsideSafeZone(
trigger: RectLike | null,
content: RectLike | null,
x: number,
y: number,
): boolean {
if (isInsideRect(trigger, x, y)) return true;
if (isInsideRect(content, x, y)) return true;
if (!trigger || !content) return false;
// Bridge: the horizontal strip connecting trigger and content, stretched
// vertically to span both. If they overlap horizontally there's no bridge.
const bridgeLeft = Math.min(trigger.right, content.right);
const bridgeRight = Math.max(trigger.left, content.left);
if (bridgeLeft >= bridgeRight) return false;
const bridgeTop = Math.min(trigger.top, content.top);
const bridgeBottom = Math.max(trigger.bottom, content.bottom);
return x >= bridgeLeft && x <= bridgeRight && y >= bridgeTop && y <= bridgeBottom;
}