-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Pin correctParentTransform workaround for Reorder inside a scaled parent (#2750) #3759
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| import { | ||
| correctParentTransform, | ||
| MotionConfig, | ||
| Reorder, | ||
| useMotionValue, | ||
| } from "framer-motion" | ||
| import { ReactNode, useRef, useState } from "react" | ||
|
|
||
| /** | ||
| * Reproduction + supported-workaround fixture for the scaled-parent Reorder | ||
| * cluster (#2449 / #2750). | ||
| * | ||
| * A Reorder list sits inside a parent with a raw CSS `transform: scale()` and | ||
| * a non-centre `transform-origin`. Raw CSS transforms on ancestors are | ||
| * invisible to the projection / drag measurement system (it only tracks motion | ||
| * values), so by default the dragged item translates faster/slower than the | ||
| * cursor and reorder thresholds fire at the wrong positions — the list appears | ||
| * to "move around" relative to the pointer. | ||
| * | ||
| * The supported fix is `correctParentTransform(ref)` fed to | ||
| * `MotionConfig transformPagePoint`: it routes BOTH pan-session pointer points | ||
| * and projection viewport measurements through the inverse of the parent's | ||
| * computed matrix, putting gesture offsets and measured boxes back in the same | ||
| * (local) space. | ||
| * | ||
| * - `?scale=` parent scale factor (default `0.5`) | ||
| * - `?corrected=` when `"true"`, applies `correctParentTransform` | ||
| */ | ||
| const initialItems = [0, 1, 2, 3] | ||
|
|
||
| const Item = ({ item }: { item: number }) => { | ||
| const y = useMotionValue(0) | ||
| const hue = item * 90 | ||
|
|
||
| return ( | ||
| <Reorder.Item | ||
| value={item} | ||
| id={`item-${item}`} | ||
| data-testid={`item-${item}`} | ||
| style={{ | ||
| y, | ||
| height: 50, | ||
| marginBottom: 10, | ||
| borderRadius: 8, | ||
| listStyle: "none", | ||
| cursor: "grab", | ||
| background: `hsl(${hue}, 70%, 50%)`, | ||
| }} | ||
| /> | ||
| ) | ||
| } | ||
|
|
||
| export const App = () => { | ||
| const params = new URLSearchParams(window.location.search) | ||
| const scale = parseFloat(params.get("scale") || "0.5") | ||
| const corrected = params.get("corrected") === "true" | ||
|
|
||
| const ref = useRef<HTMLDivElement>(null) | ||
| const [items, setItems] = useState(initialItems) | ||
|
|
||
| const group = ( | ||
| <Reorder.Group | ||
| axis="y" | ||
| onReorder={setItems} | ||
| values={items} | ||
| id="reorder-group" | ||
| style={{ listStyle: "none", padding: 0, margin: 0, width: 280 }} | ||
| > | ||
| {items.map((item) => ( | ||
| <Item key={item} item={item} /> | ||
| ))} | ||
| </Reorder.Group> | ||
| ) | ||
|
|
||
| return ( | ||
| <div | ||
| ref={ref} | ||
| id="scaled-parent" | ||
| style={{ | ||
| position: "absolute", | ||
| top: 0, | ||
| left: 0, | ||
| width: 300, | ||
| height: 600, | ||
| padding: 10, | ||
| boxSizing: "border-box", | ||
| background: "#222", | ||
| transform: `scale(${scale})`, | ||
| transformOrigin: "top left", | ||
| }} | ||
| > | ||
| <Wrapper corrected={corrected} parentRef={ref}> | ||
| {group} | ||
| </Wrapper> | ||
| <style>{styles}</style> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| const Wrapper = ({ | ||
| corrected, | ||
| parentRef, | ||
| children, | ||
| }: { | ||
| corrected: boolean | ||
| parentRef: React.RefObject<HTMLDivElement | null> | ||
| children: ReactNode | ||
| }) => | ||
| corrected ? ( | ||
| <MotionConfig transformPagePoint={correctParentTransform(parentRef)}> | ||
| {children} | ||
| </MotionConfig> | ||
| ) : ( | ||
| <>{children}</> | ||
| ) | ||
|
|
||
| const styles = ` | ||
| body { | ||
| margin: 0; | ||
| padding: 0; | ||
| background: #ffaa00; | ||
| } | ||
| ` |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| /** | ||
| * Reorder inside a parent with a raw CSS `transform: scale()` and a non-centre | ||
| * `transform-origin`. | ||
| * | ||
| * Issues: https://github.com/motiondivision/motion/issues/2449 | ||
| * https://github.com/motiondivision/motion/issues/2750 | ||
| * | ||
| * Raw CSS transforms on ancestors are invisible to the projection / drag | ||
| * measurement system, so without correction the dragged item does not track | ||
| * the cursor and reorder thresholds fire at the wrong positions. The supported | ||
| * workaround is `correctParentTransform(ref)` via `MotionConfig | ||
| * transformPagePoint`. These tests pin that workaround so future drag / | ||
| * projection refactors can't silently regress it. | ||
| */ | ||
| function domOrder(): Cypress.Chainable<string[]> { | ||
| return cy.get("#reorder-group [data-testid]").then(($items) => | ||
| Cypress._.map($items, (el) => el.getAttribute("data-testid") as string) | ||
| ) | ||
| } | ||
|
|
||
| describe("Reorder inside a scaled parent (#2449 / #2750)", () => { | ||
| it("dragged item tracks the cursor with correctParentTransform (scale 0.5)", () => { | ||
| cy.visit("?test=reorder-scaled-parent&corrected=true&scale=0.5") | ||
| .wait(300) | ||
| .get("[data-testid='item-0']") | ||
| .then(($el: any) => { | ||
| const start = $el[0].getBoundingClientRect() | ||
| const startMidX = start.left + start.width / 2 | ||
| const startMidY = start.top + start.height / 2 | ||
|
|
||
| cy.wrap($el) | ||
| .trigger("pointerdown", startMidX, startMidY, { | ||
| force: true, | ||
| }) | ||
| .wait(50) | ||
| // Move past the drag threshold. | ||
| .trigger("pointermove", startMidX, startMidY + 5, { | ||
| force: true, | ||
| }) | ||
| .wait(50) | ||
| // Move the pointer 80px down the screen. | ||
| .trigger("pointermove", startMidX, startMidY + 80, { | ||
| force: true, | ||
| }) | ||
| .wait(80) | ||
| .then(([item]: any) => { | ||
| const moved = item.getBoundingClientRect() | ||
| const movedMidY = moved.top + moved.height / 2 | ||
| const screenDelta = movedMidY - startMidY | ||
|
|
||
| // The element must follow the cursor (~80px on screen), | ||
| // not 80 / scale (160px) as it would without the | ||
| // transformPagePoint correction. | ||
| expect(screenDelta).to.be.greaterThan(60) | ||
| expect(screenDelta).to.be.lessThan(100) | ||
| }) | ||
| .trigger("pointerup", { force: true }) | ||
| }) | ||
| }) | ||
|
|
||
| it("reorders correctly and settles aligned with correctParentTransform", () => { | ||
| cy.visit("?test=reorder-scaled-parent&corrected=true&scale=0.5") | ||
| .wait(300) | ||
|
|
||
| domOrder().should("deep.equal", [ | ||
| "item-0", | ||
| "item-1", | ||
| "item-2", | ||
| "item-3", | ||
| ]) | ||
|
|
||
| cy.get("[data-testid='item-0']").then(($el: any) => { | ||
| const start = $el[0].getBoundingClientRect() | ||
| const startMidX = start.left + start.width / 2 | ||
| const startMidY = start.top + start.height / 2 | ||
|
|
||
| let chain = cy | ||
| .wrap($el) | ||
| .trigger("pointerdown", startMidX, startMidY, { force: true }) | ||
| .wait(50) | ||
| // Move past the drag threshold first. | ||
| .trigger("pointermove", startMidX, startMidY + 6, { | ||
| force: true, | ||
| }) | ||
| .wait(50) | ||
|
|
||
| // Steady drag downward. With scale 0.5 the offset is corrected | ||
| // into local space, so the on-screen distance must be ~2x the | ||
| // local row pitch to cross item-1's centre. Incremental moves | ||
| // keep a non-zero velocity so checkReorder engages. | ||
| for (let i = 1; i <= 6; i++) { | ||
| chain = chain | ||
| .trigger("pointermove", startMidX, startMidY + i * 14, { | ||
| force: true, | ||
| }) | ||
| .wait(60) | ||
| } | ||
|
|
||
| chain.trigger("pointerup", { force: true }).wait(700) | ||
| }) | ||
|
|
||
| // item-0 should have moved down past item-1 (a swap occurred) ... | ||
| domOrder().then((order) => { | ||
| expect(order.indexOf("item-0")).to.be.greaterThan( | ||
| order.indexOf("item-1"), | ||
| `expected item-0 to sit below item-1, got ${order.join(",")}` | ||
| ) | ||
| }) | ||
|
|
||
| // ... and the released item should settle flush in the list, i.e. no | ||
| // stranded drag transform (translateY ≈ 0 in local space). | ||
| cy.get("[data-testid='item-0']").then(($el: any) => { | ||
| const transform = getComputedStyle($el[0]).transform | ||
| if (transform && transform !== "none") { | ||
| const parts = transform.match(/matrix\(([^)]+)\)/) | ||
| if (parts) { | ||
| const translateY = parseFloat(parts[1].split(",")[5]) | ||
| expect(Math.abs(translateY)).to.be.lessThan(10) | ||
|
Comment on lines
+107
to
+118
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } | ||
| } | ||
| }) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
startMidXandstartMidYare obtained fromgetBoundingClientRect()(viewport-absolute values), then passed directly asx, yto.trigger(). Cypress adds the element's currentgetBoundingClientRect().left/topto those values when computingclientX/clientY, so the resulting pointer events fire at2 * element.left + width/2rather than at the element's centre. The displacement in client space still works out to the intended 80 px (the same absolute offset is added to bothpointerdownandpointermove), and the finalscreenDeltacomparison uses the same absolute baseline, so the first test passes.The concern is the second test's incremental loop: Cypress re-evaluates the element's bounding rect at each
.trigger(), so if the item has moved between ticks, the effective client-space delta grows beyond the intendedi * 14 px. The existing tests in this repo (e.g.,drag-scaled-parent.ts) use hardcoded element-relative coordinates (10, 10,110, 110) to avoid this. Using relative coordinates like$el[0].getBoundingClientRect().width / 2at capture time and passing small fixed deltas would be more robust.