diff --git a/.changeset/lemon-balloons-wonder.md b/.changeset/lemon-balloons-wonder.md new file mode 100644 index 0000000..efc2665 --- /dev/null +++ b/.changeset/lemon-balloons-wonder.md @@ -0,0 +1,5 @@ +--- +"@codiume/hooks": patch +--- + +Add use-scroll hook diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fe8227c --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/package.json b/package.json index c4879ba..a7e75aa 100644 --- a/package.json +++ b/package.json @@ -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 ", "contributors": [ { @@ -21,9 +15,7 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", - "files": [ - "dist" - ], + "files": ["dist"], "exports": { ".": { "import": { diff --git a/src/index.ts b/src/index.ts index a13eff9..820c2e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export { useQueue } from './use-queue/use-queue'; +export { useScroll } from './use-scroll/use-scroll'; diff --git a/src/use-scroll/use-scroll.test.ts b/src/use-scroll/use-scroll.test.ts new file mode 100644 index 0000000..b07eb13 --- /dev/null +++ b/src/use-scroll/use-scroll.test.ts @@ -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 { + scrollLeft: number; + scrollTop: number; + addEventListener: Mock; + removeEventListener: Mock; + scrollTo: Mock; +} + +describe('useScroll', () => { + let ref: RefObject; + 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' + }); + }); +}); diff --git a/src/use-scroll/use-scroll.ts b/src/use-scroll/use-scroll.ts new file mode 100644 index 0000000..e3fe8d3 --- /dev/null +++ b/src/use-scroll/use-scroll.ts @@ -0,0 +1,60 @@ +import { type RefObject, useCallback, useEffect, useState } from 'react'; + +type ScrollPosition = { + x: number; + y: number; +}; + +export function useScroll(ref: RefObject) { + const [scrollPosition, setScrollPosition] = useState({ + x: 0, + y: 0 + }); + + const scrollTo = useCallback( + ( + { x, y }: Partial, + 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; +}