",
14 | "license": "MIT",
15 | "scripts": {
16 | "build": "yarn clean:build && babel src --out-dir lib",
17 | "clean": "yarn clean:build & yarn clean:coverage & yarn clean:modules",
18 | "clean:build": "rimraf lib",
19 | "clean:coverage": "rimraf coverage",
20 | "clean:modules": "rimraf node_modules",
21 | "lint": "eslint --cache src",
22 | "lint:fix": "yarn lint --fix",
23 | "pretest": "yarn lint",
24 | "test": "jest",
25 | "test:coverage": "yarn test --coverage",
26 | "test:watch": "yarn test --watch"
27 | },
28 | "files": [
29 | "lib"
30 | ],
31 | "dependencies": {
32 | "lodash.throttle": "^4.1.1"
33 | },
34 | "peerDependencies": {
35 | "react": "^16.8.0",
36 | "react-dom": "^16.8.0"
37 | },
38 | "devDependencies": {
39 | "@babel/cli": "^7.6.2",
40 | "@babel/core": "^7.6.2",
41 | "@babel/preset-env": "^7.6.2",
42 | "@babel/preset-react": "^7.0.0",
43 | "@testing-library/jest-dom": "^4.1.0",
44 | "@testing-library/react": "^9.2.0",
45 | "babel-core": "^7.0.0-bridge.0",
46 | "babel-eslint": "10.0.3",
47 | "babel-jest": "^24.9.0",
48 | "eslint": "6.5.1",
49 | "eslint-config-prettier": "^6.3.0",
50 | "eslint-config-react-app": "^5.0.2",
51 | "eslint-plugin-flowtype": "4.3.0",
52 | "eslint-plugin-import": "2.18.2",
53 | "eslint-plugin-jsx-a11y": "6.2.3",
54 | "eslint-plugin-prettier": "^3.1.1",
55 | "eslint-plugin-react": "7.15.1",
56 | "eslint-plugin-react-hooks": "^2.1.1",
57 | "husky": "^3.0.8",
58 | "jest": "^24.9.0",
59 | "lint-staged": "^9.4.1",
60 | "prettier": "1.18.2",
61 | "react": "^16.10.1",
62 | "react-dom": "^16.10.1",
63 | "regenerator-runtime": "^0.13.3",
64 | "rimraf": "^3.0.0"
65 | },
66 | "jest": {
67 | "clearMocks": true,
68 | "setupFilesAfterEnv": [
69 | "./jest.setup.js"
70 | ]
71 | },
72 | "husky": {
73 | "hooks": {
74 | "pre-commit": "lint-staged"
75 | }
76 | },
77 | "lint-staged": {
78 | "*.{js,json,css,md}": [
79 | "prettier --write",
80 | "git add"
81 | ]
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/checkVisibility.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Checks if the given element is on the screen.
3 | *
4 | * @param {Element} el The element.
5 | * @param {boolean} partial Whether to consider the element visible when only a
6 | * part of it is on the screen.
7 | *
8 | * @returns {boolean} Whether the element is visible.
9 | */
10 | function checkVisibility(el, partial) {
11 | if (!el) {
12 | return false;
13 | }
14 |
15 | const {
16 | top,
17 | right,
18 | bottom,
19 | left,
20 | width,
21 | height,
22 | } = el.getBoundingClientRect();
23 |
24 | if (top + right + bottom + left === 0) {
25 | return false;
26 | }
27 |
28 | const topCheck = partial ? top + height : top;
29 | const bottomCheck = partial ? bottom - height : bottom;
30 | const rightCheck = partial ? right - width : right;
31 | const leftCheck = partial ? left + width : left;
32 |
33 | const windowWidth = window.innerWidth;
34 | const windowHeight = window.innerHeight;
35 |
36 | return (
37 | topCheck >= 0 &&
38 | leftCheck >= 0 &&
39 | bottomCheck <= windowHeight &&
40 | rightCheck <= windowWidth
41 | );
42 | }
43 |
44 | export default checkVisibility;
45 |
--------------------------------------------------------------------------------
/src/checkVisibility.test.js:
--------------------------------------------------------------------------------
1 | import checkVisibility from './checkVisibility';
2 |
3 | describe('checkVisibility', () => {
4 | let el;
5 |
6 | beforeEach(() => {
7 | window.innerWidth = 1024;
8 | window.innerHeight = 768;
9 | });
10 |
11 | afterEach(() => {
12 | window.innerWidth = null;
13 | window.innerHeight = null;
14 | });
15 |
16 | describe('with no element', () => {
17 | beforeEach(() => {
18 | el = null;
19 | });
20 |
21 | test('is not visible', () => {
22 | expect(checkVisibility(el, false)).toBe(false);
23 | });
24 |
25 | test('is not partially visible', () => {
26 | expect(checkVisibility(el, true)).toBe(false);
27 | });
28 | });
29 |
30 | describe('when element has no size', () => {
31 | beforeEach(() => {
32 | el = {
33 | getBoundingClientRect: () => ({
34 | top: 0,
35 | right: 0,
36 | bottom: 0,
37 | left: 0,
38 | width: 0,
39 | height: 0,
40 | }),
41 | };
42 | });
43 |
44 | test('is not visible', () => {
45 | expect(checkVisibility(el, false)).toBe(false);
46 | });
47 |
48 | test('is not partially visible', () => {
49 | expect(checkVisibility(el, true)).toBe(false);
50 | });
51 | });
52 |
53 | describe('when element is on the screen', () => {
54 | beforeEach(() => {
55 | el = {
56 | getBoundingClientRect: () => ({
57 | top: 0,
58 | right: 100,
59 | bottom: 100,
60 | left: 0,
61 | width: 100,
62 | height: 100,
63 | }),
64 | };
65 | });
66 |
67 | test('is visible', () => {
68 | expect(checkVisibility(el, false)).toBe(true);
69 | });
70 |
71 | test('is partially visible', () => {
72 | expect(checkVisibility(el, true)).toBe(true);
73 | });
74 | });
75 |
76 | describe('when element is not fully on the screen', () => {
77 | describe('appearing from the top', () => {
78 | beforeEach(() => {
79 | el = {
80 | getBoundingClientRect: () => ({
81 | top: -50,
82 | right: 100,
83 | bottom: 50,
84 | left: 0,
85 | width: 100,
86 | height: 100,
87 | }),
88 | };
89 | });
90 |
91 | test('is not visible', () => {
92 | expect(checkVisibility(el, false)).toBe(false);
93 | });
94 |
95 | test('is partially visible', () => {
96 | expect(checkVisibility(el, true)).toBe(true);
97 | });
98 | });
99 |
100 | describe('appearing from the right', () => {
101 | beforeEach(() => {
102 | el = {
103 | getBoundingClientRect: () => ({
104 | top: 0,
105 | right: 1074,
106 | bottom: 100,
107 | left: 974,
108 | width: 100,
109 | height: 100,
110 | }),
111 | };
112 | });
113 |
114 | test('is not visible', () => {
115 | expect(checkVisibility(el, false)).toBe(false);
116 | });
117 |
118 | test('is partially visible', () => {
119 | expect(checkVisibility(el, true)).toBe(true);
120 | });
121 | });
122 |
123 | describe('appearing from the bottom', () => {
124 | beforeEach(() => {
125 | el = {
126 | getBoundingClientRect: () => ({
127 | top: 758,
128 | right: 100,
129 | bottom: 858,
130 | left: 0,
131 | width: 100,
132 | height: 100,
133 | }),
134 | };
135 | });
136 |
137 | test('is not visible', () => {
138 | expect(checkVisibility(el, false)).toBe(false);
139 | });
140 |
141 | test('is partially visible', () => {
142 | expect(checkVisibility(el, true)).toBe(true);
143 | });
144 | });
145 |
146 | describe('appearing from the left', () => {
147 | beforeEach(() => {
148 | el = {
149 | getBoundingClientRect: () => ({
150 | top: 0,
151 | right: 50,
152 | bottom: 100,
153 | left: -50,
154 | width: 100,
155 | height: 100,
156 | }),
157 | };
158 | });
159 |
160 | test('is not visible', () => {
161 | expect(checkVisibility(el, false)).toBe(false);
162 | });
163 |
164 | test('is partially visible', () => {
165 | expect(checkVisibility(el, true)).toBe(true);
166 | });
167 | });
168 | });
169 | });
170 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import throttle from 'lodash.throttle';
2 | import { useEffect, useState } from 'react';
3 |
4 | import checkVisibility from './checkVisibility';
5 |
6 | const throttleInterval = 150;
7 |
8 | const useVisibility = (el, { partial = false, scrollableEl = window } = {}) => {
9 | const [isVisible, setIsVisible] = useState(false);
10 |
11 | useEffect(() => {
12 | const handleScrollOrResize = throttle(
13 | () => setIsVisible(checkVisibility(el, partial)),
14 | throttleInterval,
15 | );
16 |
17 | scrollableEl.addEventListener('scroll', handleScrollOrResize);
18 | window.addEventListener('resize', handleScrollOrResize);
19 |
20 | setIsVisible(checkVisibility(el, partial));
21 |
22 | return () => {
23 | scrollableEl.removeEventListener('scroll', handleScrollOrResize);
24 | window.removeEventListener('resize', handleScrollOrResize);
25 | };
26 | }, [el, partial, scrollableEl]);
27 |
28 | return isVisible;
29 | };
30 |
31 | export default useVisibility;
32 |
--------------------------------------------------------------------------------
/src/index.test.js:
--------------------------------------------------------------------------------
1 | import React, { useRef } from 'react';
2 | import { render } from '@testing-library/react';
3 |
4 | import checkVisibility from './checkVisibility';
5 | import useVisibility from '.';
6 |
7 | jest.mock('./checkVisibility');
8 | const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
9 |
10 | const Foo = ({ partial, scrollableEl }) => {
11 | const elRef = useRef(null);
12 | const isVisible = useVisibility(elRef.current, { partial, scrollableEl });
13 | return {isVisible ? 'visible' : 'not visible'}
;
14 | };
15 |
16 | describe('useVisibility', () => {
17 | test('is initially not visible', () => {
18 | const { container } = render();
19 | const el = container.firstChild;
20 | expect(el).toHaveTextContent('not visible');
21 | });
22 |
23 | test('attaches scroll and resize event listeners to window', () => {
24 | const { rerender } = render();
25 | // Force useEffect to run.
26 | rerender();
27 | expect(addEventListenerSpy).toHaveBeenCalledWith(
28 | 'scroll',
29 | expect.any(Function),
30 | );
31 | expect(addEventListenerSpy).toHaveBeenCalledWith(
32 | 'resize',
33 | expect.any(Function),
34 | );
35 | });
36 |
37 | test('updates visibility after component is rendered', () => {
38 | const { container, rerender } = render();
39 | const el = container.firstChild;
40 | expect(el).toHaveTextContent('not visible');
41 | checkVisibility.mockReturnValue(true);
42 | // Force useEffect to run.
43 | rerender();
44 | expect(el).toHaveTextContent('visible');
45 | expect(checkVisibility).toHaveBeenCalledWith(el, false);
46 | });
47 |
48 | test.skip('updates visibility on window scroll', () => {});
49 |
50 | test.skip('updates visibility on window resize', () => {});
51 |
52 | describe('with partial option', () => {
53 | test('checks partial visibility', () => {
54 | const { container, rerender } = render();
55 | const el = container.firstChild;
56 | // Force useEffect to run.
57 | rerender();
58 | expect(checkVisibility).toHaveBeenCalledWith(el, true);
59 | });
60 | });
61 |
62 | describe('with scrollableEl option', () => {
63 | test('attaches scroll event listener to passed in scrollable element', () => {
64 | const scrollableEl = {
65 | addEventListener: jest.fn(),
66 | removeEventListener: jest.fn(),
67 | };
68 | const { rerender } = render();
69 | // Force useEffect to run.
70 | rerender();
71 | expect(scrollableEl.addEventListener).toHaveBeenCalledWith(
72 | 'scroll',
73 | expect.any(Function),
74 | );
75 | });
76 | });
77 | });
78 |
--------------------------------------------------------------------------------