Skip to content

Commit

Permalink
Horizontally scroll when dragging near the edges
Browse files Browse the repository at this point in the history
  • Loading branch information
canac committed Aug 14, 2024
1 parent bbc74f4 commit 8ece230
Show file tree
Hide file tree
Showing 4 changed files with 296 additions and 13 deletions.
7 changes: 5 additions & 2 deletions src/components/Contacts/ContactFlow/ContactFlow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useRef } from 'react';
import { Box } from '@mui/material';
import { useSnackbar } from 'notistack';
import { DndProvider } from 'react-dnd';
Expand Down Expand Up @@ -133,12 +133,14 @@ export const ContactFlow: React.FC<Props> = ({
}
};

const ref = useRef<HTMLElement | null>(null);

return loadingUserOptions ? (
<Loading loading={loadingUserOptions} />
) : (
flowOptions && (
<DndProvider backend={HTML5Backend}>
<ContactFlowDragLayer />
<ContactFlowDragLayer containerRef={ref} />
<Box
display="grid"
minWidth="100%"
Expand All @@ -149,6 +151,7 @@ export const ContactFlow: React.FC<Props> = ({
style={{ overflowX: 'auto' }}
gridAutoColumns="300px"
data-testid="contactsFlow"
ref={ref}
>
{flowOptions.map((column) => (
<Box
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { CSSProperties } from 'react';
import React, { CSSProperties, RefObject } from 'react';
import { XYCoord, useDragLayer } from 'react-dnd';
import theme from '../../../../theme';
import theme from 'src/theme';
import { ContactFlowRowPreview } from './ContactFlowRowPreview';
import { useAutoScroll } from './useAutoScroll';

const layerStyles: CSSProperties = {
position: 'absolute',
Expand Down Expand Up @@ -36,15 +37,36 @@ function getItemStyles(
};
}

export const ContactFlowDragLayer: React.FC = () => {
const { isDragging, item, itemType, initialOffset, currentOffset } =
useDragLayer((monitor) => ({
item: monitor.getItem(),
itemType: monitor.getItemType(),
initialOffset: monitor.getInitialSourceClientOffset(),
currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(),
}));
interface ContactFlowDragLayerProps {
containerRef: RefObject<HTMLElement>;
}

export const ContactFlowDragLayer: React.FC<ContactFlowDragLayerProps> = ({
containerRef,
}) => {
const {
isDragging,
item,
itemType,
initialOffset,
currentOffset,
clientOffset,
} = useDragLayer((monitor) => ({
item: monitor.getItem(),
itemType: monitor.getItemType(),
initialOffset: monitor.getInitialSourceClientOffset(),
currentOffset: monitor.getSourceClientOffset(),
isDragging: monitor.isDragging(),
clientOffset: monitor.getClientOffset(),
}));

useAutoScroll({
containerRef,
enabled: clientOffset !== null,
mouseX: clientOffset?.x ?? 0,
scrollThreshold: 100,
scrollVelocity: 800,
});

function renderItem() {
switch (itemType) {
Expand Down
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();
});
});
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]);
};

0 comments on commit 8ece230

Please sign in to comment.