Skip to content

Commit bc4e1a2

Browse files
committed
fix: suppress selection during standup long press
1 parent 8881c3c commit bc4e1a2

2 files changed

Lines changed: 132 additions & 1 deletion

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import type { TouchEvent } from 'react';
2+
import { act, renderHook } from '@testing-library/react';
3+
import { useTouchLongPress } from './useTouchLongPress';
4+
5+
const createTouchEvent = (x = 0, y = 0): TouchEvent<Element> =>
6+
({
7+
currentTarget: document.createElement('div'),
8+
touches: [{ clientX: x, clientY: y }],
9+
} as unknown as TouchEvent<Element>);
10+
11+
describe('useTouchLongPress', () => {
12+
beforeEach(() => {
13+
jest.useFakeTimers();
14+
});
15+
16+
afterEach(() => {
17+
document.documentElement.style.userSelect = '';
18+
document.documentElement.style.removeProperty('-webkit-user-select');
19+
jest.useRealTimers();
20+
});
21+
22+
it('suppresses text selection while a long press is pending', () => {
23+
const onLongPress = jest.fn();
24+
const { result } = renderHook(() =>
25+
useTouchLongPress({
26+
enabled: true,
27+
onLongPress,
28+
}),
29+
);
30+
31+
const previousUserSelect = 'text';
32+
document.documentElement.style.userSelect = previousUserSelect;
33+
34+
act(() => {
35+
result.current.onTouchStart(createTouchEvent(), 'message-1');
36+
});
37+
38+
expect(document.documentElement.style.userSelect).toBe('none');
39+
40+
act(() => {
41+
result.current.onTouchEnd();
42+
});
43+
44+
expect(document.documentElement.style.userSelect).toBe(previousUserSelect);
45+
expect(onLongPress).not.toHaveBeenCalled();
46+
});
47+
48+
it('keeps selection suppressed until touch end after long press fires', () => {
49+
const onLongPress = jest.fn();
50+
const previousUserSelect = document.documentElement.style.userSelect;
51+
const { result } = renderHook(() =>
52+
useTouchLongPress({
53+
enabled: true,
54+
onLongPress,
55+
}),
56+
);
57+
58+
act(() => {
59+
result.current.onTouchStart(createTouchEvent(), 'message-1');
60+
});
61+
62+
act(() => {
63+
jest.advanceTimersByTime(500);
64+
});
65+
66+
expect(onLongPress).toHaveBeenCalledWith('message-1');
67+
expect(document.documentElement.style.userSelect).toBe('none');
68+
69+
act(() => {
70+
result.current.onTouchEnd();
71+
});
72+
73+
expect(document.documentElement.style.userSelect).toBe(previousUserSelect);
74+
});
75+
76+
it('restores text selection when movement cancels the long press', () => {
77+
const onLongPress = jest.fn();
78+
const { result } = renderHook(() =>
79+
useTouchLongPress({
80+
enabled: true,
81+
onLongPress,
82+
}),
83+
);
84+
85+
act(() => {
86+
result.current.onTouchStart(createTouchEvent(), 'message-1');
87+
result.current.onTouchMove(createTouchEvent(20, 0));
88+
jest.advanceTimersByTime(500);
89+
});
90+
91+
expect(onLongPress).not.toHaveBeenCalled();
92+
expect(document.documentElement.style.userSelect).toBe('');
93+
});
94+
});

packages/shared/src/hooks/useTouchLongPress.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ interface UseTouchLongPressOptions<T> {
55
enabled: boolean;
66
delayMs?: number;
77
moveTolerancePx?: number;
8+
suppressTextSelection?: boolean;
89
onLongPress: (value: T) => void;
910
}
1011

@@ -18,20 +19,50 @@ export interface TouchLongPressHandlers<T> {
1819
const DEFAULT_LONG_PRESS_DELAY_MS = 500;
1920
const DEFAULT_MOVE_TOLERANCE_PX = 10;
2021

22+
const suppressDocumentTextSelection = (
23+
ownerDocument: Document,
24+
): (() => void) => {
25+
const root = ownerDocument.documentElement;
26+
const previousUserSelect = root.style.userSelect;
27+
const previousWebkitUserSelect = root.style.getPropertyValue(
28+
'-webkit-user-select',
29+
);
30+
31+
ownerDocument.defaultView?.getSelection()?.removeAllRanges();
32+
root.style.userSelect = 'none';
33+
root.style.setProperty('-webkit-user-select', 'none');
34+
35+
return () => {
36+
ownerDocument.defaultView?.getSelection()?.removeAllRanges();
37+
root.style.userSelect = previousUserSelect;
38+
39+
if (previousWebkitUserSelect) {
40+
root.style.setProperty('-webkit-user-select', previousWebkitUserSelect);
41+
return;
42+
}
43+
44+
root.style.removeProperty('-webkit-user-select');
45+
};
46+
};
47+
2148
export const useTouchLongPress = <T>({
2249
enabled,
2350
delayMs = DEFAULT_LONG_PRESS_DELAY_MS,
2451
moveTolerancePx = DEFAULT_MOVE_TOLERANCE_PX,
52+
suppressTextSelection = true,
2553
onLongPress,
2654
}: UseTouchLongPressOptions<T>): TouchLongPressHandlers<T> => {
2755
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
2856
const startPosRef = useRef<{ x: number; y: number } | null>(null);
57+
const restoreTextSelectionRef = useRef<(() => void) | null>(null);
2958

3059
const cancelLongPress = useCallback((): void => {
3160
if (timerRef.current) {
3261
clearTimeout(timerRef.current);
3362
timerRef.current = null;
3463
}
64+
restoreTextSelectionRef.current?.();
65+
restoreTextSelectionRef.current = null;
3566
startPosRef.current = null;
3667
}, []);
3768

@@ -55,13 +86,19 @@ export const useTouchLongPress = <T>({
5586
return;
5687
}
5788

89+
const { ownerDocument } = event.currentTarget;
5890
startPosRef.current = { x: touch.clientX, y: touch.clientY };
91+
if (suppressTextSelection) {
92+
restoreTextSelectionRef.current =
93+
suppressDocumentTextSelection(ownerDocument);
94+
}
5995
timerRef.current = setTimeout(() => {
6096
timerRef.current = null;
97+
ownerDocument.defaultView?.getSelection()?.removeAllRanges();
6198
onLongPress(value);
6299
}, delayMs);
63100
},
64-
[cancelLongPress, delayMs, enabled, onLongPress],
101+
[cancelLongPress, delayMs, enabled, onLongPress, suppressTextSelection],
65102
);
66103

67104
const onTouchMove = useCallback(

0 commit comments

Comments
 (0)