Files
paseo/packages/app/src/hooks/use-hover-safe-zone.ts
T

109 lines
3.4 KiB
TypeScript

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 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;
const triggerRect = triggerNode ? triggerNode.getBoundingClientRect() : null;
const contentRect = contentNode ? contentNode.getBoundingClientRect() : null;
const inside = isInsideSafeZone(triggerRect, contentRect, event.clientX, event.clientY);
if (inside) {
if (wasInside) {
onEnterSafeZone();
return;
}
enterSafeZone();
return;
}
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]);
}
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;
}