Skip to content

Commit 039fb7e

Browse files
committed
refactor to touch events to ensure touchmove is called continuously
1 parent f45a406 commit 039fb7e

File tree

4 files changed

+84
-41
lines changed

4 files changed

+84
-41
lines changed

packages/react/src/dialog/root/useDialogRoot.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@ export function useDialogRoot(params: useDialogRoot.Parameters): useDialogRoot.R
7777
};
7878
},
7979
outsidePress(event) {
80-
if (event.button !== 0) {
80+
// For mouse events, only accept left button (button 0)
81+
// For touch events, a single touch is equivalent to left button
82+
if ('button' in event && event.button !== 0) {
83+
return false;
84+
}
85+
if ('touches' in event && event.touches.length !== 1) {
8186
return false;
8287
}
8388
const target = getTarget(event) as Element | null;

packages/react/src/floating-ui-react/hooks/useDismiss.ts

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export interface UseDismissProps {
8484
* ```
8585
* @default true
8686
*/
87-
outsidePress?: boolean | ((event: MouseEvent) => boolean);
87+
outsidePress?: boolean | ((event: MouseEvent | TouchEvent) => boolean);
8888
/**
8989
* The type of event to use to determine an outside "press".
9090
* - `intentional` requires the user to click outside intentionally, firing on `pointerup` for mouse, and requiring minimal `touchmove`s for touch.
@@ -155,7 +155,7 @@ export function useDismiss(
155155
startTime: number;
156156
startX: number;
157157
startY: number;
158-
dismissOnPointerUp: boolean;
158+
dismissOnTouchEnd: boolean;
159159
dismissOnMouseDown: boolean;
160160
} | null>(null);
161161
const cancelDismissOnEndTimeout = useTimeout();
@@ -242,7 +242,7 @@ export function useDismiss(
242242
});
243243

244244
const closeOnPressOutside = useEventCallback(
245-
(event: MouseEvent, endedOrStartedInside = false) => {
245+
(event: MouseEvent | PointerEvent | TouchEvent, endedOrStartedInside = false) => {
246246
if (shouldIgnoreEvent(event)) {
247247
return;
248248
}
@@ -299,7 +299,8 @@ export function useDismiss(
299299
}
300300

301301
// Check if the click occurred on the scrollbar
302-
if (isHTMLElement(target)) {
302+
// Skip for touch events: scrollbars don't receive touch events on most platforms
303+
if (isHTMLElement(target) && !('touches' in event)) {
303304
const lastTraversableNode = isLastTraversableNode(target);
304305
const style = getComputedStyle(target);
305306
const scrollRe = /auto|scroll/;
@@ -369,6 +370,7 @@ export function useDismiss(
369370
const handlePointerDown = useEventCallback((event: PointerEvent) => {
370371
if (
371372
getOutsidePressEvent() !== 'sloppy' ||
373+
event.pointerType === 'touch' ||
372374
!open ||
373375
!enabled ||
374376
isEventTargetWithin(event, elements.floating) ||
@@ -377,25 +379,46 @@ export function useDismiss(
377379
return;
378380
}
379381

380-
if (event.pointerType === 'touch') {
382+
closeOnPressOutside(event);
383+
});
384+
385+
const handleTouchStart = useEventCallback((event: TouchEvent) => {
386+
if (
387+
getOutsidePressEvent() !== 'sloppy' ||
388+
!open ||
389+
!enabled ||
390+
isEventTargetWithin(event, elements.floating) ||
391+
isEventTargetWithin(event, elements.domReference)
392+
) {
393+
return;
394+
}
395+
396+
const touch = event.touches[0];
397+
if (touch) {
381398
touchStateRef.current = {
382399
startTime: Date.now(),
383-
startX: event.clientX,
384-
startY: event.clientY,
385-
dismissOnPointerUp: false,
400+
startX: touch.clientX,
401+
startY: touch.clientY,
402+
dismissOnTouchEnd: false,
386403
dismissOnMouseDown: true,
387404
};
388405

389406
cancelDismissOnEndTimeout.start(1000, () => {
390407
if (touchStateRef.current) {
391-
touchStateRef.current.dismissOnPointerUp = false;
408+
touchStateRef.current.dismissOnTouchEnd = false;
392409
touchStateRef.current.dismissOnMouseDown = false;
393410
}
394411
});
395-
return;
396412
}
413+
});
397414

398-
closeOnPressOutside(event);
415+
const handleTouchStartCapture = useEventCallback((event: TouchEvent) => {
416+
const target = getTarget(event);
417+
function callback() {
418+
handleTouchStart(event);
419+
target?.removeEventListener(event.type, callback);
420+
}
421+
target?.addEventListener(event.type, callback);
399422
});
400423

401424
const closeOnPressOutsideCapture = useEventCallback((event: PointerEvent | MouseEvent) => {
@@ -433,23 +456,27 @@ export function useDismiss(
433456
target?.addEventListener(event.type, callback);
434457
});
435458

436-
const handlePointerMove = useEventCallback((event: PointerEvent) => {
459+
const handleTouchMove = useEventCallback((event: TouchEvent) => {
437460
if (
438461
getOutsidePressEvent() !== 'sloppy' ||
439-
event.pointerType !== 'touch' ||
440462
!touchStateRef.current ||
441463
isEventTargetWithin(event, elements.floating) ||
442464
isEventTargetWithin(event, elements.domReference)
443465
) {
444466
return;
445467
}
446468

447-
const deltaX = Math.abs(event.clientX - touchStateRef.current.startX);
448-
const deltaY = Math.abs(event.clientY - touchStateRef.current.startY);
469+
const touch = event.touches[0];
470+
if (!touch) {
471+
return;
472+
}
473+
474+
const deltaX = Math.abs(touch.clientX - touchStateRef.current.startX);
475+
const deltaY = Math.abs(touch.clientY - touchStateRef.current.startY);
449476
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
450477

451478
if (distance > 5) {
452-
touchStateRef.current.dismissOnPointerUp = true;
479+
touchStateRef.current.dismissOnTouchEnd = true;
453480
}
454481

455482
if (distance > 10) {
@@ -459,38 +486,37 @@ export function useDismiss(
459486
}
460487
});
461488

462-
const handlePointerMoveCapture = useEventCallback((event: PointerEvent) => {
489+
const handleTouchMoveCapture = useEventCallback((event: TouchEvent) => {
463490
const target = getTarget(event);
464491
function callback() {
465-
handlePointerMove(event);
492+
handleTouchMove(event);
466493
target?.removeEventListener(event.type, callback);
467494
}
468495
target?.addEventListener(event.type, callback);
469496
});
470497

471-
const handlePointerUp = useEventCallback((event: PointerEvent) => {
498+
const handleTouchEnd = useEventCallback((event: TouchEvent) => {
472499
if (
473500
getOutsidePressEvent() !== 'sloppy' ||
474-
event.pointerType !== 'touch' ||
475501
!touchStateRef.current ||
476502
isEventTargetWithin(event, elements.floating) ||
477503
isEventTargetWithin(event, elements.domReference)
478504
) {
479505
return;
480506
}
481507

482-
if (touchStateRef.current.dismissOnPointerUp) {
508+
if (touchStateRef.current.dismissOnTouchEnd) {
483509
closeOnPressOutside(event);
484510
}
485511

486512
cancelDismissOnEndTimeout.clear();
487513
touchStateRef.current = null;
488514
});
489515

490-
const handlePointerUpCapture = useEventCallback((event: PointerEvent) => {
516+
const handleTouchEndCapture = useEventCallback((event: TouchEvent) => {
491517
const target = getTarget(event);
492518
function callback() {
493-
handlePointerUp(event);
519+
handleTouchEnd(event);
494520
target?.removeEventListener(event.type, callback);
495521
}
496522
target?.addEventListener(event.type, callback);
@@ -554,8 +580,9 @@ export function useDismiss(
554580
outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside,
555581
outsidePressCapture,
556582
);
557-
doc.addEventListener('pointermove', handlePointerMoveCapture, outsidePressCapture);
558-
doc.addEventListener('pointerup', handlePointerUpCapture, outsidePressCapture);
583+
doc.addEventListener('touchstart', handleTouchStartCapture, outsidePressCapture);
584+
doc.addEventListener('touchmove', handleTouchMoveCapture, outsidePressCapture);
585+
doc.addEventListener('touchend', handleTouchEndCapture, outsidePressCapture);
559586
doc.addEventListener('mousedown', closeOnPressOutsideCapture, outsidePressCapture);
560587
}
561588

@@ -610,8 +637,9 @@ export function useDismiss(
610637
outsidePressCapture ? closeOnPressOutsideCapture : closeOnPressOutside,
611638
outsidePressCapture,
612639
);
613-
doc.removeEventListener('pointermove', handlePointerMoveCapture, outsidePressCapture);
614-
doc.removeEventListener('pointerup', handlePointerUpCapture, outsidePressCapture);
640+
doc.removeEventListener('touchstart', handleTouchStartCapture, outsidePressCapture);
641+
doc.removeEventListener('touchmove', handleTouchMoveCapture, outsidePressCapture);
642+
doc.removeEventListener('touchend', handleTouchEndCapture, outsidePressCapture);
615643
doc.removeEventListener('mousedown', closeOnPressOutsideCapture, outsidePressCapture);
616644
}
617645

@@ -639,8 +667,9 @@ export function useDismiss(
639667
outsidePressCapture,
640668
closeOnPressOutsideCapture,
641669
handlePointerDown,
642-
handlePointerMoveCapture,
643-
handlePointerUpCapture,
670+
handleTouchStartCapture,
671+
handleTouchMoveCapture,
672+
handleTouchEndCapture,
644673
trackPointerType,
645674
]);
646675

@@ -689,7 +718,8 @@ export function useDismiss(
689718
onMouseDownCapture: handleCaptureInside,
690719
onClickCapture: handleCaptureInside,
691720
onMouseUpCapture: handleCaptureInside,
692-
onPointerMoveCapture: handleCaptureInside,
721+
onTouchMoveCapture: handleCaptureInside,
722+
onTouchEndCapture: handleCaptureInside,
693723
}),
694724
[closeOnEscapeKeyDown, handlePressedInside, handleCaptureInside],
695725
);

packages/react/src/popover/root/PopoverRoot.test.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1632,28 +1632,36 @@ describe('<Popover.Root />', () => {
16321632
const comboboxPopup = screen.getByTestId('combobox-popup');
16331633
expect(comboboxPopup).not.to.equal(null);
16341634

1635-
// Simulate touch scroll: pointerdown + pointermove on the scrollable list
1636-
fireEvent.pointerDown(comboboxPopup, {
1635+
// Simulate touch scroll: touchstart + touchmove on the scrollable list
1636+
const touch1 = new Touch({
1637+
identifier: 1,
1638+
target: comboboxPopup,
16371639
clientX: 100,
16381640
clientY: 100,
1639-
pointerType: 'touch',
1641+
});
1642+
1643+
fireEvent.touchStart(comboboxPopup, {
1644+
touches: [touch1],
16401645
});
16411646

16421647
// Wait for the markInsideReactTree timeout to finish
16431648
await new Promise((resolve) => {
16441649
setTimeout(resolve);
16451650
});
16461651

1647-
fireEvent.pointerMove(comboboxPopup, {
1652+
const touch2 = new Touch({
1653+
identifier: 1,
1654+
target: comboboxPopup,
16481655
clientX: 100,
16491656
clientY: 50,
1650-
pointerType: 'touch',
16511657
});
16521658

1653-
fireEvent.pointerUp(comboboxPopup, {
1654-
clientX: 100,
1655-
clientY: 50,
1656-
pointerType: 'touch',
1659+
fireEvent.touchMove(comboboxPopup, {
1660+
touches: [touch2],
1661+
});
1662+
1663+
fireEvent.touchEnd(comboboxPopup, {
1664+
changedTouches: [touch2],
16571665
});
16581666

16591667
await flushMicrotasks();

packages/react/src/utils/createBaseUIEventDetails.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export type ReasonToEvent<Reason extends string> = Reason extends 'trigger-press
88
: Reason extends 'trigger-hover'
99
? MouseEvent
1010
: Reason extends 'outside-press'
11-
? MouseEvent | PointerEvent
11+
? MouseEvent | PointerEvent | TouchEvent
1212
: Reason extends 'item-press' | 'close-press'
1313
? MouseEvent | KeyboardEvent | PointerEvent
1414
: Reason extends 'cancel-open'

0 commit comments

Comments
 (0)