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 type { PrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||||
import { openExternalUrl } from "@/utils/open-external-url";
|
import { openExternalUrl } from "@/utils/open-external-url";
|
||||||
import { PrBadge } from "@/components/sidebar-workspace-list";
|
import { PrBadge } from "@/components/sidebar-workspace-list";
|
||||||
|
import { useHoverSafeZone } from "@/hooks/use-hover-safe-zone";
|
||||||
|
|
||||||
interface Rect {
|
interface Rect {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -99,10 +100,10 @@ function WorkspaceHoverCardDesktop({
|
|||||||
children,
|
children,
|
||||||
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
}: PropsWithChildren<WorkspaceHoverCardProps>): ReactElement {
|
||||||
const triggerRef = useRef<View>(null);
|
const triggerRef = useRef<View>(null);
|
||||||
|
const contentRef = useRef<View>(null);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const graceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const triggerHoveredRef = useRef(false);
|
const triggerHoveredRef = useRef(false);
|
||||||
const contentHoveredRef = useRef(false);
|
|
||||||
|
|
||||||
const hasContent = prHint !== null || !!workspace.diffStat;
|
const hasContent = prHint !== null || !!workspace.diffStat;
|
||||||
|
|
||||||
@@ -114,14 +115,12 @@ function WorkspaceHoverCardDesktop({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const scheduleClose = useCallback(() => {
|
const scheduleClose = useCallback(() => {
|
||||||
clearGraceTimer();
|
if (graceTimerRef.current) return;
|
||||||
graceTimerRef.current = setTimeout(() => {
|
graceTimerRef.current = setTimeout(() => {
|
||||||
if (!triggerHoveredRef.current && !contentHoveredRef.current) {
|
|
||||||
setOpen(false);
|
|
||||||
}
|
|
||||||
graceTimerRef.current = null;
|
graceTimerRef.current = null;
|
||||||
|
setOpen(false);
|
||||||
}, HOVER_GRACE_MS);
|
}, HOVER_GRACE_MS);
|
||||||
}, [clearGraceTimer]);
|
}, []);
|
||||||
|
|
||||||
const handleTriggerEnter = useCallback(() => {
|
const handleTriggerEnter = useCallback(() => {
|
||||||
triggerHoveredRef.current = true;
|
triggerHoveredRef.current = true;
|
||||||
@@ -133,18 +132,18 @@ function WorkspaceHoverCardDesktop({
|
|||||||
|
|
||||||
const handleTriggerLeave = useCallback(() => {
|
const handleTriggerLeave = useCallback(() => {
|
||||||
triggerHoveredRef.current = false;
|
triggerHoveredRef.current = false;
|
||||||
scheduleClose();
|
}, []);
|
||||||
}, [scheduleClose]);
|
|
||||||
|
|
||||||
const handleContentEnter = useCallback(() => {
|
// While open, the safe zone covers trigger + content + the bridge between
|
||||||
contentHoveredRef.current = true;
|
// them. Close only fires when the pointer leaves the safe zone; re-entering
|
||||||
clearGraceTimer();
|
// it (including the bridge) cancels the pending close.
|
||||||
}, [clearGraceTimer]);
|
useHoverSafeZone({
|
||||||
|
enabled: open,
|
||||||
const handleContentLeave = useCallback(() => {
|
triggerRef,
|
||||||
contentHoveredRef.current = false;
|
contentRef,
|
||||||
scheduleClose();
|
onEnterSafeZone: clearGraceTimer,
|
||||||
}, [scheduleClose]);
|
onLeaveSafeZone: scheduleClose,
|
||||||
|
});
|
||||||
|
|
||||||
// Close when drag starts
|
// Close when drag starts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -182,8 +181,7 @@ function WorkspaceHoverCardDesktop({
|
|||||||
workspace={workspace}
|
workspace={workspace}
|
||||||
prHint={prHint}
|
prHint={prHint}
|
||||||
triggerRef={triggerRef}
|
triggerRef={triggerRef}
|
||||||
onContentEnter={handleContentEnter}
|
contentRef={contentRef}
|
||||||
onContentLeave={handleContentLeave}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
@@ -194,14 +192,12 @@ function WorkspaceHoverCardContent({
|
|||||||
workspace,
|
workspace,
|
||||||
prHint,
|
prHint,
|
||||||
triggerRef,
|
triggerRef,
|
||||||
onContentEnter,
|
contentRef,
|
||||||
onContentLeave,
|
|
||||||
}: {
|
}: {
|
||||||
workspace: SidebarWorkspaceEntry;
|
workspace: SidebarWorkspaceEntry;
|
||||||
prHint: PrHint | null;
|
prHint: PrHint | null;
|
||||||
triggerRef: React.RefObject<View | null>;
|
triggerRef: React.RefObject<View | null>;
|
||||||
onContentEnter: () => void;
|
contentRef: React.RefObject<View | null>;
|
||||||
onContentLeave: () => void;
|
|
||||||
}): ReactElement | null {
|
}): ReactElement | null {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
const bottomSheetInternal = useBottomSheetModalInternal(true);
|
||||||
@@ -250,12 +246,11 @@ function WorkspaceHoverCardContent({
|
|||||||
<Portal hostName={bottomSheetInternal?.hostName}>
|
<Portal hostName={bottomSheetInternal?.hostName}>
|
||||||
<View pointerEvents="box-none" style={styles.portalOverlay}>
|
<View pointerEvents="box-none" style={styles.portalOverlay}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
ref={contentRef}
|
||||||
entering={FadeIn.duration(80)}
|
entering={FadeIn.duration(80)}
|
||||||
exiting={FadeOut.duration(80)}
|
exiting={FadeOut.duration(80)}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
onLayout={handleLayout}
|
onLayout={handleLayout}
|
||||||
onPointerEnter={onContentEnter}
|
|
||||||
onPointerLeave={onContentLeave}
|
|
||||||
accessibilityRole="menu"
|
accessibilityRole="menu"
|
||||||
accessibilityLabel="Workspace scripts"
|
accessibilityLabel="Workspace scripts"
|
||||||
testID="workspace-hover-card"
|
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