diff --git a/CHANGELOG.md b/CHANGELOG.md index 269f11a8c0..b03018ba15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ Motion adheres to [Semantic Versioning](http://semver.org/). Undocumented APIs should be considered internal and may change without warning. +## [12.29.3] 2026-02-02 + +### Fixed + +- `Reorder`: Fixed viewport autoscroll. + ## [12.29.2] 2026-01-26 ### Fixed diff --git a/dev/html/package.json b/dev/html/package.json index 0373cedb5a..4ce792b5d4 100644 --- a/dev/html/package.json +++ b/dev/html/package.json @@ -1,7 +1,7 @@ { "name": "html-env", "private": true, - "version": "12.29.2", + "version": "12.29.3", "type": "module", "scripts": { "dev": "vite", @@ -10,8 +10,8 @@ "preview": "vite preview" }, "dependencies": { - "framer-motion": "^12.29.2", - "motion": "^12.29.2", + "framer-motion": "^12.29.3", + "motion": "^12.29.3", "motion-dom": "^12.29.2", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/dev/next/package.json b/dev/next/package.json index 5325cb2d96..d54351379c 100644 --- a/dev/next/package.json +++ b/dev/next/package.json @@ -1,7 +1,7 @@ { "name": "next-env", "private": true, - "version": "12.29.2", + "version": "12.29.3", "type": "module", "scripts": { "dev": "next dev", @@ -10,7 +10,7 @@ "build": "next build" }, "dependencies": { - "motion": "^12.29.2", + "motion": "^12.29.3", "next": "15.4.10", "react": "19.0.0", "react-dom": "19.0.0" diff --git a/dev/react-19/package.json b/dev/react-19/package.json index 00b238eaed..d45f0e04f9 100644 --- a/dev/react-19/package.json +++ b/dev/react-19/package.json @@ -1,7 +1,7 @@ { "name": "react-19-env", "private": true, - "version": "12.29.2", + "version": "12.29.3", "type": "module", "scripts": { "dev": "vite", @@ -11,7 +11,7 @@ "preview": "vite preview" }, "dependencies": { - "motion": "^12.29.2", + "motion": "^12.29.3", "react": "^19.0.0", "react-dom": "^19.0.0" }, diff --git a/dev/react/package.json b/dev/react/package.json index 56ce8f783e..91461b767f 100644 --- a/dev/react/package.json +++ b/dev/react/package.json @@ -1,7 +1,7 @@ { "name": "react-env", "private": true, - "version": "12.29.2", + "version": "12.29.3", "type": "module", "scripts": { "dev": "yarn vite", @@ -11,7 +11,7 @@ "preview": "yarn vite preview" }, "dependencies": { - "framer-motion": "^12.29.2", + "framer-motion": "^12.29.3", "react": "^18.3.1", "react-dom": "^18.3.1" }, diff --git a/dev/react/src/tests/reorder-auto-scroll-container.tsx b/dev/react/src/tests/reorder-auto-scroll-container.tsx new file mode 100644 index 0000000000..4fe46e4a77 --- /dev/null +++ b/dev/react/src/tests/reorder-auto-scroll-container.tsx @@ -0,0 +1,104 @@ +import * as React from "react" +import { useState } from "react" +import { Reorder, useMotionValue } from "framer-motion" + +const initialItems = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] + +interface ItemProps { + item: number +} + +const Item = ({ item }: ItemProps) => { + const y = useMotionValue(0) + const hue = item * 30 + + return ( + + ) +} + +/** + * Test case for auto-scroll when the scrollable container is inside a scrollable page. + * + * This tests the bug where autoscroll wouldn't work because the gesture system + * uses pageX/pageY coordinates but getBoundingClientRect() returns viewport + * coordinates. When the page is scrolled, these coordinate systems don't match. + */ +export const App = () => { + const [items, setItems] = useState(initialItems) + + return ( + <> + {/* Spacer to make page scrollable */} +
+

+ Scroll down to see the reorder list. The container and the page are both scrollable. +

+
+
+ + {items.map((item) => ( + + ))} + +
+ {/* More spacer to ensure page is scrollable */} +
+

+ Bottom spacer +

+
+ + + ) +} + +const styles = ` +html, body { + width: 100%; + min-height: 100%; + background: #333; + padding: 0; + margin: 0; +} + +ul, +li { + list-style: none; + padding: 0; + margin: 0; +} + +ul { + position: relative; + width: 100%; +} + +li { + border-radius: 10px; + margin-bottom: 10px; + width: 100%; + height: 60px; + position: relative; + border-radius: 5px; + flex-shrink: 0; + cursor: grab; +} +` diff --git a/dev/react/src/tests/reorder-auto-scroll-page.tsx b/dev/react/src/tests/reorder-auto-scroll-page.tsx index 0d305300b3..7c7f9a288d 100644 --- a/dev/react/src/tests/reorder-auto-scroll-page.tsx +++ b/dev/react/src/tests/reorder-auto-scroll-page.tsx @@ -46,8 +46,6 @@ export const App = () => {
{ describe("with scrollable container inside scrollable page", () => { it("Auto-scrolls container after page has been scrolled", () => { - cy.visit("?test=reorder-auto-scroll-page") + cy.visit("?test=reorder-auto-scroll-container") .wait(200) // Scroll the page down so the container is in view .window() @@ -117,4 +117,40 @@ describe("Reorder auto-scroll", () => { }) }) }) + + describe("without scrollable container inside scrollable page", () => { + it("Auto-scrolls page after dragging near the edges", () => { + cy.visit("?test=reorder-auto-scroll-page") + .wait(200) + // Check window position, then scroll + .window() + .then((win) => { + expect(win.scrollY).to.equal(0) + }) + .wait(100) + .get("[data-testid='0']") + .then(() => { + cy.window().then((win) => { + const nearBottom = + win.innerHeight - win.screenTop - 20 + + cy.get("[data-testid='0']") + .trigger("pointerdown", 50, 25) + .wait(50) + .trigger("pointermove", 50, 30, { force: true }) + .wait(50) + .trigger("pointermove", 50, nearBottom, { + force: true, + }) + .wait(300) + .window() + .then((win) => { + expect(win.scrollY).to.greaterThan(0) + }) + .get("[data-testid='0']") + .trigger("pointerup", { force: true }) + }) + }) + }) + }) }) diff --git a/packages/framer-motion/package.json b/packages/framer-motion/package.json index 9d89260011..4b1d17b012 100644 --- a/packages/framer-motion/package.json +++ b/packages/framer-motion/package.json @@ -1,6 +1,6 @@ { "name": "framer-motion", - "version": "12.29.2", + "version": "12.29.3", "description": "A simple and powerful JavaScript animation library", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", diff --git a/packages/framer-motion/src/components/Reorder/utils/auto-scroll.ts b/packages/framer-motion/src/components/Reorder/utils/auto-scroll.ts index 154ddba85c..30e5e12a50 100644 --- a/packages/framer-motion/src/components/Reorder/utils/auto-scroll.ts +++ b/packages/framer-motion/src/components/Reorder/utils/auto-scroll.ts @@ -39,7 +39,12 @@ export function resetAutoScrollState(): void { function isScrollableElement(element: Element, axis: "x" | "y"): boolean { const style = getComputedStyle(element) const overflow = axis === "x" ? style.overflowX : style.overflowY - return overflowStyles.has(overflow) + + const isDocumentScroll = + element === document.body || + element === document.documentElement + + return overflowStyles.has(overflow) || isDocumentScroll } function findScrollableAncestor( @@ -63,8 +68,8 @@ function getScrollAmount( ): { amount: number; edge: ActiveEdge } { const rect = scrollElement.getBoundingClientRect() - const start = axis === "x" ? rect.left : rect.top - const end = axis === "x" ? rect.right : rect.bottom + const start = axis === "x" ? Math.max(0, rect.left) : Math.max(0, rect.top) + const end = axis === "x" ? Math.min(window.innerWidth, rect.right) : Math.min(window.innerHeight, rect.bottom) const distanceFromStart = pointerPosition - start const distanceFromEnd = end - pointerPosition @@ -115,6 +120,10 @@ export function autoScrollIfNeeded( const currentActiveEdge = activeScrollEdge.get(scrollableAncestor) + const isDocumentScroll = + scrollableAncestor === document.body || + scrollableAncestor === document.documentElement + // If not currently scrolling this edge, check velocity to see if we should start if (currentActiveEdge !== edge) { // Only start scrolling if velocity is towards the edge @@ -129,10 +138,9 @@ export function autoScrollIfNeeded( // Record initial scroll limit (prevents infinite scroll) const maxScroll = axis === "x" - ? scrollableAncestor.scrollWidth - - scrollableAncestor.clientWidth - : scrollableAncestor.scrollHeight - - scrollableAncestor.clientHeight + ? scrollableAncestor.scrollWidth - (isDocumentScroll ? window.innerWidth : scrollableAncestor.clientWidth) + : scrollableAncestor.scrollHeight - (isDocumentScroll ? window.innerHeight : scrollableAncestor.clientHeight) + initialScrollLimits.set(scrollableAncestor, maxScroll) } @@ -141,15 +149,23 @@ export function autoScrollIfNeeded( const initialLimit = initialScrollLimits.get(scrollableAncestor)! const currentScroll = axis === "x" - ? scrollableAncestor.scrollLeft - : scrollableAncestor.scrollTop + ? (isDocumentScroll ? window.scrollX : scrollableAncestor.scrollLeft) + : (isDocumentScroll ? window.scrollY : scrollableAncestor.scrollTop) if (currentScroll >= initialLimit) return } // Apply scroll if (axis === "x") { - scrollableAncestor.scrollLeft += scrollAmount + if (isDocumentScroll) { + window.scrollBy({ left: scrollAmount }) + } else { + scrollableAncestor.scrollLeft += scrollAmount + } } else { - scrollableAncestor.scrollTop += scrollAmount + if (isDocumentScroll) { + window.scrollBy({ top: scrollAmount }) + } else { + scrollableAncestor.scrollTop += scrollAmount + } } } diff --git a/packages/motion/package.json b/packages/motion/package.json index 36f8919573..fda38ed78c 100644 --- a/packages/motion/package.json +++ b/packages/motion/package.json @@ -1,6 +1,6 @@ { "name": "motion", - "version": "12.29.2", + "version": "12.29.3", "description": "An animation library for JavaScript and React.", "main": "dist/cjs/index.js", "module": "dist/es/index.mjs", @@ -76,7 +76,7 @@ "postpublish": "git push --tags" }, "dependencies": { - "framer-motion": "^12.29.2", + "framer-motion": "^12.29.3", "tslib": "^2.4.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index f5c8ca9fa0..e08f109223 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7420,7 +7420,7 @@ __metadata: languageName: node linkType: hard -"framer-motion@^12.29.2, framer-motion@workspace:packages/framer-motion": +"framer-motion@^12.29.3, framer-motion@workspace:packages/framer-motion": version: 0.0.0-use.local resolution: "framer-motion@workspace:packages/framer-motion" dependencies: @@ -8192,8 +8192,8 @@ __metadata: version: 0.0.0-use.local resolution: "html-env@workspace:dev/html" dependencies: - framer-motion: ^12.29.2 - motion: ^12.29.2 + framer-motion: ^12.29.3 + motion: ^12.29.3 motion-dom: ^12.29.2 react: ^18.3.1 react-dom: ^18.3.1 @@ -11013,11 +11013,11 @@ __metadata: languageName: unknown linkType: soft -"motion@^12.29.2, motion@workspace:packages/motion": +"motion@^12.29.3, motion@workspace:packages/motion": version: 0.0.0-use.local resolution: "motion@workspace:packages/motion" dependencies: - framer-motion: ^12.29.2 + framer-motion: ^12.29.3 tslib: ^2.4.0 peerDependencies: "@emotion/is-prop-valid": "*" @@ -11134,7 +11134,7 @@ __metadata: version: 0.0.0-use.local resolution: "next-env@workspace:dev/next" dependencies: - motion: ^12.29.2 + motion: ^12.29.3 next: 15.4.10 react: 19.0.0 react-dom: 19.0.0 @@ -12599,7 +12599,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - motion: ^12.29.2 + motion: ^12.29.3 react: ^19.0.0 react-dom: ^19.0.0 vite: ^5.2.0 @@ -12683,7 +12683,7 @@ __metadata: "@typescript-eslint/parser": ^7.2.0 "@vitejs/plugin-react-swc": ^3.5.0 eslint-plugin-react-refresh: ^0.4.6 - framer-motion: ^12.29.2 + framer-motion: ^12.29.3 react: ^18.3.1 react-dom: ^18.3.1 vite: ^5.2.0