-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Horizontally scroll when dragging near the edges
- Loading branch information
Showing
4 changed files
with
296 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
181 changes: 181 additions & 0 deletions
181
src/components/Contacts/ContactFlow/ContactFlowDragLayer/useAutoScroll.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,181 @@ | ||
import { renderHook } from '@testing-library/react-hooks'; | ||
import { useAutoScroll } from './useAutoScroll'; | ||
|
||
// Mock requestAnimationFrame to allow fine-grained control of frames in tests. | ||
// requestAnimationFrame overwrites window.requestAnimationFrame and returns a nextFrame method. | ||
// Tests should call nextFrame with the number of milliseconds that have supposedly elapsed since | ||
// the last frame to cause the last callback registered with requestAnimationFrame to be called. | ||
const mockRequestAnimationFrame = () => { | ||
let lastCallback: FrameRequestCallback | null = null; | ||
|
||
let frameId = 0; | ||
window.requestAnimationFrame = jest.fn((callback) => { | ||
if (lastCallback) { | ||
throw new Error('Unused requestAnimationFrame available'); | ||
} | ||
lastCallback = callback; | ||
return ++frameId; | ||
}); | ||
window.cancelAnimationFrame = jest.fn((handle) => { | ||
if (handle !== frameId) { | ||
throw new Error('Attempted to cancel invalid frame'); | ||
} | ||
lastCallback = null; | ||
}); | ||
|
||
let time = 0; | ||
const nextFrame = (elapsedTime: number) => { | ||
const callback = lastCallback; | ||
if (!callback) { | ||
throw new Error('No requestAnimationFrame available'); | ||
} | ||
lastCallback = null; | ||
time += elapsedTime; | ||
callback(time); | ||
}; | ||
return nextFrame; | ||
}; | ||
|
||
// Creates a container element with a specific position and width | ||
const makeContainer = (): HTMLDivElement => { | ||
const container = document.createElement('div'); | ||
container.scrollBy = jest.fn(); | ||
container.getBoundingClientRect = jest | ||
.fn() | ||
.mockReturnValue({ x: 100, width: 1000 }); | ||
return container; | ||
}; | ||
|
||
describe('useAutoScroll', () => { | ||
it('scrolls right when the mouse is near the left edge', () => { | ||
const container = makeContainer(); | ||
const nextFrame = mockRequestAnimationFrame(); | ||
|
||
const { unmount } = renderHook(() => | ||
useAutoScroll({ | ||
containerRef: { current: container }, | ||
mouseX: 150, | ||
scrollThreshold: 100, | ||
scrollVelocity: 300, | ||
}), | ||
); | ||
|
||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(1, { left: -0 }); | ||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(2, { left: -3 }); // 300 pixels/s for 10ms = 3px | ||
nextFrame(20); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(3, { left: -6 }); // 300 pixels/s for 20ms = 6px | ||
|
||
unmount(); | ||
expect(cancelAnimationFrame).toHaveBeenCalledWith(4); | ||
}); | ||
|
||
it('scrolls left when the mouse is near the right edge', () => { | ||
const container = makeContainer(); | ||
const nextFrame = mockRequestAnimationFrame(); | ||
|
||
const { unmount } = renderHook(() => | ||
useAutoScroll({ | ||
containerRef: { current: container }, | ||
mouseX: 1050, | ||
scrollThreshold: 100, | ||
scrollVelocity: 300, | ||
}), | ||
); | ||
|
||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(1, { left: 0 }); | ||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(2, { left: 3 }); // 300 pixels/s for 10ms = 3px | ||
nextFrame(20); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(3, { left: 6 }); // 300 pixels/s for 20ms = 6px | ||
|
||
unmount(); | ||
expect(cancelAnimationFrame).toHaveBeenCalledWith(4); | ||
}); | ||
|
||
it('does not scroll when the mouse is not near an edge', () => { | ||
const container = makeContainer(); | ||
const nextFrame = mockRequestAnimationFrame(); | ||
|
||
const { unmount } = renderHook(() => | ||
useAutoScroll({ | ||
containerRef: { current: container }, | ||
mouseX: 550, | ||
scrollThreshold: 100, | ||
scrollVelocity: 300, | ||
}), | ||
); | ||
|
||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenCalledTimes(0); | ||
|
||
unmount(); | ||
expect(cancelAnimationFrame).toHaveBeenCalledWith(2); | ||
}); | ||
|
||
it('does not scroll if enabled is false', () => { | ||
const container = makeContainer(); | ||
const nextFrame = mockRequestAnimationFrame(); | ||
|
||
const { unmount } = renderHook(() => | ||
useAutoScroll({ | ||
containerRef: { current: null }, | ||
enabled: false, | ||
mouseX: 150, | ||
scrollThreshold: 100, | ||
scrollVelocity: 300, | ||
}), | ||
); | ||
|
||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenCalledTimes(0); | ||
|
||
unmount(); | ||
expect(cancelAnimationFrame).toHaveBeenLastCalledWith(1); | ||
}); | ||
|
||
it('handles prop changes in the middle of scrolling', () => { | ||
const container = makeContainer(); | ||
const nextFrame = mockRequestAnimationFrame(); | ||
|
||
const { rerender } = renderHook( | ||
({ mouseX, enabled }: { mouseX: number; enabled: boolean }) => | ||
useAutoScroll({ | ||
containerRef: { current: container }, | ||
enabled, | ||
mouseX, | ||
scrollThreshold: 100, | ||
scrollVelocity: 300, | ||
}), | ||
); | ||
|
||
// Start scrolling right | ||
rerender({ mouseX: 150, enabled: true }); | ||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(1, { left: -0 }); | ||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(2, { left: -3 }); | ||
|
||
// Stop scrolling because enabled is false | ||
(container.scrollBy as jest.Mock).mockReset(); | ||
rerender({ mouseX: 550, enabled: false }); | ||
nextFrame(10); | ||
expect(container.scrollBy).not.toHaveBeenCalled(); | ||
|
||
// Switch to scrolling left | ||
(container.scrollBy as jest.Mock).mockReset(); | ||
rerender({ mouseX: 1050, enabled: true }); | ||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(1, { left: 0 }); | ||
nextFrame(10); | ||
expect(container.scrollBy).toHaveBeenNthCalledWith(2, { left: 3 }); | ||
|
||
// Stop scrolling because the mouse moves outside of the threshold | ||
(container.scrollBy as jest.Mock).mockReset(); | ||
rerender({ mouseX: 550, enabled: true }); | ||
nextFrame(10); | ||
expect(container.scrollBy).not.toHaveBeenCalled(); | ||
}); | ||
}); |
77 changes: 77 additions & 0 deletions
77
src/components/Contacts/ContactFlow/ContactFlowDragLayer/useAutoScroll.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
import { RefObject, useCallback, useEffect, useRef } from 'react'; | ||
|
||
interface UseAutoScrollProps { | ||
// The container to scroll | ||
containerRef: RefObject<HTMLElement>; | ||
|
||
// Should auto-scrolling be performed? | ||
enabled?: boolean; | ||
|
||
// Mouse X absolute position in pixels | ||
mouseX: number; | ||
|
||
// How many pixels from the edge should the container start scrolling? | ||
scrollThreshold: number; | ||
|
||
// How many pixels per second should the container scroll? | ||
scrollVelocity: number; | ||
} | ||
|
||
// This hook automatically scrolls an element horizontally when the mouse nears its edge. It is intended to be | ||
export const useAutoScroll = ({ | ||
containerRef, | ||
mouseX, | ||
enabled = true, | ||
scrollThreshold, | ||
scrollVelocity, | ||
}: UseAutoScrollProps) => { | ||
const rafId = useRef<number | null>(null); | ||
const lastFrameTime = useRef<number | null>(null); | ||
|
||
const handleScroll: FrameRequestCallback = useCallback( | ||
(time) => { | ||
// Time since handleScroll was last called in seconds | ||
const elapsedTime = | ||
lastFrameTime.current === null | ||
? 0 | ||
: (time - lastFrameTime.current) / 1000; | ||
lastFrameTime.current = time; | ||
|
||
if (!enabled || !containerRef.current) { | ||
return; | ||
} | ||
|
||
const containerRect = containerRef.current.getBoundingClientRect(); | ||
const distanceFromLeftEdge = mouseX - containerRect.x; | ||
|
||
if (distanceFromLeftEdge <= scrollThreshold) { | ||
containerRef.current.scrollBy({ left: -elapsedTime * scrollVelocity }); | ||
} else if ( | ||
distanceFromLeftEdge >= | ||
containerRect.width - scrollThreshold | ||
) { | ||
containerRef.current.scrollBy({ left: elapsedTime * scrollVelocity }); | ||
} | ||
|
||
// Schedule the next scroll check | ||
rafId.current = requestAnimationFrame(handleScroll); | ||
}, | ||
[enabled, mouseX], | ||
); | ||
|
||
// Reset the elapsed time counter when disabled. Otherwise, when it is enabled again, the first frame will calculate | ||
// the elapsed time as the time since the last time it was enabled. | ||
useEffect(() => { | ||
lastFrameTime.current = null; | ||
}, [enabled]); | ||
|
||
useEffect(() => { | ||
// Schedule the first scroll check | ||
rafId.current = requestAnimationFrame(handleScroll); | ||
return () => { | ||
if (rafId.current !== null) { | ||
cancelAnimationFrame(rafId.current); | ||
} | ||
}; | ||
}, [handleScroll]); | ||
}; |