diff --git a/.changeset/use-long-press-emulated-mouse.md b/.changeset/use-long-press-emulated-mouse.md new file mode 100644 index 00000000..d976ace5 --- /dev/null +++ b/.changeset/use-long-press-emulated-mouse.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': patch +--- + +`useLongPress` no longer calls `onClick` twice for a tap on touch devices. After `touchend`, browsers fire emulated `mousedown`/`mouseup` events on the same element, and the hook handled them as a second, mouse press. That also called `onClick` after `onLongPressEnd` when the long press came from touch. Mouse presses that start within 1 second of a touch event are now ignored. diff --git a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx index 70968e52..ede2e7a6 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx +++ b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx @@ -389,6 +389,59 @@ describe('useLongPress', () => { expect(onLongPress).toHaveBeenCalledTimes(1); }); + // Browsers follow a tap with emulated mousedown/mouseup events on the same element. + it('calls onClick once for a tap followed by emulated mouse events', () => { + const callbacks = createCallbacks(); + const { getByRole } = render(); + const button = getByRole('button'); + + fireEvent.touchStart(button, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchEnd(button); + fireEvent.mouseDown(button, { clientX: 0, clientY: 0 }); + fireEvent.mouseUp(button); + act(() => { + vi.advanceTimersByTime(600); + }); + + expect(callbacks.onClick).toHaveBeenCalledTimes(1); + expect(callbacks.onLongPress).not.toHaveBeenCalled(); + }); + + it('does not call onClick for emulated mouse events after a long press', async () => { + const callbacks = createCallbacks(); + const { getByRole } = render(); + const button = getByRole('button'); + + fireEvent.touchStart(button, { touches: [{ clientX: 0, clientY: 0 }] }); + await act(async () => { + vi.advanceTimersByTime(1500); + }); + fireEvent.touchEnd(button); + fireEvent.mouseDown(button, { clientX: 0, clientY: 0 }); + fireEvent.mouseUp(button); + + expect(callbacks.onLongPress).toHaveBeenCalledTimes(1); + expect(callbacks.onLongPressEnd).toHaveBeenCalledTimes(1); + expect(callbacks.onClick).not.toHaveBeenCalled(); + }); + + it('handles a mouse press again once the touch has ended a while ago', async () => { + const callbacks = createCallbacks(); + const { getByRole } = render(); + const button = getByRole('button'); + + fireEvent.touchStart(button, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchEnd(button); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + fireEvent.mouseDown(button, { clientX: 0, clientY: 0 }); + fireEvent.mouseUp(button); + + expect(callbacks.onClick).toHaveBeenCalledTimes(2); + }); + it.each(['mouse', 'touch'] as const)('cancels a pending %s press on unmount', async input => { const callbacks = createCallbacks(); const { getByRole, unmount } = render(); diff --git a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.ts b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.ts index ba728804..b41a2050 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.ts +++ b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.ts @@ -1,6 +1,11 @@ import { type MouseEvent, type TouchEvent, useCallback, useEffect, useRef } from 'react'; import { usePreservedCallback } from '../usePreservedCallback/index.ts'; + +// After a tap, browsers fire emulated `mousedown`/`mouseup` events on the same element. +// Mouse presses that start this soon after a touch event are treated as part of that touch. +const EMULATED_MOUSE_EVENT_WINDOW_MS = 1000; + type Handler = (event: MouseEvent | TouchEvent) => void; export type UseLongPressOptions = { @@ -67,6 +72,7 @@ export function useLongPress( const timeoutRef = useRef(null); const isLongPressActiveRef = useRef(false); const initialPositionRef = useRef({ x: 0, y: 0 }); + const lastTouchTimeRef = useRef(null); const preservedOnLongPress = usePreservedCallback(onLongPress); const preservedOnClick = usePreservedCallback(onClick || (() => {})); @@ -75,7 +81,7 @@ export function useLongPress( const hasThreshold = moveThreshold?.x !== undefined || moveThreshold?.y !== undefined; const getClientPosition = useCallback((event: MouseEvent | TouchEvent) => { - if ('touches' in event.nativeEvent) { + if (isTouchEvent(event)) { const touch = event.nativeEvent.touches[0]; return { x: touch.clientX, y: touch.clientY }; } @@ -115,6 +121,15 @@ export function useLongPress( const handlePressStart = useCallback( (event: MouseEvent | TouchEvent) => { + if (isTouchEvent(event)) { + lastTouchTimeRef.current = Date.now(); + } else if ( + lastTouchTimeRef.current !== null && + Date.now() - lastTouchTimeRef.current < EMULATED_MOUSE_EVENT_WINDOW_MS + ) { + return; + } + cancelLongPress(); const position = getClientPosition(event); @@ -131,6 +146,10 @@ export function useLongPress( const handlePressEnd = useCallback( (event: MouseEvent | TouchEvent) => { + if (isTouchEvent(event)) { + lastTouchTimeRef.current = Date.now(); + } + if (isLongPressActiveRef.current) { preservedOnLongPressEnd(event); } else if (timeoutRef.current !== null) { @@ -161,3 +180,7 @@ export function useLongPress( ...(hasThreshold ? { onTouchMove: handlePressMove, onMouseMove: handlePressMove } : {}), }; } + +function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { + return 'touches' in event.nativeEvent; +}