Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ motion (public API)

## Writing Tests

**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.

When waiting for the next frame in async tests:

```javascript
Expand Down
112 changes: 112 additions & 0 deletions dev/react/src/tests/drag-input-propagation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { motion } from "framer-motion"

/**
* Test page for issue #1674: Interactive elements inside draggable elements
* should not trigger drag when clicked/interacted with.
*/
export const App = () => {
return (
<div style={{ padding: 100 }}>
<motion.div
id="draggable"
data-testid="draggable"
drag
dragElastic={0}
dragMomentum={false}
style={{
width: 400,
height: 200,
background: "red",
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "center",
gap: 10,
padding: 10,
}}
>
<input
type="text"
data-testid="input"
defaultValue="Select me"
style={{
width: 80,
height: 30,
padding: 5,
}}
/>
<textarea
data-testid="textarea"
defaultValue="Text"
style={{
width: 60,
height: 30,
padding: 5,
}}
/>
<button
data-testid="button"
style={{
width: 60,
height: 30,
padding: 5,
}}
>
Click
</button>
<a
href="#test"
data-testid="link"
style={{
display: "inline-block",
width: 60,
height: 30,
padding: 5,
background: "white",
}}
>
Link
</a>
<select
data-testid="select"
style={{
width: 80,
height: 30,
}}
>
<option value="1">Option 1</option>
<option value="2">Option 2</option>
<option value="3">Option 3</option>
</select>
<label
data-testid="label"
style={{
display: "flex",
alignItems: "center",
gap: 5,
background: "white",
padding: 5,
}}
>
<input
type="checkbox"
data-testid="checkbox"
/>
Check
</label>
<div
contentEditable
data-testid="contenteditable"
style={{
width: 80,
height: 30,
padding: 5,
background: "white",
}}
>
Edit me
</div>
</motion.div>
</div>
)
}
222 changes: 222 additions & 0 deletions packages/framer-motion/cypress/integration/drag-input-propagation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* Tests for issue #1674: Interactive elements inside draggable elements should not trigger drag
* https://github.com/motiondivision/motion/issues/1674
*/
describe("Drag Input Propagation", () => {
it("Should not drag when clicking and dragging on an input inside draggable", () => {
cy.visit("?test=drag-input-propagation")
.wait(200)
.get("[data-testid='draggable']")
.should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
// Initial position is at padding: 100
expect(left).to.equal(100)
expect(top).to.equal(100)
})

// Attempt to drag by clicking on the input
cy.get("[data-testid='input']")
.trigger("pointerdown", 5, 5)
.trigger("pointermove", 10, 10)
.wait(50)
.trigger("pointermove", 200, 200, { force: true })
.wait(50)
.trigger("pointerup", { force: true })

// Verify the draggable element did NOT move
cy.get("[data-testid='draggable']").should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
// Element should still be at its initial position
expect(left).to.equal(100)
expect(top).to.equal(100)
})
})

it("Should not drag when clicking and dragging on a textarea inside draggable", () => {
cy.visit("?test=drag-input-propagation")
.wait(200)
.get("[data-testid='draggable']")
.should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})

// Attempt to drag by clicking on the textarea
cy.get("[data-testid='textarea']")
.trigger("pointerdown", 5, 5)
.trigger("pointermove", 10, 10)
.wait(50)
.trigger("pointermove", 200, 200, { force: true })
.wait(50)
.trigger("pointerup", { force: true })

// Verify the draggable element did NOT move
cy.get("[data-testid='draggable']").should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
// Element should still be at its initial position
expect(left).to.equal(100)
expect(top).to.equal(100)
})
})

it("Should not drag when clicking and dragging on a button inside draggable", () => {
cy.visit("?test=drag-input-propagation")
.wait(200)
.get("[data-testid='draggable']")
.should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})

// Attempt to drag by clicking on the button
cy.get("[data-testid='button']")
.trigger("pointerdown", 5, 5)
.trigger("pointermove", 10, 10)
.wait(50)
.trigger("pointermove", 200, 200, { force: true })
.wait(50)
.trigger("pointerup", { force: true })

// Verify the draggable element did NOT move
cy.get("[data-testid='draggable']").should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})
})

it("Should not drag when clicking and dragging on a link inside draggable", () => {
cy.visit("?test=drag-input-propagation")
.wait(200)
.get("[data-testid='draggable']")
.should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})

// Attempt to drag by clicking on the link
cy.get("[data-testid='link']")
.trigger("pointerdown", 5, 5)
.trigger("pointermove", 10, 10)
.wait(50)
.trigger("pointermove", 200, 200, { force: true })
.wait(50)
.trigger("pointerup", { force: true })

// Verify the draggable element did NOT move
cy.get("[data-testid='draggable']").should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})
})

it("Should not drag when clicking and dragging on a select inside draggable", () => {
cy.visit("?test=drag-input-propagation")
.wait(200)
.get("[data-testid='draggable']")
.should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})

// Attempt to drag by clicking on the select
cy.get("[data-testid='select']")
.trigger("pointerdown", 5, 5)
.trigger("pointermove", 10, 10)
.wait(50)
.trigger("pointermove", 200, 200, { force: true })
.wait(50)
.trigger("pointerup", { force: true })

// Verify the draggable element did NOT move
cy.get("[data-testid='draggable']").should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})
})

it("Should not drag when clicking and dragging on a checkbox inside a label inside draggable", () => {
cy.visit("?test=drag-input-propagation")
.wait(200)
.get("[data-testid='draggable']")
.should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})

// Attempt to drag by clicking on the checkbox (nested inside label)
cy.get("[data-testid='checkbox']")
.trigger("pointerdown", 2, 2)
.trigger("pointermove", 5, 5)
.wait(50)
.trigger("pointermove", 200, 200, { force: true })
.wait(50)
.trigger("pointerup", { force: true })

// Verify the draggable element did NOT move
cy.get("[data-testid='draggable']").should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})
})

it("Should not drag when clicking and dragging on a contenteditable element inside draggable", () => {
cy.visit("?test=drag-input-propagation")
.wait(200)
.get("[data-testid='draggable']")
.should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})

// Attempt to drag by clicking on the contenteditable element
cy.get("[data-testid='contenteditable']")
.trigger("pointerdown", 5, 5)
.trigger("pointermove", 10, 10)
.wait(50)
.trigger("pointermove", 200, 200, { force: true })
.wait(50)
.trigger("pointerup", { force: true })

// Verify the draggable element did NOT move
cy.get("[data-testid='draggable']").should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})
})

it("Should still drag when clicking on the draggable area outside interactive elements", () => {
cy.visit("?test=drag-input-propagation")
.wait(200)
.get("[data-testid='draggable']")
.should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
expect(left).to.equal(100)
expect(top).to.equal(100)
})
// Click on edge of draggable, not on interactive elements (at coordinates 5,5 which is top-left corner)
.trigger("pointerdown", 5, 5)
.trigger("pointermove", 10, 10)
.wait(50)
.trigger("pointermove", 200, 200, { force: true })
.wait(50)
.trigger("pointerup", { force: true })
.should(($draggable) => {
const { left, top } = $draggable[0].getBoundingClientRect()
// Element should have moved - the exact position depends on gesture calculation
// but should NOT be at original position of 100,100
expect(left).to.be.greaterThan(200)
expect(top).to.be.greaterThan(200)
})
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
isElementKeyboardAccessible,
PanInfo,
ResolvedConstraints,
Transition,
Expand Down Expand Up @@ -643,7 +644,13 @@ export class VisualElementDragControls {
"pointerdown",
(event) => {
const { drag, dragListener = true } = this.getProps()
drag && dragListener && this.start(event)
if (
drag &&
dragListener &&
!isElementKeyboardAccessible(event.target as Element)
) {
this.start(event)
}
}
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
const focusableElements = new Set([
const interactiveElements = new Set([
"BUTTON",
"INPUT",
"SELECT",
"TEXTAREA",
"A",
])

/**
* Checks if an element is an interactive form element that should prevent
* drag gestures from starting when clicked.
*
* This specifically targets form controls, buttons, and links - not just any
* element with tabIndex, since motion elements with tap handlers automatically
* get tabIndex=0 for keyboard accessibility.
*/
export function isElementKeyboardAccessible(element: Element) {
return (
focusableElements.has(element.tagName) ||
(element as HTMLElement).tabIndex !== -1
interactiveElements.has(element.tagName) ||
(element as HTMLElement).isContentEditable === true
)
}
1 change: 1 addition & 0 deletions packages/motion-dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export * from "./gestures/hover"
export * from "./gestures/pan/types"
export * from "./gestures/press"
export * from "./gestures/press/types"
export * from "./gestures/press/utils/is-keyboard-accessible"
export * from "./gestures/types"
export * from "./gestures/utils/is-node-or-child"
export * from "./gestures/utils/is-primary-pointer"
Expand Down