Skip to content

Commit a646a8a

Browse files
mattgperryclaude
andcommitted
Fix drag gesture triggering on interactive elements inside draggables
Prevents drag from starting when clicking on input, textarea, select, button, anchor, or contenteditable elements inside a draggable component. This allows normal interaction with form elements (focus, text selection) without triggering the parent's drag gesture. Fixes #1674 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4f59576 commit a646a8a

File tree

4 files changed

+167
-1
lines changed

4 files changed

+167
-1
lines changed

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ motion (public API)
7373

7474
## Writing Tests
7575

76+
**IMPORTANT: Always write a failing test FIRST before implementing any bug fix or feature.** This ensures the issue is reproducible and the fix is verified. For UI interaction bugs (like gesture handling), prefer E2E tests using Playwright or Cypress.
77+
7678
When waiting for the next frame in async tests:
7779

7880
```javascript
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { motion } from "framer-motion"
2+
3+
/**
4+
* Test page for issue #1674: Inputs inside draggable elements should not trigger drag
5+
* when clicked/interacted with.
6+
*/
7+
export const App = () => {
8+
return (
9+
<div style={{ padding: 100 }}>
10+
<motion.div
11+
id="draggable"
12+
data-testid="draggable"
13+
drag
14+
dragElastic={0}
15+
dragMomentum={false}
16+
style={{
17+
width: 200,
18+
height: 100,
19+
background: "red",
20+
display: "flex",
21+
alignItems: "center",
22+
justifyContent: "center",
23+
gap: 10,
24+
}}
25+
>
26+
<input
27+
type="text"
28+
data-testid="input"
29+
defaultValue="Select me"
30+
style={{
31+
width: 80,
32+
height: 30,
33+
padding: 5,
34+
}}
35+
/>
36+
<textarea
37+
data-testid="textarea"
38+
defaultValue="Text"
39+
style={{
40+
width: 60,
41+
height: 30,
42+
padding: 5,
43+
}}
44+
/>
45+
</motion.div>
46+
</div>
47+
)
48+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Tests for issue #1674: Inputs inside draggable elements should not trigger drag
3+
* https://github.com/motiondivision/motion/issues/1674
4+
*/
5+
describe("Drag Input Propagation", () => {
6+
it("Should not drag when clicking and dragging on an input inside draggable", () => {
7+
cy.visit("?test=drag-input-propagation")
8+
.wait(200)
9+
.get("[data-testid='draggable']")
10+
.should(($draggable) => {
11+
const { left, top } = $draggable[0].getBoundingClientRect()
12+
// Initial position is at padding: 100
13+
expect(left).to.equal(100)
14+
expect(top).to.equal(100)
15+
})
16+
17+
// Attempt to drag by clicking on the input
18+
cy.get("[data-testid='input']")
19+
.trigger("pointerdown", 5, 5)
20+
.trigger("pointermove", 10, 10)
21+
.wait(50)
22+
.trigger("pointermove", 200, 200, { force: true })
23+
.wait(50)
24+
.trigger("pointerup", { force: true })
25+
26+
// Verify the draggable element did NOT move
27+
cy.get("[data-testid='draggable']").should(($draggable) => {
28+
const { left, top } = $draggable[0].getBoundingClientRect()
29+
// Element should still be at its initial position
30+
expect(left).to.equal(100)
31+
expect(top).to.equal(100)
32+
})
33+
})
34+
35+
it("Should not drag when clicking and dragging on a textarea inside draggable", () => {
36+
cy.visit("?test=drag-input-propagation")
37+
.wait(200)
38+
.get("[data-testid='draggable']")
39+
.should(($draggable) => {
40+
const { left, top } = $draggable[0].getBoundingClientRect()
41+
expect(left).to.equal(100)
42+
expect(top).to.equal(100)
43+
})
44+
45+
// Attempt to drag by clicking on the textarea
46+
cy.get("[data-testid='textarea']")
47+
.trigger("pointerdown", 5, 5)
48+
.trigger("pointermove", 10, 10)
49+
.wait(50)
50+
.trigger("pointermove", 200, 200, { force: true })
51+
.wait(50)
52+
.trigger("pointerup", { force: true })
53+
54+
// Verify the draggable element did NOT move
55+
cy.get("[data-testid='draggable']").should(($draggable) => {
56+
const { left, top } = $draggable[0].getBoundingClientRect()
57+
// Element should still be at its initial position
58+
expect(left).to.equal(100)
59+
expect(top).to.equal(100)
60+
})
61+
})
62+
63+
it("Should still drag when clicking on the draggable area outside inputs", () => {
64+
cy.visit("?test=drag-input-propagation")
65+
.wait(200)
66+
.get("[data-testid='draggable']")
67+
.should(($draggable) => {
68+
const { left, top } = $draggable[0].getBoundingClientRect()
69+
expect(left).to.equal(100)
70+
expect(top).to.equal(100)
71+
})
72+
// Click on edge of draggable, not on inputs (at coordinates 5,5 which is top-left corner)
73+
.trigger("pointerdown", 5, 5)
74+
.trigger("pointermove", 10, 10)
75+
.wait(50)
76+
.trigger("pointermove", 200, 200, { force: true })
77+
.wait(50)
78+
.trigger("pointerup", { force: true })
79+
.should(($draggable) => {
80+
const { left, top } = $draggable[0].getBoundingClientRect()
81+
// Element should have moved - the exact position depends on gesture calculation
82+
// but should NOT be at original position of 100,100
83+
expect(left).to.be.greaterThan(200)
84+
expect(top).to.be.greaterThan(200)
85+
})
86+
})
87+
})

packages/framer-motion/src/gestures/drag/VisualElementDragControls.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -631,7 +631,13 @@ export class VisualElementDragControls {
631631
"pointerdown",
632632
(event) => {
633633
const { drag, dragListener = true } = this.getProps()
634-
drag && dragListener && this.start(event)
634+
if (
635+
drag &&
636+
dragListener &&
637+
!isInteractiveElement(event.target as Element)
638+
) {
639+
this.start(event)
640+
}
635641
}
636642
)
637643

@@ -756,3 +762,26 @@ export function expectsResolvedDragConstraints({
756762
}: MotionProps) {
757763
return isRefObject(dragConstraints) && !!onMeasureDragConstraints
758764
}
765+
766+
/**
767+
* Check if an element is an interactive form element that should
768+
* prevent drag from starting when clicked.
769+
*/
770+
const interactiveElements = new Set([
771+
"INPUT",
772+
"TEXTAREA",
773+
"SELECT",
774+
"BUTTON",
775+
"A",
776+
])
777+
778+
function isInteractiveElement(element: Element | null): boolean {
779+
if (!element) return false
780+
if (
781+
interactiveElements.has(element.tagName) ||
782+
(element as HTMLElement).isContentEditable
783+
) {
784+
return true
785+
}
786+
return false
787+
}

0 commit comments

Comments
 (0)