Skip to content

Commit

Permalink
feat: new hook useIntersectionObserver
Browse files Browse the repository at this point in the history
  • Loading branch information
xobotyi committed Jul 1, 2021
1 parent a0df12c commit d8e0758
Show file tree
Hide file tree
Showing 7 changed files with 424 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export {
} from './useAsync/useAsync';

// Sensor
export {
useIntersectionObserver,
IUseIntersectionObserverOptions,
} from './useIntersectionObserver/useIntersectionObserver';
export {
useResizeObserver,
IUseResizeObserverCallback,
Expand Down
50 changes: 50 additions & 0 deletions src/useIntersectionObserver/__docs__/example.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import * as React from 'react';
import { useRef } from 'react';
import { useIntersectionObserver } from '../..';

export const Example: React.FC = () => {
const rootRef = useRef<HTMLDivElement>(null);
const elementRef = useRef<HTMLDivElement>(null);
const intersection = useIntersectionObserver(elementRef, { root: rootRef, threshold: [0, 0.5] });

return (
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
<div style={{ width: '100%', marginBottom: '16px' }}>
Below scrollable container holds the rectangle that turns green when ot visible by 50% or
more.
</div>

<div
ref={rootRef}
style={{
width: '40%',
height: '40vh',
overflow: 'auto',
}}>
<div
ref={elementRef}
style={{
background: (intersection?.intersectionRatio ?? 0) >= 0.5 ? 'green' : 'red',
width: '10vw',
height: '10vw',
margin: '50vh auto',
}}
/>
</div>
<pre style={{ flexGrow: 1, marginLeft: '16px' }}>
{JSON.stringify(
{
boundingClientRect: intersection?.boundingClientRect,
intersectionRatio: intersection?.intersectionRatio,
intersectionRect: intersection?.intersectionRect,
isIntersecting: intersection?.isIntersecting,
rootBounds: intersection?.rootBounds,
time: intersection?.time,
},
null,
2
)}
</pre>
</div>
);
};
49 changes: 49 additions & 0 deletions src/useIntersectionObserver/__docs__/story.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks';
import { Example } from './example.stories';

<Meta title="Sensor/useIntersectionObserver" component={Example} />

# 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

<Canvas>
<Story story={Example} inline />
</Canvas>

## Reference

```ts
export function useIntersectionObserver<T extends Element>(
target: RefObject<T> | T | null,
{ threshold = [0], root, rootMargin = '0px' }: IUseIntersectionObserverOptions = {}
): IntersectionObserverEntry | undefined;
```

#### Arguments

- **target** _`RefObject<T> | T | null`_ - React reference or Element to track.
- **options** - Like `IntersectionObserver` options but `root` can also be react reference.
- **threshold** _`RefObject<Element | Document> | 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`
136 changes: 136 additions & 0 deletions src/useIntersectionObserver/__tests__/dom.ts
Original file line number Diff line number Diff line change
@@ -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<IntersectionObserver>;
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();
});
});
13 changes: 13 additions & 0 deletions src/useIntersectionObserver/__tests__/ssr.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading

0 comments on commit d8e0758

Please sign in to comment.