Skip to content

Commit 7e0c777

Browse files
Fix usePress user-select not removed on HTML element (#926)
* fix usePress user-select not removed * add tests for usePress user-select not removed * Use global state to avoid race conditions between multiple elements Co-authored-by: Devon Govett <[email protected]>
1 parent 72be369 commit 7e0c777

File tree

3 files changed

+183
-30
lines changed

3 files changed

+183
-30
lines changed
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {runAfterTransition} from '@react-aria/utils';
14+
15+
// Safari on iOS starts selecting text on long press. The only way to avoid this, it seems,
16+
// is to add user-select: none to the entire page. Adding it to the pressable element prevents
17+
// that element from being selected, but nearby elements may still receive selection. We add
18+
// user-select: none on touch start, and remove it again on touch end to prevent this.
19+
// This must be implemented using global state to avoid race conditions between multiple elements.
20+
21+
// There are three possible states due to the delay before removing user-select: none after
22+
// pointer up. The 'default' state always transitions to the 'disabled' state, which transitions
23+
// to 'restoring'. The 'restoring' state can either transition back to 'disabled' or 'default'.
24+
type State = 'default' | 'disabled' | 'restoring';
25+
26+
let state: State = 'default';
27+
let savedUserSelect = '';
28+
29+
export function disableTextSelection() {
30+
if (state === 'default') {
31+
savedUserSelect = document.documentElement.style.webkitUserSelect;
32+
document.documentElement.style.webkitUserSelect = 'none';
33+
}
34+
35+
state = 'disabled';
36+
}
37+
38+
export function restoreTextSelection() {
39+
// If the state is already default, there's nothing to do.
40+
// If it is restoring, then there's no need to queue a second restore.
41+
if (state !== 'disabled') {
42+
return;
43+
}
44+
45+
state = 'restoring';
46+
47+
// There appears to be a delay on iOS where selection still might occur
48+
// after pointer up, so wait a bit before removing user-select.
49+
setTimeout(() => {
50+
// Wait for any CSS transitions to complete so we don't recompute style
51+
// for the whole page in the middle of the animation and cause jank.
52+
runAfterTransition(() => {
53+
// Avoid race conditions
54+
if (state === 'restoring') {
55+
if (document.documentElement.style.webkitUserSelect === 'none') {
56+
document.documentElement.style.webkitUserSelect = savedUserSelect || '';
57+
}
58+
59+
savedUserSelect = '';
60+
state = 'default';
61+
}
62+
});
63+
}, 300);
64+
}

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

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
// NOTICE file in the root directory of this source tree.
1616
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
1717

18-
import {focusWithoutScrolling, mergeProps, runAfterTransition} from '@react-aria/utils';
18+
import {disableTextSelection, restoreTextSelection} from './textSelection';
19+
import {focusWithoutScrolling, mergeProps} from '@react-aria/utils';
1920
import {HTMLAttributes, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
2021
import {isVirtualClick} from './utils';
2122
import {PointerType, PressEvents} from '@react-types/shared';
@@ -115,11 +116,11 @@ export function usePress(props: PressHookProps): PressResult {
115116
let addGlobalListener = useCallback((eventTarget, type, listener, options) => {
116117
globalListeners.current.set(listener, {type, eventTarget, options});
117118
eventTarget.addEventListener(type, listener, options);
118-
}, [globalListeners.current]);
119+
}, []);
119120
let removeGlobalListener = useCallback((eventTarget, type, listener, options) => {
120121
eventTarget.removeEventListener(type, listener, options);
121122
globalListeners.current.delete(listener);
122-
}, [globalListeners.current]);
123+
}, []);
123124

124125
let pressProps = useMemo(() => {
125126
let state = ref.current;
@@ -268,31 +269,6 @@ export function usePress(props: PressHookProps): PressResult {
268269
}
269270
};
270271

271-
// Safari on iOS starts selecting text on long press. The only way to avoid this, it seems,
272-
// is to add user-select: none to the entire page. Adding it to the pressable element prevents
273-
// that element from being selected, but nearby elements may still receive selection. We add
274-
// user-select: none on touch start, and remove it again on touch end to prevent this.
275-
let disableTextSelection = () => {
276-
state.userSelect = document.documentElement.style.webkitUserSelect;
277-
document.documentElement.style.webkitUserSelect = 'none';
278-
};
279-
280-
let restoreTextSelection = () => {
281-
// There appears to be a delay on iOS where selection still might occur
282-
// after pointer up, so wait a bit before removing user-select.
283-
setTimeout(() => {
284-
// Wait for any CSS transitions to complete so we don't recompute style
285-
// for the whole page in the middle of the animation and cause jank.
286-
runAfterTransition(() => {
287-
// Avoid race conditions
288-
if (!state.isPressed && document.documentElement.style.webkitUserSelect === 'none') {
289-
document.documentElement.style.webkitUserSelect = state.userSelect || '';
290-
state.userSelect = null;
291-
}
292-
});
293-
}, 300);
294-
};
295-
296272
if (typeof PointerEvent !== 'undefined') {
297273
pressProps.onPointerDown = (e) => {
298274
// Only handle left clicks
@@ -564,7 +540,7 @@ export function usePress(props: PressHookProps): PressResult {
564540
}
565541

566542
return pressProps;
567-
}, [onPress, onPressStart, onPressEnd, onPressChange, onPressUp, isDisabled]);
543+
}, [isDisabled, onPressStart, onPressChange, onPressEnd, onPress, onPressUp, addGlobalListener, preventFocusOnPress, removeGlobalListener]);
568544

569545
// eslint-disable-next-line arrow-body-style
570546
useEffect(() => {
@@ -573,7 +549,7 @@ export function usePress(props: PressHookProps): PressResult {
573549
removeGlobalListener(value.eventTarget, value.type, key, value.options);
574550
});
575551
};
576-
}, [globalListeners.current]);
552+
}, [removeGlobalListener]);
577553

578554
return {
579555
isPressed: isPressedProp || isPressed,

packages/@react-aria/interactions/test/usePress.test.js

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ function pointerEvent(type, opts) {
3232
}
3333

3434
describe('usePress', function () {
35+
beforeAll(() => {
36+
jest.useFakeTimers();
37+
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => cb());
38+
});
39+
40+
afterAll(() => {
41+
jest.useRealTimers();
42+
window.requestAnimationFrame.mockRestore();
43+
});
44+
45+
afterEach(() => {
46+
jest.runAllTimers();
47+
});
48+
3549
// TODO: JSDOM doesn't yet support pointer events. Once they do, convert these tests.
3650
// https://github.com/jsdom/jsdom/issues/2527
3751
describe('pointer events', function () {
@@ -1651,4 +1665,103 @@ describe('usePress', function () {
16511665

16521666
expect(document.activeElement).toBe(el);
16531667
});
1668+
1669+
describe('disable text-selection when pressed', function () {
1670+
let handler = jest.fn();
1671+
let mockUserSelect = 'contain';
1672+
let oldUserSelect = document.documentElement.style.webkitUserSelect;
1673+
1674+
afterAll(() => {
1675+
handler.mockClear();
1676+
});
1677+
1678+
beforeEach(() => {
1679+
document.documentElement.style.webkitUserSelect = mockUserSelect;
1680+
});
1681+
afterEach(() => {
1682+
document.documentElement.style.webkitUserSelect = oldUserSelect;
1683+
});
1684+
1685+
it('should add user-select: none to html element when press start', function () {
1686+
let {getByText} = render(
1687+
<Example
1688+
onPressStart={handler}
1689+
onPressEnd={handler}
1690+
onPressChange={handler}
1691+
onPress={handler}
1692+
onPressUp={handler} />
1693+
);
1694+
1695+
let el = getByText('test');
1696+
fireEvent.touchStart(el, {targetTouches: [{identifier: 1}]});
1697+
expect(document.documentElement.style.webkitUserSelect).toBe('none');
1698+
});
1699+
1700+
it('should remove user-select: none to html element when press end', function () {
1701+
let {getByText} = render(
1702+
<Example
1703+
onPressStart={handler}
1704+
onPressEnd={handler}
1705+
onPressChange={handler}
1706+
onPress={handler}
1707+
onPressUp={handler} />
1708+
);
1709+
1710+
let el = getByText('test');
1711+
1712+
fireEvent.touchStart(el, {targetTouches: [{identifier: 1}]});
1713+
fireEvent.touchEnd(el, {changedTouches: [{identifier: 1}]});
1714+
1715+
jest.advanceTimersByTime(300);
1716+
expect(document.documentElement.style.webkitUserSelect).toBe(mockUserSelect);
1717+
1718+
// Checkbox doesn't remove `user-select: none;` style from HTML Element issue
1719+
// see https://github.com/adobe/react-spectrum/issues/862
1720+
fireEvent.touchStart(el, {targetTouches: [{identifier: 1}]});
1721+
fireEvent.touchEnd(el, {changedTouches: [{identifier: 1}]});
1722+
fireEvent.touchStart(el, {targetTouches: [{identifier: 1}]});
1723+
fireEvent.touchMove(el, {changedTouches: [{identifier: 1, clientX: 100, clientY: 100}]});
1724+
jest.advanceTimersByTime(300);
1725+
fireEvent.touchEnd(el, {changedTouches: [{identifier: 1, clientX: 100, clientY: 100}]});
1726+
jest.advanceTimersByTime(300);
1727+
1728+
expect(document.documentElement.style.webkitUserSelect).toBe(mockUserSelect);
1729+
});
1730+
1731+
it('should not remove user-select: none when pressing two different elements quickly', function () {
1732+
let {getAllByText} = render(
1733+
<>
1734+
<Example
1735+
onPressStart={handler}
1736+
onPressEnd={handler}
1737+
onPressChange={handler}
1738+
onPress={handler}
1739+
onPressUp={handler} />
1740+
<Example
1741+
onPressStart={handler}
1742+
onPressEnd={handler}
1743+
onPressChange={handler}
1744+
onPress={handler}
1745+
onPressUp={handler} />
1746+
</>
1747+
);
1748+
1749+
let els = getAllByText('test');
1750+
1751+
fireEvent.touchStart(els[0], {targetTouches: [{identifier: 1}]});
1752+
fireEvent.touchEnd(els[0], {changedTouches: [{identifier: 1}]});
1753+
1754+
expect(document.documentElement.style.webkitUserSelect).toBe('none');
1755+
1756+
fireEvent.touchStart(els[1], {targetTouches: [{identifier: 1}]});
1757+
1758+
jest.advanceTimersByTime(300);
1759+
expect(document.documentElement.style.webkitUserSelect).toBe('none');
1760+
1761+
fireEvent.touchEnd(els[1], {changedTouches: [{identifier: 1}]});
1762+
1763+
jest.advanceTimersByTime(300);
1764+
expect(document.documentElement.style.webkitUserSelect).toBe(mockUserSelect);
1765+
});
1766+
});
16541767
});

0 commit comments

Comments
 (0)