diff --git a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx index d780b3a0d9..9810a4deea 100644 --- a/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx +++ b/packages/framer-motion/src/gestures/drag/__tests__/index.test.tsx @@ -7,7 +7,7 @@ import { MotionValue, useWillChange, } from "../../../" -import { pointerDown, pointerMove, render } from "../../../jest.setup" +import { pointerDown, pointerMove, pointerUp, render } from "../../../jest.setup" import { WillChangeMotionValue } from "../../../value/use-will-change/WillChangeMotionValue" import { nextFrame } from "../../__tests__/utils" import { deferred, drag, dragFrame, MockDrag, Point, sleep } from "./utils" @@ -143,6 +143,31 @@ describe("dragging", () => { expect(onDragEnd).toBeCalledTimes(1) }) + test("dragEnd fires when a child stops pointerup propagation (#2794)", async () => { + const onDragEnd = jest.fn() + const Component = () => ( + + +
event.stopPropagation()} + /> + + + ) + + const { container, getByTestId, rerender } = render() + rerender() + + await drag(container.firstChild).to(0, 100) + + pointerUp(getByTestId("child")) + + await nextFrame() + + expect(onDragEnd).toBeCalledTimes(1) + }) + test("dragEnd doesn't fire if dragging never initiated", async () => { const onDragEnd = jest.fn() const Component = () => ( diff --git a/packages/framer-motion/src/gestures/pan/PanSession.ts b/packages/framer-motion/src/gestures/pan/PanSession.ts index d7df4ba49c..7bb5d1fdc6 100644 --- a/packages/framer-motion/src/gestures/pan/PanSession.ts +++ b/packages/framer-motion/src/gestures/pan/PanSession.ts @@ -148,21 +148,28 @@ export class PanSession { onSessionStart && onSessionStart(event, getPanInfo(initialInfo, this.history)) + // Listen in the capture phase so a descendant calling + // stopPropagation() (e.g. in its own pointerup handler) can't + // prevent the gesture from ending. See #2794. + const eventOptions = { passive: true, capture: true } this.removeListeners = pipe( addPointerEvent( this.contextWindow, "pointermove", - this.handlePointerMove + this.handlePointerMove, + eventOptions ), addPointerEvent( this.contextWindow, "pointerup", - this.handlePointerUp + this.handlePointerUp, + eventOptions ), addPointerEvent( this.contextWindow, "pointercancel", - this.handlePointerUp + this.handlePointerUp, + eventOptions ) ) diff --git a/packages/motion-dom/src/events/add-dom-event.ts b/packages/motion-dom/src/events/add-dom-event.ts index a45c5158d8..e54517fe0c 100644 --- a/packages/motion-dom/src/events/add-dom-event.ts +++ b/packages/motion-dom/src/events/add-dom-event.ts @@ -6,5 +6,5 @@ export function addDomEvent( ) { target.addEventListener(eventName, handler, options) - return () => target.removeEventListener(eventName, handler) + return () => target.removeEventListener(eventName, handler, options) } diff --git a/packages/motion-dom/src/gestures/press/index.ts b/packages/motion-dom/src/gestures/press/index.ts index d82bc3565d..fb863f3918 100644 --- a/packages/motion-dom/src/gestures/press/index.ts +++ b/packages/motion-dom/src/gestures/press/index.ts @@ -68,9 +68,25 @@ export function press( const onPressEnd = onPressStart(target, startEvent) + /** + * End listeners run in the capture phase so a descendant calling + * stopPropagation() in its own pointerup handler can't prevent the + * press gesture from ending. This also keeps the gesture-end + * ordering consistent with the drag gesture. See #2794. + */ + const endEventOptions = { ...eventOptions, capture: true } + const onPointerEnd = (endEvent: PointerEvent, success: boolean) => { - window.removeEventListener("pointerup", onPointerUp) - window.removeEventListener("pointercancel", onPointerCancel) + window.removeEventListener( + "pointerup", + onPointerUp, + endEventOptions + ) + window.removeEventListener( + "pointercancel", + onPointerCancel, + endEventOptions + ) if (isPressing.has(target)) { isPressing.delete(target) @@ -99,8 +115,12 @@ export function press( onPointerEnd(cancelEvent, false) } - window.addEventListener("pointerup", onPointerUp, eventOptions) - window.addEventListener("pointercancel", onPointerCancel, eventOptions) + window.addEventListener("pointerup", onPointerUp, endEventOptions) + window.addEventListener( + "pointercancel", + onPointerCancel, + endEventOptions + ) } targets.forEach((target: EventTarget) => {