From 68ddf4b30e39626768cfce4253716faf62b86a75 Mon Sep 17 00:00:00 2001 From: Clauderic Demers Date: Thu, 2 Apr 2026 08:21:38 -0400 Subject: [PATCH 1/2] fix: set operation.shape before computing initial transform in Feedback plugin (closes #1986) --- .../fix-shape-null-first-modifier-call.md | 5 +++++ .../dom/src/core/plugins/feedback/Feedback.ts | 20 ++++++++++++------- 2 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 .changeset/fix-shape-null-first-modifier-call.md diff --git a/.changeset/fix-shape-null-first-modifier-call.md b/.changeset/fix-shape-null-first-modifier-call.md new file mode 100644 index 000000000..7d27bdcae --- /dev/null +++ b/.changeset/fix-shape-null-first-modifier-call.md @@ -0,0 +1,5 @@ +--- +"@dnd-kit/dom": patch +--- + +Fixed `operation.shape` being `null` on the first `modifier.apply()` call. The `Feedback` plugin now measures the feedback element's shape and sets it on the drag operation *before* computing the initial transform, so modifiers that depend on `shape.initial` (e.g. snap-to-cursor) receive the correct bounding rect on the first frame. This restores the v1 behaviour where `draggingNodeRect` was always available in modifier functions. diff --git a/packages/dom/src/core/plugins/feedback/Feedback.ts b/packages/dom/src/core/plugins/feedback/Feedback.ts index 2b688e53e..acbaae596 100644 --- a/packages/dom/src/core/plugins/feedback/Feedback.ts +++ b/packages/dom/src/core/plugins/feedback/Feedback.ts @@ -308,22 +308,18 @@ export class Feedback extends Plugin { /* ---- Apply initial feedback styles ---- */ - feedbackElement.setAttribute(ATTRIBUTE, 'true'); - - const transform = untracked(() => dragOperation.transform); const initialTranslate = initial.translate ?? {x: 0, y: 0}; - const tX = transform.x * frameTransform.scaleX + initialTranslate.x; - const tY = transform.y * frameTransform.scaleY + initialTranslate.y; - const fixedOffset = getFixedPositionOffset(); + feedbackElement.setAttribute(ATTRIBUTE, 'true'); + styles.set( { width: width - widthOffset, height: height - heightOffset, top: projected.top + fixedOffset.y, left: projected.left + fixedOffset.x, - translate: `${tX}px ${tY}px 0`, + translate: `${initialTranslate.x}px ${initialTranslate.y}px 0`, transform: this.overlay ? 'none' : initialTransformStyle, transition: feedbackTransition ? `${feedbackTransition}, translate 0ms linear` @@ -391,6 +387,16 @@ export class Feedback extends Plugin { const initialShape = new DOMRectangle(feedbackElement); untracked(() => (dragOperation.shape = initialShape)); + // Compute the initial transform now that shape is set, so modifiers + // (e.g. snap-to-cursor) have access to shape.initial on the first frame. + // In v1, draggingNodeRect was always available in modifiers; this restores + // that behaviour. On the first frame position.delta is {x:0,y:0}, so for + // modifiers that don't need shape the second styles.set is a no-op. + const transform = untracked(() => dragOperation.transform); + const tX = transform.x * frameTransform.scaleX + initialTranslate.x; + const tY = transform.y * frameTransform.scaleY + initialTranslate.y; + styles.set({translate: `${tX}px ${tY}px 0`}, CSS_PREFIX); + const feedbackWindow = getWindow(feedbackElement); const handleWindowResize = (event: Event) => { this.manager.actions.stop({event}); From 8a8f19cbc33c692961b61cf74a3cdb02865cd340 Mon Sep 17 00:00:00 2001 From: Clauderic Demers Date: Thu, 2 Apr 2026 08:35:03 -0400 Subject: [PATCH 2/2] fix: set operation.shape before computing initial transform in Feedback plugin (closes #1986) Add e2e tests for modifiers (vertical axis, horizontal axis, restrict to window, snap to grid). --- apps/stories-shared/tests/modifiers.tests.ts | 159 ++++++++++++++++++ apps/stories/tests/modifiers.spec.ts | 8 + .../dom/src/core/plugins/feedback/Feedback.ts | 7 +- 3 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 apps/stories-shared/tests/modifiers.tests.ts create mode 100644 apps/stories/tests/modifiers.spec.ts diff --git a/apps/stories-shared/tests/modifiers.tests.ts b/apps/stories-shared/tests/modifiers.tests.ts new file mode 100644 index 000000000..b3d265a17 --- /dev/null +++ b/apps/stories-shared/tests/modifiers.tests.ts @@ -0,0 +1,159 @@ +import {test, expect} from './fixtures.ts'; + +interface ModifierStories { + verticalAxis: string; + horizontalAxis: string; + restrictToWindow: string; + snapToGrid: string; +} + +export function modifierTests(stories: ModifierStories) { + test.describe('Modifiers', () => { + test.describe('RestrictToVerticalAxis', () => { + test('restricts drag movement to the vertical axis only', async ({ + dnd, + }) => { + await dnd.goto(stories.verticalAxis); + await dnd.disableTransitions(); + + const button = dnd.buttons.first(); + await expect(button).toBeVisible(); + + const box = await button.boundingBox(); + if (!box) throw new Error('Could not get bounding box'); + + const startX = box.x + box.width / 2; + const startY = box.y + box.height / 2; + + await dnd.page.mouse.move(startX, startY); + await dnd.page.mouse.down(); + // Drag diagonally: 200px right, 120px down + await dnd.page.mouse.move(startX + 200, startY + 120, {steps: 15}); + await expect(dnd.dragging).toHaveCount(1, {timeout: 3_000}); + + const draggingBox = await dnd.dragging.boundingBox(); + if (!draggingBox) throw new Error('Could not get dragging bounding box'); + + const tolerance = 3; + // X position should not have changed + expect(Math.abs(draggingBox.x - box.x)).toBeLessThan(tolerance); + // Y position should have moved down + expect(draggingBox.y).toBeGreaterThan(box.y + 50); + + await dnd.page.mouse.up(); + await dnd.waitForDrop(); + }); + }); + + test.describe('RestrictToHorizontalAxis', () => { + test('restricts drag movement to the horizontal axis only', async ({ + dnd, + }) => { + await dnd.goto(stories.horizontalAxis); + await dnd.disableTransitions(); + + const button = dnd.buttons.first(); + await expect(button).toBeVisible(); + + const box = await button.boundingBox(); + if (!box) throw new Error('Could not get bounding box'); + + const startX = box.x + box.width / 2; + const startY = box.y + box.height / 2; + + await dnd.page.mouse.move(startX, startY); + await dnd.page.mouse.down(); + // Drag diagonally: 200px right, 120px down + await dnd.page.mouse.move(startX + 200, startY + 120, {steps: 15}); + await expect(dnd.dragging).toHaveCount(1, {timeout: 3_000}); + + const draggingBox = await dnd.dragging.boundingBox(); + if (!draggingBox) throw new Error('Could not get dragging bounding box'); + + const tolerance = 3; + // Y position should not have changed + expect(Math.abs(draggingBox.y - box.y)).toBeLessThan(tolerance); + // X position should have moved right + expect(draggingBox.x).toBeGreaterThan(box.x + 100); + + await dnd.page.mouse.up(); + await dnd.waitForDrop(); + }); + }); + + test.describe('RestrictToWindow', () => { + test('clamps dragged element within viewport bounds', async ({dnd}) => { + await dnd.goto(stories.restrictToWindow); + await dnd.disableTransitions(); + + const button = dnd.buttons.first(); + await expect(button).toBeVisible(); + + const box = await button.boundingBox(); + if (!box) throw new Error('Could not get bounding box'); + + const startX = box.x + box.width / 2; + const startY = box.y + box.height / 2; + + await dnd.page.mouse.move(startX, startY); + await dnd.page.mouse.down(); + // Drag far above the viewport + await dnd.page.mouse.move(startX, startY - 2000, {steps: 20}); + await expect(dnd.dragging).toHaveCount(1, {timeout: 3_000}); + + const draggingBox = await dnd.dragging.boundingBox(); + if (!draggingBox) throw new Error('Could not get dragging bounding box'); + + // Element must not have left the top of the viewport + expect(draggingBox.y).toBeGreaterThanOrEqual(-1); + + await dnd.page.mouse.up(); + await dnd.waitForDrop(); + }); + }); + + test.describe('SnapModifier', () => { + test('snaps the drop position to the configured grid size', async ({ + dnd, + }) => { + await dnd.goto(stories.snapToGrid); + await dnd.disableTransitions(); + + const button = dnd.buttons.first(); + await expect(button).toBeVisible(); + + const initialBox = await button.boundingBox(); + if (!initialBox) throw new Error('Could not get initial bounding box'); + + const gridSize = 30; + // Drag an amount that is not a multiple of gridSize (45px). + // The snap modifier (ceil) should round up to 60px. + const dragBy = 45; + const expectedSnap = Math.ceil(dragBy / gridSize) * gridSize; // 60 + + const startX = initialBox.x + initialBox.width / 2; + const startY = initialBox.y + initialBox.height / 2; + + await dnd.page.mouse.move(startX, startY); + await dnd.page.mouse.down(); + await dnd.page.mouse.move(startX + dragBy, startY + dragBy, { + steps: 10, + }); + await expect(dnd.dragging).toHaveCount(1, {timeout: 3_000}); + await dnd.page.mouse.up(); + await dnd.waitForDrop(); + + const finalBox = await button.boundingBox(); + if (!finalBox) throw new Error('Could not get final bounding box'); + + const tolerance = 2; + expect( + Math.abs(finalBox.x - initialBox.x - expectedSnap) + ).toBeLessThan(tolerance); + expect( + Math.abs(finalBox.y - initialBox.y - expectedSnap) + ).toBeLessThan(tolerance); + }); + }); + }); +} diff --git a/apps/stories/tests/modifiers.spec.ts b/apps/stories/tests/modifiers.spec.ts new file mode 100644 index 000000000..002872637 --- /dev/null +++ b/apps/stories/tests/modifiers.spec.ts @@ -0,0 +1,8 @@ +import {modifierTests} from '../../stories-shared/tests/modifiers.tests.ts'; + +modifierTests({ + verticalAxis: 'react-draggable-modifiers--vertical-axis', + horizontalAxis: 'react-draggable-modifiers--horizontal-axis', + restrictToWindow: 'react-draggable-modifiers--window-modifier', + snapToGrid: 'react-draggable-modifiers--snap-modifier-example', +}); diff --git a/packages/dom/src/core/plugins/feedback/Feedback.ts b/packages/dom/src/core/plugins/feedback/Feedback.ts index acbaae596..39d231e4e 100644 --- a/packages/dom/src/core/plugins/feedback/Feedback.ts +++ b/packages/dom/src/core/plugins/feedback/Feedback.ts @@ -388,10 +388,9 @@ export class Feedback extends Plugin { untracked(() => (dragOperation.shape = initialShape)); // Compute the initial transform now that shape is set, so modifiers - // (e.g. snap-to-cursor) have access to shape.initial on the first frame. - // In v1, draggingNodeRect was always available in modifiers; this restores - // that behaviour. On the first frame position.delta is {x:0,y:0}, so for - // modifiers that don't need shape the second styles.set is a no-op. + // have access to shape.initial on the first frame. On the first frame + // position.delta is {x:0,y:0}, so for modifiers that don't read shape + // the second styles.set below is a no-op. const transform = untracked(() => dragOperation.transform); const tX = transform.x * frameTransform.scaleX + initialTranslate.x; const tY = transform.y * frameTransform.scaleY + initialTranslate.y;