From c904e066c34160803a0aa081e13821d8f67e3e84 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 16 Sep 2024 16:41:03 -0400 Subject: [PATCH] Workaround Android issue where click events are fired on the wrong target (#7026) Co-authored-by: Daniel Lu --- .../@react-aria/interactions/src/usePress.ts | 41 +++++++-- .../interactions/stories/usePress-stories.css | 76 ++++++++++++++++ .../interactions/stories/usePress.stories.tsx | 86 +++++++++++++++++++ .../interactions/test/usePress.test.js | 55 ++++++++++++ 4 files changed, 251 insertions(+), 7 deletions(-) create mode 100644 packages/@react-aria/interactions/stories/usePress-stories.css create mode 100644 packages/@react-aria/interactions/stories/usePress.stories.tsx diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 1bb1e36c224..eddd8602bc9 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -410,7 +410,7 @@ export function usePress(props: PressHookProps): PressResult { // Due to browser inconsistencies, especially on mobile browsers, we prevent // default on pointer down and handle focusing the pressable element ourselves. - if (shouldPreventDefault(e.currentTarget as Element)) { + if (shouldPreventDefaultDown(e.currentTarget as Element)) { e.preventDefault(); } @@ -452,7 +452,7 @@ export function usePress(props: PressHookProps): PressResult { // Chrome and Firefox on touch Windows devices require mouse down events // to be canceled in addition to pointer events, or an extra asynchronous // focus event will be fired. - if (shouldPreventDefault(e.currentTarget as Element)) { + if (shouldPreventDefaultDown(e.currentTarget as Element)) { e.preventDefault(); } @@ -510,6 +510,25 @@ export function usePress(props: PressHookProps): PressResult { if (!allowTextSelectionOnPress) { restoreTextSelection(state.target); } + + // Prevent subsequent touchend event from triggering onClick on unrelated elements on Android. See below. + // Both 'touch' and 'pen' pointerTypes trigger onTouchEnd, but 'mouse' does not. + if ('ontouchend' in state.target && e.pointerType !== 'mouse') { + addGlobalListener(state.target, 'touchend', onTouchEnd, {once: true}); + } + } + }; + + // This is a workaround for an Android Chrome/Firefox issue where click events are fired on an incorrect element + // if the original target is removed during onPointerUp (before onClick). + // https://github.com/adobe/react-spectrum/issues/1513 + // https://issues.chromium.org/issues/40732224 + // Note: this event must be registered directly on the element, not via React props in order to work. + // https://github.com/facebook/react/issues/9809 + let onTouchEnd = (e: TouchEvent) => { + // Don't preventDefault if we actually want the default (e.g. submit/link click). + if (shouldPreventDefaultUp(e.target as Element)) { + e.preventDefault(); } }; @@ -534,7 +553,7 @@ export function usePress(props: PressHookProps): PressResult { // Due to browser inconsistencies, especially on mobile browsers, we prevent // default on mouse down and handle focusing the pressable element ourselves. - if (shouldPreventDefault(e.currentTarget)) { + if (shouldPreventDefaultDown(e.currentTarget)) { e.preventDefault(); } @@ -914,16 +933,16 @@ function isOverTarget(point: EventPoint, target: Element) { return areRectanglesOverlapping(rect, pointRect); } -function shouldPreventDefault(target: Element) { +function shouldPreventDefaultDown(target: Element) { // We cannot prevent default if the target is a draggable element. return !(target instanceof HTMLElement) || !target.hasAttribute('draggable'); } -function shouldPreventDefaultKeyboard(target: Element, key: string) { +function shouldPreventDefaultUp(target: Element) { if (target instanceof HTMLInputElement) { - return !isValidInputKey(target, key); + return false; } - + if (target instanceof HTMLButtonElement) { return target.type !== 'submit' && target.type !== 'reset'; } @@ -935,6 +954,14 @@ function shouldPreventDefaultKeyboard(target: Element, key: string) { return true; } +function shouldPreventDefaultKeyboard(target: Element, key: string) { + if (target instanceof HTMLInputElement) { + return !isValidInputKey(target, key); + } + + return shouldPreventDefaultUp(target); +} + const nonTextInputTypes = new Set([ 'checkbox', 'radio', diff --git a/packages/@react-aria/interactions/stories/usePress-stories.css b/packages/@react-aria/interactions/stories/usePress-stories.css new file mode 100644 index 00000000000..67b21f28be9 --- /dev/null +++ b/packages/@react-aria/interactions/stories/usePress-stories.css @@ -0,0 +1,76 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +.outer-div { + background-color: #333; + width: 320px; + height: 320px; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + margin-bottom: 1000px; +} + +.open-btn { + background-color: blue; + font-size: 32px; + padding: 10px; +} + +.visit-link { + color: #9999ff; + font-size: 16px; +} + +.fake-modal { + background-color: rgba(224, 64, 0, 0.5); + position: absolute; + width: 320px; + height: 320px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-around; +} + +.side-by-side { + color: white; + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.my-btn { + background-color: rgb(0, 128, 0); + cursor: pointer; + padding: 5px; +} + +.fake-modal h1 { + color: white; + font-size: 60px; + margin: 0; + padding: 0; +} + +.fake-modal .close-btn { + background-color: red; + font-size: 16px; + padding: 10px; +} + +.OnPress { + cursor: pointer; + color: #ffffff; +} diff --git a/packages/@react-aria/interactions/stories/usePress.stories.tsx b/packages/@react-aria/interactions/stories/usePress.stories.tsx new file mode 100644 index 00000000000..0faee2343ee --- /dev/null +++ b/packages/@react-aria/interactions/stories/usePress.stories.tsx @@ -0,0 +1,86 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import React from 'react'; +import styles from './usePress-stories.css'; +import {usePress} from '@react-aria/interactions'; + +export default { + title: 'usePress' +}; + +export function TouchIssue() { + const [opened, setOpened] = React.useState(false); + const handleOpen = React.useCallback(() => { + console.log('opening'); + setOpened(true); + }, []); + const handleClose = React.useCallback(() => { + console.log('closing'); + setOpened(false); + }, []); + const handleOnClick = React.useCallback(() => { + alert('clicked it'); + }, []); + + return ( +
+ + Open + +
+
Some text
+ + Another Link + + +
+ + {opened && ( +
+

Header

+
+ + Close 1 + + + Close 2 + + + Close 3 + +
+
+ )} +
+ ); +} + +function OnPress(props) { + const {className, onPress, children} = props; + + const {pressProps} = usePress({ + onPress + }); + + return ( +
+ {children} +
+ ); +} diff --git a/packages/@react-aria/interactions/test/usePress.test.js b/packages/@react-aria/interactions/test/usePress.test.js index bd6e83134b8..23273ff726f 100644 --- a/packages/@react-aria/interactions/test/usePress.test.js +++ b/packages/@react-aria/interactions/test/usePress.test.js @@ -822,6 +822,61 @@ describe('usePress', function () { fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse'})); expect(el).not.toHaveStyle('user-select: none'); }); + + it('should preventDefault on touchend to prevent click events on the wrong element', function () { + let res = render(); + + let el = res.getByText('test'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(false); + }); + + it('should not preventDefault on touchend when element is a submit button', function () { + let res = render(); + + let el = res.getByText('test'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(true); + }); + + it('should not preventDefault on touchend when element is an ', function () { + let res = render(); + + let el = res.getByRole('button'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(true); + }); + + it('should not preventDefault on touchend when element is an ', function () { + let res = render(); + + let el = res.getByRole('checkbox'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(true); + }); + + it('should not preventDefault on touchend when element is a link', function () { + let res = render(); + + let el = res.getByText('test'); + el.ontouchend = () => {}; // So that 'ontouchend' in target works + fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'})); + fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'})); + let browserDefault = fireEvent.touchEnd(el); + expect(browserDefault).toBe(true); + }); }); describe('mouse events', function () {