From b0ee7dd48f868c5c02884345f723bebaf616b13d Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Thu, 8 Oct 2026 19:52:56 -0500 Subject: [PATCH] fix(web): drop sidebar context before cancelling pointer drag (#17373) --- .../src/components/Sidebar.pointer.test.ts | 42 +++++++++++++++++++ apps/web/src/components/Sidebar.pointer.ts | 9 ++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.pointer.test.ts b/apps/web/src/components/Sidebar.pointer.test.ts index 0c171313612e8452238e1070b7e04cb1871576b0..2a142a32e2cae36621b173e65d5186d53a3e5259 100644 --- a/apps/web/src/components/Sidebar.pointer.test.ts +++ b/apps/web/src/components/Sidebar.pointer.test.ts @@ -147,6 +147,48 @@ describe("sidebar pointer lifecycle", () => { expect(inside.onCancel).not.toHaveBeenCalled(); }); + it("drops context when a zero-button move precedes pointerup", () => { + const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); + const drag = gesture({ onMove: (point) => point.x > 50, onDrop }); + document.dispatchEvent(pointer("pointermove", { clientY: 20 })); + document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); + + // Native desktop releases can report the released button on the final move. + document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 94, clientY: 32 })); + document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 94, clientY: 32 })); + document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 100, clientY: 40 })); + + expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 94, y: 32 }); + expect(drag.onCancel).toHaveBeenCalledOnce(); + expect(drag.onEnd).not.toHaveBeenCalled(); + expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true); + }); + + it("cancels a missed sidebar release without committing a reorder", () => { + const onDrop = vi.fn((point: { x: number; y: number }) => point.x > 50); + const drag = gesture({ onMove: (point) => point.x > 50, onDrop }); + document.dispatchEvent(pointer("pointermove", { clientX: 90, clientY: 30 })); + document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 30, clientY: 40 })); + document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 30, clientY: 40 })); + + expect(onDrop).toHaveBeenCalledExactlyOnceWith({ x: 30, y: 40 }); + expect(drag.onCancel).toHaveBeenCalledOnce(); + expect(drag.onEnd).not.toHaveBeenCalled(); + expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(true); + }); + + it("does not drop context when the button is released before activation", () => { + const onDrop = vi.fn(() => true); + const drag = gesture({ onDrop }); + document.dispatchEvent(pointer("pointermove", { buttons: 0, clientX: 90, clientY: 30 })); + document.dispatchEvent(pointer("pointerup", { buttons: 0, clientX: 90, clientY: 30 })); + + expect(onDrop).not.toHaveBeenCalled(); + expect(drag.onStart).not.toHaveBeenCalled(); + expect(drag.onAbort).toHaveBeenCalledOnce(); + expect(drag.onFinish).toHaveBeenCalledExactlyOnceWith(false); + }); + it("resumes reordering when a context drag returns to the sidebar", () => { const drag = gesture({ onMove: (point) => point.x > 50, diff --git a/apps/web/src/components/Sidebar.pointer.ts b/apps/web/src/components/Sidebar.pointer.ts index 23f95481485dc104876cc4470987e83b2f837a6c..ef5fab2edd7ccab0680dbf35e7a1016992799249 100644 --- a/apps/web/src/components/Sidebar.pointer.ts +++ b/apps/web/src/components/Sidebar.pointer.ts @@ -68,10 +68,13 @@ export class SidebarPointerSensor { private move = (event: PointerEvent) => { if (this.phase === "finished" || event.pointerId !== this.pointer.pointerId) return; - // A release outside the window can be missed. Never activate or continue - // a drag when the initiating button is no longer held. - if ((event.buttons & 1) === 0) return this.cancel(); const coordinates = { x: event.clientX, y: event.clientY }; + // Desktop can report buttons=0 on the final move before pointerup. Let an + // active context drag drop there before cancelling the sidebar sort. + if ((event.buttons & 1) === 0) { + if (this.phase === "dragging") this.props.options.onDrop?.(coordinates); + return this.cancel(); + } if (this.phase === "pending") { const offset = { x: event.clientX - this.pointer.clientX, -- 2.54.0