From c435c2e0dd9dfb1278748f28ee179e527ccb6566 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 7 Apr 2025 10:50:47 -0700 Subject: [PATCH 1/2] fix: Apply touch-action by default in usePress --- .../@react-aria/interactions/src/usePress.ts | 20 ++++-- .../interactions/stories/usePress.stories.tsx | 69 ++++++++++++++++++- .../selection/src/useSelectableItem.ts | 4 +- 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 5a9edad1fcb..8db93ba8817 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -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); @@ -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) { + // 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) { diff --git a/packages/@react-aria/interactions/stories/usePress.stories.tsx b/packages/@react-aria/interactions/stories/usePress.stories.tsx index ac982394ce3..0ac998aedd3 100644 --- a/packages/@react-aria/interactions/stories/usePress.stories.tsx +++ b/packages/@react-aria/interactions/stories/usePress.stories.tsx @@ -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'; @@ -234,3 +234,70 @@ export function SoftwareKeyboardIssue() { ); } + +export function AndroidUnmountIssue() { + let [showButton, setShowButton] = useState(true); + + return ( +
+

This story tests an Android issue where tapping a button that unmounts causes the element behind it to receive onClick.

+
+ + {showButton && ( + + )} +
+
+ ); +} + +export function IOSScrollIssue() { + return ( +
+

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.

+
+ {Array.from({length: 10}).map((_, i) => ( + + ))} +
+
+ ); +} + +function Card() { + return ( + + ); +} diff --git a/packages/@react-aria/selection/src/useSelectableItem.ts b/packages/@react-aria/selection/src/useSelectableItem.ts index f6bd48f0c11..fe2f556543b 100644 --- a/packages/@react-aria/selection/src/useSelectableItem.ts +++ b/packages/@react-aria/selection/src/useSelectableItem.ts @@ -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'; @@ -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; From 420831719a3a9b19de0692dc6fefbe2efae10dd8 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Mon, 7 Apr 2025 11:04:45 -0700 Subject: [PATCH 2/2] fix test --- packages/@react-aria/interactions/src/usePress.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/interactions/src/usePress.ts b/packages/@react-aria/interactions/src/usePress.ts index 8db93ba8817..e48240a10c3 100644 --- a/packages/@react-aria/interactions/src/usePress.ts +++ b/packages/@react-aria/interactions/src/usePress.ts @@ -816,7 +816,7 @@ export function usePress(props: PressHookProps): PressResult { // Avoid onClick delay for double tap to zoom by default. useEffect(() => { let element = domRef?.current; - if (element) { + 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') {