From 4d96be19a9330dc403af1cb65615f2aa55c6b7c0 Mon Sep 17 00:00:00 2001 From: Jan-Gerke Salomon Date: Wed, 8 Jul 2020 14:37:58 +0200 Subject: [PATCH 1/3] feat(intersectiondetector): add component Relates to TECH-378 --- .../IntersectionDetector.js | 116 ++++++++++++++++++ packages/core/src/index.js | 1 + 2 files changed, 117 insertions(+) create mode 100644 packages/core/src/IntersectionDetector/IntersectionDetector.js diff --git a/packages/core/src/IntersectionDetector/IntersectionDetector.js b/packages/core/src/IntersectionDetector/IntersectionDetector.js new file mode 100644 index 0000000000..0bda8168db --- /dev/null +++ b/packages/core/src/IntersectionDetector/IntersectionDetector.js @@ -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 ( +
+ {children} + + +
+ ) +} + +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, +} diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 4254819aab..51f61476a5 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -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' From 16e2d8e65e0ebf06a35ac6e6773736ef861265fe Mon Sep 17 00:00:00 2001 From: Jan-Gerke Salomon Date: Wed, 8 Jul 2020 14:38:38 +0200 Subject: [PATCH 2/3] docs(intersectiondetector): add stories Relates to TECH-378 --- .../IntersectionDetector.stories.js | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 packages/core/src/IntersectionDetector/IntersectionDetector.stories.js diff --git a/packages/core/src/IntersectionDetector/IntersectionDetector.stories.js b/packages/core/src/IntersectionDetector/IntersectionDetector.stories.js new file mode 100644 index 0000000000..590cc0cb27 --- /dev/null +++ b/packages/core/src/IntersectionDetector/IntersectionDetector.stories.js @@ -0,0 +1,140 @@ +/* eslint-disable prettier/prettier, react/prop-types */ +import React, { useState, useRef } from 'react' +import { IntersectionDetector } from './IntersectionDetector.js' + +const Text = () => ( +

+ 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. + + +

+) + +const SizeLimitDecorator = ({ rootRef, isIntersecting, children }) => ( +
+

Is intersecting: {` ${isIntersecting}`}

+ +
+
+ {children} +
+
+ + +
+) + +const sizeLimitDecorator = tolerance => fn => { + const rootRef = useRef() + const [isIntersecting, setIsIntersecting] = useState(false) + + return ( + + {fn({ setIsIntersecting, rootRef })} + + ) +} + +export default { + title: 'IntersectionObserver', + decorators: [sizeLimitDecorator(100)] +} + +export const AbsoluteBottomArea = ({ setIsIntersecting, rootRef }) => ( + <> + + +
+ setIsIntersecting(isIntersecting)} + rootRef={rootRef} + /> +
+ +) + +export const AbsoluteTopArea = ({ setIsIntersecting, rootRef }) => ( + <> + + +
+ setIsIntersecting(isIntersecting)} + rootRef={rootRef} + /> +
+ +) + +export const BottomArea = ({ setIsIntersecting, rootRef }) => ( + <> + + +
+ setIsIntersecting(isIntersecting)} + rootRef={rootRef} + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras + tempor venenatis hendrerit. Donec dictum sed ligula id + efficitur. + + +
+ +) From eaa99ae45db86d9547ec49da560054298e0128c7 Mon Sep 17 00:00:00 2001 From: Jan-Gerke Salomon Date: Wed, 8 Jul 2020 14:39:00 +0200 Subject: [PATCH 3/3] test(intersectiondetector): add cypress tests Relates to TECH-378 --- .../IntersectionDetector.stories.e2e.js | 61 +++++++++++++++++++ .../features/Visibility_notification.feature | 13 ++++ .../features/Visibility_notification/index.js | 48 +++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 packages/core/src/IntersectionDetector/IntersectionDetector.stories.e2e.js create mode 100644 packages/core/src/IntersectionDetector/features/Visibility_notification.feature create mode 100644 packages/core/src/IntersectionDetector/features/Visibility_notification/index.js diff --git a/packages/core/src/IntersectionDetector/IntersectionDetector.stories.e2e.js b/packages/core/src/IntersectionDetector/IntersectionDetector.stories.e2e.js new file mode 100644 index 0000000000..808bdb0c0e --- /dev/null +++ b/packages/core/src/IntersectionDetector/IntersectionDetector.stories.e2e.js @@ -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 ( +
+
+ + +
+ +
+ ) +} + +export const InView = () => { + const rootRef = useRef() + + return ( +
+
+ +
+ +
+
+ ) +} diff --git a/packages/core/src/IntersectionDetector/features/Visibility_notification.feature b/packages/core/src/IntersectionDetector/features/Visibility_notification.feature new file mode 100644 index 0000000000..92ef60e69a --- /dev/null +++ b/packages/core/src/IntersectionDetector/features/Visibility_notification.feature @@ -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 diff --git a/packages/core/src/IntersectionDetector/features/Visibility_notification/index.js b/packages/core/src/IntersectionDetector/features/Visibility_notification/index.js new file mode 100644 index 0000000000..3ea984d3ce --- /dev/null +++ b/packages/core/src/IntersectionDetector/features/Visibility_notification/index.js @@ -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, + }) +})