diff --git a/README.md b/README.md index a11efa2a..e2cc6ca6 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ import { useMountEffect } from "@react-hookz/web/esnext"; - #### Sensor + - [**`useIntersectionObserver`**](https://react-hookz.github.io/web/?path=/docs/sensor-useintersectionobserver) + — Observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport. - [**`useMeasure`**](https://react-hookz.github.io/web/?path=/docs/sensor-usemeasure) — Uses ResizeObserver to track element dimensions and re-render component when they change. - [**`useMediaQuery`**](https://react-hookz.github.io/web/?path=/docs/sensor-usemediaquery) diff --git a/src/index.ts b/src/index.ts index 700f5ba2..c6f4a981 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,6 +69,10 @@ export { } from './useAsync/useAsync'; // Sensor +export { + useIntersectionObserver, + IUseIntersectionObserverOptions, +} from './useIntersectionObserver/useIntersectionObserver'; export { useResizeObserver, IUseResizeObserverCallback, diff --git a/src/useIntersectionObserver/__docs__/example.stories.tsx b/src/useIntersectionObserver/__docs__/example.stories.tsx new file mode 100644 index 00000000..3b86561c --- /dev/null +++ b/src/useIntersectionObserver/__docs__/example.stories.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { useRef } from 'react'; +import { useIntersectionObserver } from '../..'; + +export const Example: React.FC = () => { + const rootRef = useRef(null); + const elementRef = useRef(null); + const intersection = useIntersectionObserver(elementRef, { root: rootRef, threshold: [0, 0.5] }); + + return ( +
+
+ Below scrollable container holds the rectangle that turns green when ot visible by 50% or + more. +
+ +
+
= 0.5 ? 'green' : 'red', + width: '10vw', + height: '10vw', + margin: '50vh auto', + }} + /> +
+
+        {JSON.stringify(
+          {
+            boundingClientRect: intersection?.boundingClientRect,
+            intersectionRatio: intersection?.intersectionRatio,
+            intersectionRect: intersection?.intersectionRect,
+            isIntersecting: intersection?.isIntersecting,
+            rootBounds: intersection?.rootBounds,
+            time: intersection?.time,
+          },
+          null,
+          2
+        )}
+      
+
+ ); +}; diff --git a/src/useIntersectionObserver/__docs__/story.mdx b/src/useIntersectionObserver/__docs__/story.mdx new file mode 100644 index 00000000..421ed12c --- /dev/null +++ b/src/useIntersectionObserver/__docs__/story.mdx @@ -0,0 +1,49 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { Example } from './example.stories'; + + + +# useIntersectionObserver + +Tracks intersection of a target element with an ancestor element or with a top-level document's viewport. + +- SSR-friendly. +- Effective - uses single `IntersectionObserver` for hooks with same options. +- Allows using React reference as root. +- Does not produce references for you. + +#### Example + + + + + +## Reference + +```ts +export function useIntersectionObserver( + target: RefObject | T | null, + { threshold = [0], root, rootMargin = '0px' }: IUseIntersectionObserverOptions = {} +): IntersectionObserverEntry | undefined; +``` + +#### Arguments + +- **target** _`RefObject | T | null`_ - React reference or Element to track. +- **options** - Like `IntersectionObserver` options but `root` can also be react reference. + - **threshold** _`RefObject | Element | Document | null`_ + _(default: `document`)_ - An `Element` or `Document` object (or it's react reference) which is + an ancestor of the intended target, whose bounding rectangle will be considered the viewport. + Any part of the target not visible in the visible area of the root is not considered visible. + - **rootMargin** _`string`_ _(default: `0px`)_ - A string which specifies a set of offsets to add + to the root's bounding_box when calculating intersections, effectively shrinking or growing the + root for calculation purposes. The syntax is approximately the same as that for the CSS margin + property. + - **threshold** _`number[]`_ _(default: `[0]`)_ - Array of numbers between 0.0 and 1.0, specifying + a ratio of intersection area to total bounding box area for the observed target. A value of 0.0 + means that even a single visible pixel counts as the target being visible. 1.0 means that the + entire target element is visible. + +#### Return + +`IntersectionObserverEntry` as it is returned from `IntersectionObserver` diff --git a/src/useIntersectionObserver/__tests__/dom.ts b/src/useIntersectionObserver/__tests__/dom.ts new file mode 100644 index 00000000..f030e589 --- /dev/null +++ b/src/useIntersectionObserver/__tests__/dom.ts @@ -0,0 +1,136 @@ +import { act, renderHook } from '@testing-library/react-hooks/dom'; +import { useIntersectionObserver } from '../..'; +import Mock = jest.Mock; + +describe('useIntersectionObserver', () => { + let IntersectionObserverSpy: Mock; + const initialRO = global.ResizeObserver; + + beforeAll(() => { + IntersectionObserverSpy = jest.fn(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + takeRecords: () => [], + root: document, + rootMargin: '0px', + thresholds: [0], + })); + + global.IntersectionObserver = IntersectionObserverSpy; + jest.useFakeTimers(); + }); + + beforeEach(() => { + IntersectionObserverSpy.mockClear(); + }); + + afterAll(() => { + global.ResizeObserver = initialRO; + jest.useRealTimers(); + }); + + it('should be defined', () => { + expect(useIntersectionObserver).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useIntersectionObserver(null)); + expect(result.error).toBeUndefined(); + }); + + it('should return undefined on first render', () => { + const div1 = document.createElement('div'); + const { result } = renderHook(() => useIntersectionObserver(div1)); + expect(result.current).toBeUndefined(); + }); + + it('should create IntersectionObserver instance only for unique set of options', () => { + expect(IntersectionObserverSpy).toHaveBeenCalledTimes(0); + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + + renderHook(() => useIntersectionObserver(div1)); + renderHook(() => useIntersectionObserver(div2)); + + expect(IntersectionObserverSpy).toHaveBeenCalledTimes(1); + }); + + it('should return intersection entry', () => { + const div1 = document.createElement('div'); + const div1Ref = { current: div1 }; + const div2 = document.createElement('div'); + + const { result: res1 } = renderHook(() => useIntersectionObserver(div1Ref)); + const { result: res2, unmount } = renderHook(() => + useIntersectionObserver(div2, { threshold: [0, 1] }) + ); + + expect(res1.current).toBeUndefined(); + expect(res2.current).toBeUndefined(); + + const entry1 = { target: div1 }; + const entry2 = { target: div2 }; + + act(() => { + IntersectionObserverSpy.mock.calls[0][0]([entry1]); + IntersectionObserverSpy.mock.calls[1][0]([entry2]); + jest.advanceTimersByTime(1); + }); + + expect(res1.current).toBe(entry1); + expect(res2.current).toBe(entry2); + + unmount(); + + const entry3 = { target: div1 }; + act(() => { + IntersectionObserverSpy.mock.calls[0][0]([entry3]); + jest.advanceTimersByTime(1); + }); + + expect(res1.current).toBe(entry3); + }); + + it('two hooks observing same target should use single observer', () => { + const div1 = document.createElement('div'); + const div2 = document.createElement('div'); + + const { result: res1 } = renderHook(() => + useIntersectionObserver(div1, { root: { current: div2 } }) + ); + const { result: res2, unmount } = renderHook(() => + useIntersectionObserver(div1, { root: { current: div2 } }) + ); + + expect(res1.current).toBeUndefined(); + expect(res2.current).toBeUndefined(); + + const entry1 = { target: div1 }; + + act(() => { + IntersectionObserverSpy.mock.calls[0][0]([entry1]); + jest.advanceTimersByTime(1); + }); + + expect(res1.current).toBe(entry1); + expect(res2.current).toBe(entry1); + }); + + it('should disconnect observer if last hook unmounted', () => { + const div1 = document.createElement('div'); + + const { result, unmount } = renderHook(() => useIntersectionObserver(div1)); + const entry1 = { target: div1 }; + + act(() => { + IntersectionObserverSpy.mock.calls[0][0]([entry1]); + jest.advanceTimersByTime(1); + }); + + expect(result.current).toBe(entry1); + + unmount(); + expect(IntersectionObserverSpy.mock.results[0].value.disconnect).toHaveBeenCalled(); + }); +}); diff --git a/src/useIntersectionObserver/__tests__/ssr.ts b/src/useIntersectionObserver/__tests__/ssr.ts new file mode 100644 index 00000000..52bb16b8 --- /dev/null +++ b/src/useIntersectionObserver/__tests__/ssr.ts @@ -0,0 +1,13 @@ +import { renderHook } from '@testing-library/react-hooks/server'; +import { useIntersectionObserver } from '../..'; + +describe('useIntersectionObserver', () => { + it('should be defined', () => { + expect(useIntersectionObserver).toBeDefined(); + }); + + it('should render', () => { + const { result } = renderHook(() => useIntersectionObserver(null)); + expect(result.error).toBeUndefined(); + }); +}); diff --git a/src/useIntersectionObserver/useIntersectionObserver.ts b/src/useIntersectionObserver/useIntersectionObserver.ts new file mode 100644 index 00000000..dbb1d731 --- /dev/null +++ b/src/useIntersectionObserver/useIntersectionObserver.ts @@ -0,0 +1,170 @@ +import { RefObject, useEffect } from 'react'; +import { useSafeState } from '..'; + +const DEFAULT_THRESHOLD = [0]; +const DEFAULT_ROOT_MARGIN = '0px'; + +interface IIntersectionEntryCallback { + (entry: IntersectionObserverEntry): void; +} + +interface IObserverEntry { + observer: IntersectionObserver; + observe: (target: Element, callback: IIntersectionEntryCallback) => void; + unobserve: (target: Element, callback: IIntersectionEntryCallback) => void; +} + +const observers: Map> = new Map(); + +const getObserverEntry = (options: IntersectionObserverInit): IObserverEntry => { + const root = options.root ?? document; + + let rootObservers = observers.get(root); + + if (!rootObservers) { + rootObservers = new Map(); + observers.set(root, rootObservers); + } + + const opt = JSON.stringify([options.rootMargin, options.threshold]); + + let entry = rootObservers.get(opt); + + if (!entry) { + const callbacks = new Map>(); + + const observer = new IntersectionObserver( + (entries) => + entries.forEach((e) => + callbacks.get(e.target)?.forEach((cb) => setTimeout(() => cb(e), 0)) + ), + options + ); + + entry = { + observer, + observe(target, callback) { + let cbs = callbacks.get(target); + + if (!cbs) { + // if target has no observers yet - register it + cbs = new Set(); + callbacks.set(target, cbs); + observer.observe(target); + } + + // as Set is duplicate-safe - simply add callback on each call + cbs.add(callback); + }, + unobserve(target, callback) { + const cbs = callbacks.get(target); + + // else branch should never occur in case of normal execution + // because callbacks map is hidden in closure - it is impossible to + // simulate situation with non-existent `cbs` Set + /* istanbul ignore else */ + if (cbs) { + // remove current observer + cbs.delete(callback); + + if (!cbs.size) { + // if no observers left unregister target completely + callbacks.delete(target); + observer.unobserve(target); + + // if not tracked elements left - disconnect observer + if (!callbacks.size) { + observer.disconnect(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + rootObservers!.delete(opt); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (!rootObservers!.size) { + observers.delete(root); + } + } + } + } + }, + }; + + rootObservers.set(opt, entry); + } + + return entry; +}; + +export interface IUseIntersectionObserverOptions { + /** + * An Element or Document object (or it's react reference) which is an + * ancestor of the intended target, whose bounding rectangle will be + * considered the viewport. Any part of the target not visible in the visible + * area of the root is not considered visible. + */ + root?: RefObject | Element | Document | null; + /** + * A string which specifies a set of offsets to add to the root's bounding_box + * when calculating intersections, effectively shrinking or growing the root + * for calculation purposes. The syntax is approximately the same as that for + * the CSS margin property; The default is `0px`. + */ + rootMargin?: string; + /** + * Array of numbers between 0.0 and 1.0, specifying a ratio of intersection + * area to total bounding box area for the observed target. A value of 0.0 + * means that even a single visible pixel counts as the target being visible. + * 1.0 means that the entire target element is visible. + * The default is a threshold of `[0]`. + */ + threshold?: number[]; +} + +/** + * Tracks intersection of a target element with an ancestor element or with a + * top-level document's viewport. + * + * @param target React reference or Element to track. + * @param options Like `IntersectionObserver` options but `root` can also be + * react reference + */ +export function useIntersectionObserver( + target: RefObject | T | null, + { + threshold = DEFAULT_THRESHOLD, + root: r, + rootMargin = DEFAULT_ROOT_MARGIN, + }: IUseIntersectionObserverOptions = {} +): IntersectionObserverEntry | undefined { + const [state, setState] = useSafeState(); + + useEffect(() => { + const tgt = target && 'current' in target ? target.current : target; + if (!tgt) return undefined; + + let subscribed = true; + const observerEntry = getObserverEntry({ + root: r && 'current' in r ? r.current : r, + rootMargin, + threshold, + }); + + const handler: IIntersectionEntryCallback = (entry) => { + // it is reinsurance for the highly asynchronous invocations, almost + // impossible to achieve in tests, thus excluding from LOC + /* istanbul ignore else */ + if (subscribed) { + setState(entry); + } + }; + + observerEntry.observe(tgt, handler); + + return () => { + subscribed = false; + observerEntry.unobserve(tgt, handler); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [target, r, rootMargin, ...threshold]); + + return state; +}