Skip to content

Commit

Permalink
Workaround Android issue where click events are fired on the wrong ta…
Browse files Browse the repository at this point in the history
…rget (#7026)

Co-authored-by: Daniel Lu <[email protected]>
  • Loading branch information
devongovett and LFDanLu committed Sep 16, 2024
1 parent dcbb9dc commit c904e06
Show file tree
Hide file tree
Showing 4 changed files with 251 additions and 7 deletions.
41 changes: 34 additions & 7 deletions packages/@react-aria/interactions/src/usePress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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();
}
};

Expand All @@ -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();
}

Expand Down Expand Up @@ -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';
}
Expand All @@ -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',
Expand Down
76 changes: 76 additions & 0 deletions packages/@react-aria/interactions/stories/usePress-stories.css
Original file line number Diff line number Diff line change
@@ -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;
}
86 changes: 86 additions & 0 deletions packages/@react-aria/interactions/stories/usePress.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={styles['outer-div']}>
<OnPress onPress={handleOpen} className={styles['open-btn']}>
Open
</OnPress>
<div className={styles['side-by-side']}>
<div>Some text</div>
<a href="https://www.google.com" className={styles['visit-link']}>
Another Link
</a>
<button className={styles['my-btn']} onClick={handleOnClick}>
On Click
</button>
</div>

{opened && (
<div className={styles['fake-modal']}>
<h1>Header</h1>
<div className={styles['side-by-side']}>
<OnPress onPress={handleClose} className={styles['close-btn']}>
Close 1
</OnPress>
<OnPress onPress={handleClose} className={styles['close-btn']}>
Close 2
</OnPress>
<OnPress onPress={handleClose} className={styles['close-btn']}>
Close 3
</OnPress>
</div>
</div>
)}
</div>
);
}

function OnPress(props) {
const {className, onPress, children} = props;

const {pressProps} = usePress({
onPress
});

return (
<div
{...pressProps}
role="button"
tabIndex={0}
className={`OnPress ${className || ''}`}>
{children}
</div>
);
}
55 changes: 55 additions & 0 deletions packages/@react-aria/interactions/test/usePress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Example />);

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(<Example elementType="button" type="submit" />);

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 <input type="submit">', function () {
let res = render(<Example elementType="input" type="submit" />);

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 <input type="checkbox">', function () {
let res = render(<Example elementType="input" type="checkbox" />);

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(<Example elementType="a" href="http://google.com" />);

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 () {
Expand Down

1 comment on commit c904e06

@rspbot
Copy link

@rspbot rspbot commented on c904e06 Sep 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.