diff --git a/README.md b/README.md index c89e541..17f23af 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,11 @@ yarn add react-waypoint ## Usage -```javascript +```jsx var Waypoint = require('react-waypoint'); ``` -```javascript +```jsx ``` +Waypoints can take a child, allowing you to track when a section of content +enters or leaves the viewport. For details, see [Children](#children), below. + +```jsx + +
+ Some content here +
+
+``` ### Example: [JSFiddle Example][jsfiddle-example] @@ -84,7 +94,7 @@ below) has changed. ## Prop types -```javascript +```jsx propTypes: { /** @@ -217,7 +227,7 @@ then the boundaries will be pushed inward, toward the center of the page. If you specify a negative value for an offset, then the boundary will be pushed outward from the center of the page. -#### Horizontal Scrolling +#### Horizontal Scrolling Offsets and Boundaries By default, waypoints listen to vertical scrolling. If you want to switch to horizontal scrolling instead, use the `horizontal` prop. For simplicity's sake, @@ -248,6 +258,37 @@ the `onLeave` and `onEnter` callback will be called. By using the arguments passed to the callbacks, you can determine whether the waypoint has crossed the top boundary or the bottom boundary. +## Children + +If you don't pass a child into your Waypoint, then you can think of the +waypoint as a line across the page. Whenever that line crosses a +[boundary](#offsets-and-boundaries), then the `onEnter` or `onLeave` callbacks +will be called. + +If you do pass a child, it must be a single DOM Element (eg; a `
`) +and *not* a Component Element (eg; ``). + +The `onEnter` callback will be called when *any* part of the child is visible +in the viewport. The `onLeave` callback will be called when *all* of the child +has exited the viewport. + +(Note that this is measured only on a single axis. What this means is that for a +Waypoint within a vertically scrolling parent, it could be off of the screen +horizontally yet still fire an onEnter event, because it is within the vertical +boundaries). + +Deciding whether to pass a child or not will depend on your use case. One +example of when passing a child is useful is for a scrollspy +(like [Bootstrap's](https://bootstrapdocs.com/v3.3.6/docs/javascript/#scrollspy)). +Imagine if you want to fire a waypoint when a particularly long piece of content +is visible onscreen. When the page loads, it is conceivable that both the top +and bottom of this piece of content could lie outside of the boundaries, +because the content is taller than the viewport. If you didn't pass a child, +and instead put the waypoint above or below the content, then you will not +receive an `onEnter` callback (nor any other callback from this library). +Instead, passing this long content as a child of the Waypoint would fire the `onEnter` +callback when the page loads. + ## Containing elements and `scrollableAncestor` React Waypoint positions its [boundaries](#offsets-and-boundaries) based on the @@ -277,6 +318,7 @@ This might look something like: ``` ## Troubleshooting + If your waypoint isn't working the way you expect it to, there are a few ways you can debug your setup. diff --git a/spec/waypoint_spec.js b/spec/waypoint_spec.js index 2f7e316..1fde22b 100644 --- a/spec/waypoint_spec.js +++ b/spec/waypoint_spec.js @@ -1,3 +1,4 @@ +/* eslint-disable react/no-multi-comp */ import React from 'react'; import ReactDOM from 'react-dom'; import Waypoint from '../src/waypoint.jsx'; @@ -100,6 +101,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -112,6 +114,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -138,6 +141,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin - (this.parentHeight * 2), viewportBottom: this.margin + this.parentHeight, }); @@ -150,6 +154,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin - (this.parentHeight * 2), viewportBottom: this.margin + this.parentHeight, }); @@ -176,6 +181,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin, viewportBottom: this.margin + (this.parentHeight * 3), }); @@ -188,6 +194,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin, viewportBottom: this.margin + (this.parentHeight * 3), }); @@ -215,6 +222,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin - (this.parentHeight * 2), viewportBottom: this.margin + (this.parentHeight * 3), }); @@ -227,6 +235,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin - (this.parentHeight * 2), viewportBottom: this.margin + (this.parentHeight * 3), }); @@ -275,6 +284,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: this.margin - 10, + waypointBottom: this.margin - 10, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -291,6 +301,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: this.margin - 10, + waypointBottom: this.margin - 10, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -324,11 +335,53 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); }); + describe('with children', () => { + beforeEach(() => { + this.childrenHeight = 80; + this.props.children = React.createElement('div', {}, [ + React.createElement('div', { + key: 1, + style: { + height: this.childrenHeight / 2, + } + }), + React.createElement('div', { + key: 2, + style: { + height: this.childrenHeight / 2, + } + }), + ]); + }); + + describe('when scrolling down far enough', () => { + beforeEach(() => { + this.component = this.subject(); + this.props.onPositionChange.calls.reset(); + scrollNodeTo(this.component, 100); + }); + + it('calls the onEnter handler', () => { + expect(this.props.onEnter). + toHaveBeenCalledWith({ + currentPosition: Waypoint.inside, + previousPosition: Waypoint.below, + event: jasmine.any(Event), + waypointTop: this.margin + this.topSpacerHeight - 100, + waypointBottom: this.margin + this.topSpacerHeight - 100 + this.childrenHeight, + viewportTop: this.margin, + viewportBottom: this.margin + this.parentHeight, + }); + }); + }); + }); + describe('when scrolling down just below the threshold', () => { beforeEach(() => { this.component = this.subject(); @@ -362,6 +415,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 100, + waypointBottom: this.margin + this.topSpacerHeight - 100, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -375,6 +429,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 100, + waypointBottom: this.margin + this.topSpacerHeight - 100, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -398,6 +453,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 100, + waypointBottom: this.margin + this.topSpacerHeight - 100, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -411,6 +467,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 100, + waypointBottom: this.margin + this.topSpacerHeight - 100, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -444,6 +501,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin - this.bottomSpacerHeight + this.parentHeight, + waypointBottom: this.margin - this.bottomSpacerHeight + this.parentHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -457,6 +515,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: this.margin - this.bottomSpacerHeight + this.parentHeight, + waypointBottom: this.margin - this.bottomSpacerHeight + this.parentHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -470,6 +529,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin - this.bottomSpacerHeight + this.parentHeight, + waypointBottom: this.margin - this.bottomSpacerHeight + this.parentHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -498,6 +558,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin - this.bottomSpacerHeight + this.parentHeight, + waypointBottom: this.margin - this.bottomSpacerHeight + this.parentHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -525,6 +586,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 211, + waypointBottom: this.margin + this.topSpacerHeight - 211, viewportTop: this.margin + this.parentHeight * -0.1, viewportBottom: this.margin + this.parentHeight, }); @@ -573,6 +635,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 90, + waypointBottom: this.margin + this.topSpacerHeight - 90, viewportTop: this.margin, viewportBottom: this.margin + Math.floor(this.parentHeight * 1.1), }); @@ -589,6 +652,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 90, + waypointBottom: this.margin + this.topSpacerHeight - 90, viewportTop: this.margin, viewportBottom: this.margin + Math.floor(this.parentHeight * 1.1), }); @@ -635,6 +699,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 90, + waypointBottom: this.margin + this.topSpacerHeight - 90, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight + 10, }); @@ -651,6 +716,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 90, + waypointBottom: this.margin + this.topSpacerHeight - 90, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight + 10, }); @@ -697,6 +763,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 90, + waypointBottom: this.margin + this.topSpacerHeight - 90, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight + 10, }); @@ -713,6 +780,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 90, + waypointBottom: this.margin + this.topSpacerHeight - 90, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight + 10, }); @@ -759,6 +827,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 90, + waypointBottom: this.margin + this.topSpacerHeight - 90, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight + 10, }); @@ -775,6 +844,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 90, + waypointBottom: this.margin + this.topSpacerHeight - 90, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight + 10, }); @@ -784,6 +854,82 @@ describe('', function() { }); }); + describe('when the Waypoint has children that are not DOM Elements', () => { + const errorMessage = 'You must wrap any Component Elements passed to Waypoint ' + + 'in a DOM Element (eg; a
).'; + + it('errors with a stateless component', () => { + const StatelessComponent = () => React.createElement('div'); + this.props.children = React.createElement(StatelessComponent); + + expect(this.subject).toThrowError(errorMessage); + }); + + it('errors with a class-based component', () => { + class ClassBasedComponent extends React.Component { + render() { + return React.createElement('div'); + } + } + this.props.children = React.createElement(ClassBasedComponent); + + expect(this.subject).toThrowError(errorMessage); + }); + }); + + describe('when the Waypoint has children and is above the top', () => { + beforeEach(() => { + this.topSpacerHeight = 200; + this.bottomSpacerHeight = 200; + this.childrenHeight = 100; + this.props.children = React.createElement('div', { + style: { + height: this.childrenHeight, + } + }); + this.scrollable = this.subject(); + + // Because of how we detect when a Waypoint is scrolled past without any + // scroll event fired when it was visible, we need to reset callback + // spies. + scrollNodeTo(this.scrollable, 400); + this.props.onEnter.calls.reset(); + this.props.onLeave.calls.reset(); + scrollNodeTo(this.scrollable, 400); + }); + + it('does not call the onEnter handler', () => { + expect(this.props.onEnter).not.toHaveBeenCalled(); + }); + + it('does not call the onLeave handler', () => { + expect(this.props.onLeave).not.toHaveBeenCalled(); + }); + + describe('when scrolled back up just past the bottom', () => { + beforeEach(() => { + scrollNodeTo(this.scrollable, this.topSpacerHeight + 50); + }); + + it('calls the onEnter handler', () => { + expect(this.props.onEnter). + toHaveBeenCalledWith({ + currentPosition: Waypoint.inside, + previousPosition: Waypoint.above, + event: jasmine.any(Event), + waypointTop: -40, + waypointBottom: -40 + this.childrenHeight, + viewportTop: this.margin, + viewportBottom: this.margin + this.parentHeight, + }); + }); + + it('does not call the onLeave handler', () => { + expect(this.props.onLeave).not.toHaveBeenCalled(); + }); + }); + }); + describe('when the Waypoint is above the top', () => { beforeEach(() => { this.topSpacerHeight = 200; @@ -842,6 +988,7 @@ describe('', function() { previousPosition: Waypoint.above, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 200, + waypointBottom: this.margin + this.topSpacerHeight - 200, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -858,6 +1005,7 @@ describe('', function() { previousPosition: Waypoint.above, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 200, + waypointBottom: this.margin + this.topSpacerHeight - 200, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -876,6 +1024,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 99, + waypointBottom: this.margin + this.topSpacerHeight - 99, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -892,6 +1041,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight - 99, + waypointBottom: this.margin + this.topSpacerHeight - 99, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -915,6 +1065,7 @@ describe('', function() { previousPosition: Waypoint.above, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -927,6 +1078,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -939,6 +1091,7 @@ describe('', function() { previousPosition: Waypoint.above, event: jasmine.any(Event), waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: this.margin, viewportBottom: this.margin + this.parentHeight, }); @@ -958,6 +1111,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: 0, + waypointBottom: 0, viewportTop: 0, viewportBottom: 0, }); @@ -988,6 +1142,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.topSpacerHeight, + waypointBottom: this.margin + this.topSpacerHeight, viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1006,6 +1161,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + Math.ceil(window.innerHeight / 2), + waypointBottom: this.margin + Math.ceil(window.innerHeight / 2), viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1018,6 +1174,7 @@ describe('', function() { previousPosition: Waypoint.below, event: jasmine.any(Event), waypointTop: this.margin + Math.ceil(window.innerHeight / 2), + waypointBottom: this.margin + Math.ceil(window.innerHeight / 2), viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1110,6 +1267,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: 20 + this.topSpacerHeight, + waypointBottom: 20 + this.topSpacerHeight, viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1126,6 +1284,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: 20 + this.topSpacerHeight, + waypointBottom: 20 + this.topSpacerHeight, viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1161,6 +1320,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: 20 + this.topSpacerHeight - 25, + waypointBottom: 20 + this.topSpacerHeight - 25, viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1177,6 +1337,7 @@ describe('', function() { previousPosition: Waypoint.inside, event: jasmine.any(Event), waypointTop: 20 + this.topSpacerHeight - 25, + waypointBottom: 20 + this.topSpacerHeight - 25, viewportTop: 0, viewportBottom: window.innerHeight, }); @@ -1208,7 +1369,7 @@ const scrollNodeToHorizontal = function(node, scrollLeft) { node.dispatchEvent(event); }; -describe('', function() { +describe(' Horizontal', function() { beforeEach(() => { jasmine.clock().install(); document.body.style.margin = 'auto'; // should be no horizontal margin @@ -1277,6 +1438,7 @@ describe('', function() { previousPosition: undefined, event: null, waypointTop: this.margin + this.leftSpacerWidth, + waypointBottom: this.margin + this.leftSpacerWidth, viewportTop: this.margin, viewportBottom: this.margin + this.parentWidth, }); diff --git a/src/waypoint.jsx b/src/waypoint.jsx index 3093ac4..f2853ad 100644 --- a/src/waypoint.jsx +++ b/src/waypoint.jsx @@ -34,11 +34,24 @@ function getCurrentPosition(bounds) { return POSITIONS.invisible; } + // top is within the viewport if (bounds.viewportTop <= bounds.waypointTop && bounds.waypointTop <= bounds.viewportBottom) { return POSITIONS.inside; } + // bottom is within the viewport + if (bounds.viewportTop <= bounds.waypointBottom && + bounds.waypointBottom <= bounds.viewportBottom) { + return POSITIONS.inside; + } + + // top is above the viewport and bottom is below the viewport + if (bounds.waypointTop <= bounds.viewportTop && + bounds.viewportBottom <= bounds.waypointBottom) { + return POSITIONS.inside; + } + if (bounds.viewportBottom < bounds.waypointTop) { return POSITIONS.below; } @@ -107,6 +120,35 @@ function computeOffsetPixels(offset, contextHeight) { } } +/** + * When an element's type is a string, it represents a DOM node with that tag name + * https://facebook.github.io/react/blog/2015/12/18/react-components-elements-and-instances.html#dom-elements + * + * @param {React.element} Component + * @return {bool} Whether the component is a DOM Element + */ +function isDOMElement(Component) { + return (typeof Component.type === 'string'); +} + + +/** + * Raise an error if "children" isn't a single DOM Element + * + * @param {React.element|null} children + * @return {undefined} + */ +function ensureChildrenIsSingleDOMElement(children) { + if (children) { + React.Children.only(children); + + if (!isDOMElement(children)) { + throw new Error( + 'You must wrap any Component Elements passed to Waypoint in a DOM Element (eg; a
).' + ); + } + } +} /** * Calls a function when you scroll to the element. @@ -119,6 +161,8 @@ export default class Waypoint extends React.Component { } componentWillMount() { + ensureChildrenIsSingleDOMElement(this.props.children); + if (this.props.scrollableParent) { // eslint-disable-line react/prop-types throw new Error('The `scrollableParent` prop has changed name to `scrollableAncestor`.'); } @@ -158,6 +202,10 @@ export default class Waypoint extends React.Component { }, 0); } + componentWillReceiveProps(nextProps) { + ensureChildrenIsSingleDOMElement(nextProps.children); + } + componentDidUpdate() { if (!Waypoint.getWindow()) { return; @@ -255,6 +303,7 @@ export default class Waypoint extends React.Component { previousPosition, event, waypointTop: bounds.waypointTop, + waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom, }; @@ -279,6 +328,7 @@ export default class Waypoint extends React.Component { previousPosition, event, waypointTop: bounds.waypointTop, + waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom, }); @@ -287,6 +337,7 @@ export default class Waypoint extends React.Component { previousPosition: POSITIONS.inside, event, waypointTop: bounds.waypointTop, + waypointBottom: bounds.waypointBottom, viewportTop: bounds.viewportTop, viewportBottom: bounds.viewportBottom, }); @@ -295,8 +346,9 @@ export default class Waypoint extends React.Component { _getBounds() { const horizontal = this.props.horizontal; - const waypointTop = horizontal ? this._ref.getBoundingClientRect().left : - this._ref.getBoundingClientRect().top; + const { left, top, right, bottom } = this._ref.getBoundingClientRect(); + const waypointTop = horizontal ? left : top; + const waypointBottom = horizontal ? right : bottom; let contextHeight; let contextScrollTop; @@ -313,6 +365,7 @@ export default class Waypoint extends React.Component { if (this.props.debug) { debugLog('waypoint top', waypointTop); + debugLog('waypoint bottom', waypointBottom); debugLog('scrollableAncestor height', contextHeight); debugLog('scrollableAncestor scrollTop', contextScrollTop); } @@ -324,6 +377,7 @@ export default class Waypoint extends React.Component { return { waypointTop, + waypointBottom, viewportTop: contextScrollTop + topOffsetPx, viewportBottom: contextBottom - bottomOffsetPx, }; @@ -333,13 +387,27 @@ export default class Waypoint extends React.Component { * @return {Object} */ render() { - // We need an element that we can locate in the DOM to determine where it is - // rendered relative to the top of its context. - return ; + const { children } = this.props; + + if (!children) { + // We need an element that we can locate in the DOM to determine where it is + // rendered relative to the top of its context. + return ; + } + + const ref = (node) => { + this.refElement(node); + if (children.ref) { + children.ref(node); + } + }; + + return React.cloneElement(children, { ref }); } } Waypoint.propTypes = { + children: PropTypes.element, debug: PropTypes.bool, onEnter: PropTypes.func, onLeave: PropTypes.func,