Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/use-long-press-emulated-mouse.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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(<PressTarget {...callbacks} />);
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(<PressTarget {...callbacks} />);
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(<PressTarget {...callbacks} />);
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(<PressTarget {...callbacks} />);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<E extends HTMLElement> = (event: MouseEvent<E> | TouchEvent<E>) => void;

export type UseLongPressOptions<E extends HTMLElement> = {
Expand Down Expand Up @@ -67,6 +72,7 @@ export function useLongPress<E extends HTMLElement = HTMLElement>(
const timeoutRef = useRef<number | null>(null);
const isLongPressActiveRef = useRef(false);
const initialPositionRef = useRef({ x: 0, y: 0 });
const lastTouchTimeRef = useRef<number | null>(null);

const preservedOnLongPress = usePreservedCallback(onLongPress);
const preservedOnClick = usePreservedCallback(onClick || (() => {}));
Expand All @@ -75,7 +81,7 @@ export function useLongPress<E extends HTMLElement = HTMLElement>(
const hasThreshold = moveThreshold?.x !== undefined || moveThreshold?.y !== undefined;

const getClientPosition = useCallback((event: MouseEvent<E> | TouchEvent<E>) => {
if ('touches' in event.nativeEvent) {
if (isTouchEvent(event)) {
const touch = event.nativeEvent.touches[0];
return { x: touch.clientX, y: touch.clientY };
}
Expand Down Expand Up @@ -115,6 +121,15 @@ export function useLongPress<E extends HTMLElement = HTMLElement>(

const handlePressStart = useCallback(
(event: MouseEvent<E> | TouchEvent<E>) => {
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);

Expand All @@ -131,6 +146,10 @@ export function useLongPress<E extends HTMLElement = HTMLElement>(

const handlePressEnd = useCallback(
(event: MouseEvent<E> | TouchEvent<E>) => {
if (isTouchEvent(event)) {
lastTouchTimeRef.current = Date.now();
}

if (isLongPressActiveRef.current) {
preservedOnLongPressEnd(event);
} else if (timeoutRef.current !== null) {
Expand Down Expand Up @@ -161,3 +180,7 @@ export function useLongPress<E extends HTMLElement = HTMLElement>(
...(hasThreshold ? { onTouchMove: handlePressMove, onMouseMove: handlePressMove } : {}),
};
}

function isTouchEvent<E extends HTMLElement>(event: MouseEvent<E> | TouchEvent<E>): event is TouchEvent<E> {
return 'touches' in event.nativeEvent;
}
Loading