Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-shape-null-first-modifier-call.md
Original file line number Diff line number Diff line change
@@ -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.
159 changes: 159 additions & 0 deletions apps/stories-shared/tests/modifiers.tests.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
}
8 changes: 8 additions & 0 deletions apps/stories/tests/modifiers.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
});
19 changes: 12 additions & 7 deletions packages/dom/src/core/plugins/feedback/Feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,22 +308,18 @@ export class Feedback extends Plugin<DragDropManager, FeedbackOptions> {

/* ---- 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`
Expand Down Expand Up @@ -391,6 +387,15 @@ export class Feedback extends Plugin<DragDropManager, FeedbackOptions> {
const initialShape = new DOMRectangle(feedbackElement);
untracked(() => (dragOperation.shape = initialShape));

// Compute the initial transform now that shape is set, so modifiers
// 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;
styles.set({translate: `${tX}px ${tY}px 0`}, CSS_PREFIX);

const feedbackWindow = getWindow(feedbackElement);
const handleWindowResize = (event: Event) => {
this.manager.actions.stop({event});
Expand Down
Loading