Files
fusion/packages/dashboard/app/hooks/useOverlayDismiss.ts
gsxdsm b22112af89 feat(FN-5482): suppress touch-synthesized mouse events on overlay dismiss
Adds a `useOverlayDismiss` hook that suppresses touch-synthesized mouse events on modal/dropdown overlays to prevent unintended close behavior on touch devices, with tests covering TaskCard dismissal and overlay interaction edge cases. Documentation updates in AGENTS.md and docs/architecture.md capt

Fusion-Task-Id: FN-5482

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
Fusion-Task-Id: FN-5482
2026-05-23 02:06:11 -07:00

68 lines
2.4 KiB
TypeScript

import { useCallback, useEffect, useRef } from "react";
/**
* Returns props for a modal-overlay element that dismisses only when a real
* overlay click happens — i.e. both mousedown AND mouseup land on the overlay
* itself.
*
* This avoids a subtle dismiss-during-resize bug: when a user drags the
* native CSS `resize: both` grip from inside a modal and releases the mouse
* over the overlay, the synthesised click event targets the common ancestor
* (the overlay). A naive `onClick` handler that checks `e.target === e.currentTarget`
* is fooled and closes the modal mid-resize.
*
* Spread the returned props on the overlay element. The inner modal element
* does NOT need to stopPropagation — mousedown on the modal sets the ref to
* `false`, so the overlay's mouseup handler bails.
*/
export function useOverlayDismiss(onClose: () => void): {
onMouseDown: (e: React.MouseEvent) => void;
onMouseUp: (e: React.MouseEvent) => void;
onTouchStart: () => void;
onTouchEnd: () => void;
} {
const startedOnOverlayRef = useRef(false);
const lastTouchAtRef = useRef(0);
const markTouch = useCallback(() => {
lastTouchAtRef.current = Date.now();
}, []);
const onMouseDown = useCallback((e: React.MouseEvent) => {
// Android/webview may emit compatibility mouse events right after touchend.
// Ignore those so a newly-mounted overlay is not dismissed immediately.
if (Date.now() - lastTouchAtRef.current < 500) {
startedOnOverlayRef.current = false;
return;
}
startedOnOverlayRef.current = e.target === e.currentTarget;
}, []);
useEffect(() => {
if (typeof document === "undefined") return;
const handleDocumentTouch = () => {
lastTouchAtRef.current = Date.now();
};
document.addEventListener("touchstart", handleDocumentTouch, { passive: true });
document.addEventListener("touchend", handleDocumentTouch, { passive: true });
return () => {
document.removeEventListener("touchstart", handleDocumentTouch);
document.removeEventListener("touchend", handleDocumentTouch);
};
}, []);
const onMouseUp = useCallback(
(e: React.MouseEvent) => {
const shouldClose = startedOnOverlayRef.current && e.target === e.currentTarget;
startedOnOverlayRef.current = false;
if (shouldClose) onClose();
},
[onClose],
);
return { onMouseDown, onMouseUp, onTouchStart: markTouch, onTouchEnd: markTouch };
}