From d8e07589a30cb8d12806ccca59aaee7cebbefd72 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Thu, 1 Jul 2021 00:05:42 +0300 Subject: [PATCH 1/4] feat: new hook `useIntersectionObserver` --- README.md | 2 + src/index.ts | 4 + .../__docs__/example.stories.tsx | 50 ++++++ .../__docs__/story.mdx | 49 +++++ src/useIntersectionObserver/__tests__/dom.ts | 136 ++++++++++++++ src/useIntersectionObserver/__tests__/ssr.ts | 13 ++ .../useIntersectionObserver.ts | 170 ++++++++++++++++++ 7 files changed, 424 insertions(+) create mode 100644 src/useIntersectionObserver/__docs__/example.stories.tsx create mode 100644 src/useIntersectionObserver/__docs__/story.mdx create mode 100644 src/useIntersectionObserver/__tests__/dom.ts create mode 100644 src/useIntersectionObserver/__tests__/ssr.ts create mode 100644 src/useIntersectionObserver/useIntersectionObserver.ts diff --git a/README.md b/README.md index a11efa2a7..e2cc6ca60 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 700f5ba29..c6f4a9816 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 000000000..3b86561cb --- /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 000000000..421ed12c8 --- /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 000000000..f030e5893 --- /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 000000000..52bb16b8f --- /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 000000000..dbb1d731c --- /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; +} From f24671983eb2d6021e95d4feb35efca313756814 Mon Sep 17 00:00:00 2001 From: Joe Duncko Date: Sat, 3 Jul 2021 13:24:06 -0400 Subject: [PATCH 2/4] Make example more obvious --- src/useIntersectionObserver/__docs__/example.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/useIntersectionObserver/__docs__/example.stories.tsx b/src/useIntersectionObserver/__docs__/example.stories.tsx index 3b86561cb..0e6dccde5 100644 --- a/src/useIntersectionObserver/__docs__/example.stories.tsx +++ b/src/useIntersectionObserver/__docs__/example.stories.tsx @@ -27,7 +27,7 @@ export const Example: React.FC = () => { background: (intersection?.intersectionRatio ?? 0) >= 0.5 ? 'green' : 'red', width: '10vw', height: '10vw', - margin: '50vh auto', + margin: '39vh auto', }} />
From 8e38cf3a326dafc50927a2fe755a8a670afb282c Mon Sep 17 00:00:00 2001 From: Joe Duncko Date: Sat, 3 Jul 2021 13:28:05 -0400 Subject: [PATCH 3/4] Clean up example text --- src/useIntersectionObserver/__docs__/example.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/useIntersectionObserver/__docs__/example.stories.tsx b/src/useIntersectionObserver/__docs__/example.stories.tsx index 0e6dccde5..f8064060b 100644 --- a/src/useIntersectionObserver/__docs__/example.stories.tsx +++ b/src/useIntersectionObserver/__docs__/example.stories.tsx @@ -10,8 +10,8 @@ export const Example: React.FC = () => { return (
- Below scrollable container holds the rectangle that turns green when ot visible by 50% or - more. + Below scrollable container holds a rectangle that turns green when 50% or more of it is + visible.
Date: Sat, 3 Jul 2021 13:34:11 -0400 Subject: [PATCH 4/4] Typo cleanup --- src/useIntersectionObserver/__docs__/story.mdx | 2 +- src/useIntersectionObserver/useIntersectionObserver.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/useIntersectionObserver/__docs__/story.mdx b/src/useIntersectionObserver/__docs__/story.mdx index 421ed12c8..697ee69c1 100644 --- a/src/useIntersectionObserver/__docs__/story.mdx +++ b/src/useIntersectionObserver/__docs__/story.mdx @@ -32,7 +32,7 @@ export function useIntersectionObserver( - **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 + _(default: `document`)_ - An `Element` or `Document` object (or its 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 diff --git a/src/useIntersectionObserver/useIntersectionObserver.ts b/src/useIntersectionObserver/useIntersectionObserver.ts index dbb1d731c..706b1a20e 100644 --- a/src/useIntersectionObserver/useIntersectionObserver.ts +++ b/src/useIntersectionObserver/useIntersectionObserver.ts @@ -96,7 +96,7 @@ const getObserverEntry = (options: IntersectionObserverInit): IObserverEntry => export interface IUseIntersectionObserverOptions { /** - * An Element or Document object (or it's react reference) which is an + * An Element or Document object (or its 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.