Skip to content

Commit

Permalink
Merge pull request #204 from dhis2/TECH-378_intersection_detector
Browse files Browse the repository at this point in the history
feat(intersection detector): add component, docs & test

Relates to TECH-378
  • Loading branch information
Mohammer5 authored Jul 28, 2020
2 parents 73d1781 + eaa99ae commit ff0a04c
Show file tree
Hide file tree
Showing 6 changed files with 379 additions and 0 deletions.
116 changes: 116 additions & 0 deletions packages/core/src/IntersectionDetector/IntersectionDetector.js
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,
}
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 packages/core/src/IntersectionDetector/IntersectionDetector.stories.js
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>
</>
)
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
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,
})
})
1 change: 1 addition & 0 deletions packages/core/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export { FileListPlaceholder } from './FileListPlaceholder/FileListPlaceholder.j
export { FlyoutMenu } from './FlyoutMenu/FlyoutMenu.js'
export { Help } from './Help/Help.js'
export { Input } from './Input/Input.js'
export { IntersectionDetector } from './IntersectionDetector/IntersectionDetector.js'
export { Label } from './Label/Label.js'
export { Layer, useLayerContext } from './Layer/Layer.js'
export { Legend } from './Legend/Legend.js'
Expand Down

0 comments on commit ff0a04c

Please sign in to comment.