Skip to content
Open
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
2 changes: 1 addition & 1 deletion packages/dev/s2-docs/pages/react-aria/useLongPress.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 18 additions & 2 deletions packages/react-aria/src/interactions/useLongPress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
244 changes: 244 additions & 0 deletions packages/react-aria/test/interactions/useLongPress.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Example onLongPressStart={addEvent} onLongPressEnd={addEvent} onLongPress={addEvent} />
);

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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

shouldn't longpress end have already happened by here? it's canceled on pointerDown

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

No, and the pre-fix behavior is what makes it look like it should.

onLongPressEnd is emitted from one place only: usePress's onPressEnd (useLongPress.ts:150-160). In the uninterrupted path it arrives before pointerup because the timer callback dispatches a synthetic pointercancel (useLongPress.ts:95), which runs usePress's cancel (usePress.ts:309-315) and fires press end. That is why should perform a long press sees longpressstart, longpressend, longpress.

The cancel path does not dispatch pointercancel. It only clears the timer (useLongPress.ts:126-129), so the press is still live and nothing fires press end. The second pointerdown itself produces no event at all, because usePress.ts:560 skips the entire block when state.isPressed is already true.

I instrumented the element with both usePress and useLongPress handlers to confirm the order:

pointerDown 1  -> longpressstart, pressstart
pointerDown 2  -> (nothing)
+600ms         -> (nothing)
pointerUp 2    -> (nothing)
pointerUp 1    -> longpressend, pressend, press

So at that line only longpressstart has fired, and longpressend arrives on lift, which the second assertion at the end of the test covers. Restoring the pre-fix source and keeping the tests turns exactly that assertion red, receiving longpressstart, longpressend, longpress, which is the sequence you have in mind.

One decision this exposes, though: since the press is left untouched, lifting the finger still fires onPress, so a three-finger tap on a menu item cancels the long press but still activates the item. Dispatching pointercancel on the second touch instead of just clearing the timer would both give you longpressend at that point and suppress the press. I kept the scope narrow deliberately, but I am happy to make that change if you prefer it.

]);

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(
<Example onLongPressStart={addEvent} onLongPressEnd={addEvent} onLongPress={addEvent} />
);

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(
<Example
pointerType="mouse"
onLongPressStart={addEvent}
onLongPressEnd={addEvent}
onLongPress={addEvent}
/>
);

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(
<Example onLongPressStart={addEvent} onLongPressEnd={addEvent} onLongPress={addEvent} />
);

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);
Expand Down