Skip to content

Commit e81cfec

Browse files
authored
Preventing long press on touch devices from selecting text of pressable elements (#2457)
1 parent 2f5cabd commit e81cfec

File tree

4 files changed

+295
-49
lines changed

4 files changed

+295
-49
lines changed

packages/@react-aria/interactions/src/textSelection.ts

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -21,52 +21,75 @@ import {isIOS, runAfterTransition} from '@react-aria/utils';
2121
// There are three possible states due to the delay before removing user-select: none after
2222
// pointer up. The 'default' state always transitions to the 'disabled' state, which transitions
2323
// to 'restoring'. The 'restoring' state can either transition back to 'disabled' or 'default'.
24+
25+
// For non-iOS devices, we apply user-select: none to the pressed element instead to avoid possible
26+
// performance issues that arise from applying and removing user-select: none to the entire page
27+
// (see https://github.com/adobe/react-spectrum/issues/1609).
2428
type State = 'default' | 'disabled' | 'restoring';
2529

30+
// Note that state only matters here for iOS. Non-iOS gets user-select: none applied to the target element
31+
// rather than at the document level so we just need to apply/remove user-select: none for each pressed element individually
2632
let state: State = 'default';
2733
let savedUserSelect = '';
34+
let modifiedElementMap = new WeakMap<HTMLElement, string>();
2835

29-
export function disableTextSelection() {
30-
// Limit this behavior to iOS only. Android devices don't text select nearby element
31-
// when long pressing on a different element.
32-
if (!isIOS()) {
33-
return;
34-
}
36+
export function disableTextSelection(target?: HTMLElement) {
37+
if (isIOS()) {
38+
if (state === 'default') {
39+
savedUserSelect = document.documentElement.style.webkitUserSelect;
40+
document.documentElement.style.webkitUserSelect = 'none';
41+
}
3542

36-
if (state === 'default') {
37-
savedUserSelect = document.documentElement.style.webkitUserSelect;
38-
document.documentElement.style.webkitUserSelect = 'none';
43+
state = 'disabled';
44+
} else if (target) {
45+
// If not iOS, store the target's original user-select and change to user-select: none
46+
// Ignore state since it doesn't apply for non iOS
47+
modifiedElementMap.set(target, target.style.userSelect);
48+
target.style.userSelect = 'none';
3949
}
40-
41-
state = 'disabled';
4250
}
4351

44-
export function restoreTextSelection() {
45-
// If the state is already default, there's nothing to do.
46-
// If it is restoring, then there's no need to queue a second restore.
47-
// Limit this behavior to iOS only. Android devices don't text select nearby element
48-
// when long pressing on a different element.
49-
if (state !== 'disabled' || !isIOS()) {
50-
return;
51-
}
52+
export function restoreTextSelection(target?: HTMLElement) {
53+
if (isIOS()) {
54+
// If the state is already default, there's nothing to do.
55+
// If it is restoring, then there's no need to queue a second restore.
56+
if (state !== 'disabled') {
57+
return;
58+
}
5259

53-
state = 'restoring';
60+
state = 'restoring';
5461

55-
// There appears to be a delay on iOS where selection still might occur
56-
// after pointer up, so wait a bit before removing user-select.
57-
setTimeout(() => {
58-
// Wait for any CSS transitions to complete so we don't recompute style
59-
// for the whole page in the middle of the animation and cause jank.
60-
runAfterTransition(() => {
61-
// Avoid race conditions
62-
if (state === 'restoring') {
63-
if (document.documentElement.style.webkitUserSelect === 'none') {
64-
document.documentElement.style.webkitUserSelect = savedUserSelect || '';
62+
// There appears to be a delay on iOS where selection still might occur
63+
// after pointer up, so wait a bit before removing user-select.
64+
setTimeout(() => {
65+
// Wait for any CSS transitions to complete so we don't recompute style
66+
// for the whole page in the middle of the animation and cause jank.
67+
runAfterTransition(() => {
68+
// Avoid race conditions
69+
if (state === 'restoring') {
70+
if (document.documentElement.style.webkitUserSelect === 'none') {
71+
document.documentElement.style.webkitUserSelect = savedUserSelect || '';
72+
}
73+
74+
savedUserSelect = '';
75+
state = 'default';
6576
}
77+
});
78+
}, 300);
79+
} else {
80+
// If not iOS, restore the target's original user-select if any
81+
// Ignore state since it doesn't apply for non iOS
82+
if (target && modifiedElementMap.has(target)) {
83+
let targetOldUserSelect = modifiedElementMap.get(target);
84+
85+
if (target.style.userSelect === 'none') {
86+
target.style.userSelect = targetOldUserSelect;
87+
}
6688

67-
savedUserSelect = '';
68-
state = 'default';
89+
if (target.getAttribute('style') === '') {
90+
target.removeAttribute('style');
6991
}
70-
});
71-
}, 300);
92+
modifiedElementMap.delete(target);
93+
}
94+
}
7295
}

packages/@react-aria/interactions/src/usePress.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ export interface PressProps extends PressEvents {
3535
* still pressed, onPressStart will be fired again. If set to `true`, the press is canceled
3636
* when the pointer leaves the target and onPressStart will not be fired if the pointer returns.
3737
*/
38-
shouldCancelOnPointerExit?: boolean
38+
shouldCancelOnPointerExit?: boolean,
39+
/** Whether text selection should be enabled on the pressable element. */
40+
allowTextSelectionOnPress?: boolean
3941
}
4042

4143
export interface PressHookProps extends PressProps {
@@ -99,6 +101,7 @@ export function usePress(props: PressHookProps): PressResult {
99101
isPressed: isPressedProp,
100102
preventFocusOnPress,
101103
shouldCancelOnPointerExit,
104+
allowTextSelectionOnPress,
102105
// eslint-disable-next-line @typescript-eslint/no-unused-vars
103106
ref: _, // Removing `ref` from `domProps` because TypeScript is dumb,
104107
...domProps
@@ -217,7 +220,9 @@ export function usePress(props: PressHookProps): PressResult {
217220
state.activePointerId = null;
218221
state.pointerType = null;
219222
removeAllGlobalListeners();
220-
restoreTextSelection();
223+
if (!allowTextSelectionOnPress) {
224+
restoreTextSelection(state.target);
225+
}
221226
}
222227
};
223228

@@ -329,7 +334,10 @@ export function usePress(props: PressHookProps): PressResult {
329334
focusWithoutScrolling(e.currentTarget);
330335
}
331336

332-
disableTextSelection();
337+
if (!allowTextSelectionOnPress) {
338+
disableTextSelection(state.target);
339+
}
340+
333341
triggerPressStart(e, state.pointerType);
334342

335343
addGlobalListener(document, 'pointermove', onPointerMove, false);
@@ -404,7 +412,9 @@ export function usePress(props: PressHookProps): PressResult {
404412
state.activePointerId = null;
405413
state.pointerType = null;
406414
removeAllGlobalListeners();
407-
restoreTextSelection();
415+
if (!allowTextSelectionOnPress) {
416+
restoreTextSelection(state.target);
417+
}
408418
}
409419
};
410420

@@ -535,7 +545,10 @@ export function usePress(props: PressHookProps): PressResult {
535545
focusWithoutScrolling(e.currentTarget);
536546
}
537547

538-
disableTextSelection();
548+
if (!allowTextSelectionOnPress) {
549+
disableTextSelection(state.target);
550+
}
551+
539552
triggerPressStart(e, state.pointerType);
540553

541554
addGlobalListener(window, 'scroll', onScroll, true);
@@ -588,7 +601,9 @@ export function usePress(props: PressHookProps): PressResult {
588601
state.activePointerId = null;
589602
state.isOverTarget = false;
590603
state.ignoreEmulatedMouseEvents = true;
591-
restoreTextSelection();
604+
if (!allowTextSelectionOnPress) {
605+
restoreTextSelection(state.target);
606+
}
592607
removeAllGlobalListeners();
593608
};
594609

@@ -625,13 +640,17 @@ export function usePress(props: PressHookProps): PressResult {
625640
}
626641

627642
return pressProps;
628-
}, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners]);
643+
}, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners, allowTextSelectionOnPress]);
629644

630645
// Remove user-select: none in case component unmounts immediately after pressStart
631646
// eslint-disable-next-line arrow-body-style
632647
useEffect(() => {
633-
return () => restoreTextSelection();
634-
}, []);
648+
return () => {
649+
if (!allowTextSelectionOnPress) {
650+
restoreTextSelection(ref.current.target);
651+
}
652+
};
653+
}, [allowTextSelectionOnPress]);
635654

636655
return {
637656
isPressed: isPressedProp || isPressed,

0 commit comments

Comments
 (0)