Skip to content

Commit 04a2a00

Browse files
authored
Merge pull request #3448 from motiondivision/drag-input-propagation
Fix drag gesture triggering on interactive elements inside draggables
2 parents 210f72b + cb8e1a8 commit 04a2a00

File tree

6 files changed

+356
-4
lines changed

6 files changed

+356
-4
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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { motion } from "framer-motion"
2+
3+
/**
4+
* Test page for issue #1674: Interactive elements inside draggable elements
5+
* should not trigger drag 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: 400,
18+
height: 200,
19+
background: "red",
20+
display: "flex",
21+
flexWrap: "wrap",
22+
alignItems: "center",
23+
justifyContent: "center",
24+
gap: 10,
25+
padding: 10,
26+
}}
27+
>
28+
<input
29+
type="text"
30+
data-testid="input"
31+
defaultValue="Select me"
32+
style={{
33+
width: 80,
34+
height: 30,
35+
padding: 5,
36+
}}
37+
/>
38+
<textarea
39+
data-testid="textarea"
40+
defaultValue="Text"
41+
style={{
42+
width: 60,
43+
height: 30,
44+
padding: 5,
45+
}}
46+
/>
47+
<button
48+
data-testid="button"
49+
style={{
50+
width: 60,
51+
height: 30,
52+
padding: 5,
53+
}}
54+
>
55+
Click
56+
</button>
57+
<a
58+
href="#test"
59+
data-testid="link"
60+
style={{
61+
display: "inline-block",
62+
width: 60,
63+
height: 30,
64+
padding: 5,
65+
background: "white",
66+
}}
67+
>
68+
Link
69+
</a>
70+
<select
71+
data-testid="select"
72+
style={{
73+
width: 80,
74+
height: 30,
75+
}}
76+
>
77+
<option value="1">Option 1</option>
78+
<option value="2">Option 2</option>
79+
<option value="3">Option 3</option>
80+
</select>
81+
<label
82+
data-testid="label"
83+
style={{
84+
display: "flex",
85+
alignItems: "center",
86+
gap: 5,
87+
background: "white",
88+
padding: 5,
89+
}}
90+
>
91+
<input
92+
type="checkbox"
93+
data-testid="checkbox"
94+
/>
95+
Check
96+
</label>
97+
<div
98+
contentEditable
99+
data-testid="contenteditable"
100+
style={{
101+
width: 80,
102+
height: 30,
103+
padding: 5,
104+
background: "white",
105+
}}
106+
>
107+
Edit me
108+
</div>
109+
</motion.div>
110+
</div>
111+
)
112+
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* Tests for issue #1674: Interactive elements 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 not drag when clicking and dragging on a button inside draggable", () => {
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+
73+
// Attempt to drag by clicking on the button
74+
cy.get("[data-testid='button']")
75+
.trigger("pointerdown", 5, 5)
76+
.trigger("pointermove", 10, 10)
77+
.wait(50)
78+
.trigger("pointermove", 200, 200, { force: true })
79+
.wait(50)
80+
.trigger("pointerup", { force: true })
81+
82+
// Verify the draggable element did NOT move
83+
cy.get("[data-testid='draggable']").should(($draggable) => {
84+
const { left, top } = $draggable[0].getBoundingClientRect()
85+
expect(left).to.equal(100)
86+
expect(top).to.equal(100)
87+
})
88+
})
89+
90+
it("Should not drag when clicking and dragging on a link inside draggable", () => {
91+
cy.visit("?test=drag-input-propagation")
92+
.wait(200)
93+
.get("[data-testid='draggable']")
94+
.should(($draggable) => {
95+
const { left, top } = $draggable[0].getBoundingClientRect()
96+
expect(left).to.equal(100)
97+
expect(top).to.equal(100)
98+
})
99+
100+
// Attempt to drag by clicking on the link
101+
cy.get("[data-testid='link']")
102+
.trigger("pointerdown", 5, 5)
103+
.trigger("pointermove", 10, 10)
104+
.wait(50)
105+
.trigger("pointermove", 200, 200, { force: true })
106+
.wait(50)
107+
.trigger("pointerup", { force: true })
108+
109+
// Verify the draggable element did NOT move
110+
cy.get("[data-testid='draggable']").should(($draggable) => {
111+
const { left, top } = $draggable[0].getBoundingClientRect()
112+
expect(left).to.equal(100)
113+
expect(top).to.equal(100)
114+
})
115+
})
116+
117+
it("Should not drag when clicking and dragging on a select inside draggable", () => {
118+
cy.visit("?test=drag-input-propagation")
119+
.wait(200)
120+
.get("[data-testid='draggable']")
121+
.should(($draggable) => {
122+
const { left, top } = $draggable[0].getBoundingClientRect()
123+
expect(left).to.equal(100)
124+
expect(top).to.equal(100)
125+
})
126+
127+
// Attempt to drag by clicking on the select
128+
cy.get("[data-testid='select']")
129+
.trigger("pointerdown", 5, 5)
130+
.trigger("pointermove", 10, 10)
131+
.wait(50)
132+
.trigger("pointermove", 200, 200, { force: true })
133+
.wait(50)
134+
.trigger("pointerup", { force: true })
135+
136+
// Verify the draggable element did NOT move
137+
cy.get("[data-testid='draggable']").should(($draggable) => {
138+
const { left, top } = $draggable[0].getBoundingClientRect()
139+
expect(left).to.equal(100)
140+
expect(top).to.equal(100)
141+
})
142+
})
143+
144+
it("Should not drag when clicking and dragging on a checkbox inside a label inside draggable", () => {
145+
cy.visit("?test=drag-input-propagation")
146+
.wait(200)
147+
.get("[data-testid='draggable']")
148+
.should(($draggable) => {
149+
const { left, top } = $draggable[0].getBoundingClientRect()
150+
expect(left).to.equal(100)
151+
expect(top).to.equal(100)
152+
})
153+
154+
// Attempt to drag by clicking on the checkbox (nested inside label)
155+
cy.get("[data-testid='checkbox']")
156+
.trigger("pointerdown", 2, 2)
157+
.trigger("pointermove", 5, 5)
158+
.wait(50)
159+
.trigger("pointermove", 200, 200, { force: true })
160+
.wait(50)
161+
.trigger("pointerup", { force: true })
162+
163+
// Verify the draggable element did NOT move
164+
cy.get("[data-testid='draggable']").should(($draggable) => {
165+
const { left, top } = $draggable[0].getBoundingClientRect()
166+
expect(left).to.equal(100)
167+
expect(top).to.equal(100)
168+
})
169+
})
170+
171+
it("Should not drag when clicking and dragging on a contenteditable element inside draggable", () => {
172+
cy.visit("?test=drag-input-propagation")
173+
.wait(200)
174+
.get("[data-testid='draggable']")
175+
.should(($draggable) => {
176+
const { left, top } = $draggable[0].getBoundingClientRect()
177+
expect(left).to.equal(100)
178+
expect(top).to.equal(100)
179+
})
180+
181+
// Attempt to drag by clicking on the contenteditable element
182+
cy.get("[data-testid='contenteditable']")
183+
.trigger("pointerdown", 5, 5)
184+
.trigger("pointermove", 10, 10)
185+
.wait(50)
186+
.trigger("pointermove", 200, 200, { force: true })
187+
.wait(50)
188+
.trigger("pointerup", { force: true })
189+
190+
// Verify the draggable element did NOT move
191+
cy.get("[data-testid='draggable']").should(($draggable) => {
192+
const { left, top } = $draggable[0].getBoundingClientRect()
193+
expect(left).to.equal(100)
194+
expect(top).to.equal(100)
195+
})
196+
})
197+
198+
it("Should still drag when clicking on the draggable area outside interactive elements", () => {
199+
cy.visit("?test=drag-input-propagation")
200+
.wait(200)
201+
.get("[data-testid='draggable']")
202+
.should(($draggable) => {
203+
const { left, top } = $draggable[0].getBoundingClientRect()
204+
expect(left).to.equal(100)
205+
expect(top).to.equal(100)
206+
})
207+
// Click on edge of draggable, not on interactive elements (at coordinates 5,5 which is top-left corner)
208+
.trigger("pointerdown", 5, 5)
209+
.trigger("pointermove", 10, 10)
210+
.wait(50)
211+
.trigger("pointermove", 200, 200, { force: true })
212+
.wait(50)
213+
.trigger("pointerup", { force: true })
214+
.should(($draggable) => {
215+
const { left, top } = $draggable[0].getBoundingClientRect()
216+
// Element should have moved - the exact position depends on gesture calculation
217+
// but should NOT be at original position of 100,100
218+
expect(left).to.be.greaterThan(200)
219+
expect(top).to.be.greaterThan(200)
220+
})
221+
})
222+
})

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
isElementKeyboardAccessible,
23
PanInfo,
34
ResolvedConstraints,
45
Transition,
@@ -643,7 +644,13 @@ export class VisualElementDragControls {
643644
"pointerdown",
644645
(event) => {
645646
const { drag, dragListener = true } = this.getProps()
646-
drag && dragListener && this.start(event)
647+
if (
648+
drag &&
649+
dragListener &&
650+
!isElementKeyboardAccessible(event.target as Element)
651+
) {
652+
this.start(event)
653+
}
647654
}
648655
)
649656

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
const focusableElements = new Set([
1+
const interactiveElements = new Set([
22
"BUTTON",
33
"INPUT",
44
"SELECT",
55
"TEXTAREA",
66
"A",
77
])
88

9+
/**
10+
* Checks if an element is an interactive form element that should prevent
11+
* drag gestures from starting when clicked.
12+
*
13+
* This specifically targets form controls, buttons, and links - not just any
14+
* element with tabIndex, since motion elements with tap handlers automatically
15+
* get tabIndex=0 for keyboard accessibility.
16+
*/
917
export function isElementKeyboardAccessible(element: Element) {
1018
return (
11-
focusableElements.has(element.tagName) ||
12-
(element as HTMLElement).tabIndex !== -1
19+
interactiveElements.has(element.tagName) ||
20+
(element as HTMLElement).isContentEditable === true
1321
)
1422
}

packages/motion-dom/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export * from "./gestures/hover"
5656
export * from "./gestures/pan/types"
5757
export * from "./gestures/press"
5858
export * from "./gestures/press/types"
59+
export * from "./gestures/press/utils/is-keyboard-accessible"
5960
export * from "./gestures/types"
6061
export * from "./gestures/utils/is-node-or-child"
6162
export * from "./gestures/utils/is-primary-pointer"

0 commit comments

Comments
 (0)