`, () => {
100 | const wrapper = shallow(
101 |
102 | {() => HelloWorld!}
103 |
104 | );
105 | expect(wrapper.type()).toEqual('span');
106 | });
107 | });
108 |
109 | describe(`when component="div"`, () => {
110 | it(`should render the wrapper as `, () => {
111 | const wrapper = shallow(
112 |
113 | {() => HelloWorld!}
114 |
115 | );
116 | expect(wrapper.type()).toEqual('div');
117 | });
118 | });
119 |
120 | it('should call the render fn with true when the component is visible in the viewport', () => {
121 | setupElementInView();
122 | const render = jest.fn();
123 | const element = wrapper(
124 |
125 | {render}
126 |
127 | );
128 | expect(render).toBeCalledWith(true);
129 | });
130 |
131 | it('should call the render fn with false when the component is not visible in the viewport', () => {
132 | setupElementOutOfView();
133 | const render = jest.fn();
134 | const element = wrapper(
135 |
136 | {render}
137 |
138 | );
139 | expect(render).toBeCalledWith(false);
140 | });
141 |
142 | describe('has not been scrolled into view:', () => {
143 | beforeEach(() => setupElementOutOfView());
144 |
145 | it('should render the "placeholder" when there is a "placeholder"', () => {
146 | const element = wrapper(
147 |
148 | {() => children}
149 |
150 | );
151 | expect(getWrapper(element).contains(placeholder)).toBeTruthy();
152 | });
153 |
154 | it('should render the "render fn" when there is no "placeholder"', () => {
155 | const element = wrapper(
156 |
157 | {() => children}
158 |
159 | );
160 | expect(getWrapper(element).contains(children)).toBeTruthy();
161 | });
162 |
163 | it('should render "null" when there is no "placeholder" and no "render fn"', () => {
164 | const element = wrapper();
165 | expect(getWrapper(element).children().exists()).toBeFalsy();
166 | });
167 |
168 | });
169 |
170 | describe('has been scrolled into view:', () => {
171 | beforeEach(() => setupElementInView());
172 |
173 | it('should render the "content" when there is "content"', () => {
174 | const element = wrapper(
175 |
176 | {() => children}
177 |
178 | );
179 | expect(getWrapper(element).contains(content)).toBeTruthy();
180 | });
181 |
182 | it('should render the "render fn" when there is no "content"', () => {
183 | const element = wrapper(
184 |
185 | {() => children}
186 |
187 | );
188 | expect(getWrapper(element).contains(children)).toBeTruthy();
189 | });
190 |
191 | it('should render "null" when there is no "content" and no "render fn"', () => {
192 | const element = wrapper();
193 | expect(getWrapper(element).children().exists()).toBeFalsy();
194 | });
195 |
196 | });
197 |
198 | });
199 |
200 | describe('Event handling', () => {
201 |
202 | let windowAddEventSpy;
203 | let bodyAddEventSpy;
204 | let windowRemoveEventSpy;
205 | let bodyRemoveEventSpy;
206 |
207 | beforeEach(() => {
208 | windowAddEventSpy = jest.spyOn(window, 'addEventListener');
209 | bodyAddEventSpy = jest.spyOn(document.body, 'addEventListener');
210 | windowRemoveEventSpy = jest.spyOn(window, 'removeEventListener');
211 | bodyRemoveEventSpy = jest.spyOn(document.body, 'removeEventListener');
212 | });
213 |
214 | it('should use passive event listeners when available', () => {
215 | const element = wrapper(
216 |
217 | {() => children}
218 |
219 | );
220 |
221 | expect(windowAddEventSpy).toBeCalledWith('scroll', expect.anything(), {
222 | passive: true
223 | });
224 |
225 | element.unmount();
226 |
227 | expect(windowRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), {
228 | passive: true
229 | });
230 | });
231 |
232 | it('should use passive event listeners when available, container specified', () => {
233 | const bodyAddEventSpy = jest.spyOn(document.body, 'addEventListener');
234 | const bodyRemoveEventSpy = jest.spyOn(document.body, 'removeEventListener');
235 |
236 | const element = wrapper(
237 |
238 | {() => children}
239 |
240 | );
241 |
242 | expect(bodyAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
243 |
244 | bodyAddEventSpy.mockClear();
245 | bodyRemoveEventSpy.mockClear();
246 |
247 | element.unmount();
248 |
249 | expect(bodyRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
250 | });
251 |
252 | it('should cleanup and setup containers accurately when container changes', () => {
253 | // Aim: consumer wants to switch to a nested scroll container.
254 | const innerScrollContainer = document.createElement('div', { className: 'inner-scroll-container' });
255 | document.body.appendChild(innerScrollContainer);
256 |
257 | const bodyAddEventSpy = jest.spyOn(document.body, 'addEventListener');
258 | const bodyRemoveEventSpy = jest.spyOn(document.body, 'removeEventListener');
259 | const containerAddEventSpy = jest.spyOn(innerScrollContainer, 'addEventListener');
260 | const containerRemoveEventSpy = jest.spyOn(innerScrollContainer, 'removeEventListener');
261 |
262 | const element = wrapper(
263 |
264 | {() => children}
265 |
266 | );
267 |
268 | // Initial events should be attached to the first scroll container specified.
269 | expect(bodyAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
270 | bodyAddEventSpy.mockClear();
271 |
272 | // Switch to the next container.
273 | element.setProps({ scrollContainer: innerScrollContainer });
274 | expect(element.prop('scrollContainer')).toBe(innerScrollContainer);
275 |
276 | // Next, a cleanup should be done of the first container, and setup of the new container.
277 | expect(bodyRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
278 | expect(containerAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
279 | containerAddEventSpy.mockClear();
280 | bodyRemoveEventSpy.mockClear();
281 |
282 | // Lastly, the component teardown should reference the second container.
283 | element.unmount();
284 | expect(containerRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
285 | containerRemoveEventSpy.mockClear();
286 | });
287 |
288 | it('should listen to the window when scrollparent returns body in CSS1Compat mode', () => {
289 | mockCompatMode('CSS1Compat');
290 | mockScrollParent = document.body;
291 |
292 | const element = wrapper(
293 |
294 | {() => children}
295 |
296 | );
297 |
298 | expect(windowAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
299 | expect(bodyAddEventSpy).not.toBeCalledWith('scroll', expect.anything(), expect.anything());
300 |
301 | windowAddEventSpy.mockClear();
302 | bodyAddEventSpy.mockClear();
303 | windowRemoveEventSpy.mockClear();
304 | bodyRemoveEventSpy.mockClear();
305 |
306 | element.unmount();
307 |
308 | expect(windowRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
309 | expect(bodyRemoveEventSpy).not.toBeCalledWith('scroll', expect.anything(), expect.anything());
310 |
311 | unmockCompatMode();
312 | });
313 |
314 | it('should listen to the body when scrollparent returns body in BackCompat mode', () => {
315 | mockCompatMode('BackCompat');
316 | mockScrollParent = document.body;
317 | const windowAddEventSpy = jest.spyOn(window, 'addEventListener');
318 | const bodyAddEventSpy = jest.spyOn(document.body, 'addEventListener');
319 | const windowRemoveEventSpy = jest.spyOn(window, 'removeEventListener');
320 | const bodyRemoveEventSpy = jest.spyOn(document.body, 'removeEventListener');
321 |
322 | const element = wrapper(
323 |
324 | {() => children}
325 |
326 | );
327 |
328 | expect(windowAddEventSpy).not.toBeCalledWith('scroll', expect.anything(), expect.anything());
329 | expect(bodyAddEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
330 |
331 | windowAddEventSpy.mockClear();
332 | bodyAddEventSpy.mockClear();
333 | windowRemoveEventSpy.mockClear();
334 | bodyRemoveEventSpy.mockClear();
335 |
336 | element.unmount();
337 |
338 | expect(windowRemoveEventSpy).not.toBeCalledWith('scroll', expect.anything(), expect.anything());
339 | expect(bodyRemoveEventSpy).toBeCalledWith('scroll', expect.anything(), expect.anything());
340 |
341 | unmockCompatMode();
342 | });
343 |
344 | });
345 |
346 | });
347 |
--------------------------------------------------------------------------------
/src/types.js:
--------------------------------------------------------------------------------
1 |
2 | type Window = EventTarget;
3 |
4 | export type Bounds = {
5 | left: number;
6 | top: number;
7 | right: number;
8 | bottom: number;
9 | };
10 |
--------------------------------------------------------------------------------
/src/utils/convertOffsetToBounds.js:
--------------------------------------------------------------------------------
1 | //@flow
2 | import {type Bounds} from '../types';
3 |
4 | export default function(offset?: number | {top?: number, right?: number, bottom?: number, left?: number}): ?Bounds {
5 |
6 | if (!offset) {
7 | return undefined;
8 | }
9 |
10 | let offsetTop;
11 | let offsetRight;
12 | let offsetBottom;
13 | let offsetLeft;
14 |
15 | if (typeof offset === 'object') {
16 | offsetTop = offset.top || 0;
17 | offsetRight = offset.right || 0;
18 | offsetBottom = offset.bottom || 0;
19 | offsetLeft = offset.left || 0;
20 | } else {
21 | offsetTop = offset || 0;
22 | offsetRight = offset || 0;
23 | offsetBottom = offset || 0;
24 | offsetLeft = offset || 0;
25 | }
26 |
27 | return {
28 | top: offsetTop,
29 | right: offsetRight,
30 | bottom: offsetBottom,
31 | left: offsetLeft
32 | };
33 |
34 | }
--------------------------------------------------------------------------------
/src/utils/eventListenerOptions.js:
--------------------------------------------------------------------------------
1 | const isPassiveListenerSupported = () => {
2 | let supported = false;
3 |
4 | try {
5 | const opts = Object.defineProperty({}, 'passive', {
6 | get() {
7 | supported = true;
8 | }
9 | });
10 |
11 | window.addEventListener('test', null, opts);
12 | window.removeEventListener('test', null, opts);
13 | } catch (e) {}
14 |
15 | return supported;
16 | };
17 |
18 | export default isPassiveListenerSupported() ? {
19 | passive: true
20 | } : undefined;
--------------------------------------------------------------------------------
/src/utils/getElementBounds.js:
--------------------------------------------------------------------------------
1 | //@flow
2 | import {type Bounds} from '../types';
3 |
4 | export default function getElementBounds(element: ?HTMLElement): ?Bounds {
5 | if (!element) {
6 | return undefined;
7 | }
8 | const rect = element.getBoundingClientRect();
9 | return {
10 | left: window.pageXOffset + rect.left,
11 | right: window.pageXOffset + rect.left + rect.width,
12 | top: window.pageYOffset + rect.top,
13 | bottom: window.pageYOffset + rect.top + rect.height
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/src/utils/getElementBounds.test.js:
--------------------------------------------------------------------------------
1 |
2 | describe.skip('getElementBounds()', () => {
3 | it();
4 | });
5 |
--------------------------------------------------------------------------------
/src/utils/getViewportBounds.js:
--------------------------------------------------------------------------------
1 | //@flow
2 | import {type Bounds, type Window} from '../types';
3 | import getElementBounds from './getElementBounds';
4 |
5 | export default function(container: ?HTMLElement | ?Window): ?Bounds {
6 | if (!container) {
7 | return undefined;
8 | }
9 | if (container === window) {
10 | return {
11 | top: window.pageYOffset,
12 | left: window.pageXOffset,
13 | bottom: window.pageYOffset + window.innerHeight,
14 | right: window.pageXOffset + window.innerWidth
15 | };
16 | } else {
17 | const bounds = getElementBounds(container);
18 | if (bounds) {
19 | return {
20 | ...bounds,
21 | bottom: bounds.top + container.offsetHeight,
22 | right: bounds.left + container.offsetWidth
23 | };
24 | } else {
25 | return undefined;
26 | }
27 | }
28 | }
--------------------------------------------------------------------------------
/src/utils/getViewportBounds.test.js:
--------------------------------------------------------------------------------
1 |
2 | describe.skip('getViewportBounds()', () => {
3 | it();
4 | });
5 |
--------------------------------------------------------------------------------
/src/utils/isBackCompatMode.js:
--------------------------------------------------------------------------------
1 | export default () => {
2 | return document.compatMode === "BackCompat";
3 | }
--------------------------------------------------------------------------------
/src/utils/isElementInViewport.js:
--------------------------------------------------------------------------------
1 | //@flow
2 | import {type Bounds} from '../types';
3 |
4 | export default function (elementBounds: Bounds, viewportBounds: Bounds, offset?: Bounds): boolean {
5 | const offsetTop = offset && offset.top || 0;
6 | const offsetRight = offset && offset.right || 0;
7 | const offsetBottom = offset && offset.bottom || 0;
8 | const offsetLeft = offset && offset.left || 0;
9 | return (
10 | elementBounds.bottom + offsetBottom >= viewportBounds.top && elementBounds.top - offsetTop <= viewportBounds.bottom &&
11 | elementBounds.right + offsetRight >= viewportBounds.left && elementBounds.left - offsetLeft <= viewportBounds.right
12 | );
13 |
14 | }
--------------------------------------------------------------------------------
/src/utils/isElementInViewport.test.js:
--------------------------------------------------------------------------------
1 | import isElementInViewport from './isElementInViewport';
2 |
3 | describe('isElementInViewport()', () => {
4 |
5 | it('should return true when the element is fully in the viewport', () => {
6 | const result = isElementInViewport(
7 | {
8 | left: 0,
9 | top: 100,
10 | right: 100,
11 | bottom: 300
12 | },
13 | {
14 | left: 0,
15 | top: 0,
16 | right: 300,
17 | bottom: 300
18 | }
19 | );
20 | expect(result).toBeTruthy();
21 | });
22 |
23 | it('should return true when the bottom half of the element is in the viewport', () => {
24 | const result = isElementInViewport(
25 | {
26 | left: 0,
27 | top: 100,
28 | right: 100,
29 | bottom: 200
30 | },
31 | {
32 | left: 0,
33 | top: 150,
34 | right: 300,
35 | bottom: 350
36 | }
37 | );
38 | expect(result).toBeTruthy();
39 | });
40 |
41 | it('should return true when the top half of the element is in the viewport', () => {
42 | const result = isElementInViewport(
43 | {
44 | left: 0,
45 | top: 100,
46 | right: 100,
47 | bottom: 300
48 | },
49 | {
50 | left: 0,
51 | top: 150,
52 | right: 300,
53 | bottom: 200
54 | }
55 | );
56 | expect(result).toBeTruthy();
57 | });
58 |
59 | it('should return false when the element is fully above the viewport', () => {
60 | const result = isElementInViewport(
61 | {
62 | left: 0,
63 | top: 100,
64 | right: 300,
65 | bottom: 200
66 | },
67 | {
68 | left: 0,
69 | top: 300,
70 | right: 1000,
71 | bottom: 1100
72 | }
73 | );
74 | expect(result).toBeFalsy();
75 | });
76 |
77 | it('should return false when the element is fully below the viewport', () => {
78 | const result = isElementInViewport(
79 | {
80 | left: 0,
81 | top: 900,
82 | right: 300,
83 | bottom: 1000
84 | },
85 | {
86 | left: 0,
87 | top: 0,
88 | right: 1000,
89 | bottom: 800
90 | }
91 | );
92 | expect(result).toBeFalsy();
93 | });
94 |
95 | describe('With offset:', () => {
96 |
97 | it('should return true when the element is fully in the viewport', () => {
98 | const result = isElementInViewport(
99 | {
100 | top: 1400,
101 | right: 1800,
102 | bottom: 1800,
103 | left: 1400
104 | },
105 | {
106 | top: 1000,
107 | right: 2000,
108 | bottom: 2000,
109 | left: 1000
110 | },
111 | {
112 | left: 50,
113 | top: 50,
114 | right: 50,
115 | bottom: 50
116 | }
117 | );
118 | expect(result).toBeTruthy();
119 | });
120 |
121 | describe('When element near the top of the viewport:', () => {
122 |
123 | it('should return true when the element is partially in the viewport', () => {
124 | const result = isElementInViewport(
125 | {
126 | top: 800,
127 | right: 2000,
128 | bottom: 1200,
129 | left: 1000
130 | },
131 | {
132 | top: 1000,
133 | right: 2000,
134 | bottom: 2000,
135 | left: 1000
136 | },
137 | {
138 | left: 0,
139 | top: 0,
140 | right: 0,
141 | bottom: 50
142 | }
143 | );
144 | expect(result).toBeTruthy();
145 | });
146 |
147 | it('should return true when the offset is partially in the viewport', () => {
148 | const result = isElementInViewport(
149 | {
150 | top: 575,
151 | right: 2000,
152 | bottom: 975,
153 | left: 1000
154 | },
155 | {
156 | top: 1000,
157 | right: 2000,
158 | bottom: 2000,
159 | left: 1000
160 | },
161 | {
162 | left: 0,
163 | top: 0,
164 | right: 0,
165 | bottom: 50
166 | }
167 | );
168 | expect(result).toBeTruthy();
169 | });
170 |
171 | it('should return false when the element and the offset is not in the viewport', () => {
172 | const result = isElementInViewport(
173 | {
174 | top: 500,
175 | right: 2000,
176 | bottom: 900,
177 | left: 1000
178 | },
179 | {
180 | top: 1000,
181 | right: 2000,
182 | bottom: 2000,
183 | left: 1000
184 | },
185 | {
186 | left: 0,
187 | top: 0,
188 | right: 0,
189 | bottom: 50
190 | }
191 | );
192 | expect(result).toBeFalsy();
193 | });
194 |
195 | });
196 |
197 | describe('When element is near the bottom of the viewport:', () => {
198 |
199 | it('should return true when the element is partially in the viewport', () => {
200 | const result = isElementInViewport(
201 | {
202 | top: 1800,
203 | right: 2000,
204 | bottom: 2200,
205 | left: 1000
206 | },
207 | {
208 | top: 1000,
209 | right: 2000,
210 | bottom: 2000,
211 | left: 1000
212 | },
213 | {
214 | left: 0,
215 | top: 50,
216 | right: 0,
217 | bottom: 0
218 | }
219 | );
220 | expect(result).toBeTruthy();
221 | });
222 |
223 | it('should return true when the offset is partially in the viewport', () => {
224 | const result = isElementInViewport(
225 | {
226 | top: 2025,
227 | right: 2000,
228 | bottom: 2425,
229 | left: 1000
230 | },
231 | {
232 | top: 1000,
233 | right: 2000,
234 | bottom: 2000,
235 | left: 1000
236 | },
237 | {
238 | left: 0,
239 | top: 50,
240 | right: 0,
241 | bottom: 0
242 | }
243 | );
244 | expect(result).toBeTruthy();
245 | });
246 |
247 | it('should return false when the element and the offset is not in the viewport', () => {
248 | const result = isElementInViewport(
249 | {
250 | top: 2100,
251 | right: 2000,
252 | bottom: 2500,
253 | left: 1000
254 | },
255 | {
256 | top: 1000,
257 | right: 2000,
258 | bottom: 2000,
259 | left: 1000
260 | },
261 | {
262 | left: 0,
263 | top: 50,
264 | right: 0,
265 | bottom: 0
266 | }
267 | );
268 | expect(result).toBeFalsy();
269 | });
270 |
271 | });
272 |
273 | describe('When element is near the left of the viewport:', () => {
274 |
275 | it('should return true when the element is partially in the viewport', () => {
276 | const result = isElementInViewport(
277 | {
278 | top: 1000,
279 | right: 1200,
280 | bottom: 2000,
281 | left: 800
282 | },
283 | {
284 | top: 1000,
285 | right: 2000,
286 | bottom: 2000,
287 | left: 1000
288 | },
289 | {
290 | left: 0,
291 | top: 0,
292 | right: 50,
293 | bottom: 0
294 | }
295 | );
296 | expect(result).toBeTruthy();
297 | });
298 |
299 | it('should return true when the offset is partially in the viewport', () => {
300 | const result = isElementInViewport(
301 | {
302 | top: 1000,
303 | right: 975,
304 | bottom: 2000,
305 | left: 575
306 | },
307 | {
308 | top: 1000,
309 | right: 2000,
310 | bottom: 2000,
311 | left: 1000
312 | },
313 | {
314 | left: 0,
315 | top: 0,
316 | right: 50,
317 | bottom: 0
318 | }
319 | );
320 | expect(result).toBeTruthy();
321 | });
322 |
323 | it('should return false when the element and the offset is not in the viewport', () => {
324 | const result = isElementInViewport(
325 | {
326 | top: 1000,
327 | right: 900,
328 | bottom: 2000,
329 | left: 500
330 | },
331 | {
332 | top: 1000,
333 | right: 2000,
334 | bottom: 2000,
335 | left: 1000
336 | },
337 | {
338 | left: 0,
339 | top: 0,
340 | right: 50,
341 | bottom: 0
342 | }
343 | );
344 | expect(result).toBeFalsy();
345 | });
346 |
347 | });
348 |
349 | describe('When element is near the right of the viewport:', () => {
350 |
351 | it('should return true when the element is partially in the viewport', () => {
352 | const result = isElementInViewport(
353 | {
354 | top: 1000,
355 | right: 2200,
356 | bottom: 2000,
357 | left: 1800
358 | },
359 | {
360 | top: 1000,
361 | right: 2000,
362 | bottom: 2000,
363 | left: 1000
364 | },
365 | {
366 | left: 50,
367 | top: 0,
368 | right: 0,
369 | bottom: 0
370 | }
371 | );
372 | expect(result).toBeTruthy();
373 | });
374 |
375 | it('should return true when the offset is partially in the viewport', () => {
376 | const result = isElementInViewport(
377 | {
378 | top: 1000,
379 | right: 2425,
380 | bottom: 2000,
381 | left: 2025
382 | },
383 | {
384 | top: 1000,
385 | right: 2000,
386 | bottom: 2000,
387 | left: 1000
388 | },
389 | {
390 | left: 50,
391 | top: 0,
392 | right: 0,
393 | bottom: 0
394 | }
395 | );
396 | expect(result).toBeTruthy();
397 | });
398 |
399 | it('should return false when the element and the offset is not in the viewport', () => {
400 | const result = isElementInViewport(
401 | {
402 | top: 1000,
403 | right: 2500,
404 | bottom: 2000,
405 | left: 2100
406 | },
407 | {
408 | top: 1000,
409 | right: 2000,
410 | bottom: 2000,
411 | left: 1000
412 | },
413 | {
414 | left: 50,
415 | top: 0,
416 | right: 0,
417 | bottom: 0
418 | }
419 | );
420 | expect(result).toBeFalsy();
421 | });
422 |
423 | });
424 |
425 | });
426 |
427 | });
428 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const HtmlWebpackPlugin = require('html-webpack-plugin');
3 |
4 | module.exports = {
5 | entry: './example/index.js',
6 | output: {
7 | path: path.resolve('./dist/example'),
8 | filename: './index.js'
9 | },
10 | module: {
11 | rules: [
12 | {
13 | test: /\.js$/,
14 | exclude: /(node_modules)/,
15 | use: {
16 | loader: 'babel-loader'
17 | }
18 | }
19 | ]
20 | },
21 | resolve: {
22 | alias: {
23 | 'react-lazily-render': path.resolve('./src')
24 | }
25 | },
26 | plugins: [
27 | new HtmlWebpackPlugin({
28 | title: 'react-lazily-render',
29 | filename: './index.html',
30 | template: './src/index.html',
31 | })
32 | ]
33 | };
34 |
--------------------------------------------------------------------------------