Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: new hook useIntersectionObserver #170

Merged
merged 4 commits into from
Jul 3, 2021
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
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 a rectangle that turns green when 50% or more of it is
visible.
</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: '39vh 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 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
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