Skip to content

Commit

Permalink
Add use-scroll hook (#24)
Browse files Browse the repository at this point in the history
* Add useScroll hook with tests

* Add format rules

* chore

* docs(changeset): Add use-scroll hook
  • Loading branch information
mhdcodes authored Aug 10, 2024
1 parent ce82570 commit a778506
Show file tree
Hide file tree
Showing 6 changed files with 204 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/lemon-balloons-wonder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@codiume/hooks": patch
---

Add use-scroll hook
12 changes: 12 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://editorconfig.org

# top-most EditorConfig file
root = true

# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 2
12 changes: 2 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,7 @@
"version": "0.0.5",
"description": "A collection of reusable react hooks for state and UI management",
"homepage": "https://github.com/codiume/hooks.git",
"keywords": [
"hooks",
"library",
"react",
"react-hooks",
"state"
],
"keywords": ["hooks", "library", "react", "react-hooks", "state"],
"author": "MHD <[email protected]>",
"contributors": [
{
Expand All @@ -21,9 +15,7 @@
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"files": ["dist"],
"exports": {
".": {
"import": {
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { useQueue } from './use-queue/use-queue';
export { useScroll } from './use-scroll/use-scroll';
124 changes: 124 additions & 0 deletions src/use-scroll/use-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { act, renderHook } from '@testing-library/react';
import type { RefObject } from 'react';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { useScroll } from './use-scroll';

interface MockElement extends Partial<HTMLElement> {
scrollLeft: number;
scrollTop: number;
addEventListener: Mock;
removeEventListener: Mock;
scrollTo: Mock;
}

describe('useScroll', () => {
let ref: RefObject<HTMLElement>;
let mockElement: MockElement;

beforeEach(() => {
mockElement = {
scrollLeft: 0,
scrollTop: 0,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
scrollTo: vi.fn()
};
ref = { current: mockElement as HTMLElement };
});

it('should initialize with scroll position (0, 0)', () => {
const { result } = renderHook(() => useScroll(ref));
const [scrollPosition] = result.current;
expect(scrollPosition).toEqual({ x: 0, y: 0 });
});

it('should update scroll position on scroll', () => {
const { result } = renderHook(() => useScroll(ref));

act(() => {
mockElement.scrollLeft = 100;
mockElement.scrollTop = 200;
/* @ts-ignore */
mockElement.addEventListener.mock.calls[0][1]();
});

const [scrollPosition] = result.current;
expect(scrollPosition).toEqual({ x: 100, y: 200 });
});

it('should add scroll event listeners', () => {
renderHook(() => useScroll(ref));
expect(mockElement.addEventListener).toHaveBeenCalledWith(
'scroll',
expect.any(Function),
expect.objectContaining({
passive: true,
signal: expect.any(AbortSignal)
})
);
});

it('should clean up event listeners on unmount', () => {
const { unmount } = renderHook(() => useScroll(ref));
unmount();
expect(mockElement.removeEventListener).not.toHaveBeenCalled(); // AbortController is used instead
});

it('should provide a scrollTo function', () => {
const { result } = renderHook(() => useScroll(ref));
const [, scrollTo] = result.current;

act(() => {
scrollTo({ x: 50, y: 100 });
});

expect(mockElement.scrollTo).toHaveBeenCalledWith({
left: 50,
top: 100,
behavior: 'smooth'
});
});

it('should allow partial scroll with only x', () => {
const { result } = renderHook(() => useScroll(ref));
const [, scrollTo] = result.current;

act(() => {
scrollTo({ x: 50 });
});

expect(mockElement.scrollTo).toHaveBeenCalledWith({
left: 50,
behavior: 'smooth'
});
});

it('should allow partial scroll with only y', () => {
const { result } = renderHook(() => useScroll(ref));
const [, scrollTo] = result.current;

act(() => {
scrollTo({ y: 100 });
});

expect(mockElement.scrollTo).toHaveBeenCalledWith({
top: 100,
behavior: 'smooth'
});
});

it('should allow custom scroll options', () => {
const { result } = renderHook(() => useScroll(ref));
const [, scrollTo] = result.current;

act(() => {
scrollTo({ x: 50, y: 100 }, { behavior: 'auto' });
});

expect(mockElement.scrollTo).toHaveBeenCalledWith({
left: 50,
top: 100,
behavior: 'auto'
});
});
});
60 changes: 60 additions & 0 deletions src/use-scroll/use-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { type RefObject, useCallback, useEffect, useState } from 'react';

type ScrollPosition = {
x: number;
y: number;
};

export function useScroll(ref: RefObject<HTMLElement>) {
const [scrollPosition, setScrollPosition] = useState<ScrollPosition>({
x: 0,
y: 0
});

const scrollTo = useCallback(
(
{ x, y }: Partial<ScrollPosition>,
options: ScrollOptions = { behavior: 'smooth' }
) => {
const el = ref.current;
if (!el) return;

const scrollOptions: ScrollToOptions = { ...options };

if (typeof x === 'number') {
scrollOptions.left = x;
}

if (typeof y === 'number') {
scrollOptions.top = y;
}

el.scrollTo(scrollOptions);
},
[ref]
);

useEffect(() => {
const el = ref.current;
if (!el) return;

const abortController = new AbortController();

const handler = () =>
setScrollPosition({
x: el.scrollLeft,
y: el.scrollTop
});

el.addEventListener('scroll', handler, {
passive: true,
signal: abortController.signal
});

return () => {
abortController.abort();
};
}, [ref]);

return [scrollPosition, scrollTo] as const;
}

0 comments on commit a778506

Please sign in to comment.