Skip to content

Commit b0f1569

Browse files
authored
refactor: Remove custom hit testing in usePress (#7427)
* refactor: Remove custom hit testing in usePress * Only allow dragging to select an item with mouse, not touch * Don't close when dragging outside submenu
1 parent 2d45210 commit b0f1569

File tree

6 files changed

+71
-42
lines changed

6 files changed

+71
-42
lines changed

packages/@react-aria/interactions/src/usePress.ts

+16-17
Original file line numberDiff line numberDiff line change
@@ -433,7 +433,13 @@ export function usePress(props: PressHookProps): PressResult {
433433

434434
shouldStopPropagation = triggerPressStart(e, state.pointerType);
435435

436-
addGlobalListener(getOwnerDocument(e.currentTarget), 'pointermove', onPointerMove, false);
436+
// Release pointer capture so that touch interactions can leave the original target.
437+
// This enables onPointerLeave and onPointerEnter to fire.
438+
let target = e.target as Element;
439+
if ('releasePointerCapture' in target) {
440+
target.releasePointerCapture(e.pointerId);
441+
}
442+
437443
addGlobalListener(getOwnerDocument(e.currentTarget), 'pointerup', onPointerUp, false);
438444
addGlobalListener(getOwnerDocument(e.currentTarget), 'pointercancel', onPointerCancel, false);
439445
}
@@ -467,27 +473,20 @@ export function usePress(props: PressHookProps): PressResult {
467473
}
468474

469475
// Only handle left clicks
470-
// Safari on iOS sometimes fires pointerup events, even
471-
// when the touch isn't over the target, so double check.
472-
if (e.button === 0 && isOverTarget(e, e.currentTarget)) {
476+
if (e.button === 0) {
473477
triggerPressUp(e, state.pointerType || e.pointerType);
474478
}
475479
};
476480

477-
// Safari on iOS < 13.2 does not implement pointerenter/pointerleave events correctly.
478-
// Use pointer move events instead to implement our own hit testing.
479-
// See https://bugs.webkit.org/show_bug.cgi?id=199803
480-
let onPointerMove = (e: PointerEvent) => {
481-
if (e.pointerId !== state.activePointerId) {
482-
return;
481+
pressProps.onPointerEnter = (e) => {
482+
if (e.pointerId === state.activePointerId && state.target && !state.isOverTarget && state.pointerType != null) {
483+
state.isOverTarget = true;
484+
triggerPressStart(createEvent(state.target, e), state.pointerType);
483485
}
486+
};
484487

485-
if (state.target && isOverTarget(e, state.target)) {
486-
if (!state.isOverTarget && state.pointerType != null) {
487-
state.isOverTarget = true;
488-
triggerPressStart(createEvent(state.target, e), state.pointerType);
489-
}
490-
} else if (state.target && state.isOverTarget && state.pointerType != null) {
488+
pressProps.onPointerLeave = (e) => {
489+
if (e.pointerId === state.activePointerId && state.target && state.isOverTarget && state.pointerType != null) {
491490
state.isOverTarget = false;
492491
triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
493492
cancelOnPointerExit(e);
@@ -496,7 +495,7 @@ export function usePress(props: PressHookProps): PressResult {
496495

497496
let onPointerUp = (e: PointerEvent) => {
498497
if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) {
499-
if (isOverTarget(e, state.target) && state.pointerType != null) {
498+
if (state.target.contains(e.target as Element) && state.pointerType != null) {
500499
triggerPressEnd(createEvent(state.target, e), state.pointerType);
501500
} else if (state.isOverTarget && state.pointerType != null) {
502501
triggerPressEnd(createEvent(state.target, e), state.pointerType, false);

packages/@react-aria/interactions/test/usePress.test.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,15 @@ describe('usePress', function () {
141141
);
142142

143143
let el = res.getByText('test');
144+
el.releasePointerCapture = jest.fn();
144145
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
146+
expect(el.releasePointerCapture).toHaveBeenCalled();
145147
fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
146-
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
148+
// react listens for pointerout and pointerover instead of pointerleave and pointerenter...
149+
fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
150+
fireEvent(document, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
147151
fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
152+
fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
148153

149154
expect(events).toEqual([
150155
{
@@ -182,7 +187,10 @@ describe('usePress', function () {
182187
events = [];
183188
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
184189
fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
190+
// react listens for pointerout and pointerover instead of pointerleave and pointerenter...
191+
fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
185192
fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
193+
fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
186194
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
187195

188196
expect(events).toEqual([
@@ -387,7 +395,9 @@ describe('usePress', function () {
387395
let el = res.getByText('test');
388396
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
389397
fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
398+
fireEvent(el, pointerEvent('pointerout', {pointerId: 1, pointerType: 'mouse', clientX: 100, clientY: 100}));
390399
fireEvent(el, pointerEvent('pointermove', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
400+
fireEvent(el, pointerEvent('pointerover', {pointerId: 1, pointerType: 'mouse', clientX: 0, clientY: 0}));
391401

392402
expect(events).toEqual([
393403
{

packages/@react-aria/menu/src/useMenuItem.ts

+22-8
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
110110
'aria-haspopup': hasPopup,
111111
onPressStart: pressStartProp,
112112
onPressUp: pressUpProp,
113-
onPress,
113+
onPress: pressProp,
114114
onPressChange,
115115
onPressEnd,
116116
onHoverStart: hoverStartProp,
@@ -196,20 +196,34 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
196196
pressStartProp?.(e);
197197
};
198198

199+
let maybeClose = () => {
200+
// Pressing a menu item should close by default in single selection mode but not multiple
201+
// selection mode, except if overridden by the closeOnSelect prop.
202+
if (!isTrigger && onClose && (closeOnSelect ?? (selectionManager.selectionMode !== 'multiple' || selectionManager.isLink(key)))) {
203+
onClose();
204+
}
205+
};
206+
199207
let onPressUp = (e: PressEvent) => {
200-
if (e.pointerType !== 'keyboard') {
208+
// If interacting with mouse, allow the user to mouse down on the trigger button,
209+
// drag, and release over an item (matching native behavior).
210+
if (e.pointerType === 'mouse') {
201211
performAction(e);
202-
203-
// Pressing a menu item should close by default in single selection mode but not multiple
204-
// selection mode, except if overridden by the closeOnSelect prop.
205-
if (!isTrigger && onClose && (closeOnSelect ?? (selectionManager.selectionMode !== 'multiple' || selectionManager.isLink(key)))) {
206-
onClose();
207-
}
212+
maybeClose();
208213
}
209214

210215
pressUpProp?.(e);
211216
};
212217

218+
let onPress = (e: PressEvent) => {
219+
if (e.pointerType !== 'keyboard' && e.pointerType !== 'mouse') {
220+
performAction(e);
221+
maybeClose();
222+
}
223+
224+
pressProp?.(e);
225+
};
226+
213227
let {itemProps, isFocused} = useSelectableItem({
214228
selectionManager: selectionManager,
215229
key,

packages/@react-aria/selection/src/useSelectableItem.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
241241
}
242242
};
243243

244-
// If allowsDifferentPressOrigin, make selection happen on pressUp (e.g. open menu on press down, selection on menu item happens on press up.)
244+
// If allowsDifferentPressOrigin and interacting with mouse, make selection happen on pressUp (e.g. open menu on press down, selection on menu item happens on press up.)
245245
// Otherwise, have selection happen onPress (prevents listview row selection when clicking on interactable elements in the row)
246246
if (!allowsDifferentPressOrigin) {
247247
itemPressProps.onPress = (e) => {
@@ -257,12 +257,16 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
257257
};
258258
} else {
259259
itemPressProps.onPressUp = hasPrimaryAction ? undefined : (e) => {
260-
if (e.pointerType !== 'keyboard' && allowsSelection) {
260+
if (e.pointerType === 'mouse' && allowsSelection) {
261261
onSelect(e);
262262
}
263263
};
264264

265-
itemPressProps.onPress = hasPrimaryAction ? performAction : undefined;
265+
itemPressProps.onPress = hasPrimaryAction ? performAction : (e) => {
266+
if (e.pointerType !== 'keyboard' && e.pointerType !== 'mouse' && allowsSelection) {
267+
onSelect(e);
268+
}
269+
};
266270
}
267271
} else {
268272
itemPressProps.onPressStart = (e) => {

packages/@react-spectrum/menu/src/Menu.tsx

-10
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@ import {mergeProps, useLayoutEffect, useSlotId, useSyncRef} from '@react-aria/ut
2424
import React, {ReactElement, useContext, useEffect, useRef, useState} from 'react';
2525
import {SpectrumMenuProps} from '@react-types/menu';
2626
import styles from '@adobe/spectrum-css-temp/components/menu/vars.css';
27-
import {useInteractOutside} from '@react-aria/interactions';
2827
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
2928
import {useMenu} from '@react-aria/menu';
3029
import {useTreeState} from '@react-stately/tree';
@@ -70,15 +69,6 @@ export const Menu = React.forwardRef(function Menu<T extends object>(props: Spec
7069
let nextMenuLevel = state.collection.getItem(nextMenuLevelKey);
7170
hasOpenSubmenu = nextMenuLevel != null;
7271
}
73-
useInteractOutside({
74-
ref: domRef,
75-
onInteractOutside: (e) => {
76-
if (!popoverContainer?.contains(e.target as Node) && !trayContainerRef.current?.contains(e.target as Node)) {
77-
rootMenuTriggerState?.close();
78-
}
79-
},
80-
isDisabled: isSubmenu || !hasOpenSubmenu
81-
});
8272

8373
return (
8474
<MenuStateContext.Provider value={{popoverContainer, trayContainerRef, menu: domRef, submenu: submenuRef, rootMenuTriggerState, state}}>

packages/@react-spectrum/menu/src/MenuTrigger.tsx

+15-3
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {classNames, SlotProvider, useDOMRef, useIsMobileDevice} from '@react-spectrum/utils';
13+
import {classNames, SlotProvider, unwrapDOMRef, useDOMRef, useIsMobileDevice} from '@react-spectrum/utils';
1414
import {DOMRef} from '@react-types/shared';
1515
import {MenuContext} from './context';
1616
import {Placement} from '@react-types/overlays';
1717
import {Popover, Tray} from '@react-spectrum/overlays';
18-
import {PressResponder} from '@react-aria/interactions';
18+
import {PressResponder, useInteractOutside} from '@react-aria/interactions';
1919
import React, {forwardRef, Fragment, useRef} from 'react';
2020
import {SpectrumMenuTriggerProps} from '@react-types/menu';
2121
import styles from '@adobe/spectrum-css-temp/components/menu/vars.css';
@@ -74,17 +74,29 @@ export const MenuTrigger = forwardRef(function MenuTrigger(props: SpectrumMenuTr
7474
state
7575
};
7676

77+
// Close when clicking outside the root menu when a submenu is open.
78+
let rootOverlayRef = useRef(null);
79+
let rootOverlayDomRef = unwrapDOMRef(rootOverlayRef);
80+
useInteractOutside({
81+
ref: rootOverlayDomRef,
82+
onInteractOutside: () => {
83+
state?.close();
84+
},
85+
isDisabled: !state.isOpen || state.expandedKeysStack.length === 0
86+
});
87+
7788
// On small screen devices, the menu is rendered in a tray, otherwise a popover.
7889
let overlay;
7990
if (isMobile) {
8091
overlay = (
81-
<Tray state={state} isFixedHeight>
92+
<Tray state={state} isFixedHeight ref={rootOverlayRef}>
8293
{menu}
8394
</Tray>
8495
);
8596
} else {
8697
overlay = (
8798
<Popover
99+
ref={rootOverlayRef}
88100
UNSAFE_style={{clipPath: 'unset', overflow: 'visible', filter: 'unset', borderWidth: '0px'}}
89101
state={state}
90102
triggerRef={menuTriggerRef}

0 commit comments

Comments
 (0)