-
Notifications
You must be signed in to change notification settings - Fork 16
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #204 from dhis2/TECH-378_intersection_detector
feat(intersection detector): add component, docs & test Relates to TECH-378
- Loading branch information
Showing
6 changed files
with
379 additions
and
0 deletions.
There are no files selected for viewing
116 changes: 116 additions & 0 deletions
116
packages/core/src/IntersectionDetector/IntersectionDetector.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
import React, { useEffect, useRef } from 'react' | ||
import { PropTypes } from '@dhis2/prop-types' | ||
;('') // TODO: https://github.com/jsdoc/jsdoc/issues/1718 | ||
|
||
/** | ||
* @module | ||
* | ||
* @param {IntersectionDetector.PropTypes} props | ||
* @returns {React.Component} | ||
*/ | ||
export const IntersectionDetector = ({ | ||
threshold, | ||
onChange, | ||
children, | ||
className, | ||
rootRef, | ||
}) => { | ||
// Use useRef instead of useState to prevent unnecessary re-render: | ||
// The state changes won't be reflected in what this component renders, | ||
// so there's no need for re-rendering the (potentially computational | ||
// heavy) children. Also: If the parent re-renders (e. g. due to a state | ||
// change), then this component will re-render as well. | ||
|
||
// @var {Object} | ||
// @prop {IntersectionObserver} current | ||
const observer = useRef() | ||
|
||
// @var {Object} | ||
// @prop {bool} current | ||
const isIntersecting = useRef() | ||
|
||
// @var {Object} | ||
// @prop {HTMLElement} current | ||
const intersectionRef = useRef() | ||
|
||
useEffect(() => { | ||
const rootEl = rootRef.current | ||
const intersectionEl = intersectionRef.current | ||
|
||
if (rootEl && intersectionEl && !observer.current) { | ||
const onIntersection = entries => { | ||
// Currently there's no way to supply multiple thresholds, | ||
// so a single entry can be assumed safely | ||
const [entry] = entries | ||
|
||
// Make sure the callback is not called multiple times | ||
// if there is no change | ||
const intersectionChange = | ||
entry.isIntersecting !== isIntersecting.current | ||
|
||
if (intersectionChange) { | ||
isIntersecting.current = entry.isIntersecting | ||
onChange({ | ||
isIntersecting: entry.isIntersecting, | ||
}) | ||
} | ||
} | ||
|
||
const observerOptions = { root: rootEl, threshold } | ||
const intersectionObserver = new IntersectionObserver( | ||
onIntersection, | ||
observerOptions | ||
) | ||
|
||
intersectionObserver.observe(intersectionEl) | ||
observer.current = intersectionObserver | ||
|
||
// Make sure to clean up everything when un-mounting. | ||
// Using an arrow function instead of just returning | ||
// the disconnect function for better readability. | ||
return () => intersectionObserver.disconnect() | ||
} | ||
}, [rootRef.current, intersectionRef.current]) | ||
|
||
return ( | ||
<div ref={intersectionRef} className={className}> | ||
{children} | ||
|
||
<style jsx>{` | ||
// Enables compatibility with "position: absolute;" containers. | ||
// If the parent does not have a height, this will have no | ||
// effect, which makes it work with standard containers. | ||
div { | ||
height: 100%; | ||
} | ||
`}</style> | ||
</div> | ||
) | ||
} | ||
|
||
IntersectionDetector.defaultProps = { | ||
threshold: 0, | ||
} | ||
|
||
/** | ||
* @typedef {Object} PropTypes | ||
* @static | ||
* | ||
* @prop {Object} rootRef | ||
* @prop {HTMLElement} [rootRef.current] | ||
* @prop {Function} onChange | ||
* @prop {any} [children] | ||
* @prop {string} [className] | ||
* @prop {number} [threshold] | ||
*/ | ||
IntersectionDetector.propTypes = { | ||
rootRef: PropTypes.shape({ | ||
// not required so `current` can be `null` | ||
current: PropTypes.instanceOf(HTMLElement), | ||
}).isRequired, | ||
onChange: PropTypes.func.isRequired, | ||
|
||
children: PropTypes.any, | ||
className: PropTypes.string, | ||
threshold: PropTypes.number, | ||
} |
61 changes: 61 additions & 0 deletions
61
packages/core/src/IntersectionDetector/IntersectionDetector.stories.e2e.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import React, { useRef } from 'react' | ||
import { IntersectionDetector } from './IntersectionDetector.js' | ||
|
||
window.onChange = window.Cypress ? window.Cypress.cy.stub() : () => null | ||
|
||
export default { title: 'IntersectionDetector' } | ||
|
||
export const OutOfView = () => { | ||
const rootRef = useRef() | ||
|
||
return ( | ||
<div | ||
ref={rootRef} | ||
data-test="scroll-container" | ||
style={{ width: 200, height: 300, overflow: 'auto' }} | ||
> | ||
<div | ||
// spacer to push indicator out of the intersecting area | ||
style={{ | ||
width: 200, | ||
height: 310, | ||
overflow: 'auto', | ||
background: 'blue', | ||
}} | ||
/> | ||
|
||
<IntersectionDetector rootRef={rootRef} onChange={window.onChange}> | ||
<div style={{ background: 'red', height: 200 }} /> | ||
</IntersectionDetector> | ||
</div> | ||
) | ||
} | ||
|
||
export const InView = () => { | ||
const rootRef = useRef() | ||
|
||
return ( | ||
<div | ||
ref={rootRef} | ||
data-test="scroll-container" | ||
style={{ width: 200, height: 300, overflow: 'auto' }} | ||
> | ||
<div style={{ background: 'red', height: 200 }}> | ||
<IntersectionDetector | ||
rootRef={rootRef} | ||
onChange={window.onChange} | ||
/> | ||
</div> | ||
|
||
<div | ||
// spacer to push indicator out of the intersecting area | ||
style={{ | ||
width: 200, | ||
height: 310, | ||
overflow: 'auto', | ||
background: 'blue', | ||
}} | ||
/> | ||
</div> | ||
) | ||
} |
140 changes: 140 additions & 0 deletions
140
packages/core/src/IntersectionDetector/IntersectionDetector.stories.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,140 @@ | ||
/* eslint-disable prettier/prettier, react/prop-types */ | ||
import React, { useState, useRef } from 'react' | ||
import { IntersectionDetector } from './IntersectionDetector.js' | ||
|
||
const Text = () => ( | ||
<p> | ||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. | ||
Cras tempor venenatis hendrerit. Donec dictum sed ligula id | ||
efficitur. Suspendisse feugiat, elit in dictum imperdiet, | ||
mi tellus euismod nibh, vitae hendrerit turpis odio ut | ||
mauris. Vestibulum rhoncus interdum nunc eu eleifend. | ||
Aenean viverra nibh hendrerit nulla iaculis, vitae | ||
tincidunt erat ullamcorper. Donec tempus mattis faucibus. | ||
Donec nec lacus vitae elit aliquet pharetra. Cras vitae | ||
odio eu lorem euismod malesuada. Nunc eu rhoncus mauris. | ||
Nullam vehicula elit id vehicula maximus. Phasellus | ||
gravida tincidunt mauris, vitae laoreet erat commodo id. | ||
Nullam vitae erat ante. Proin id ultricies risus, in | ||
ultricies mauris. Vivamus lectus enim, ultricies vel | ||
egestas nec, tempor a magna. Nam sed fermentum ipsum, a | ||
ullamcorper felis. Aenean finibus erat elit, at eleifend | ||
nulla rutrum at. | ||
|
||
<style jsx>{` | ||
p { | ||
margin: 0; | ||
} | ||
`}</style> | ||
</p> | ||
) | ||
|
||
const SizeLimitDecorator = ({ rootRef, isIntersecting, children }) => ( | ||
<div className="container"> | ||
<p>Is intersecting: {` ${isIntersecting}`}</p> | ||
|
||
<div className="scrollContainer" ref={rootRef}> | ||
<div className="contentContainer"> | ||
{children} | ||
</div> | ||
</div> | ||
|
||
<style jsx>{` | ||
.scrollContainer { | ||
//border: 1px solid black; | ||
width: 200px; | ||
height: 300px; | ||
overflow: hidden; | ||
overflow-y: auto; | ||
} | ||
.contentContainer { | ||
position: relative; | ||
} | ||
`}</style> | ||
</div> | ||
) | ||
|
||
const sizeLimitDecorator = tolerance => fn => { | ||
const rootRef = useRef() | ||
const [isIntersecting, setIsIntersecting] = useState(false) | ||
|
||
return ( | ||
<SizeLimitDecorator tolerance={tolerance} rootRef={rootRef} isIntersecting={isIntersecting}> | ||
{fn({ setIsIntersecting, rootRef })} | ||
</SizeLimitDecorator> | ||
) | ||
} | ||
|
||
export default { | ||
title: 'IntersectionObserver', | ||
decorators: [sizeLimitDecorator(100)] | ||
} | ||
|
||
export const AbsoluteBottomArea = ({ setIsIntersecting, rootRef }) => ( | ||
<> | ||
<Text /> | ||
|
||
<div style={{ | ||
boxSizing: 'border-box', | ||
border: '1px solid #f76a8c', | ||
background: 'rgba(246,172,200,0.4)', | ||
width: '100%', | ||
height: '100px', | ||
position: 'absolute', | ||
bottom: '0', | ||
left: '0', | ||
zIndex: '-1', | ||
}}> | ||
<IntersectionDetector | ||
onChange={({ isIntersecting }) => setIsIntersecting(isIntersecting)} | ||
rootRef={rootRef} | ||
/> | ||
</div> | ||
</> | ||
) | ||
|
||
export const AbsoluteTopArea = ({ setIsIntersecting, rootRef }) => ( | ||
<> | ||
<Text /> | ||
|
||
<div style={{ | ||
boxSizing: 'border-box', | ||
border: '1px solid #f76a8c', | ||
background: 'rgba(246,172,200,0.4)', | ||
width: '100%', | ||
height: '100px', | ||
position: 'absolute', | ||
top: '0', | ||
left: '0', | ||
zIndex: '-1', | ||
}}> | ||
<IntersectionDetector | ||
onChange={({ isIntersecting }) => setIsIntersecting(isIntersecting)} | ||
rootRef={rootRef} | ||
/> | ||
</div> | ||
</> | ||
) | ||
|
||
export const BottomArea = ({ setIsIntersecting, rootRef }) => ( | ||
<> | ||
<Text /> | ||
|
||
<div style={{ | ||
boxSizing: 'border-box', | ||
border: '1px solid #f76a8c', | ||
background: 'rgba(246,172,200,0.4)', | ||
}}> | ||
<IntersectionDetector | ||
onChange={({ isIntersecting }) => setIsIntersecting(isIntersecting)} | ||
rootRef={rootRef} | ||
> | ||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras | ||
tempor venenatis hendrerit. Donec dictum sed ligula id | ||
efficitur. | ||
</IntersectionDetector> | ||
|
||
</div> | ||
</> | ||
) |
13 changes: 13 additions & 0 deletions
13
packages/core/src/IntersectionDetector/features/Visibility_notification.feature
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
Feature: The IntersectionDetector notifies the consumer when becoming visible | ||
|
||
Scenario: The user scrolls the IntersectionDetector into view | ||
Given the detector is not intersecting with the root | ||
When the user scrolls the detector into view | ||
Then the callback passed to onChange should be called | ||
And the isIntersecting prop of its payload should be true | ||
|
||
Scenario: The user scrolls the IntersectionDetector out of view | ||
Given the detector is intersecting with the root | ||
When the user scrolls the detector out of view | ||
Then the callback passed to onChange should be called | ||
And the isIntersecting prop of its payload should be false |
48 changes: 48 additions & 0 deletions
48
packages/core/src/IntersectionDetector/features/Visibility_notification/index.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' | ||
|
||
Given('the detector is not intersecting with the root', () => { | ||
cy.visitStory('IntersectionDetector', 'Out Of View') | ||
cy.window().then(win => { | ||
cy.wrap(win.onChange) | ||
.as('onChangeStub') | ||
.should('be.calledOnce') | ||
.should('be.calledWith', { isIntersecting: false }) | ||
}) | ||
}) | ||
|
||
Given('the detector is intersecting with the root', () => { | ||
cy.visitStory('IntersectionDetector', 'In View') | ||
cy.window().then(win => { | ||
cy.wrap(win.onChange) | ||
.as('onChangeStub') | ||
.should('be.calledOnce') | ||
.should('be.calledWith', { isIntersecting: true }) | ||
}) | ||
}) | ||
|
||
When('the user scrolls the detector into view', () => { | ||
// using 311 to scroll 1px more than "just enough" | ||
cy.get('[data-test="scroll-container"]').scrollTo(0, 311) | ||
}) | ||
|
||
When('the user scrolls the detector out of view', () => { | ||
// Scroll 1px more than "just enough" to make sure | ||
// the detector is completely hidden | ||
cy.get('[data-test="scroll-container"]').scrollTo(0, 201) | ||
}) | ||
|
||
Then('the callback passed to onChange should be called', () => { | ||
cy.get('@onChangeStub').should(stub => { | ||
expect(stub).to.be.calledTwice | ||
}) | ||
}) | ||
|
||
Then('the isIntersecting prop of its payload should be true', () => { | ||
cy.get('@onChangeStub').should('be.calledWith', { isIntersecting: true }) | ||
}) | ||
|
||
Then('the isIntersecting prop of its payload should be false', () => { | ||
cy.get('@onChangeStub').should('be.calledWith', { | ||
isIntersecting: false, | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters