Skip to content

Commit

Permalink
feat: useIntersection (#652)
Browse files Browse the repository at this point in the history
React sensor hook that tracks the changes in the intersection of a target element with an ancestor element or with a top-level document's viewport, using the Intersection Observer API
  • Loading branch information
Kevin Norris authored and wardoost committed Oct 12, 2019
1 parent 87d4613 commit d5f359f
Show file tree
Hide file tree
Showing 8 changed files with 334 additions and 4 deletions.
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
- [`useGeolocation`](./docs/useGeolocation.md) — tracks geo location state of user's device. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usegeolocation--demo)
- [`useHover` and `useHoverDirty`](./docs/useHover.md) — tracks mouse hover state of some element. [![][img-demo]](https://codesandbox.io/s/zpn583rvx)
- [`useIdle`](./docs/useIdle.md) — tracks whether user is being inactive.
- [`useIntersection`](./docs/useIntersection.md) — tracks an HTML element's intersection. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-useintersection--demo)
- [`useKey`](./docs/useKey.md), [`useKeyPress`](./docs/useKeyPress.md), [`useKeyboardJs`](./docs/useKeyboardJs.md), and [`useKeyPressEvent`](./docs/useKeyPressEvent.md) — track keys. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usekeypressevent--demo)
- [`useLocation`](./docs/useLocation.md) and [`useSearchParam`](./docs/useSearchParam.md) — tracks page navigation bar location state.
- [`useMedia`](./docs/useMedia.md) — tracks state of a CSS media query. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/sensors-usemedia--demo)
Expand Down Expand Up @@ -104,7 +105,7 @@
- [`useTitle`](./docs/useTitle.md) — sets title of the page.
- [`usePermission`](./docs/usePermission.md) — query permission status for browser APIs.
<br/>
<br/>
<br/>
- [**Lifecycles**](./docs/Lifecycles.md)
- [`useEffectOnce`](./docs/useEffectOnce.md) &mdash; a modified [`useEffect`](https://reactjs.org/docs/hooks-reference.html#useeffect) hook that only runs once.
- [`useEvent`](./docs/useEvent.md) &mdash; subscribe to events.
Expand Down Expand Up @@ -135,7 +136,6 @@
- [`useMap`](./docs/useMap.md) &mdash; tracks state of an object. [![][img-demo]](https://codesandbox.io/s/quirky-dewdney-gi161)
- [`useStateValidator`](./docs/useStateValidator.md) &mdash; tracks state of an object. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/state-usestatevalidator--demo)


<br />
<br />
<br />
Expand All @@ -160,7 +160,6 @@

[img-demo]: https://img.shields.io/badge/demo-%20%20%20%F0%9F%9A%80-green.svg


<div align="center">
<h1>Contributors</h1>
</div>
Expand Down
36 changes: 36 additions & 0 deletions docs/useIntersection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# `useIntersection`

React sensor hook that tracks the changes in the intersection of a target element with an ancestor element or with a top-level document's viewport. Uses the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and returns a [IntersectionObserverEntry](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry).

## Usage

```jsx
import * as React from 'react';
import { useIntersection } from 'react-use';

const Demo = () => {
const intersectionRef = React.useRef(null);
const intersection = useIntersection(intersectionRef, {
root: null,
rootMargin: '0px',
threshold: 1
});

return (
<div ref={intersectionRef}>
{intersection && intersection.intersectionRatio < 1
? 'Obscured'
: 'Fully in view'}
</div>
);
};
```

## Reference

```ts
useIntersection(
ref: RefObject<HTMLElement>,
options: IntersectionObserverInit,
): IntersectionObserverEntry | null;
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@semantic-release/changelog": "3.0.4",
"@semantic-release/git": "7.0.16",
"@semantic-release/npm": "5.1.13",
"@shopify/jest-dom-mocks": "^2.8.2",
"@storybook/addon-actions": "5.1.11",
"@storybook/addon-knobs": "5.1.11",
"@storybook/addon-notes": "5.1.11",
Expand Down
53 changes: 53 additions & 0 deletions src/__stories__/useIntersection.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import { useIntersection } from '..';
import ShowDocs from './util/ShowDocs';

const Spacer = () => (
<div
style={{
width: '200px',
height: '300px',
backgroundColor: 'whitesmoke',
}}
/>
);

const Demo = () => {
const intersectionRef = React.useRef(null);
const intersection = useIntersection(intersectionRef, {
root: null,
rootMargin: '0px',
threshold: 1,
});

return (
<div
style={{
width: '400px',
height: '400px',
backgroundColor: 'whitesmoke',
overflow: 'scroll',
}}
>
Scroll me
<Spacer />
<div
ref={intersectionRef}
style={{
width: '100px',
height: '100px',
padding: '20px',
backgroundColor: 'palegreen',
}}
>
{intersection && intersection.intersectionRatio < 1 ? 'Obscured' : 'Fully in view'}
</div>
<Spacer />
</div>
);
};

storiesOf('Sensors/useIntersection', module)
.add('Docs', () => <ShowDocs md={require('../../docs/useIntersection.md')} />)
.add('Demo', () => <Demo />);
119 changes: 119 additions & 0 deletions src/__tests__/useIntersection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React, { createRef } from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react-dom/test-utils';
import TestRenderer from 'react-test-renderer';
import { intersectionObserver } from '@shopify/jest-dom-mocks';
import { renderHook } from '@testing-library/react-hooks';
import { useIntersection } from '..';

beforeEach(() => {
intersectionObserver.mock();
});

afterEach(() => {
intersectionObserver.restore();
});

describe('useIntersection', () => {
const container = document.createElement('div');
let targetRef;

it('should be defined', () => {
expect(useIntersection).toBeDefined();
});

it('should setup an IntersectionObserver targeting the ref element and using the options provided', () => {
TestUtils.act(() => {
targetRef = createRef();
ReactDOM.render(<div ref={targetRef} />, container);
});

expect(intersectionObserver.observers).toHaveLength(0);
const observerOptions = { root: null, threshold: 0.8 };

renderHook(() => useIntersection(targetRef, observerOptions));

expect(intersectionObserver.observers).toHaveLength(1);
expect(intersectionObserver.observers[0].target).toEqual(targetRef.current);
expect(intersectionObserver.observers[0].options).toEqual(observerOptions);
});

it('should return null if a ref without a current value is provided', () => {
targetRef = createRef();

const { result } = renderHook(() => useIntersection(targetRef, { root: null, threshold: 1 }));
expect(result.current).toBe(null);
});

it('should return the first IntersectionObserverEntry when the IntersectionObserver registers an intersection', () => {
TestUtils.act(() => {
targetRef = createRef();
ReactDOM.render(<div ref={targetRef} />, container);
});

const { result } = renderHook(() => useIntersection(targetRef, { root: container, threshold: 0.8 }));

const mockIntersectionObserverEntry = {
boundingClientRect: targetRef.current.getBoundingClientRect(),
intersectionRatio: 0.81,
intersectionRect: container.getBoundingClientRect(),
isIntersecting: true,
rootBounds: container.getBoundingClientRect(),
target: targetRef.current,
time: 300,
};
TestRenderer.act(() => {
intersectionObserver.simulate(mockIntersectionObserverEntry);
});

expect(result.current).toEqual(mockIntersectionObserverEntry);
});

it('should setup a new IntersectionObserver when the ref changes', () => {
let newRef;
TestUtils.act(() => {
targetRef = createRef();
newRef = createRef();
ReactDOM.render(
<div ref={targetRef}>
<span ref={newRef} />
</div>,
container
);
});

const observerOptions = { root: null, threshold: 0.8 };
const { rerender } = renderHook(({ ref, options }) => useIntersection(ref, options), {
initialProps: { ref: targetRef, options: observerOptions },
});

expect(intersectionObserver.observers[0].target).toEqual(targetRef.current);

TestRenderer.act(() => {
rerender({ ref: newRef, options: observerOptions });
});

expect(intersectionObserver.observers[0].target).toEqual(newRef.current);
});

it('should setup a new IntersectionObserver when the options change', () => {
TestUtils.act(() => {
targetRef = createRef();
ReactDOM.render(<div ref={targetRef} />, container);
});

const initialObserverOptions = { root: null, threshold: 0.8 };
const { rerender } = renderHook(({ ref, options }) => useIntersection(ref, options), {
initialProps: { ref: targetRef, options: initialObserverOptions },
});

expect(intersectionObserver.observers[0].options).toEqual(initialObserverOptions);

const newObserverOptions = { root: container, threshold: 1 };
TestRenderer.act(() => {
rerender({ ref: targetRef, options: newObserverOptions });
});

expect(intersectionObserver.observers[0].options).toEqual(newObserverOptions);
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export { default as useHarmonicIntervalFn } from './useHarmonicIntervalFn';
export { default as useHover } from './useHover';
export { default as useHoverDirty } from './useHoverDirty';
export { default as useIdle } from './useIdle';
export { default as useIntersection } from './useIntersection';
export { default as useInterval } from './useInterval';
export { default as useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
export { default as useKey } from './useKey';
Expand Down
30 changes: 30 additions & 0 deletions src/useIntersection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { RefObject, useEffect, useState } from 'react';

const useIntersection = (
ref: RefObject<HTMLElement>,
options: IntersectionObserverInit
): IntersectionObserverEntry | null => {
const [intersectionObserverEntry, setIntersectionObserverEntry] = useState<IntersectionObserverEntry | null>(null);

useEffect(() => {
if (ref.current) {
const handler = (entries: IntersectionObserverEntry[]) => {
setIntersectionObserverEntry(entries[0]);
};

const observer = new IntersectionObserver(handler, options);
observer.observe(ref.current);

return () => {
if (ref.current) {
observer.disconnect();
}
};
}
return () => {};
}, [ref, options.threshold, options.root, options.rootMargin]);

return intersectionObserverEntry;
};

export default useIntersection;
Loading

0 comments on commit d5f359f

Please sign in to comment.