diff --git a/dev/html/public/playwright/gestures/press.html b/dev/html/public/playwright/gestures/press.html index 68ad250c98..ba5369916c 100644 --- a/dev/html/public/playwright/gestures/press.html +++ b/dev/html/public/playwright/gestures/press.html @@ -33,6 +33,10 @@
+
+
child
+
+ diff --git a/packages/framer-motion/src/gestures/__tests__/press.test.tsx b/packages/framer-motion/src/gestures/__tests__/press.test.tsx index 144f394e75..77975ad6b5 100644 --- a/packages/framer-motion/src/gestures/__tests__/press.test.tsx +++ b/packages/framer-motion/src/gestures/__tests__/press.test.tsx @@ -767,6 +767,130 @@ describe("press", () => { ]) }) + test("propagate={{ tap: false }} prevents parent onTap from firing", async () => { + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + parentTap()}> + childTap()} + propagate={{ tap: false }} + /> + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(0) + }) + + test("without propagate both parent and child onTap fire", async () => { + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + parentTap()}> + childTap()} + /> + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(1) + }) + + test("propagate={{ tap: false }} isolates whileTap to child only", () => { + const promise = new Promise(async (resolve) => { + const parentOpacityHistory: number[] = [] + const childOpacityHistory: number[] = [] + const parentOpacity = motionValue(0.5) + const childOpacity = motionValue(0.5) + const logOpacities = () => { + parentOpacityHistory.push(parentOpacity.get()) + childOpacityHistory.push(childOpacity.get()) + } + const Component = () => ( + + + + ) + + const { getByTestId } = render() + await nextFrame() + logOpacities() // both 0.5 + + pointerDown(getByTestId("child")) + await nextFrame() + logOpacities() // child 1, parent 0.5 + + pointerUp(getByTestId("child")) + await nextFrame() + logOpacities() // both 0.5 + + resolve({ parentOpacityHistory, childOpacityHistory }) + }) + + return expect(promise).resolves.toEqual({ + parentOpacityHistory: [0.5, 0.5, 0.5], + childOpacityHistory: [0.5, 1, 0.5], + }) + }) + + test("propagate={{ tap: false }} prevents all ancestor onTap handlers (three levels)", async () => { + const grandparentTap = jest.fn() + const parentTap = jest.fn() + const childTap = jest.fn() + const Component = () => ( + grandparentTap()}> + parentTap()}> + childTap()} + propagate={{ tap: false }} + /> + + + ) + + const { getByTestId, rerender } = render() + rerender() + + pointerDown(getByTestId("child")) + pointerUp(getByTestId("child")) + await nextFrame() + + expect(childTap).toBeCalledTimes(1) + expect(parentTap).toBeCalledTimes(0) + expect(grandparentTap).toBeCalledTimes(0) + }) + test("ignore press event when button is disabled", async () => { const press = jest.fn() const Component = () => press()} disabled /> diff --git a/packages/framer-motion/src/gestures/press.ts b/packages/framer-motion/src/gestures/press.ts index e5d2650896..f75f7b8a2f 100644 --- a/packages/framer-motion/src/gestures/press.ts +++ b/packages/framer-motion/src/gestures/press.ts @@ -32,6 +32,8 @@ export class PressGesture extends Feature { const { current } = this.node if (!current) return + const { globalTapTarget, propagate } = this.node.props + this.unmount = press( current, (_element, startEvent) => { @@ -44,7 +46,10 @@ export class PressGesture extends Feature { success ? "End" : "Cancel" ) }, - { useGlobalTarget: this.node.props.globalTapTarget } + { + useGlobalTarget: globalTapTarget, + stopPropagation: propagate?.tap === false, + } ) } diff --git a/packages/framer-motion/src/motion/utils/valid-prop.ts b/packages/framer-motion/src/motion/utils/valid-prop.ts index 1c9a8ecf87..515942d845 100644 --- a/packages/framer-motion/src/motion/utils/valid-prop.ts +++ b/packages/framer-motion/src/motion/utils/valid-prop.ts @@ -35,6 +35,7 @@ const validMotionProps = new Set([ "onViewportEnter", "onViewportLeave", "globalTapTarget", + "propagate", "ignoreStrict", "viewport", ]) diff --git a/packages/motion-dom/src/gestures/press/index.ts b/packages/motion-dom/src/gestures/press/index.ts index ecc6f9805b..d82bc3565d 100644 --- a/packages/motion-dom/src/gestures/press/index.ts +++ b/packages/motion-dom/src/gestures/press/index.ts @@ -18,8 +18,11 @@ function isValidPressEvent(event: PointerEvent) { return isPrimaryPointer(event) && !isDragActive() } +const claimedPointerDownEvents = new WeakSet() + export interface PointerEventOptions extends EventOptions { useGlobalTarget?: boolean + stopPropagation?: boolean } /** @@ -55,9 +58,14 @@ export function press( const target = startEvent.currentTarget as Element if (!isValidPressEvent(startEvent)) return + if (claimedPointerDownEvents.has(startEvent)) return isPressing.add(target) + if (options.stopPropagation) { + claimedPointerDownEvents.add(startEvent) + } + const onPressEnd = onPressStart(target, startEvent) const onPointerEnd = (endEvent: PointerEvent, success: boolean) => { diff --git a/packages/motion-dom/src/node/types.ts b/packages/motion-dom/src/node/types.ts index d5aa01b977..b7d49751fe 100644 --- a/packages/motion-dom/src/node/types.ts +++ b/packages/motion-dom/src/node/types.ts @@ -538,6 +538,7 @@ export interface MotionNodeTapHandlers { * Note: This is not supported publically. */ globalTapTarget?: boolean + } /** @@ -1043,6 +1044,15 @@ export interface MotionNodeAdvancedOptions { "data-framer-appear-id"?: string } +export interface PropagateOptions { + /** + * If `false`, this element's tap gesture will prevent any parent + * element's tap gesture handlers (`onTap`, `onTapStart`, `whileTap`) + * from firing. Defaults to `true`. + */ + tap?: boolean +} + export interface MotionNodeOptions extends MotionNodeAnimationOptions, MotionNodeEventOptions, @@ -1054,4 +1064,17 @@ export interface MotionNodeOptions MotionNodeDragHandlers, MotionNodeDraggableOptions, MotionNodeLayoutOptions, - MotionNodeAdvancedOptions {} + MotionNodeAdvancedOptions { + /** + * Controls whether gesture events propagate to parent motion components. + * By default all gestures propagate. Set individual gestures to `false` + * to prevent parent handlers from firing. + * + * ```jsx + * + * + * + * ``` + */ + propagate?: PropagateOptions +} diff --git a/tests/gestures/press.spec.ts b/tests/gestures/press.spec.ts index 25a8b2b98d..b54e042eb9 100644 --- a/tests/gestures/press.spec.ts +++ b/tests/gestures/press.spec.ts @@ -245,6 +245,30 @@ test.describe("press events", () => { // await expect(windowOutput).toHaveValue("cancel") }) + test("stopPropagation prevents parent press from firing", async ({ + page, + }) => { + const child = page.locator("#propagate-child") + const output = page.locator("#propagate-output") + + // Press child - only child handlers should fire + await child.dispatchEvent("pointerdown", pointerOptions) + await child.dispatchEvent("pointerup", pointerOptions) + await expect(output).toHaveValue("child-start,child-end,") + }) + + test("parent press fires when clicking outside child", async ({ + page, + }) => { + const parent = page.locator("#propagate-parent") + const output = page.locator("#propagate-output") + + // Press parent directly - parent handlers should fire + await parent.dispatchEvent("pointerdown", pointerOptions) + await parent.dispatchEvent("pointerup", pointerOptions) + await expect(output).toHaveValue("parent-start,parent-end,") + }) + test("nested click handlers", async ({ page }) => { const button = page.locator("#press-click-button") const box = await button.boundingBox()