Skip to content
Merged
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
65 changes: 65 additions & 0 deletions src/lib/hooks/use-intersection-observer.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
62 changes: 62 additions & 0 deletions src/lib/hooks/use-intersection-observer.ts
Original file line number Diff line number Diff line change
@@ -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<Element | null>,
{
threshold = 0,
root = null,
rootMargin = "0px",
freezeOnceVisible = false,
}: UseIntersectionObserverOptions = {}
): UseIntersectionObserverResult {
const [entry, setEntry] = useState<IntersectionObserverEntry>();

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,
};
}
57 changes: 57 additions & 0 deletions src/lib/hooks/use-media-query.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
40 changes: 40 additions & 0 deletions src/lib/hooks/use-media-query.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(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;
}