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
3 changes: 3 additions & 0 deletions docs/useScrolling.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

React sensor hook that keeps track of whether the user is scrolling or not.

The value returns to `false` 150ms after the last scroll event. Pending timeouts and
the scroll listener are cleaned up when the component unmounts or the ref object changes.

## Usage

```jsx
Expand Down
13 changes: 7 additions & 6 deletions src/useScrolling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ const useScrolling = (ref: RefObject<HTMLElement>): boolean => {
const [scrolling, setScrolling] = useState<boolean>(false);

useEffect(() => {
if (ref.current) {
let scrollingTimeout;
const element = ref.current;

if (element) {
let scrollingTimeout: ReturnType<typeof setTimeout>;

const handleScrollEnd = () => {
setScrolling(false);
Expand All @@ -18,11 +20,10 @@ const useScrolling = (ref: RefObject<HTMLElement>): boolean => {
scrollingTimeout = setTimeout(() => handleScrollEnd(), 150);
};

on(ref.current, 'scroll', handleScroll, false);
on(element, 'scroll', handleScroll, false);
return () => {
if (ref.current) {
off(ref.current, 'scroll', handleScroll, false);
}
clearTimeout(scrollingTimeout);
off(element, 'scroll', handleScroll, false);
};
}
return () => {};
Expand Down
138 changes: 138 additions & 0 deletions tests/useScrolling.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import React, { useRef } from 'react';
import { act, renderHook } from '@testing-library/react-hooks/dom';
import { fireEvent, render } from '@testing-library/react';
import useScrolling from '../src/useScrolling';

describe('useScrolling', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});

it('should remain false when there is no element', () => {
const { result, unmount } = renderHook(() => useScrolling({ current: null }));

expect(result.current).toBe(false);
unmount();
});

it('should stop scrolling 150ms after the last scroll event', () => {
const element = document.createElement('div');
const ref = { current: element };
const { result } = renderHook(() => useScrolling(ref));

expect(result.current).toBe(false);

act(() => {
fireEvent.scroll(element);
});
expect(result.current).toBe(true);

act(() => {
jest.advanceTimersByTime(100);
fireEvent.scroll(element);
jest.advanceTimersByTime(149);
});
expect(result.current).toBe(true);
expect(jest.getTimerCount()).toBe(1);

act(() => {
jest.advanceTimersByTime(1);
});
expect(result.current).toBe(false);
expect(jest.getTimerCount()).toBe(0);
});

it('should clear a pending timeout on unmount', () => {
const element = document.createElement('div');
const ref = { current: element };
const { unmount } = renderHook(() => useScrolling(ref));

act(() => {
fireEvent.scroll(element);
});
expect(jest.getTimerCount()).toBe(1);

unmount();
expect(jest.getTimerCount()).toBe(0);
});

it('should remove the listener when unmounted without scrolling', () => {
const element = document.createElement('div');
const ref = { current: element };
const { unmount } = renderHook(() => useScrolling(ref));

unmount();
fireEvent.scroll(element);

expect(jest.getTimerCount()).toBe(0);
});

it('should clean up the original element after React clears its ref', () => {
const Demo = () => {
const ref = useRef<HTMLDivElement>(null);
const scrolling = useScrolling(ref);

return <div ref={ref}>{scrolling ? 'Scrolling' : 'Not scrolling'}</div>;
};
const { container, unmount } = render(<Demo />);
const element = container.firstChild as HTMLDivElement;

fireEvent.scroll(element);
expect(element.textContent).toBe('Scrolling');
expect(jest.getTimerCount()).toBe(1);

unmount();
expect(jest.getTimerCount()).toBe(0);

fireEvent.scroll(element);
expect(jest.getTimerCount()).toBe(0);
});

it('should clear the previous timeout and listener when the ref changes', () => {
const previousElement = document.createElement('div');
const nextElement = document.createElement('div');
const { result, rerender } = renderHook(({ ref }) => useScrolling(ref), {
initialProps: { ref: { current: previousElement } },
});

act(() => {
fireEvent.scroll(previousElement);
});
expect(jest.getTimerCount()).toBe(1);

rerender({ ref: { current: nextElement } });
expect(jest.getTimerCount()).toBe(0);

act(() => {
fireEvent.scroll(previousElement);
});
expect(jest.getTimerCount()).toBe(0);

act(() => {
fireEvent.scroll(nextElement);
});
expect(jest.getTimerCount()).toBe(1);

act(() => {
jest.advanceTimersByTime(150);
});
expect(result.current).toBe(false);
});

it('should remove the listener if the ref points to another element before cleanup', () => {
const element = document.createElement('div');
const ref = { current: element };
const { unmount } = renderHook(() => useScrolling(ref));

ref.current = document.createElement('div');
unmount();
fireEvent.scroll(element);

expect(jest.getTimerCount()).toBe(0);
});
});