diff --git a/packages/dev/s2-docs/pages/react-aria/useLongPress.mdx b/packages/dev/s2-docs/pages/react-aria/useLongPress.mdx
index 9adaaaa6e5f..e3b039e1a3b 100644
--- a/packages/dev/s2-docs/pages/react-aria/useLongPress.mdx
+++ b/packages/dev/s2-docs/pages/react-aria/useLongPress.mdx
@@ -86,7 +86,7 @@ friendly alternative to all long press interactions if you are using this hook d
## Features
-A long press is triggered when a user presses and holds their pointer over a target for a minimum period of time. If the user moves their pointer off of the target before the time threshold, the interaction is canceled. Once a long press event is triggered, other pointer interactions that may be active such as `usePress` and `useMove` will be canceled so that only the long press is activated.
+A long press is triggered when a user presses and holds their pointer over a target for a minimum period of time. If the user moves their pointer off of the target before the time threshold, the interaction is canceled. On touch devices, a second finger touching the screen also cancels the interaction, matching native iOS and Android behavior. Once a long press event is triggered, other pointer interactions that may be active such as `usePress` and `useMove` will be canceled so that only the long press is activated.
* Handles mouse and touch events
* Prevents text selection on touch devices while long pressing
diff --git a/packages/react-aria/src/interactions/useLongPress.ts b/packages/react-aria/src/interactions/useLongPress.ts
index 61a45fc7250..0d960b51ede 100644
--- a/packages/react-aria/src/interactions/useLongPress.ts
+++ b/packages/react-aria/src/interactions/useLongPress.ts
@@ -111,12 +111,28 @@ export function useLongPress(props: LongPressProps): LongPressResult {
timeRef.current = undefined;
}, threshold);
- // Prevent context menu, which may be opened on long press on touch devices
+ let ownerWindow = getOwnerWindow(e.target);
+
if (e.pointerType === 'touch') {
+ // Prevent context menu, which may be opened on long press on touch devices
addGlobalListener(e.target, 'contextmenu', e => e.preventDefault(), {once: true});
+
+ // A second finger cancels the long press, matching iOS and Android. The capture phase skips
+ // the pointerdown that started this press, and still sees the next one, which usePress
+ // stops bubbling.
+ addGlobalListener(
+ ownerWindow,
+ 'pointerdown',
+ event => {
+ if (event.pointerType === 'touch' && timeRef.current) {
+ clearTimeout(timeRef.current);
+ timeRef.current = undefined;
+ }
+ },
+ true
+ );
}
- let ownerWindow = getOwnerWindow(e.target);
addGlobalListener(
ownerWindow,
'pointerup',
diff --git a/packages/react-aria/test/interactions/useLongPress.test.js b/packages/react-aria/test/interactions/useLongPress.test.js
index 47fee40d5e8..a01db58d18b 100644
--- a/packages/react-aria/test/interactions/useLongPress.test.js
+++ b/packages/react-aria/test/interactions/useLongPress.test.js
@@ -202,6 +202,250 @@ describe('useLongPress', function () {
]);
});
+ it('should cancel the long press when a second touch begins', function () {
+ let events = [];
+ let addEvent = e => events.push(e);
+ let res = render(
+
+ );
+
+ let el = res.getByText('test');
+
+ fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 1});
+ act(() => jest.advanceTimersByTime(100));
+ fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 2});
+ act(() => jest.advanceTimersByTime(600));
+ expect(events).toEqual([
+ {
+ type: 'longpressstart',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ }
+ ]);
+
+ fireEvent.pointerUp(el, {pointerType: 'touch', pointerId: 2});
+ fireEvent.pointerUp(el, {pointerType: 'touch', pointerId: 1});
+ act(() => jest.advanceTimersByTime(800));
+ expect(events).toEqual([
+ {
+ type: 'longpressstart',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpressend',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ }
+ ]);
+ });
+
+ it('should not cancel the long press when a mouse pointer goes down elsewhere', function () {
+ let events = [];
+ let addEvent = e => events.push(e);
+ let res = render(
+
+ );
+
+ let el = res.getByText('test');
+
+ fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 1});
+ act(() => jest.advanceTimersByTime(100));
+ fireEvent.pointerDown(document.body, {pointerType: 'mouse', pointerId: 2});
+ act(() => jest.advanceTimersByTime(600));
+ expect(events).toEqual([
+ {
+ type: 'longpressstart',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpressend',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpress',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ }
+ ]);
+ });
+
+ it('should not cancel a mouse long press when a second pointer goes down', function () {
+ let events = [];
+ let addEvent = e => events.push(e);
+ let res = render(
+
+ );
+
+ let el = res.getByText('test');
+
+ fireEvent.pointerDown(el, {pointerType: 'mouse', pointerId: 1});
+ act(() => jest.advanceTimersByTime(100));
+ fireEvent.pointerDown(document.body, {pointerType: 'touch', pointerId: 2});
+ act(() => jest.advanceTimersByTime(600));
+ expect(events).toEqual([
+ {
+ type: 'longpressstart',
+ target: el,
+ pointerType: 'mouse',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpressend',
+ target: el,
+ pointerType: 'mouse',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpress',
+ target: el,
+ pointerType: 'mouse',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ }
+ ]);
+ });
+
+ it('should perform a long press after a previous one has completed', function () {
+ let events = [];
+ let addEvent = e => events.push(e);
+ let res = render(
+
+ );
+
+ let el = res.getByText('test');
+
+ fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 1});
+ act(() => jest.advanceTimersByTime(600));
+ fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 2});
+ act(() => jest.advanceTimersByTime(600));
+ expect(events).toEqual([
+ {
+ type: 'longpressstart',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpressend',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpress',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpressstart',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpressend',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ },
+ {
+ type: 'longpress',
+ target: el,
+ pointerType: 'touch',
+ ctrlKey: false,
+ metaKey: false,
+ shiftKey: false,
+ altKey: false,
+ x: 0,
+ y: 0
+ }
+ ]);
+ });
+
it('should cancel other press events', function () {
let events = [];
let addEvent = e => events.push(e);