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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user