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
35 changes: 35 additions & 0 deletions dev/react/src/tests/drag-constraints-return.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { motion } from "framer-motion"
import { useRef } from "react"

export const App = () => {
const constraintsRef = useRef(null)
const params = new URLSearchParams(window.location.search)
const layout = params.get("layout") || undefined

return (
<motion.div
id="constraints"
ref={constraintsRef}
style={{
width: 300,
height: 300,
background: "rgba(0, 0, 255, 0.2)",
}}
>
<motion.div
id="box"
data-testid="draggable"
drag
dragConstraints={constraintsRef}
dragElastic={1}
dragMomentum={false}
layout={layout}
style={{
width: 100,
height: 100,
background: "red",
}}
/>
</motion.div>
)
}
114 changes: 114 additions & 0 deletions packages/framer-motion/cypress/integration/drag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,3 +552,117 @@ describe("Drag & Layout", () => {
})
})
})

describe("Drag Constraints Return", () => {
it("Returns to constraints when released outside bounds", () => {
cy.visit("?test=drag-constraints-return")
.wait(200)
.get("[data-testid='draggable']")
.wait(100)
.trigger("pointerdown", 50, 50, { force: true })
.trigger("pointermove", 60, 60, { force: true }) // Gesture will start from first move past threshold
.wait(50)
// Drag outside constraints (to bottom-right)
.trigger("pointermove", 400, 400, { force: true })
.wait(50)
// Release - element should animate back
.trigger("pointerup", { force: true })
.wait(2000) // Wait for animation to complete
.should(($draggable: any) => {
const draggable = $draggable[0] as HTMLDivElement
const { left, top, right, bottom } =
draggable.getBoundingClientRect()

// Constraints box is 300x300, element is 100x100
// Element should be within the constraints (max position: 200, 200)
expect(right).to.be.at.most(302)
expect(bottom).to.be.at.most(302)
expect(left).to.be.at.least(-2)
expect(top).to.be.at.least(-2)
})
})

it("Returns to constraints when released outside and clicked during animation", () => {
cy.visit("?test=drag-constraints-return")
.wait(200)
.get("[data-testid='draggable']")
.wait(100)
.trigger("pointerdown", 50, 50, { force: true })
.trigger("pointermove", 60, 60, { force: true }) // Gesture will start from first move past threshold
.wait(50)
// Drag outside constraints (to bottom-right)
.trigger("pointermove", 400, 400, { force: true })
.wait(50)
// Release - element should start animating back
.trigger("pointerup", { force: true })
// Immediately click on element while it's animating back
.wait(50)
.trigger("pointerdown", 50, 50, { force: true })
.wait(50)
.trigger("pointerup", { force: true })
.wait(2000) // Wait for animation to complete
.should(($draggable: any) => {
const draggable = $draggable[0] as HTMLDivElement
const { left, top, right, bottom } =
draggable.getBoundingClientRect()

// Constraints box is 300x300, element is 100x100
// Element should be within the constraints (max position: 200, 200)
expect(right).to.be.at.most(302)
expect(bottom).to.be.at.most(302)
expect(left).to.be.at.least(-2)
expect(top).to.be.at.least(-2)
})
})

it("Does not jump when dragged again during animation", () => {
let positionBeforeRelease = { right: 0, bottom: 0 }

cy.visit("?test=drag-constraints-return")
.wait(200)
.get("[data-testid='draggable']")
.wait(100)
.trigger("pointerdown", 50, 50, { force: true })
.trigger("pointermove", 60, 60, { force: true }) // Gesture will start from first move past threshold
.wait(50)
// Drag outside constraints (to bottom-right)
.trigger("pointermove", 400, 400, { force: true })
.wait(50)
// Release - element should start animating back
.trigger("pointerup", { force: true })
.wait(50)
// Verify element is still animating (not yet within bounds)
.should(($draggable: any) => {
const draggable = $draggable[0] as HTMLDivElement
const { right, bottom } = draggable.getBoundingClientRect()
// Element should still be outside constraints (animating back)
expect(right).to.be.greaterThan(302)
expect(bottom).to.be.greaterThan(302)
})
// Now grab and DRAG the element again with a LARGE drag (200px)
// The bug causes a jump proportional to drag size
.trigger("pointerdown", 50, 50, { force: true })
.trigger("pointermove", 60, 60, { force: true }) // Start gesture
.wait(50)
.trigger("pointermove", 250, 250, { force: true }) // Drag by ~200px
.wait(50)
// Record position before release
.then(($draggable: any) => {
const draggable = $draggable[0] as HTMLDivElement
const rect = draggable.getBoundingClientRect()
positionBeforeRelease = { right: rect.right, bottom: rect.bottom }
})
// Release again
.trigger("pointerup", { force: true })
.wait(16) // One frame
// Verify element hasn't jumped - position should be close to where it was before release
.should(($draggable: any) => {
const draggable = $draggable[0] as HTMLDivElement
const { right, bottom } = draggable.getBoundingClientRect()
// Allow small movement from animation starting, but no large jump (max 30px)
// Bug: without fix, element jumps back towards first drag end position
expect(Math.abs(right - positionBeforeRelease.right)).to.be.lessThan(30)
expect(Math.abs(bottom - positionBeforeRelease.bottom)).to.be.lessThan(30)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,22 @@ export class VisualElementDragControls {
if (presenceContext && presenceContext.isPresent === false) return

const onSessionStart = (event: PointerEvent) => {
const { dragSnapToOrigin } = this.getProps()

// Stop or pause any animations on both axis values immediately. This allows the user to throw and catch
// the component.
dragSnapToOrigin ? this.pauseAnimation() : this.stopAnimation()

// Stop or pause animations based on context:
// - snapToCursor: stop because we'll set new position values
// - otherwise: pause to allow resume if no drag starts (for constraint animations)
if (snapToCursor) {
this.stopAnimation()
this.snapToCursor(extractEventInfo(event).point)
} else {
this.pauseAnimation()
}
}

const onStart = (event: PointerEvent, info: PanInfo) => {
// Stop any paused animation so motion values reflect true current position
// (pauseAnimation was called in onSessionStart to allow resume if no drag started)
this.stopAnimation()

// Attempt to grab the global drag gesture lock - maybe make this part of PanSession
const { drag, dragPropagation, onDragStart } = this.getProps()

Expand Down
6 changes: 5 additions & 1 deletion packages/framer-motion/src/gestures/pan/PanSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,11 @@ export class PanSession {

const { onEnd, onSessionEnd, resumeAnimation } = this.handlers

if (this.dragSnapToOrigin) resumeAnimation && resumeAnimation()
// Resume animation if dragSnapToOrigin is set OR if no drag started (user just clicked)
// This ensures constraint animations continue when interrupted by a click
if (this.dragSnapToOrigin || !this.startEvent) {
resumeAnimation && resumeAnimation()
}
if (!(this.lastMoveEvent && this.lastMoveEventInfo)) return

const panInfo = getPanInfo(
Expand Down