diff --git a/README.md b/README.md index f54afa21d6..c93d6d123e 100644 --- a/README.md +++ b/README.md @@ -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) @@ -104,7 +105,7 @@ - [`useTitle`](./docs/useTitle.md) — sets title of the page. - [`usePermission`](./docs/usePermission.md) — query permission status for browser APIs.
-
+
- [**Lifecycles**](./docs/Lifecycles.md) - [`useEffectOnce`](./docs/useEffectOnce.md) — a modified [`useEffect`](https://reactjs.org/docs/hooks-reference.html#useeffect) hook that only runs once. - [`useEvent`](./docs/useEvent.md) — subscribe to events. @@ -134,7 +135,6 @@ - [`useList`](./docs/useList.md) — tracks state of an array. - [`useMap`](./docs/useMap.md) — tracks state of an object. -


@@ -159,7 +159,6 @@ [img-demo]: https://img.shields.io/badge/demo-%20%20%20%F0%9F%9A%80-green.svg -

Contributors

diff --git a/docs/useIntersection.md b/docs/useIntersection.md new file mode 100644 index 0000000000..802ee1da6a --- /dev/null +++ b/docs/useIntersection.md @@ -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 ( +
+ {intersection && intersection.intersectionRatio < 1 + ? 'Obscured' + : 'Fully in view'} +
+ ); +}; +``` + +## Reference + +```ts +useIntersection( + ref: RefObject, + options: IntersectionObserverInit, +): IntersectionObserverEntry | null; +``` diff --git a/package.json b/package.json index 325195b19a..0c76004df2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__stories__/useIntersection.story.tsx b/src/__stories__/useIntersection.story.tsx new file mode 100644 index 0000000000..fb8c1226dc --- /dev/null +++ b/src/__stories__/useIntersection.story.tsx @@ -0,0 +1,53 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useIntersection } from '..'; +import ShowDocs from './util/ShowDocs'; + +const Spacer = () => ( +
+); + +const Demo = () => { + const intersectionRef = React.useRef(null); + const intersection = useIntersection(intersectionRef, { + root: null, + rootMargin: '0px', + threshold: 1, + }); + + return ( +
+ Scroll me + +
+ {intersection && intersection.intersectionRatio < 1 ? 'Obscured' : 'Fully in view'} +
+ +
+ ); +}; + +storiesOf('Sensors/useIntersection', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/src/__tests__/useIntersection.test.tsx b/src/__tests__/useIntersection.test.tsx new file mode 100644 index 0000000000..ee7342b2a7 --- /dev/null +++ b/src/__tests__/useIntersection.test.tsx @@ -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(
, 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(
, 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( +
+ +
, + 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(
, 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); + }); +}); diff --git a/src/index.ts b/src/index.ts index b3514a7179..b75e0a8d6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/src/useIntersection.ts b/src/useIntersection.ts new file mode 100644 index 0000000000..4a7b78eaca --- /dev/null +++ b/src/useIntersection.ts @@ -0,0 +1,30 @@ +import { RefObject, useEffect, useState } from 'react'; + +const useIntersection = ( + ref: RefObject, + options: IntersectionObserverInit +): IntersectionObserverEntry | null => { + const [intersectionObserverEntry, setIntersectionObserverEntry] = useState(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; diff --git a/yarn.lock b/yarn.lock index 4117ee5620..b92fcde8bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1647,6 +1647,37 @@ into-stream "^4.0.0" lodash "^4.17.4" +"@shopify/async@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@shopify/async/-/async-2.0.7.tgz#944992bc1721df6c363b3f0f31be1dad0e75e929" + integrity sha512-wYGjqPhpna4ShYbUmlD2fPv5ZkjNlCZtU7huUU8/snnyPmdgL/Rn5M5FPP6Apr7/hU5RgqMj2tJFs37ORz/VaQ== + +"@shopify/decorators@^1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@shopify/decorators/-/decorators-1.1.5.tgz#b8da0bd5fffb04cde9730898fc04428f964cab1c" + integrity sha512-cFAwd7T5IjkPs1ef11dbA6cbJA+CtgCDanbalPlQdl5ItwDzqJXGpvbhbQXw7zPyNMLijrgrpQqltalqAy9wnQ== + dependencies: + "@shopify/function-enhancers" "^1.0.5" + +"@shopify/function-enhancers@^1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@shopify/function-enhancers/-/function-enhancers-1.0.5.tgz#7c3e516e26ce7a9b63c263679bdcf5121d994a10" + integrity sha512-34ML8DX4RmmA9hXDlf2BAz4SA37unShZxoBRPz585a+FaEzNcMvw5NzLD+Ih9XrP/wrxTUcN+p6pazvoS+jB7w== + +"@shopify/jest-dom-mocks@^2.8.2": + version "2.8.2" + resolved "https://registry.yarnpkg.com/@shopify/jest-dom-mocks/-/jest-dom-mocks-2.8.2.tgz#477c3159897807cc8d7797c33e8a79e787051779" + integrity sha512-4drt+S1cQ1ZSP1DaEHAj5XPPCiI2R8IIt+ZnH9h08Ngy8PjtjFFNHNcSJ6bKBmk7eO2c6+5UaJQzNcg56nt7gg== + dependencies: + "@shopify/async" "^2.0.7" + "@shopify/decorators" "^1.1.5" + "@types/fetch-mock" "^6.0.1" + "@types/lolex" "^2.1.3" + fetch-mock "^6.3.0" + lolex "^2.7.5" + promise "^8.0.3" + tslib "^1.9.3" + "@storybook/addon-actions@5.1.11": version "5.1.11" resolved "https://registry.yarnpkg.com/@storybook/addon-actions/-/addon-actions-5.1.11.tgz#ebc299b9dfe476b5c65eb5d148c4b064f682ca08" @@ -2154,6 +2185,11 @@ resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" integrity sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g== +"@types/fetch-mock@^6.0.1": + version "6.0.5" + resolved "https://registry.yarnpkg.com/@types/fetch-mock/-/fetch-mock-6.0.5.tgz#acbc6771d43d7ebc1f0a8b7e3d57147618f8eacb" + integrity sha512-rV8O2j/TIi0PtFCOlK55JnfKpE8Hm6PKFgrUZY/3FNHw4uBEMHnM+5ZickDO1duOyKxbpY3VES5T4NIwZXvodA== + "@types/glob@^7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" @@ -2195,6 +2231,11 @@ dependencies: "@types/jest-diff" "*" +"@types/lolex@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@types/lolex/-/lolex-2.1.3.tgz#793557c9b8ad319b4c8e4c6548b90893f4aa5f69" + integrity sha512-nEipOLYyZJ4RKHCg7tlR37ewFy91oggmip2MBzPdVQ8QhTFqjcRhE8R0t4tfpDnSlxGWHoEGJl0UCC4kYhqoiw== + "@types/minimatch@*": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" @@ -2792,7 +2833,7 @@ arrify@^1.0.1: resolved "https://registry.yarnpkg.com/arrify/-/arrify-1.0.1.tgz#898508da2226f380df904728456849c1501a4b0d" integrity sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0= -asap@^2.0.0, asap@~2.0.3: +asap@^2.0.0, asap@~2.0.3, asap@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= @@ -3252,6 +3293,15 @@ babel-plugin-transform-undefined-to-void@^6.9.4: resolved "https://registry.yarnpkg.com/babel-plugin-transform-undefined-to-void/-/babel-plugin-transform-undefined-to-void-6.9.4.tgz#be241ca81404030678b748717322b89d0c8fe280" integrity sha1-viQcqBQEAwZ4t0hxcyK4nQyP4oA= +babel-polyfill@^6.26.0: + version "6.26.0" + resolved "https://registry.yarnpkg.com/babel-polyfill/-/babel-polyfill-6.26.0.tgz#379937abc67d7895970adc621f284cd966cf2153" + integrity sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM= + dependencies: + babel-runtime "^6.26.0" + core-js "^2.5.0" + regenerator-runtime "^0.10.5" + babel-preset-jest@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-24.9.0.tgz#192b521e2217fb1d1f67cf73f70c336650ad3cdc" @@ -5579,6 +5629,15 @@ fbjs@^0.8.0, fbjs@^0.8.1: setimmediate "^1.0.5" ua-parser-js "^0.7.18" +fetch-mock@^6.3.0: + version "6.5.2" + resolved "https://registry.yarnpkg.com/fetch-mock/-/fetch-mock-6.5.2.tgz#b3842b305c13ea0f81c85919cfaa7de387adfa3e" + integrity sha512-EIvbpCLBTYyDLu4HJiqD7wC8psDwTUaPaWXNKZbhNO/peUYKiNp5PkZGKRJtnTxaPQu71ivqafvjpM7aL+MofQ== + dependencies: + babel-polyfill "^6.26.0" + glob-to-regexp "^0.4.0" + path-to-regexp "^2.2.1" + figgy-pudding@^3.4.1, figgy-pudding@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" @@ -6080,6 +6139,11 @@ glob-to-regexp@^0.3.0: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= +glob-to-regexp@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: version "7.1.4" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" @@ -8235,6 +8299,11 @@ log-update@^2.3.0: cli-cursor "^2.0.0" wrap-ansi "^3.0.1" +lolex@^2.7.5: + version "2.7.5" + resolved "https://registry.yarnpkg.com/lolex/-/lolex-2.7.5.tgz#113001d56bfc7e02d56e36291cc5c413d1aa0733" + integrity sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q== + loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -9711,6 +9780,11 @@ path-to-regexp@0.1.7: resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= +path-to-regexp@^2.2.1: + version "2.4.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.4.0.tgz#35ce7f333d5616f1c1e1bfe266c3aba2e5b2e704" + integrity sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w== + path-type@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-3.0.0.tgz#cef31dc8e0a1a3bb0d105c0cd97cf3bf47f4e36f" @@ -10031,6 +10105,13 @@ promise@^7.1.1: dependencies: asap "~2.0.3" +promise@^8.0.3: + version "8.0.3" + resolved "https://registry.yarnpkg.com/promise/-/promise-8.0.3.tgz#f592e099c6cddc000d538ee7283bb190452b0bf6" + integrity sha512-HeRDUL1RJiLhyA0/grn+PTShlBAcLuh/1BJGtrvjwbvRDCTLLMEz9rOGCV+R3vHY4MixIuoMEd9Yq/XvsTPcjw== + dependencies: + asap "~2.0.6" + prompts@^2.0.1: version "2.0.4" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.0.4.tgz#179f9d4db3128b9933aa35f93a800d8fce76a682" @@ -10827,6 +10908,11 @@ regenerate@^1.4.0: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.0.tgz#4a856ec4b56e4077c557589cae85e7a4c8869a11" integrity sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg== +regenerator-runtime@^0.10.5: + version "0.10.5" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658" + integrity sha1-M2w+/BIgrc7dosn6tntaeVWjNlg= + regenerator-runtime@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" @@ -12441,6 +12527,11 @@ tslib@1.9.0, tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.0.tgz#e37a86fda8cbbaf23a057f473c9f4dc64e5fc2e8" integrity sha512-f/qGG2tUkrISBlQZEjEqoZ3B2+npJjIf04H1wuAv9iA8i04Icp+61KRXxFdha22670NJopsZCIjhC3SnjPRKrQ== +tslib@^1.9.3: + version "1.10.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" + integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== + tslint-config-prettier@1.18.0: version "1.18.0" resolved "https://registry.yarnpkg.com/tslint-config-prettier/-/tslint-config-prettier-1.18.0.tgz#75f140bde947d35d8f0d238e0ebf809d64592c37"