Skip to content

fix: Apply touch-action by default in usePress #8047

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 8, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions packages/@react-aria/interactions/src/usePress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,7 @@ export function usePress(props: PressHookProps): PressResult {
preventFocusOnPress,
shouldCancelOnPointerExit,
allowTextSelectionOnPress,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
ref: _, // Removing `ref` from `domProps` because TypeScript is dumb
ref: domRef,
...domProps
} = usePressResponderContext(props);

Expand Down Expand Up @@ -814,13 +813,26 @@ export function usePress(props: PressHookProps): PressResult {
triggerSyntheticClick
]);

// Remove user-select: none in case component unmounts immediately after pressStart
// Avoid onClick delay for double tap to zoom by default.
useEffect(() => {
let element = domRef?.current;
if (element && (element instanceof getOwnerWindow(element).Element)) {
// Only apply touch-action if not already set by another CSS rule.
let style = getOwnerWindow(element).getComputedStyle(element);
if (style.touchAction === 'auto') {
// touchAction: 'manipulation' is supposed to be equivalent, but in
// Safari it causes onPointerCancel not to fire on scroll.
// https://bugs.webkit.org/show_bug.cgi?id=240917
(element as HTMLElement).style.touchAction = 'pan-x pan-y pinch-zoom';
}
}
}, [domRef]);

// Remove user-select: none in case component unmounts immediately after pressStart
useEffect(() => {
let state = ref.current;
return () => {
if (!allowTextSelectionOnPress) {

restoreTextSelection(state.target ?? undefined);
}
for (let dispose of state.disposables) {
Expand Down
69 changes: 68 additions & 1 deletion packages/@react-aria/interactions/stories/usePress.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
Modal,
ModalOverlay
} from 'react-aria-components';
import React from 'react';
import React, {useState} from 'react';
import styles from './usePress-stories.css';
import {usePress} from '@react-aria/interactions';

Expand Down Expand Up @@ -234,3 +234,70 @@ export function SoftwareKeyboardIssue() {
</div>
);
}

export function AndroidUnmountIssue() {
let [showButton, setShowButton] = useState(true);

return (
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
<p>This story tests an Android issue where tapping a button that unmounts causes the element behind it to receive onClick.</p>
<div style={{position: 'relative', width: 100, height: 100}}>
<button
type="button"
onClick={() => {
alert('button underneath was pressed');
}}
style={{position: 'absolute', top: 0}}>
Test 2
</button>
{showButton && (
<Button
className="foo"
style={{position: 'absolute', top: 0}}
onPress={() => {
console.log('ra Button pressed');
setShowButton(false);
}}>
Test
</Button>
)}
</div>
</div>
);
}

export function IOSScrollIssue() {
return (
<div style={{display: 'flex', flexDirection: 'column', alignItems: 'center'}}>
<p>This story tests an iOS Safari issue that causes onPointerCancel not to be fired with touch-action: manipulation. Scrolling the list should not trigger onPress.</p>
<div
style={{
marginTop: 10,
width: 500,
height: 100,
overflowY: 'hidden',
overflowX: 'auto',
border: '1px solid black',
display: 'flex',
gap: 8
}}>
{Array.from({length: 10}).map((_, i) => (
<Card key={i} />
))}
</div>
</div>
);
}

function Card() {
return (
<Button
className="foo"
style={{height: 80, width: 150, flexShrink: 0}}
onPress={() => {
alert('pressed');
}}>
Test
</Button>
);
}
4 changes: 2 additions & 2 deletions packages/@react-aria/selection/src/useSelectableItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared';
import {focusSafely, PressProps, useLongPress, usePress} from '@react-aria/interactions';
import {focusSafely, PressHookProps, useLongPress, usePress} from '@react-aria/interactions';
import {getCollectionId, isNonContiguousSelectionModifier} from './utils';
import {isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils';
import {moveVirtualFocus} from '@react-aria/focus';
Expand Down Expand Up @@ -239,7 +239,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
// we want to be able to have the pointer down on the trigger that opens the menu and
// the pointer up on the menu item rather than requiring a separate press.
// For keyboard events, selection still occurs on key down.
let itemPressProps: PressProps = {};
let itemPressProps: PressHookProps = {ref};
if (shouldSelectOnPressUp) {
itemPressProps.onPressStart = (e) => {
modality.current = e.pointerType;
Expand Down