Skip to content

Commit 5ba94ef

Browse files
authored
fix: Fixes auto-scroll releasing when transaction commits (#367)
1 parent 8ff00c0 commit 5ba94ef

File tree

4 files changed

+447
-64
lines changed

4 files changed

+447
-64
lines changed

src/board/internal.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ export function InternalBoard<D>({
155155
collisionIds: interactionType === "pointer" && isElementOverBoard(collisionRect) ? collisionIds : [],
156156
});
157157

158-
autoScrollHandlers.addPointerEventHandlers();
158+
autoScrollHandlers.run();
159159
});
160160

161161
useDragSubscription("update", ({ interactionType, collisionIds, positionOffset, collisionRect }) => {
@@ -170,7 +170,7 @@ export function InternalBoard<D>({
170170
useDragSubscription("submit", () => {
171171
dispatch({ type: "submit" });
172172

173-
autoScrollHandlers.removePointerEventHandlers();
173+
autoScrollHandlers.stop();
174174

175175
if (!transition) {
176176
throw new Error("Invariant violation: no transition.");
@@ -196,7 +196,7 @@ export function InternalBoard<D>({
196196
useDragSubscription("discard", () => {
197197
dispatch({ type: "discard" });
198198

199-
autoScrollHandlers.removePointerEventHandlers();
199+
autoScrollHandlers.stop();
200200
});
201201

202202
useDragSubscription("acquire", ({ droppableId, draggableItem, renderAcquiredItem }) => {
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { renderHook } from "@testing-library/react";
5+
import { afterEach, beforeEach, describe, expect, it, type MockedFunction, vi } from "vitest";
6+
7+
import { useAutoScroll } from "../use-auto-scroll";
8+
import { useLastInteraction } from "../use-last-interaction";
9+
10+
vi.mock("../use-last-interaction", () => ({ useLastInteraction: vi.fn() }));
11+
12+
const mockedUseLastInteraction = vi.mocked(useLastInteraction);
13+
14+
describe("useAutoScroll", () => {
15+
let mockGetLastInteraction: MockedFunction<() => "pointer" | "keyboard">;
16+
let mockScrollIntoView: MockedFunction<(options?: ScrollIntoViewOptions) => void>;
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks();
20+
vi.useFakeTimers();
21+
22+
mockGetLastInteraction = vi.fn().mockReturnValue("pointer");
23+
mockedUseLastInteraction.mockReturnValue(mockGetLastInteraction);
24+
25+
vi.spyOn(window, "addEventListener").mockImplementation(() => {});
26+
vi.spyOn(window, "removeEventListener").mockImplementation(() => {});
27+
vi.spyOn(window, "scrollBy").mockImplementation(() => {});
28+
vi.spyOn(global, "setTimeout");
29+
vi.spyOn(global, "clearTimeout");
30+
31+
mockScrollIntoView = vi.fn();
32+
33+
// Mock window dimensions
34+
Object.defineProperty(window, "innerHeight", {
35+
writable: true,
36+
configurable: true,
37+
value: 600,
38+
});
39+
40+
Object.defineProperty(document, "activeElement", {
41+
writable: true,
42+
configurable: true,
43+
value: {
44+
scrollIntoView: mockScrollIntoView,
45+
},
46+
});
47+
});
48+
49+
afterEach(() => {
50+
vi.useRealTimers();
51+
vi.restoreAllMocks();
52+
});
53+
54+
it("should set up event listeners on init and return cleanup function", () => {
55+
const { result } = renderHook(() => useAutoScroll());
56+
const controller = result.current;
57+
58+
const cleanup = controller.init();
59+
60+
expect(window.addEventListener).toHaveBeenCalledWith("pointermove", expect.any(Function));
61+
expect(window.addEventListener).toHaveBeenCalledWith("pointerup", expect.any(Function));
62+
63+
cleanup();
64+
65+
expect(clearTimeout).toHaveBeenCalled();
66+
expect(window.removeEventListener).toHaveBeenCalledWith("pointermove", expect.any(Function));
67+
expect(window.removeEventListener).toHaveBeenCalledWith("pointerup", expect.any(Function));
68+
});
69+
70+
it("should start auto-scrolling when run() is called and pointer is in bottom margin", () => {
71+
const { result } = renderHook(() => useAutoScroll());
72+
const controller = result.current;
73+
74+
controller.init();
75+
controller.run();
76+
77+
// Simulate pointer move to bottom margin (clientY > innerHeight - 50)
78+
const pointerMoveHandler = vi
79+
.mocked(window.addEventListener)
80+
.mock.calls.find((call) => call[0] === "pointermove")?.[1] as ((event: PointerEvent) => void) | undefined;
81+
82+
pointerMoveHandler?.({ clientY: 560 } as PointerEvent); // 560 > 550 (600 - 50)
83+
84+
// Fast-forward timer to trigger scroll
85+
vi.advanceTimersByTime(10);
86+
87+
expect(window.scrollBy).toHaveBeenCalledWith({ top: 5 }); // direction=1, increment=5
88+
});
89+
90+
it("should start auto-scrolling upward when pointer is in top margin", () => {
91+
const { result } = renderHook(() => useAutoScroll());
92+
const controller = result.current;
93+
94+
controller.init();
95+
controller.run();
96+
97+
// Simulate pointer move to top margin (clientY < 50)
98+
const pointerMoveHandler = vi
99+
.mocked(window.addEventListener)
100+
.mock.calls.find((call) => call[0] === "pointermove")?.[1] as ((event: PointerEvent) => void) | undefined;
101+
102+
pointerMoveHandler?.({ clientY: 30 } as PointerEvent); // 30 < 50
103+
104+
// Fast-forward timer to trigger scroll
105+
vi.advanceTimersByTime(10);
106+
107+
expect(window.scrollBy).toHaveBeenCalledWith({ top: -5 }); // direction=-1, increment=5
108+
});
109+
110+
it("should stop auto-scrolling when pointer is in middle area", () => {
111+
const { result } = renderHook(() => useAutoScroll());
112+
const controller = result.current;
113+
114+
controller.init();
115+
controller.run();
116+
117+
const pointerMoveHandler = vi
118+
.mocked(window.addEventListener)
119+
.mock.calls.find((call) => call[0] === "pointermove")?.[1] as ((event: PointerEvent) => void) | undefined;
120+
121+
// First, move to bottom margin to start scrolling
122+
pointerMoveHandler?.({ clientY: 560 } as PointerEvent);
123+
vi.advanceTimersByTime(10);
124+
expect(window.scrollBy).toHaveBeenCalledWith({ top: 5 });
125+
126+
// Then move to middle area
127+
vi.mocked(window.scrollBy).mockClear();
128+
pointerMoveHandler?.({ clientY: 300 } as PointerEvent); // Middle of screen
129+
130+
// Fast-forward timer - should not scroll anymore
131+
vi.advanceTimersByTime(10);
132+
expect(window.scrollBy).not.toHaveBeenCalled();
133+
});
134+
135+
it("should stop auto-scrolling when stop() is called", () => {
136+
const { result } = renderHook(() => useAutoScroll());
137+
const controller = result.current;
138+
139+
controller.init();
140+
controller.run();
141+
142+
const pointerMoveHandler = vi
143+
.mocked(window.addEventListener)
144+
.mock.calls.find((call) => call[0] === "pointermove")?.[1] as ((event: PointerEvent) => void) | undefined;
145+
146+
// Move to bottom margin
147+
pointerMoveHandler?.({ clientY: 560 } as PointerEvent);
148+
149+
// Stop the controller
150+
controller.stop();
151+
152+
// Fast-forward timer - should not scroll
153+
vi.advanceTimersByTime(10);
154+
expect(window.scrollBy).not.toHaveBeenCalled();
155+
});
156+
157+
it("should reset scroll direction on pointer up", () => {
158+
const { result } = renderHook(() => useAutoScroll());
159+
const controller = result.current;
160+
161+
controller.init();
162+
controller.run();
163+
164+
const pointerMoveHandler = vi
165+
.mocked(window.addEventListener)
166+
.mock.calls.find((call) => call[0] === "pointermove")?.[1] as ((event: PointerEvent) => void) | undefined;
167+
const pointerUpHandler = vi
168+
.mocked(window.addEventListener)
169+
.mock.calls.find((call) => call[0] === "pointerup")?.[1] as (() => void) | undefined;
170+
171+
// Move to bottom margin to start scrolling
172+
pointerMoveHandler?.({ clientY: 560 } as PointerEvent);
173+
vi.advanceTimersByTime(10);
174+
expect(window.scrollBy).toHaveBeenCalledWith({ top: 5 });
175+
176+
// Trigger pointer up
177+
vi.mocked(window.scrollBy).mockClear();
178+
pointerUpHandler?.();
179+
180+
// Fast-forward timer - should not scroll anymore
181+
vi.advanceTimersByTime(10);
182+
expect(window.scrollBy).not.toHaveBeenCalled();
183+
});
184+
185+
it("should not respond to pointer events when not active", () => {
186+
const { result } = renderHook(() => useAutoScroll());
187+
const controller = result.current;
188+
189+
controller.init();
190+
// Don't call run() - controller is not active
191+
192+
const pointerMoveHandler = vi
193+
.mocked(window.addEventListener)
194+
.mock.calls.find((call) => call[0] === "pointermove")?.[1] as ((event: PointerEvent) => void) | undefined;
195+
196+
// Move to bottom margin
197+
pointerMoveHandler?.({ clientY: 560 } as PointerEvent);
198+
199+
// Fast-forward timer - should not scroll
200+
vi.advanceTimersByTime(10);
201+
expect(window.scrollBy).not.toHaveBeenCalled();
202+
});
203+
204+
it("should schedule active element scroll into view for keyboard interactions", () => {
205+
mockGetLastInteraction.mockReturnValue("keyboard");
206+
207+
const { result } = renderHook(() => useAutoScroll());
208+
const controller = result.current;
209+
210+
const activeElement = { scrollIntoView: mockScrollIntoView };
211+
Object.defineProperty(document, "activeElement", {
212+
value: activeElement,
213+
configurable: true,
214+
});
215+
216+
controller.scheduleActiveElementScrollIntoView(100);
217+
218+
// Fast-forward to after the delay
219+
vi.advanceTimersByTime(100);
220+
221+
expect(mockScrollIntoView).toHaveBeenCalledWith({
222+
behavior: "smooth",
223+
block: "nearest",
224+
});
225+
});
226+
227+
it("should not scroll active element into view for pointer interactions", () => {
228+
mockGetLastInteraction.mockReturnValue("pointer");
229+
230+
const { result } = renderHook(() => useAutoScroll());
231+
const controller = result.current;
232+
233+
const activeElement = { scrollIntoView: mockScrollIntoView };
234+
Object.defineProperty(document, "activeElement", {
235+
value: activeElement,
236+
configurable: true,
237+
});
238+
239+
controller.scheduleActiveElementScrollIntoView(100);
240+
241+
// Fast-forward to after the delay
242+
vi.advanceTimersByTime(100);
243+
244+
expect(mockScrollIntoView).not.toHaveBeenCalled();
245+
});
246+
247+
it("should not scroll into view if active element changes during delay", () => {
248+
mockGetLastInteraction.mockReturnValue("keyboard");
249+
250+
const { result } = renderHook(() => useAutoScroll());
251+
const controller = result.current;
252+
253+
const originalActiveElement = { scrollIntoView: mockScrollIntoView };
254+
Object.defineProperty(document, "activeElement", {
255+
value: originalActiveElement,
256+
configurable: true,
257+
});
258+
259+
controller.scheduleActiveElementScrollIntoView(100);
260+
261+
// Change active element before delay completes
262+
const newActiveElement = { scrollIntoView: vi.fn() };
263+
Object.defineProperty(document, "activeElement", {
264+
value: newActiveElement,
265+
configurable: true,
266+
});
267+
268+
// Fast-forward to after the delay
269+
vi.advanceTimersByTime(100);
270+
271+
expect(mockScrollIntoView).not.toHaveBeenCalled();
272+
});
273+
274+
it("should not scroll into view if no active element", () => {
275+
mockGetLastInteraction.mockReturnValue("keyboard");
276+
277+
const { result } = renderHook(() => useAutoScroll());
278+
const controller = result.current;
279+
280+
Object.defineProperty(document, "activeElement", {
281+
value: null,
282+
configurable: true,
283+
});
284+
285+
controller.scheduleActiveElementScrollIntoView(100);
286+
287+
// Fast-forward to after the delay
288+
vi.advanceTimersByTime(100);
289+
290+
expect(mockScrollIntoView).not.toHaveBeenCalled();
291+
});
292+
293+
it("should clear previous timeout when scheduling new active element scroll", () => {
294+
const { result } = renderHook(() => useAutoScroll());
295+
const controller = result.current;
296+
297+
// Schedule first scroll
298+
controller.scheduleActiveElementScrollIntoView(100);
299+
const firstTimeoutId = vi.mocked(setTimeout).mock.results[vi.mocked(setTimeout).mock.results.length - 1]?.value;
300+
301+
// Schedule second scroll before first completes
302+
controller.scheduleActiveElementScrollIntoView(200);
303+
304+
expect(clearTimeout).toHaveBeenCalledWith(firstTimeoutId);
305+
});
306+
307+
it("should continue scrolling repeatedly while active and in margin", () => {
308+
const { result } = renderHook(() => useAutoScroll());
309+
const controller = result.current;
310+
311+
controller.init();
312+
controller.run();
313+
314+
const pointerMoveHandler = vi
315+
.mocked(window.addEventListener)
316+
.mock.calls.find((call) => call[0] === "pointermove")?.[1] as ((event: PointerEvent) => void) | undefined;
317+
318+
// Move to bottom margin
319+
pointerMoveHandler?.({ clientY: 560 } as PointerEvent);
320+
321+
// Advance timer once to get the first scroll call
322+
vi.advanceTimersByTime(10);
323+
324+
// Verify it's scrolling with the correct parameters
325+
expect(window.scrollBy).toHaveBeenCalledWith({ top: 5 });
326+
const initialCallCount = vi.mocked(window.scrollBy).mock.calls.length;
327+
328+
// Advance timer again to verify it continues scrolling
329+
vi.advanceTimersByTime(10);
330+
expect(vi.mocked(window.scrollBy).mock.calls.length).toBeGreaterThan(initialCallCount);
331+
332+
// Advance timer once more to verify it's still scrolling
333+
vi.advanceTimersByTime(10);
334+
expect(vi.mocked(window.scrollBy).mock.calls.length).toBeGreaterThan(initialCallCount + 1);
335+
336+
// All calls should be the same
337+
expect(window.scrollBy).toHaveBeenCalledWith({ top: 5 });
338+
});
339+
});

0 commit comments

Comments
 (0)