diff --git a/packages/@react-aria/interactions/src/textSelection.ts b/packages/@react-aria/interactions/src/textSelection.ts index bfb708daf6f..4a5ebc53a03 100644 --- a/packages/@react-aria/interactions/src/textSelection.ts +++ b/packages/@react-aria/interactions/src/textSelection.ts @@ -21,52 +21,75 @@ import {isIOS, runAfterTransition} from '@react-aria/utils'; // There are three possible states due to the delay before removing user-select: none after // pointer up. The 'default' state always transitions to the 'disabled' state, which transitions // to 'restoring'. The 'restoring' state can either transition back to 'disabled' or 'default'. + +// For non-iOS devices, we apply user-select: none to the pressed element instead to avoid possible +// performance issues that arise from applying and removing user-select: none to the entire page +// (see https://github.com/adobe/react-spectrum/issues/1609). type State = 'default' | 'disabled' | 'restoring'; +// Note that state only matters here for iOS. Non-iOS gets user-select: none applied to the target element +// rather than at the document level so we just need to apply/remove user-select: none for each pressed element individually let state: State = 'default'; let savedUserSelect = ''; +let modifiedElementMap = new WeakMap(); -export function disableTextSelection() { - // Limit this behavior to iOS only. Android devices don't text select nearby element - // when long pressing on a different element. - if (!isIOS()) { - return; - } +export function disableTextSelection(target?: HTMLElement) { + if (isIOS()) { + if (state === 'default') { + savedUserSelect = document.documentElement.style.webkitUserSelect; + document.documentElement.style.webkitUserSelect = 'none'; + } - if (state === 'default') { - savedUserSelect = document.documentElement.style.webkitUserSelect; - document.documentElement.style.webkitUserSelect = 'none'; + state = 'disabled'; + } else if (target) { + // If not iOS, store the target's original user-select and change to user-select: none + // Ignore state since it doesn't apply for non iOS + modifiedElementMap.set(target, target.style.userSelect); + target.style.userSelect = 'none'; } - - state = 'disabled'; } -export function restoreTextSelection() { - // If the state is already default, there's nothing to do. - // If it is restoring, then there's no need to queue a second restore. - // Limit this behavior to iOS only. Android devices don't text select nearby element - // when long pressing on a different element. - if (state !== 'disabled' || !isIOS()) { - return; - } +export function restoreTextSelection(target?: HTMLElement) { + if (isIOS()) { + // If the state is already default, there's nothing to do. + // If it is restoring, then there's no need to queue a second restore. + if (state !== 'disabled') { + return; + } - state = 'restoring'; + state = 'restoring'; - // There appears to be a delay on iOS where selection still might occur - // after pointer up, so wait a bit before removing user-select. - setTimeout(() => { - // Wait for any CSS transitions to complete so we don't recompute style - // for the whole page in the middle of the animation and cause jank. - runAfterTransition(() => { - // Avoid race conditions - if (state === 'restoring') { - if (document.documentElement.style.webkitUserSelect === 'none') { - document.documentElement.style.webkitUserSelect = savedUserSelect || ''; + // There appears to be a delay on iOS where selection still might occur + // after pointer up, so wait a bit before removing user-select. + setTimeout(() => { + // Wait for any CSS transitions to complete so we don't recompute style + // for the whole page in the middle of the animation and cause jank. + runAfterTransition(() => { + // Avoid race conditions + if (state === 'restoring') { + if (document.documentElement.style.webkitUserSelect === 'none') { + document.documentElement.style.webkitUserSelect = savedUserSelect || ''; + } + + savedUserSelect = ''; + state = 'default'; } + }); + }, 300); + } else { + // If not iOS, restore the target's original user-select if any + // Ignore state since it doesn't apply for non iOS + if (target && modifiedElementMap.has(target)) { + let targetOldUserSelect = modifiedElementMap.get(target); + + if (target.style.userSelect === 'none') { + target.style.userSelect = targetOldUserSelect; + } - savedUserSelect = ''; - state = 'default'; + if (target.getAttribute('style') === '') { + target.removeAttribute('style'); } - }); - }, 300); + modifiedElementMap.delete(target); + } + } } diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index eebd7ebf225..18c79aea8bf 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -35,7 +35,9 @@ export interface PressProps extends PressEvents { * still pressed, onPressStart will be fired again. If set to `true`, the press is canceled * when the pointer leaves the target and onPressStart will not be fired if the pointer returns. */ - shouldCancelOnPointerExit?: boolean + shouldCancelOnPointerExit?: boolean, + /** Whether text selection should be enabled on the pressable element. */ + allowTextSelectionOnPress?: boolean } export interface PressHookProps extends PressProps { @@ -99,6 +101,7 @@ export function usePress(props: PressHookProps): PressResult { isPressed: isPressedProp, preventFocusOnPress, shouldCancelOnPointerExit, + allowTextSelectionOnPress, // eslint-disable-next-line @typescript-eslint/no-unused-vars ref: _, // Removing `ref` from `domProps` because TypeScript is dumb, ...domProps @@ -217,7 +220,9 @@ export function usePress(props: PressHookProps): PressResult { state.activePointerId = null; state.pointerType = null; removeAllGlobalListeners(); - restoreTextSelection(); + if (!allowTextSelectionOnPress) { + restoreTextSelection(state.target); + } } }; @@ -329,7 +334,10 @@ export function usePress(props: PressHookProps): PressResult { focusWithoutScrolling(e.currentTarget); } - disableTextSelection(); + if (!allowTextSelectionOnPress) { + disableTextSelection(state.target); + } + triggerPressStart(e, state.pointerType); addGlobalListener(document, 'pointermove', onPointerMove, false); @@ -404,7 +412,9 @@ export function usePress(props: PressHookProps): PressResult { state.activePointerId = null; state.pointerType = null; removeAllGlobalListeners(); - restoreTextSelection(); + if (!allowTextSelectionOnPress) { + restoreTextSelection(state.target); + } } }; @@ -535,7 +545,10 @@ export function usePress(props: PressHookProps): PressResult { focusWithoutScrolling(e.currentTarget); } - disableTextSelection(); + if (!allowTextSelectionOnPress) { + disableTextSelection(state.target); + } + triggerPressStart(e, state.pointerType); addGlobalListener(window, 'scroll', onScroll, true); @@ -588,7 +601,9 @@ export function usePress(props: PressHookProps): PressResult { state.activePointerId = null; state.isOverTarget = false; state.ignoreEmulatedMouseEvents = true; - restoreTextSelection(); + if (!allowTextSelectionOnPress) { + restoreTextSelection(state.target); + } removeAllGlobalListeners(); }; @@ -625,13 +640,17 @@ export function usePress(props: PressHookProps): PressResult { } return pressProps; - }, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners]); + }, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners, allowTextSelectionOnPress]); // Remove user-select: none in case component unmounts immediately after pressStart // eslint-disable-next-line arrow-body-style useEffect(() => { - return () => restoreTextSelection(); - }, []); + return () => { + if (!allowTextSelectionOnPress) { + restoreTextSelection(ref.current.target); + } + }; + }, [allowTextSelectionOnPress]); return { isPressed: isPressedProp || isPressed, diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index ff7978ce9a8..6583d3ce713 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -21,9 +21,9 @@ import {theme} from '@react-spectrum/theme-default'; import {usePress} from '../'; function Example(props) { - let {elementType: ElementType = 'div', ...otherProps} = props; + let {elementType: ElementType = 'div', style, ...otherProps} = props; let {pressProps} = usePress(otherProps); - return test; + return test; } function pointerEvent(type, opts) { @@ -2199,6 +2199,15 @@ describe('usePress', function () { let oldUserSelect = document.documentElement.style.webkitUserSelect; let platformGetter; + function TestStyleChange(props) { + let {styleToApply, ...otherProps} = props; + let [show, setShow] = React.useState(false); + let {pressProps} = usePress({...otherProps, onPressStart: () => setTimeout(() => setShow(true), 3000)}); + return ( +
test
+ ); + } + beforeAll(() => { platformGetter = jest.spyOn(window.navigator, 'platform', 'get'); }); @@ -2216,7 +2225,7 @@ describe('usePress', function () { document.documentElement.style.webkitUserSelect = oldUserSelect; }); - it('should add user-select: none to html element when press start (iOS)', function () { + it('should add user-select: none to the page on press start (iOS)', function () { let {getByText} = render( + ); + + let el = getByText('test'); + + fireEvent.touchStart(el, {targetTouches: [{identifier: 1}]}); + expect(el).toHaveStyle('user-select: none'); + fireEvent.touchEnd(el, {changedTouches: [{identifier: 1}]}); + expect(el).toHaveStyle('user-select: text'); + + // Checkbox doesn't remove `user-select: none;` style from HTML Element issue + // see https://github.com/adobe/react-spectrum/issues/862 + fireEvent.touchStart(el, {targetTouches: [{identifier: 1}]}); + fireEvent.touchEnd(el, {changedTouches: [{identifier: 1}]}); + fireEvent.touchStart(el, {targetTouches: [{identifier: 1}]}); + fireEvent.touchMove(el, {changedTouches: [{identifier: 1, clientX: 100, clientY: 100}]}); + fireEvent.touchEnd(el, {changedTouches: [{identifier: 1, clientX: 100, clientY: 100}]}); + expect(el).toHaveStyle('user-select: text'); + }); + it('should not remove user-select: none when pressing two different elements quickly (iOS)', function () { let {getAllByText} = render( <> @@ -2314,7 +2355,81 @@ describe('usePress', function () { expect(document.documentElement.style.webkitUserSelect).toBe(mockUserSelect); }); - it('should remove user-select: none from html element if pressable component unmounts (iOS)', function () { + it('should not remove user-select: none when pressing two different elements quickly (iOS)', function () { + let {getAllByText} = render( + <> + + + + ); + + let els = getAllByText('test'); + + fireEvent.touchStart(els[0], {targetTouches: [{identifier: 1}]}); + fireEvent.touchEnd(els[0], {changedTouches: [{identifier: 1}]}); + + expect(document.documentElement.style.webkitUserSelect).toBe('none'); + + fireEvent.touchStart(els[1], {targetTouches: [{identifier: 1}]}); + + act(() => {jest.advanceTimersByTime(300);}); + expect(document.documentElement.style.webkitUserSelect).toBe('none'); + + fireEvent.touchEnd(els[1], {changedTouches: [{identifier: 1}]}); + + act(() => {jest.advanceTimersByTime(300);}); + expect(document.documentElement.style.webkitUserSelect).toBe(mockUserSelect); + }); + + it('should clean up user-select: none when pressing and releasing two different elements (non-iOS)', function () { + platformGetter.mockReturnValue('Android'); + let {getAllByText} = render( + <> + + + + ); + + let els = getAllByText('test'); + + fireEvent.touchStart(els[0], {targetTouches: [{identifier: 1}]}); + fireEvent.touchStart(els[1], {targetTouches: [{identifier: 2}]}); + + expect(els[0]).toHaveStyle('user-select: none'); + expect(els[1]).toHaveStyle('user-select: none'); + + fireEvent.touchEnd(els[0], {changedTouches: [{identifier: 1}]}); + expect(els[0]).toHaveStyle('user-select: text'); + expect(els[1]).toHaveStyle('user-select: none'); + + fireEvent.touchEnd(els[1], {changedTouches: [{identifier: 2}]}); + expect(els[0]).toHaveStyle('user-select: text'); + expect(els[1]).toHaveStyle('user-select: text'); + }); + + it('should remove user-select: none from the page if pressable component unmounts (iOS)', function () { let {getByText, unmount} = render( {jest.advanceTimersByTime(300);}); expect(document.documentElement.style.webkitUserSelect).toBe(mockUserSelect); }); + + it('non related style changes during press down shouldn\'t overwrite user-select on press end (non-iOS)', function () { + platformGetter.mockReturnValue('Android'); + let {getByText} = render( + + ); + + let el = getByText('test'); + fireEvent.touchStart(el, {targetTouches: [{identifier: 1}]}); + expect(el).toHaveStyle(` + user-select: none; + `); + + act(() => jest.runAllTimers()); + + expect(el).toHaveStyle(` + user-select: none; + background: red; + `); + + fireEvent.touchEnd(el, {changedTouches: [{identifier: 1}]}); + expect(el).toHaveStyle(` + background: red; + `); + }); + + it('changes to user-select during press down remain on press end (non-iOS)', function () { + platformGetter.mockReturnValue('Android'); + let {getByText} = render( + + ); + + let el = getByText('test'); + fireEvent.touchStart(el, {targetTouches: [{identifier: 1}]}); + expect(el).toHaveStyle(` + user-select: none; + `); + + act(() => jest.runAllTimers()); + + expect(el).toHaveStyle(` + user-select: text; + background: red; + `); + + fireEvent.touchEnd(el, {changedTouches: [{identifier: 1}]}); + expect(el).toHaveStyle(` + user-select: text; + background: red; + `); + }); }); describe('portal event bubbling', () => { diff --git a/packages/@react-spectrum/button/stories/Button.stories.tsx b/packages/@react-spectrum/button/stories/Button.stories.tsx index 4a651ff3dd8..9b13721b2c3 100644 --- a/packages/@react-spectrum/button/stories/Button.stories.tsx +++ b/packages/@react-spectrum/button/stories/Button.stories.tsx @@ -89,6 +89,10 @@ storiesOf('Button', module) .add( 'element: a, rel: \'noopener noreferrer\'', () => render({elementType: 'a', href: '//example.com', rel: 'noopener noreferrer', variant: 'primary'}) + ) + .add( + 'user-select:none on press test', + () => ); function render(props: SpectrumButtonProps = {variant: 'primary'}) { @@ -122,3 +126,25 @@ function render(props: SpectrumButtonProps ); } + +function Example() { + let [show, setShow] = React.useState(false); + let [show2, setShow2] = React.useState(false); + + return ( + + + + + ); +}