diff --git a/src/lib/hooks/use-intersection-observer.test.tsx b/src/lib/hooks/use-intersection-observer.test.tsx new file mode 100644 index 0000000..0e34cb6 --- /dev/null +++ b/src/lib/hooks/use-intersection-observer.test.tsx @@ -0,0 +1,65 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useIntersectionObserver } from "./use-intersection-observer"; + +describe("useIntersectionObserver", () => { + let observerCallback: IntersectionObserverCallback; + const mockObserve = vi.fn(); + const mockDisconnect = vi.fn(); + + beforeEach(() => { + mockObserve.mockClear(); + mockDisconnect.mockClear(); + + vi.stubGlobal( + "IntersectionObserver", + vi.fn().mockImplementation((cb: IntersectionObserverCallback) => { + observerCallback = cb; + return { + observe: mockObserve, + unobserve: vi.fn(), + disconnect: mockDisconnect, + }; + }) + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("observes the target element and returns default isIntersecting false", () => { + const targetEl = document.createElement("div"); + const ref = { current: targetEl }; + + const { result } = renderHook(() => useIntersectionObserver(ref)); + expect(mockObserve).toHaveBeenCalledWith(targetEl); + expect(result.current.isIntersecting).toBe(false); + }); + + it("updates isIntersecting state when observer fires", () => { + const targetEl = document.createElement("div"); + const ref = { current: targetEl }; + + const { result } = renderHook(() => useIntersectionObserver(ref)); + + act(() => { + observerCallback( + [{ isIntersecting: true, target: targetEl } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + }); + + expect(result.current.isIntersecting).toBe(true); + }); + + it("disconnects observer on unmount", () => { + const targetEl = document.createElement("div"); + const ref = { current: targetEl }; + + const { unmount } = renderHook(() => useIntersectionObserver(ref)); + unmount(); + + expect(mockDisconnect).toHaveBeenCalled(); + }); +}); diff --git a/src/lib/hooks/use-intersection-observer.ts b/src/lib/hooks/use-intersection-observer.ts new file mode 100644 index 0000000..5b4995e --- /dev/null +++ b/src/lib/hooks/use-intersection-observer.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useState, RefObject } from "react"; + +export interface UseIntersectionObserverOptions { + threshold?: number | number[]; + root?: Element | Document | null; + rootMargin?: string; + freezeOnceVisible?: boolean; +} + +export interface UseIntersectionObserverResult { + isIntersecting: boolean; + entry?: IntersectionObserverEntry; +} + +/** + * Custom hook for observing element intersection using IntersectionObserver API. + * + * @param elementRef - React RefObject targeting an HTML element + * @param options - Observer configuration (threshold, root, rootMargin, freezeOnceVisible) + * @returns Object with boolean `isIntersecting` status and optional `entry` + */ +export function useIntersectionObserver( + elementRef: RefObject, + { + threshold = 0, + root = null, + rootMargin = "0px", + freezeOnceVisible = false, + }: UseIntersectionObserverOptions = {} +): UseIntersectionObserverResult { + const [entry, setEntry] = useState(); + + const isIntersecting = !!entry?.isIntersecting; + const frozen = isIntersecting && freezeOnceVisible; + + useEffect(() => { + const node = elementRef?.current; + if (!node || frozen || typeof window === "undefined" || !("IntersectionObserver" in window)) { + return; + } + + const observer = new IntersectionObserver( + ([newEntry]) => { + setEntry(newEntry); + }, + { threshold, root, rootMargin } + ); + + observer.observe(node); + + return () => { + observer.disconnect(); + }; + }, [elementRef, JSON.stringify(threshold), root, rootMargin, frozen]); + + return { + isIntersecting, + entry, + }; +} diff --git a/src/lib/hooks/use-media-query.test.tsx b/src/lib/hooks/use-media-query.test.tsx new file mode 100644 index 0000000..049f55d --- /dev/null +++ b/src/lib/hooks/use-media-query.test.tsx @@ -0,0 +1,57 @@ +import { renderHook, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { useMediaQuery } from "./use-media-query"; + +describe("useMediaQuery", () => { + let listeners: ((e: MediaQueryListEvent) => void)[] = []; + + beforeEach(() => { + listeners = []; + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("max-width: 768px"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn((type, cb) => { + if (type === "change") listeners.push(cb); + }), + removeEventListener: vi.fn((type, cb) => { + if (type === "change") { + listeners = listeners.filter((l) => l !== cb); + } + }), + dispatchEvent: vi.fn(), + })) + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns initial matching status", () => { + const { result } = renderHook(() => useMediaQuery("(max-width: 768px)")); + expect(result.current).toBe(true); + }); + + it("returns false for non-matching query", () => { + const { result } = renderHook(() => useMediaQuery("(min-width: 1200px)")); + expect(result.current).toBe(false); + }); + + it("updates state when media query listener fires", () => { + const { result } = renderHook(() => useMediaQuery("(max-width: 768px)")); + expect(result.current).toBe(true); + + act(() => { + listeners.forEach((listener) => + listener({ matches: false, media: "(max-width: 768px)" } as MediaQueryListEvent) + ); + }); + + expect(result.current).toBe(false); + }); +}); diff --git a/src/lib/hooks/use-media-query.ts b/src/lib/hooks/use-media-query.ts new file mode 100644 index 0000000..a56f2fc --- /dev/null +++ b/src/lib/hooks/use-media-query.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useEffect, useState } from "react"; + +/** + * Custom hook for matching CSS media queries. + * + * @param query - CSS media query string (e.g. "(max-width: 768px)") + * @returns boolean indicating whether the media query matches + */ +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") return; + + const media = window.matchMedia(query); + setMatches(media.matches); + + const listener = (event: MediaQueryListEvent) => { + setMatches(event.matches); + }; + + if (media.addEventListener) { + media.addEventListener("change", listener); + } else { + media.addListener(listener); + } + + return () => { + if (media.removeEventListener) { + media.removeEventListener("change", listener); + } else { + media.removeListener(listener); + } + }; + }, [query]); + + return matches; +}